Compare commits

...

10 Commits
main ... el-ui

Author SHA1 Message Date
a15064ce37 ts 2026-05-09 11:24:55 +03:00
“kirukib”
23ab82a726 Add staff roles, subscription txns, system users, issues, FAQ, broadcast
- Introduce SUPER_ADMIN, ADMIN, CUSTOMER_SUPPORT with admin-roles helpers and useAdminRole hook
- New pages: subscription transactions, system members, issues, FAQ & support, notification broadcast
- Services and API paths for admin subscription-transactions, system-members, issues, faq, broadcast
- Nav and quick search filtered by role; login accepts all panel roles

Made-with: Cursor
2026-04-15 10:45:10 +03:00
“kirukib”
4795822065 Add dashboard quick access cards for commerce routes
Link cards for invoices, proforma, proforma requests, payments, and payment requests above metrics; show loading state below quick access.

Made-with: Cursor
2026-04-15 10:24:56 +03:00
“kirukib”
ae7a366e2f feat: Search Bar Page Navigation 2026-04-15 10:22:09 +03:00
5adda68494 ui revamp 2026-04-08 09:28:01 +03:00
“kirukib”
3290250db3 Merge branch 'main' into prod
All checks were successful
Deploy Yaltopia Tickets Admin / deploy (push) Successful in 49s
2026-03-09 20:01:25 +03:00
5a8c5639b5 Merge branch 'prod' of https://gitea.yaltopia.com/YaltopiaTech/Yaltopia-Ticket-Admin
All checks were successful
Deploy Yaltopia Tickets Admin / deploy (push) Successful in 37s
2026-02-28 06:28:54 -08:00
Binyam
c7021d8c59 Update Dockerfile to pass env variables
All checks were successful
Deploy Yaltopia Tickets Admin / deploy (push) Successful in 21s
2026-02-28 15:22:16 +03:00
Binyam
7d78a488c1 Update Dockerfile to install dev dependecies
All checks were successful
Deploy Yaltopia Tickets Admin / deploy (push) Successful in 23s
2026-02-28 14:40:10 +03:00
Binyam
05f8418a48 Add deployment workflow
Some checks failed
Deploy Yaltopia Tickets Admin / deploy (push) Failing after 2s
2026-02-28 14:37:06 +03:00
59 changed files with 12058 additions and 2927 deletions

View File

@ -1,6 +1,10 @@
# 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

View File

@ -0,0 +1,22 @@
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

View File

@ -7,11 +7,18 @@ WORKDIR /app
COPY package*.json ./ COPY package*.json ./
# Install dependencies # Install dependencies
RUN npm ci --only=production RUN npm ci
# 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

View File

@ -0,0 +1,77 @@
{
"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
View File

@ -24,10 +24,12 @@
"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",
@ -4816,6 +4818,22 @@
"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",
@ -7036,11 +7054,10 @@
} }
}, },
"node_modules/react-is": { "node_modules/react-is": {
"version": "19.2.4", "version": "19.2.5",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz",
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/react-redux": { "node_modules/react-redux": {
"version": "9.2.0", "version": "9.2.0",

View File

@ -33,10 +33,12 @@
"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",

View File

@ -1,30 +1,40 @@
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";
function App() { function App() {
return ( return (
@ -51,23 +61,60 @@ 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 path="admin/security/failed-logins" element={<FailedLoginsPage />} /> <Route
<Route path="admin/security/suspicious" element={<SuspiciousActivityPage />} /> path="admin/security/failed-logins"
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 path="admin/analytics/overview" element={<AnalyticsOverviewPage />} /> <Route
path="admin/analytics/overview"
element={<AnalyticsOverviewPage />}
/>
<Route path="admin/analytics/users" element={<AnalyticsUsersPage />} /> <Route path="admin/analytics/users" element={<AnalyticsUsersPage />} />
<Route path="admin/analytics/revenue" element={<AnalyticsRevenuePage />} /> <Route
<Route path="admin/analytics/storage" element={<AnalyticsStoragePage />} /> path="admin/analytics/revenue"
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="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;

View File

@ -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}</>;
} }

View File

@ -0,0 +1,112 @@
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>
</>
);
}

View File

@ -0,0 +1,134 @@
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,
}

View File

@ -0,0 +1,23 @@
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 };

View File

@ -0,0 +1,328 @@
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,
} 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: "Commerce",
icon: ArrowRightLeft,
},
{
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",
"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
}

View File

@ -0,0 +1,20 @@
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,
};
}

View File

@ -1,5 +1,5 @@
import { Outlet, Link, useLocation, useNavigate } from "react-router-dom" import React, { useState, type ComponentType, useEffect } from "react";
import { useState } from "react" import { Outlet, Link, useLocation, useNavigate } from "react-router-dom";
import { import {
LayoutDashboard, LayoutDashboard,
Users, Users,
@ -11,13 +11,23 @@ import {
BarChart3, BarChart3,
Activity, Activity,
Heart, Heart,
Search,
Bell, Bell,
LogOut, LogOut,
} from "lucide-react" CreditCard,
import { Button } from "@/components/ui/button" Receipt,
import { Input } from "@/components/ui/input" FileSearch,
import { Avatar, AvatarFallback } from "@/components/ui/avatar" ArrowRightLeft,
UserCog,
LifeBuoy,
HelpCircle,
Send,
ChevronDown,
ChevronRight,
Folder,
} 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,
@ -25,104 +35,263 @@ 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 { authService } from "@/services" import { roleLabel, getPermissions } from "@/lib/admin-roles";
import { toast } from "sonner" import { authService } from "@/services";
interface User { interface User {
email: string email: string;
firstName?: string firstName?: string;
lastName?: string lastName?: string;
role: string role: string;
} }
const adminNavigationItems = [ type NavItem = {
icon?: ComponentType<{ className?: string }>;
label: string;
path?: string;
children?: NavItem[];
/** Omit = visible to all panel roles */
visible?: (role: string | undefined) => boolean;
};
const adminNavigationItems: NavItem[] = [
{ icon: LayoutDashboard, label: "Dashboard", path: "/admin/dashboard" }, { icon: LayoutDashboard, label: "Dashboard", path: "/admin/dashboard" },
{ icon: Users, label: "Users", path: "/admin/users" }, {
{ icon: FileText, label: "Logs", path: "/admin/logs" }, icon: Folder,
{ icon: Settings, label: "Settings", path: "/admin/settings" }, label: "Documents",
{ icon: Wrench, label: "Maintenance", path: "/admin/maintenance" }, children: [
{
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: "Subscription transactions",
path: "/admin/transactions/subscriptions",
},
{
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: Shield, label: "Security", path: "/admin/security" }, icon: Activity,
{ icon: BarChart3, label: "Analytics", path: "/admin/analytics" }, label: "Audit",
{ icon: Heart, label: "System Health", path: "/admin/health" }, path: "/admin/audit",
] 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: "Send notification",
path: "/admin/notifications/broadcast",
visible: (role) => getPermissions(role).canSendNotifications,
},
];
const SidebarNavItem = ({
item,
depth = 0,
isActive,
}: {
item: NavItem;
depth?: number;
isActive: (path?: string) => boolean;
}) => {
const hasChildren = item.children && item.children.length > 0;
const isCurrentlyActive =
isActive(item.path) ||
(hasChildren && item.children?.some((child) => isActive(child.path)));
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">
{item.children?.map((child) => (
<SidebarNavItem
key={child.label}
item={child}
depth={depth + 1}
isActive={isActive}
/>
))}
</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) => {
return location.pathname.startsWith(path) if (!path) return false;
} 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">
@ -133,29 +302,22 @@ 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">Admin Panel</span> <span className="text-foreground font-semibold text-lg">
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">
{adminNavigationItems.map((item) => { {adminNavigationItems
const Icon = item.icon .filter((item) => (item.visible ? item.visible(user?.role) : true))
return ( .map((item) => (
<Link <SidebarNavItem
key={item.path} key={item.label}
to={item.path} item={item}
className={cn( isActive={isActive}
"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 */}
@ -165,8 +327,17 @@ 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">{getUserDisplayName()}</p> <p className="text-sm font-medium truncate">
<p className="text-xs text-muted-foreground truncate">{user?.email || 'admin@example.com'}</p> {getUserDisplayName()}
</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
@ -185,18 +356,15 @@ 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">
<h1 className="text-2xl font-bold">{getPageTitle()}</h1> <div className="flex-1" />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<form onSubmit={handleSearch} className="relative"> <AdminQuickSearch />
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Button
<Input variant="ghost"
placeholder="Quick Search..." size="icon"
className="pl-10 w-64" className="relative"
value={searchQuery} onClick={handleNotificationClick}
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>
@ -211,8 +379,12 @@ 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">{getUserDisplayName()}</p> <p className="text-sm font-medium">
<p className="text-xs text-muted-foreground">{user?.email}</p> {getUserDisplayName()}
</p>
<p className="text-xs text-muted-foreground">
{user?.email}
</p>
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@ -220,7 +392,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>
@ -240,5 +412,5 @@ export function AppShell() {
</main> </main>
</div> </div>
</div> </div>
) );
} }

115
src/lib/admin-roles.ts Normal file
View File

@ -0,0 +1,115 @@
/**
* 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;
}

View File

@ -1,195 +1,194 @@
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 {
Table, Search,
TableBody, ChevronLeft,
TableCell, ChevronRight,
TableHead, Filter,
TableHeader, Terminal,
TableRow, } from "lucide-react";
} from "@/components/ui/table" import { auditService, type AuditLog } from "@/services";
import { Search, Download, Eye, ChevronLeft, ChevronRight } 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 ActivityLogPage() { export default function ActivityLogPage() {
const [page, setPage] = useState(1) const [page, setPage] = useState(1);
const [limit] = useState(20) const [limit] = useState(15);
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, actionFilter, resourceTypeFilter], queryKey: ["activity-log", 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;
if (actionFilter) params.action = actionFilter return await auditService.getAuditLogs(params);
if (resourceTypeFilter) params.resourceType = resourceTypeFilter
return await auditService.getAuditLogs(params)
}, },
}) });
const handleExport = async () => { const getActionColor = (action: string) => {
try { const act = action.toUpperCase();
const blob = await auditService.exportAuditLogs({ format: 'csv' }) if (act.includes("CREATE"))
const url = window.URL.createObjectURL(blob) return "text-blue-600 bg-blue-50 border-blue-100";
const a = document.createElement('a') if (act.includes("UPDATE"))
a.href = url return "text-emerald-600 bg-emerald-50 border-emerald-100";
a.download = `activity-log-${format(new Date(), 'yyyy-MM-dd')}.csv` if (act.includes("DELETE"))
document.body.appendChild(a) return "text-rose-600 bg-rose-50 border-rose-100";
a.click() if (act.includes("LOGIN"))
window.URL.revokeObjectURL(url) return "text-purple-600 bg-purple-50 border-purple-100";
document.body.removeChild(a) return "text-slate-600 bg-slate-50 border-slate-100";
} 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-6"> <div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<div className="flex items-center justify-between"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<h2 className="text-3xl font-bold">Activity Log</h2> <div>
<Button onClick={handleExport}> <h1 className="text-3xl font-bold text-gray-900 tracking-tight">
<Download className="w-4 h-4 mr-2" /> Activity Log
Export Log </h1>
</Button> <p className="text-gray-500 mt-1">
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> <Card className="border shadow-none rounded-none">
<CardHeader> <CardHeader className="border-b pb-4 flex flex-row items-center justify-between space-y-0">
<div className="flex items-center justify-between"> <CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
<CardTitle>All Activities</CardTitle> System Audit
<div className="flex items-center gap-4"> </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">
<Input <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
placeholder="Search activity..." <Input
className="pl-10 w-64" className="pl-10 h-9 rounded-none border-gray-200 text-xs"
value={search} placeholder="Search activity or user..."
onChange={(e) => setSearch(e.target.value)} value={search}
/> 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> <CardContent className="p-0">
{isLoading ? ( <div className="overflow-x-auto">
<div className="text-center py-8">Loading activity logs...</div> <table className="w-full text-left">
) : ( <thead className="bg-gray-50 border-b">
<> <tr>
<Table> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<TableHeader> Action
<TableRow> </th>
<TableHead>Log ID</TableHead> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<TableHead>User</TableHead> User ID
<TableHead>Action</TableHead> </th>
<TableHead>Resource</TableHead> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<TableHead>Resource ID</TableHead> Resource
<TableHead>IP Address</TableHead> </th>
<TableHead>Timestamp</TableHead> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<TableHead>Actions</TableHead> IP Address
</TableRow> </th>
</TableHeader> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
<TableBody> Timestamp
{auditData?.data?.map((log: AuditLog) => ( </th>
<TableRow key={log.id}> </tr>
<TableCell className="font-medium">{log.id}</TableCell> </thead>
<TableCell>{log.userId || 'N/A'}</TableCell> <tbody className="divide-y">
<TableCell> {isLoading ? (
<Badge className={getActionBadgeColor(log.action)}> <tr>
<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}
</Badge> </span>
</TableCell> </td>
<TableCell>{log.resourceType}</TableCell> <td className="px-6 py-4 text-sm font-bold text-gray-900 tracking-tighter">
<TableCell className="font-mono text-sm">{log.resourceId}</TableCell> {log.userId || "SYSTEM"}
<TableCell>{log.ipAddress || 'N/A'}</TableCell> </td>
<TableCell> <td className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase flex items-center gap-1.5 mt-4">
{format(new Date(log.timestamp), 'MMM dd, yyyy HH:mm:ss')} <Terminal className="w-3 h-3" /> {log.resourceType}:{" "}
</TableCell> {log.resourceId.substring(0, 8)}...
<TableCell> </td>
<Button variant="ghost" size="icon"> <td className="px-6 py-4 text-xs font-mono text-gray-500">
<Eye className="w-4 h-4" /> {log.ipAddress || "--"}
</Button> </td>
</TableCell> <td className="px-6 py-4 text-right text-xs text-gray-500">
</TableRow> {format(new Date(log.timestamp), "MMM dd, HH:mm:ss")}
))} </td>
</TableBody> </tr>
</Table> ))
{auditData?.data?.length === 0 && ( ) : (
<div className="text-center py-8 text-muted-foreground"> <tr>
No activity logs found <td
</div> colSpan={5}
)} 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}
> >
<ChevronLeft className="w-4 h-4" /> No activity logs recorded.
Previous </td>
</Button> </tr>
<Button )}
variant="outline" </tbody>
size="sm" </table>
onClick={() => setPage(p => Math.min(auditData.totalPages, p + 1))} </div>
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>
) );
} }

View File

@ -1,109 +1,154 @@
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 { analyticsService } from "@/services";
Table, import type { ApiUsageData } from "@/types/analytics.types";
TableBody, import { Activity, Zap, AlertCircle, Clock, Terminal } from "lucide-react";
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-6"> <div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<h2 className="text-3xl font-bold">API Usage Analytics</h2> <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <h1 className="text-3xl font-bold text-gray-900 tracking-tight">
<Card> API Performance
<CardHeader> </h1>
<CardTitle className="text-sm font-medium">Total API Calls</CardTitle> <p className="text-gray-500 mt-1">
</CardHeader> Operational metrics for system integration and service health.
<CardContent> </p>
{errorRateLoading ? ( </div>
<div className="text-2xl font-bold">...</div>
) : (
<div className="text-2xl font-bold">{errorRate?.total || 0}</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Errors</CardTitle>
</CardHeader>
<CardContent>
{errorRateLoading ? (
<div className="text-2xl font-bold">...</div>
) : (
<div className="text-2xl font-bold">{errorRate?.errors || 0}</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Error Rate</CardTitle>
</CardHeader>
<CardContent>
{errorRateLoading ? (
<div className="text-2xl font-bold">...</div>
) : (
<div className="text-2xl font-bold">
{errorRate?.errorRate ? `${errorRate.errorRate.toFixed(2)}%` : '0%'}
</div>
)}
</CardContent>
</Card>
</div> </div>
<Card> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<CardHeader> {[
<CardTitle>Endpoint Usage (Last 7 Days)</CardTitle> {
label: "Total Ingress",
value: errorRate?.total || 0,
icon: Zap,
color: "text-blue-600 bg-blue-50 border-blue-100",
},
{
label: "Transit Errors",
value: errorRate?.errors || 0,
icon: AlertCircle,
color: "text-rose-600 bg-rose-50 border-rose-100",
},
{
label: "Failure Rate",
value: errorRate?.errorRate
? `${errorRate.errorRate.toFixed(2)}%`
: "0.00%",
icon: Activity,
color: "text-amber-600 bg-amber-50 border-amber-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">
{errorRateLoading ? "..." : metric.value}
</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">
Endpoint Performance Ledger
</CardTitle>
<Terminal className="w-4 h-4 text-gray-300" />
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-0">
{isLoading ? ( <div className="overflow-x-auto">
<div className="text-center py-8">Loading API usage...</div> <table className="w-full text-left">
) : ( <thead className="bg-gray-50 border-b">
<> <tr>
<Table> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<TableHeader> Temporal Reference
<TableRow> </th>
<TableHead>Endpoint</TableHead> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-center">
<TableHead>Calls</TableHead> Transaction Volume
<TableHead>Avg Duration (ms)</TableHead> </th>
</TableRow> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
</TableHeader> Avg Latency (ms)
<TableBody> </th>
{apiUsage?.map((endpoint: ApiUsageData, index: number) => ( </tr>
<TableRow key={index}> </thead>
<TableCell className="font-mono text-sm">{endpoint.date}</TableCell> <tbody className="divide-y text-gray-600">
<TableCell>{endpoint.requests}</TableCell> {isLoading ? (
<TableCell>{endpoint.avgResponseTime?.toFixed(2) || 'N/A'}</TableCell> <tr>
</TableRow> <td
))} colSpan={3}
</TableBody> className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
</Table> >
{apiUsage?.length === 0 && ( Synchronizing performance data...
<div className="text-center py-8 text-muted-foreground"> </td>
No API usage data available </tr>
</div> ) : apiUsage && apiUsage.length > 0 ? (
)} 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>
) );
} }

View File

@ -1,86 +1,89 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { BarChart3, Users, DollarSign, HardDrive, Activity } from "lucide-react" import {
import { useNavigate } from "react-router-dom" BarChart3,
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> {
<CardTitle className="flex items-center gap-2"> label: "Performance Overview",
<BarChart3 className="w-5 h-5" /> description: "Platform analytics overview",
Overview icon: BarChart3,
</CardTitle> path: "/admin/analytics/overview",
</CardHeader> color: "text-blue-600",
<CardContent> },
<p className="text-sm text-muted-foreground"> {
Platform analytics overview label: "User Dynamics",
</p> description: "User growth and statistics",
</CardContent> icon: Users,
</Card> path: "/admin/analytics/users",
color: "text-purple-600",
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/analytics/users')}> },
<CardHeader> {
<CardTitle className="flex items-center gap-2"> label: "Revenue Streams",
<Users className="w-5 h-5" /> description: "Revenue trends and breakdown",
Users Analytics icon: DollarSign,
</CardTitle> path: "/admin/analytics/revenue",
</CardHeader> color: "text-emerald-600",
<CardContent> },
<p className="text-sm text-muted-foreground"> {
User growth and statistics label: "Resource Allocation",
</p> description: "Storage usage and breakdown",
</CardContent> icon: HardDrive,
</Card> path: "/admin/analytics/storage",
color: "text-amber-600",
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/analytics/revenue')}> },
<CardHeader> {
<CardTitle className="flex items-center gap-2"> label: "API Operations",
<DollarSign className="w-5 h-5" /> description: "API endpoint usage statistics",
Revenue Analytics icon: Activity,
</CardTitle> path: "/admin/analytics/api",
</CardHeader> color: "text-rose-600",
<CardContent> },
<p className="text-sm text-muted-foreground"> ].map((item) => (
Revenue trends and breakdown <Card
</p> key={item.label}
</CardContent> className="group cursor-pointer border-slate-200/60 shadow-sm hover:shadow-md transition-all rounded-none bg-white overflow-hidden"
</Card> onClick={() => navigate(item.path)}
>
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/analytics/storage')}> <CardHeader className="pb-2 space-y-0 border-b border-slate-50 bg-slate-50/30">
<CardHeader> <div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2"> <span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
<HardDrive className="w-5 h-5" /> {item.label}
Storage Analytics </span>
</CardTitle> <item.icon
</CardHeader> className={cn("w-4 h-4 transition-colors", item.color)}
<CardContent> />
<p className="text-sm text-muted-foreground"> </div>
Storage usage and breakdown </CardHeader>
</p> <CardContent className="pt-4 flex items-end justify-between">
</CardContent> <div>
</Card> <p className="text-sm font-semibold text-slate-900 tracking-tight mb-1 group-hover:text-primary transition-colors">
{item.label}
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/analytics/api')}> </p>
<CardHeader> <p className="text-xs text-muted-foreground leading-relaxed max-w-[200px]">
<CardTitle className="flex items-center gap-2"> {item.description}
<Activity className="w-5 h-5" /> </p>
API Usage </div>
</CardTitle> <ChevronRight className="w-4 h-4 text-slate-300 group-hover:translate-x-1 transition-transform" />
</CardHeader> </CardContent>
<CardContent> </Card>
<p className="text-sm text-muted-foreground"> ))}
API endpoint usage statistics
</p>
</CardContent>
</Card>
</div> </div>
</div> </div>
) );
} }

View File

@ -1,44 +1,211 @@
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 { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts" import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } 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";
export default function AnalyticsRevenuePage() { const COLORS = ["#111827", "#4B5563", "#9CA3AF", "#D1D5DB", "#E2E8F0"];
const { data: revenue, isLoading } = useQuery({
queryKey: ['admin', 'analytics', 'revenue'],
queryFn: () => analyticsService.getRevenue('90days'),
})
return ( interface ChartDataItem {
<div className="space-y-6"> name: string;
<h2 className="text-3xl font-bold">Revenue Analytics</h2> value: number;
<Card>
<CardHeader>
<CardTitle>Revenue Trends (Last 90 Days)</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="h-[400px] flex items-center justify-center">Loading...</div>
) : revenue && revenue.length > 0 ? (
<ResponsiveContainer width="100%" height={400}>
<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-[400px] flex items-center justify-center text-muted-foreground">
No data available
</div>
)}
</CardContent>
</Card>
</div>
)
} }
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 (
<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">
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 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">
Resource Consumption
</CardTitle>
<HardDrive className="w-4 h-4 text-gray-300" />
</CardHeader>
<CardContent className="p-8 space-y-8">
{isLoading ? (
<div className="h-[300px] flex items-center justify-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]">
Quantifying resources...
</div>
) : (
<>
<div className="grid grid-cols-2 gap-8">
<div className="space-y-1">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Aggregate Payload
</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>
</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>
<FileText className="w-4 h-4 text-gray-300" />
</CardHeader>
<CardContent className="p-0">
<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>
);
}

View File

@ -1,71 +1,125 @@
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, Legend } from "recharts" import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } 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 = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8'] const COLORS = ["#111827", "#4B5563", "#9CA3AF", "#D1D5DB", "#E2E8F0"];
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 = ['Bytes', 'KB', 'MB', 'GB', 'TB'] const sizes = ["B", "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[] = storage?.byCategory?.map((cat) => ({ const chartData: ChartDataItem[] =
name: cat.category, storage?.byCategory?.map((cat) => ({
value: cat.size, name: cat.category,
})) || [] value: cat.size,
})) || [];
return ( return (
<div className="space-y-6"> <div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<h2 className="text-3xl font-bold">Storage Analytics</h2> <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">
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-6"> <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>Storage Overview</CardTitle> <CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
Resource Consumption
</CardTitle>
<HardDrive className="w-4 h-4 text-gray-300" />
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-8 space-y-8">
{isLoading ? ( {isLoading ? (
<div className="h-[300px] flex items-center justify-center">Loading...</div> <div className="h-[300px] flex items-center justify-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]">
Quantifying resources...
</div>
) : ( ) : (
<div className="space-y-4"> <>
<div> <div className="grid grid-cols-2 gap-8">
<p className="text-sm text-muted-foreground">Total Storage</p> <div className="space-y-1">
<p className="text-2xl font-bold"> <p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
{storage?.total ? formatBytes(storage.total.size) : '0 Bytes'} Aggregate Payload
</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> <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>Storage by Category</CardTitle> <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> </CardHeader>
<CardContent> <CardContent className="p-8">
{isLoading ? ( {isLoading ? (
<div className="h-[300px] flex items-center justify-center">Loading...</div> <div className="h-[300px] flex items-center justify-center">
...
</div>
) : chartData.length > 0 ? ( ) : chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}> <ResponsiveContainer width="100%" height={300}>
<PieChart> <PieChart>
@ -73,23 +127,36 @@ export default function AnalyticsStoragePage() {
data={chartData} data={chartData}
cx="50%" cx="50%"
cy="50%" cy="50%"
labelLine={false} innerRadius={60}
label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`} outerRadius={100}
outerRadius={80} paddingAngle={4}
fill="#8884d8" stroke="none"
dataKey="value" dataKey="value"
> >
{chartData.map((_entry: ChartDataItem, index: number) => ( {chartData.map((_entry: ChartDataItem, index: number) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} /> <Cell
key={`cell-${index}`}
fill={COLORS[index % COLORS.length]}
/>
))} ))}
</Pie> </Pie>
<Tooltip /> <Tooltip
<Legend /> contentStyle={{
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-muted-foreground"> <div className="h-[300px] flex items-center justify-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]">
No data available No category distribution.
</div> </div>
)} )}
</CardContent> </CardContent>
@ -97,19 +164,42 @@ export default function AnalyticsStoragePage() {
</div> </div>
{storage?.topUsers && storage.topUsers.length > 0 && ( {storage?.topUsers && storage.topUsers.length > 0 && (
<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>Top 10 Users by Storage Usage</CardTitle> <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>
<FileText className="w-4 h-4 text-gray-300" />
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-0">
<div className="space-y-2"> <div className="divide-y divide-gray-100">
{storage.topUsers.map((user: StorageByUser, index: number) => ( {storage.topUsers.map((user: StorageByUser, index: number) => (
<div key={index} className="flex items-center justify-between p-2 border rounded"> <div
<div> key={index}
<p className="font-medium">{user.userName || user.email}</p> className="flex items-center justify-between p-6 hover:bg-gray-50 transition-colors group"
<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>
@ -117,6 +207,5 @@ export default function AnalyticsStoragePage() {
</Card> </Card>
)} )}
</div> </div>
) );
} }

View File

@ -1,46 +1,156 @@
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 { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts" import {
import { analyticsService } from "@/services" LineChart,
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-6"> <div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<h2 className="text-3xl font-bold">User Analytics</h2> <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">
User Intelligence
</h1>
<p className="text-gray-500 mt-1">
Growth trajectories and demographic distribution patterns.
</p>
</div>
</div>
<Card> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<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> <CardContent className="p-8">
{isLoading ? ( {isLoading ? (
<div className="h-[400px] flex items-center justify-center">Loading...</div> <div className="h-[400px] flex items-center justify-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]">
Visualizing temporal growth...
</div>
) : userGrowth && userGrowth.length > 0 ? ( ) : userGrowth && userGrowth.length > 0 ? (
<ResponsiveContainer width="100%" height={400}> <ResponsiveContainer width="100%" height={400}>
<LineChart data={userGrowth}> <LineChart
<CartesianGrid strokeDasharray="3 3" /> data={userGrowth}
<XAxis dataKey="date" /> margin={{ top: 5, right: 30, left: 10, bottom: 5 }}
<YAxis /> >
<Tooltip /> <CartesianGrid
<Legend /> strokeDasharray="1 4"
<Line type="monotone" dataKey="total" stroke="#8884d8" name="Total Users" /> vertical={false}
<Line type="monotone" dataKey="admins" stroke="#82ca9d" name="Admins" /> stroke="#E2E8F0"
<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-muted-foreground"> <div className="h-[400px] flex items-center justify-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]">
No data available Incomplete trajectory data.
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
) );
} }

View File

@ -1,16 +1,7 @@
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,
@ -18,329 +9,433 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog";
import { Plus, Edit, Trash2 } from "lucide-react" import { Megaphone, Plus, Edit, Trash2, Filter } from "lucide-react";
import { announcementService, type Announcement, type CreateAnnouncementData } from "@/services" import {
import { toast } from "sonner" announcementService,
import { format } from "date-fns" type Announcement,
import type { ApiError } from "@/types/error.types" type CreateAnnouncementData,
} 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] = useState<Announcement | null>(null) const [selectedAnnouncement, setSelectedAnnouncement] =
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) => announcementService.createAnnouncement(data), mutationFn: (data: CreateAnnouncementData) =>
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(apiError.response?.data?.message || "Failed to create announcement") toast.error(
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(apiError.response?.data?.message || "Failed to update announcement") toast.error(
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(apiError.response?.data?.message || "Failed to delete announcement") toast.error(
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 ? announcement.startsAt.split('T')[0] : '', startsAt: announcement.startsAt
endsAt: announcement.endsAt ? announcement.endsAt.split('T')[0] : '', ? announcement.startsAt.split("T")[0]
}) : "",
setFormDialogOpen(true) endsAt: announcement.endsAt ? announcement.endsAt.split("T")[0] : "",
} });
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) { if (selectedAnnouncement) {
updateMutation.mutate({ id: selectedAnnouncement.id, data: submitData }) updateMutation.mutate({ id: selectedAnnouncement.id, data: submitData });
} else { } else {
createMutation.mutate(submitData) 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-6"> <div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<div className="flex items-center justify-between"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<h2 className="text-3xl font-bold">Announcements</h2> <div>
<Button onClick={handleOpenCreateDialog}> <h1 className="text-3xl font-bold text-gray-900 tracking-tight">
<Plus className="w-4 h-4 mr-2" /> Announcements
Create Announcement </h1>
<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> <Card className="border shadow-none rounded-none">
<CardHeader> <CardHeader className="border-b pb-4 flex flex-row items-end justify-end space-y-0">
<CardTitle>All Announcements</CardTitle> <Button
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> <CardContent className="p-0">
{isLoading ? ( <div className="overflow-x-auto">
<div className="text-center py-8">Loading announcements...</div> <table className="w-full text-left">
) : ( <thead className="bg-gray-50 border-b">
<> <tr>
<Table> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<TableHeader> Title
<TableRow> </th>
<TableHead>Title</TableHead> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<TableHead>Type</TableHead> Status
<TableHead>Priority</TableHead> </th>
<TableHead>Status</TableHead> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<TableHead>Start Date</TableHead> Type
<TableHead>End Date</TableHead> </th>
<TableHead>Actions</TableHead> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
</TableRow> Scheduled
</TableHeader> </th>
<TableBody> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
{announcements?.map((announcement: Announcement) => ( Actions
<TableRow key={announcement.id}> </th>
<TableCell className="font-medium">{announcement.title}</TableCell> </tr>
<TableCell> </thead>
<Badge>{announcement.type || 'info'}</Badge> <tbody className="divide-y">
</TableCell> {isLoading ? (
<TableCell>{announcement.priority || 0}</TableCell> <tr>
<TableCell> <td
<Badge variant={announcement.isActive ? 'default' : 'secondary'}> colSpan={5}
{announcement.isActive ? 'Active' : 'Inactive'} className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
</Badge> >
</TableCell> Loading...
<TableCell> </td>
{announcement.startsAt ? format(new Date(announcement.startsAt), 'MMM dd, yyyy') : 'N/A'} </tr>
</TableCell> ) : announcements && announcements.length > 0 ? (
<TableCell> announcements.map((announcement: Announcement) => (
{announcement.endsAt ? format(new Date(announcement.endsAt), 'MMM dd, yyyy') : 'N/A'} <tr
</TableCell> key={announcement.id}
<TableCell> className="hover:bg-gray-50 transition-colors group"
<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-4 h-4" /> <Edit className="w-3.5 h-3.5 text-gray-400" />
</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-4 h-4" /> <Trash2 className="w-3.5 h-3.5 text-gray-400" />
</Button> </Button>
</div> </div>
</TableCell> </td>
</TableRow> </tr>
))} ))
</TableBody> ) : (
</Table> <tr>
{announcements?.length === 0 && ( <td
<div className="text-center py-8 text-muted-foreground"> colSpan={5}
No announcements found className="px-6 py-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]"
</div> >
)} No active announcements.
</> </td>
)} </tr>
)}
</tbody>
</table>
</div>
</CardContent> </CardContent>
</Card> </Card>
{/* Create/Edit Dialog */} {/* Form Dialog */}
<Dialog open={formDialogOpen} onOpenChange={setFormDialogOpen}> <Dialog open={formDialogOpen} onOpenChange={setFormDialogOpen}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-xl rounded-none border border-gray-200">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle className="text-sm font-bold uppercase tracking-widest text-gray-900 border-b pb-2">
{selectedAnnouncement ? 'Edit Announcement' : 'Create Announcement'} {selectedAnnouncement ? "Edit Broadcast" : "New Broadcast"}
</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-4"> <form onSubmit={handleSubmit} className="space-y-6 pt-4">
<div className="space-y-2"> <div className="space-y-4">
<label className="text-sm font-medium">Title *</label> <div className="space-y-1.5">
<input <label className="text-[10px] font-bold uppercase tracking-widest text-gray-400">
type="text" Announcement Title
className="w-full px-3 py-2 border rounded-md" </label>
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="number" type="text"
className="w-full px-3 py-2 border rounded-md" 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"
value={formData.priority} value={formData.title}
onChange={(e) => setFormData({ ...formData, priority: parseInt(e.target.value) || 0 })} onChange={(e) =>
setFormData({ ...formData, title: e.target.value })
}
required
/> />
</div> </div>
</div> <div className="space-y-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-gray-400">
<div className="space-y-2"> BroadCast Message
<label className="text-sm font-medium">Target Audience</label> </label>
<input <textarea
type="text" 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"
className="w-full px-3 py-2 border rounded-md" value={formData.message}
value={formData.targetAudience} onChange={(e) =>
onChange={(e) => setFormData({ ...formData, targetAudience: e.target.value })} setFormData({ ...formData, message: e.target.value })
placeholder="all, admins, users, etc." }
/> required
</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>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-1.5">
<label className="text-sm font-medium">End Date</label> <label className="text-[10px] font-bold uppercase tracking-widest text-gray-400">
<input Type
type="date" </label>
className="w-full px-3 py-2 border rounded-md" <select
value={formData.endsAt} 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"
onChange={(e) => setFormData({ ...formData, endsAt: e.target.value })} 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> </div>
<DialogFooter> <DialogFooter className="border-t pt-4">
<Button <Button
type="button" type="button"
variant="outline" variant="ghost"
onClick={() => { onClick={() => setFormDialogOpen(false)}
setFormDialogOpen(false) className="rounded-none text-xs uppercase tracking-widest"
resetForm()
}}
> >
Cancel Cancel
</Button> </Button>
<Button <Button
type="submit" type="submit"
disabled={createMutation.isPending || updateMutation.isPending} className="rounded-none text-xs uppercase tracking-widest px-8"
> >
{selectedAnnouncement ? 'Update' : 'Create'} Confirm
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
@ -349,23 +444,34 @@ export default function AnnouncementsPage() {
{/* Delete Dialog */} {/* Delete Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent> <DialogContent className="rounded-none border-rose-100">
<DialogHeader> <DialogHeader>
<DialogTitle>Delete Announcement</DialogTitle> <DialogTitle className="text-rose-600 text-sm font-bold uppercase tracking-widest">
<DialogDescription> Delete BroadCast
Are you sure you want to delete "{selectedAnnouncement?.title}"? This action cannot be undone. </DialogTitle>
<DialogDescription className="text-xs">
Confirm removal of "{selectedAnnouncement?.title}". This operation
cannot be reversed.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter className="mt-4">
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}> <Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
className="rounded-none text-xs"
>
Cancel Cancel
</Button> </Button>
<Button variant="destructive" onClick={handleDelete}> <Button
Delete variant="destructive"
onClick={handleDelete}
className="rounded-none text-xs"
>
Permanent Delete
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
) );
} }

View File

@ -1,105 +1,192 @@
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 {
Table, Search,
TableBody, Eye,
TableCell, ChevronLeft,
TableHead, ChevronRight,
TableHeader, Filter,
TableRow, Terminal,
} from "@/components/ui/table" } from "lucide-react";
import { Search, Eye } from "lucide-react" import { auditService, type AuditLog } from "@/services";
import { auditService, type AuditLog } from "@/services" import { format } from "date-fns";
import { format } from "date-fns" import { cn } from "@/lib/utils";
export default function AuditPage() { export default function AuditPage() {
const [page] = useState(1) const [page, setPage] = useState(1);
const [limit] = useState(50) const [limit] = useState(15);
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-6"> <div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<h2 className="text-3xl font-bold">Audit Logs</h2> <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">
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> <Card className="border shadow-none rounded-none">
<CardHeader> <CardHeader className="border-b pb-4 flex flex-row items-center justify-between space-y-0">
<div className="flex items-center justify-between"> <CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
<CardTitle>All Audit Logs</CardTitle> Security Ledger
<div className="flex items-center gap-4"> </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">
<Input <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
placeholder="Search audit logs..." <Input
className="pl-10 w-64" className="pl-10 h-9 rounded-none border-gray-200 text-xs"
value={search} placeholder="Search resources..."
onChange={(e) => setSearch(e.target.value)} value={search}
/> 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> <CardContent className="p-0">
{isLoading ? ( <div className="overflow-x-auto">
<div className="text-center py-8">Loading audit logs...</div> <table className="w-full text-left">
) : ( <thead className="bg-gray-50 border-b">
<> <tr>
<Table> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<TableHeader> Act
<TableRow> </th>
<TableHead>Action</TableHead> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<TableHead>Resource Type</TableHead> User ID
<TableHead>Resource ID</TableHead> </th>
<TableHead>User</TableHead> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<TableHead>IP Address</TableHead> Resource
<TableHead>Date</TableHead> </th>
<TableHead>Actions</TableHead> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
</TableRow> IP
</TableHeader> </th>
<TableBody> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
{auditData?.data?.map((log: AuditLog) => ( Date
<TableRow key={log.id}> </th>
<TableCell> </tr>
<Badge>{log.action}</Badge> </thead>
</TableCell> <tbody className="divide-y">
<TableCell>{log.resourceType}</TableCell> {isLoading ? (
<TableCell className="font-mono text-sm">{log.resourceId}</TableCell> <tr>
<TableCell>{log.userId || 'N/A'}</TableCell> <td
<TableCell>{log.ipAddress || 'N/A'}</TableCell> colSpan={5}
<TableCell> className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
{format(new Date(log.timestamp), 'MMM dd, yyyy HH:mm')} >
</TableCell> Retrieving audit trail...
<TableCell> </td>
<Button variant="ghost" size="icon"> </tr>
<Eye className="w-4 h-4" /> ) : auditData?.data && auditData.data.length > 0 ? (
</Button> auditData.data.map((log: AuditLog) => (
</TableCell> <tr
</TableRow> key={log.id}
))} className="hover:bg-gray-50 transition-colors group"
</TableBody> >
</Table> <td className="px-6 py-4">
{auditData?.data?.length === 0 && ( <span
<div className="text-center py-8 text-muted-foreground"> className={cn(
No audit logs found "px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest rounded-none border",
</div> getActionColor(log.action),
)} )}
</> >
)} {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>
) );
} }

View File

@ -1,324 +1,378 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button" import {
import { Download, Users, FileText, DollarSign, HardDrive, AlertCircle } from "lucide-react" Receipt,
import { analyticsService, systemService } from "@/services" FileSearch,
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts" ClipboardList,
import { toast } from "sonner" CreditCard,
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: overview, isLoading: overviewLoading } = useQuery({ const { data: metrics, isLoading: metricsLoading } = useQuery({
queryKey: ['admin', 'analytics', 'overview'], queryKey: ["admin", "dashboard", "metrics"],
queryFn: () => analyticsService.getOverview(), queryFn: () => dashboardService.getMetrics(),
}) });
const { data: userGrowth, isLoading: growthLoading } = useQuery({ const { data: scannedInvoices, isLoading: scannedLoading } = useQuery({
queryKey: ['admin', 'analytics', 'users', 'growth'], queryKey: ["admin", "dashboard", "scanned"],
queryFn: () => analyticsService.getUserGrowth(30), queryFn: () => dashboardService.getScannedInvoices(),
}) });
const { data: revenue, isLoading: revenueLoading } = useQuery({ const { data: statusBreakdown, isLoading: statusLoading } = useQuery({
queryKey: ['admin', 'analytics', 'revenue'], queryKey: ["admin", "dashboard", "status-breakdown"],
queryFn: () => analyticsService.getRevenue('30days'), queryFn: () => dashboardService.getInvoiceStatusBreakdown(),
}) });
const { data: health, isLoading: healthLoading } = useQuery({ const { data: proformaRequests, isLoading: requestsLoading } = useQuery({
queryKey: ['admin', 'system', 'health'], queryKey: ["admin", "dashboard", "proforma-requests"],
queryFn: () => systemService.getHealth(), queryFn: () => invoiceService.getProformaRequests({ limit: 5 }),
}) });
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",
}).format(amount) maximumFractionDigits: 0,
} }).format(amount);
};
const formatBytes = (bytes: number) => { const dataLoading =
if (bytes === 0) return '0 Bytes' metricsLoading || scannedLoading || statusLoading || requestsLoading;
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="space-y-6"> <div className="p-8 space-y-12 max-w-7xl mx-auto bg-white min-h-screen">
<div className="flex items-center justify-between"> <header>
<h2 className="text-3xl font-bold">Dashboard Overview</h2> <h1 className="text-3xl font-bold text-gray-900 tracking-tight">
<div className="flex items-center gap-4"> Dashboard Overview
<div className="text-sm text-muted-foreground"> </h1>
{new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} <p className="text-gray-500 mt-1">
</div> Operational status and pending verification items.
<Button variant="outline" onClick={handleExport}> </p>
<Download className="w-4 h-4 mr-2" /> </header>
Export Data
</Button> {/* Quick access — invoices, proforma, payments */}
<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>
</div> </section>
{/* Stats Cards */} {dataLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="py-16 text-center text-gray-500 font-medium border border-dashed border-gray-200 rounded-none">
<Card> Loading dashboard data
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> </div>
<CardTitle className="text-sm font-medium">Total Users</CardTitle> ) : (
<Users className="h-4 w-4 text-muted-foreground" /> <>
</CardHeader> {/* Top Metrics Cards */}
<CardContent> <section className="grid grid-cols-1 md:grid-cols-3 gap-6">
{overviewLoading ? ( <Card className="border-none bg-gray-50 shadow-none rounded-none">
<div className="text-2xl font-bold">...</div> <CardHeader className="pb-1">
) : ( <CardTitle className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<> Gross Revenue
<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>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Invoices</CardTitle>
<FileText 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?.invoices?.total || 0}</div>
<p className="text-xs text-muted-foreground">
All time invoices
</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
<DollarSign 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?.revenue ? formatCurrency(overview.revenue.total) : '$0.00'}
</div>
<p className="text-xs text-muted-foreground">
Total revenue
</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>
)}
</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> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{errorRateLoading ? ( <div className="text-3xl font-black text-gray-900">
<div className="h-[200px] flex items-center justify-center">Loading...</div> {formatCurrency(metrics?.totalRevenue || 0)}
) : ( </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> </CardContent>
</Card> </Card>
)}
{/* System Health */} <Card className="border-none bg-gray-50 shadow-none rounded-none">
<Card> <CardHeader className="pb-1">
<CardHeader> <CardTitle className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<CardTitle className="flex items-center gap-2"> Total Payments
<AlertCircle className="h-5 w-5" /> </CardTitle>
System Health </CardHeader>
</CardTitle> <CardContent>
</CardHeader> <div className="text-3xl font-black text-gray-900">
<CardContent> {(metrics?.totalPayments || 0).toLocaleString()}
{healthLoading ? ( </div>
<div>Loading system health...</div> </CardContent>
</Card>
<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">
Total Invoices
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-black text-gray-900">
{(metrics?.totalInvoices || 0).toLocaleString()}
</div>
</CardContent>
</Card>
</section>
{/* 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>
<span className="text-lg font-black text-gray-900">
{item.count.toLocaleString()}
</span>
</div>
))
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="p-12 text-center text-gray-400 italic">
<div> No status data available.
<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>
)} )}
</CardContent> {/* Detailed Stats Row */}
</Card> <div className="p-6 grid grid-cols-3 gap-8 bg-gray-50/30">
</div> <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>
);
}

View File

@ -1,177 +1,207 @@
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 { Badge } from "@/components/ui/badge" import {
import { AlertCircle, CheckCircle, XCircle, Users } from "lucide-react" AlertCircle,
import { systemService } from "@/services" CheckCircle,
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, // Refetch every 30 seconds refetchInterval: 30000,
}) });
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> {
<CardTitle className="flex items-center gap-2"> label: "Core Services",
{getStatusIcon(health?.status)} value: health?.status,
System Status icon: Zap,
</CardTitle> color: "text-blue-600 bg-blue-50/50 border-blue-100/50",
</CardHeader> },
<CardContent> {
{healthLoading ? ( label: "Database",
<div>Loading...</div> value: health?.database,
) : ( icon: Database,
<Badge variant={health?.status === 'healthy' ? 'default' : 'destructive'}> color: "text-emerald-600 bg-emerald-50/50 border-emerald-100/50",
{health?.status || 'Unknown'} },
</Badge> {
)} label: "Critical Errors",
</CardContent> value: health?.recentErrors || 0,
</Card> icon: Activity,
color: "text-rose-600 bg-rose-50/50 border-rose-100/50",
<Card> },
<CardHeader> {
<CardTitle className="flex items-center gap-2"> label: "Active Sessions",
{getStatusIcon(health?.database)} value: health?.activeUsers || 0,
Database icon: Users,
</CardTitle> color: "text-purple-600 bg-purple-50/50 border-purple-100/50",
</CardHeader> },
<CardContent> ].map((metric) => (
{healthLoading ? ( <Card
<div>Loading...</div> key={metric.label}
) : ( 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'}> >
{health?.database || 'Unknown'} <CardHeader className="pb-2 space-y-0 border-b border-slate-50 bg-slate-50/30">
</Badge> <CardTitle className="text-[10px] font-bold uppercase tracking-widest text-slate-400 flex items-center justify-between">
)} {metric.label}
</CardContent> <metric.icon className="w-3.5 h-3.5 text-slate-300 group-hover:text-slate-400 transition-colors" />
</Card> </CardTitle>
</CardHeader>
<Card> <CardContent className="pt-4">
<CardHeader> <div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2"> <span className="text-2xl font-black text-slate-900 tracking-tighter">
<AlertCircle className="w-5 h-5" /> {healthLoading
Recent Errors ? "..."
</CardTitle> : metric.label === "Core Services" ||
</CardHeader> metric.label === "Database"
<CardContent> ? (metric.value as string)?.toUpperCase()
{healthLoading ? ( : metric.value}
<div>Loading...</div> </span>
) : ( {typeof metric.value === "string" &&
<div className="text-2xl font-bold">{health?.recentErrors || 0}</div> getStatusIcon(metric.value)}
)} </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> <Card className="border-slate-200/60 shadow-sm rounded-none overflow-hidden">
<CardHeader> <CardHeader className="border-b border-slate-100 bg-slate-50/30 flex flex-row items-center justify-between space-y-0">
<CardTitle>System Information</CardTitle> <div>
<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> <CardContent className="p-0">
{infoLoading ? ( {infoLoading ? (
<div className="text-center py-8">Loading system info...</div> <div className="text-center py-16 text-slate-400 font-bold uppercase tracking-widest text-[10px]">
Interrogating backend environment...
</div>
) : ( ) : (
<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-4 divide-x divide-y md:divide-y-0 border-b border-slate-100">
<div> {[
<p className="text-sm text-muted-foreground">Node.js Version</p> { label: "Node.js Version", value: systemInfo.nodeVersion },
<p className="font-medium">{systemInfo.nodeVersion}</p> {
</div> label: "Platform Layer",
<div> value: systemInfo.platform || "N/A",
<p className="text-sm text-muted-foreground">Platform</p> },
<p className="font-medium">{systemInfo.platform || 'N/A'}</p> {
</div> label: "Architecture",
<div> value: systemInfo.architecture || "N/A",
<p className="text-sm text-muted-foreground">Architecture</p> },
<p className="font-medium">{systemInfo.architecture || 'N/A'}</p> {
</div> label: "Temporal Uptime",
<div> value: formatUptime(systemInfo.uptime || 0),
<p className="text-sm text-muted-foreground">Uptime</p> },
<p className="font-medium">{formatUptime(systemInfo.uptime || 0)}</p> ].map((item) => (
</div> <div
<div> key={item.label}
<p className="text-sm text-muted-foreground">Environment</p> className="p-6 transition-colors hover:bg-slate-50/50"
<p className="font-medium">{systemInfo.env || systemInfo.environment}</p> >
</div> <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1.5">
<div> {item.label}
<p className="text-sm text-muted-foreground">Memory Usage</p> </p>
<p className="font-medium"> <p className="text-lg font-black text-slate-900 tracking-tighter">
{formatBytes(systemInfo.memory?.used || 0)} / {formatBytes(systemInfo.memory?.total || 0)} {item.value}
</p> </p>
</div> </div>
<div> ))}
<p className="text-sm text-muted-foreground">CPU Cores</p> </div>
<p className="font-medium">{systemInfo.cpu?.cores || 'N/A'}</p> )}
</div> {!infoLoading && (
<div> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 divide-x">
<p className="text-sm text-muted-foreground">Load Average</p> {[
<p className="font-medium"> {
{systemInfo.cpu?.loadAverage?.map((load: number) => load.toFixed(2)).join(', ') || 'N/A'} label: "Environment Scope",
</p> value: systemInfo.env || systemInfo.environment,
</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>
) );
} }

View File

@ -0,0 +1,820 @@
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>
);
}

View File

@ -0,0 +1,751 @@
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

View File

@ -0,0 +1,550 @@
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>
);
}

View File

@ -1,112 +1,146 @@
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 { Badge } from "@/components/ui/badge" import { systemService } from "@/services";
import { systemService } from "@/services" import { toast } from "sonner";
import { toast } from "sonner" import { useState } from "react";
import { useState } from "react" import type { ApiError } from "@/types/error.types";
import type { ApiError } from "@/types/error.types" import { cn } from "@/lib/utils";
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(apiError.response?.data?.message || "Failed to enable maintenance mode") toast.error(
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(apiError.response?.data?.message || "Failed to disable maintenance mode") toast.error(
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();
} }
} };
if (isLoading) { const isEnabled = status?.status === "ACTIVE";
return <div className="text-center py-8">Loading maintenance status...</div>
}
const isEnabled = status?.status === 'ACTIVE'
return ( return (
<div className="space-y-6"> <div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<h2 className="text-3xl font-bold">Maintenance Mode</h2> <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">
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> <Card className="border shadow-none rounded-none max-w-2xl">
<CardHeader> <CardHeader className="border-b pb-4 bg-gray-50/30">
<div className="flex items-center justify-between"> <CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
<CardTitle>Maintenance Status</CardTitle> Status Control
<Badge variant={isEnabled ? 'destructive' : 'default'}> </CardTitle>
{isEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="p-8 space-y-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-8">
<div> <div className="space-y-1">
<Label>Maintenance Mode</Label> <Label className="text-sm font-bold text-gray-900 uppercase tracking-tighter">
<p className="text-sm text-muted-foreground"> Toggle Maintenance
Enable maintenance mode to temporarily disable access to the platform </Label>
<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-2"> <div className="space-y-3 animate-in fade-in slide-in-from-top-2 duration-300">
<Label htmlFor="message">Maintenance Message (Optional)</Label> <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="Enter maintenance message..." placeholder="We'll be back shortly..."
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> <div className="p-4 bg-slate-50 border border-slate-100 rounded-none animate-in fade-in duration-300">
<Label>Current Message</Label> <Label className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
<p className="text-sm mt-2">{status.message}</p> Current Broadcast
</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>
) );
} }

View File

@ -0,0 +1,214 @@
import { useState } from "react"
import { Navigate } from "react-router-dom"
import { useMutation } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Bell, Mail, MessageSquare, Send } from "lucide-react"
import { notificationService } from "@/services"
import { useAdminRole } from "@/hooks/use-admin-role"
import { toast } from "sonner"
import { cn } from "@/lib/utils"
export default function NotificationBroadcastPage() {
const { canSendBroadcast } = useAdminRole()
const [title, setTitle] = useState("")
const [message, setMessage] = useState("")
const [audience, setAudience] = useState<
"all_end_users" | "system_users_only" | "everyone_with_access"
>("all_end_users")
const [channels, setChannels] = useState({
push: true,
sms: false,
email: true,
})
const mutation = useMutation({
mutationFn: () =>
notificationService.sendBroadcast({
title,
message,
audience,
channels: (
[
channels.push && "push",
channels.sms && "sms",
channels.email && "email",
].filter(Boolean) as ("push" | "sms" | "email")[]
),
}),
onSuccess: () => {
toast.success("Broadcast queued for delivery")
setTitle("")
setMessage("")
},
onError: () =>
toast.error(
"Could not send. Ensure POST /admin/notifications/broadcast exists.",
),
})
if (!canSendBroadcast) {
return <Navigate to="/admin/dashboard" replace />
}
const toggleChannel = (key: keyof typeof channels) => {
setChannels((c) => ({ ...c, [key]: !c[key] }))
}
const channelActive =
channels.push || channels.sms || channels.email
return (
<div className="space-y-8 max-w-2xl mx-auto bg-white p-4 min-h-screen">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
Send notification
</h1>
<p className="text-gray-500 mt-1">
Super Admins and Admins can broadcast via push, SMS, and email.
Delivery depends on user preferences and channel configuration.
</p>
</div>
<Card className="border shadow-none rounded-none">
<CardHeader className="border-b">
<CardTitle className="text-sm font-bold uppercase tracking-widest text-gray-400">
Channels
</CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-3">
<div className="grid grid-cols-3 gap-2">
<button
type="button"
onClick={() => toggleChannel("push")}
className={cn(
"rounded-none border p-4 text-left transition-colors",
channels.push
? "border-primary bg-primary/5"
: "border-gray-200 opacity-70 hover:opacity-100",
)}
>
<Bell className="h-5 w-5 mb-2 text-gray-700" />
<div className="text-xs font-bold uppercase tracking-wider">
Push
</div>
</button>
<button
type="button"
onClick={() => toggleChannel("sms")}
className={cn(
"rounded-none border p-4 text-left transition-colors",
channels.sms
? "border-primary bg-primary/5"
: "border-gray-200 opacity-70 hover:opacity-100",
)}
>
<MessageSquare className="h-5 w-5 mb-2 text-gray-700" />
<div className="text-xs font-bold uppercase tracking-wider">
SMS
</div>
</button>
<button
type="button"
onClick={() => toggleChannel("email")}
className={cn(
"rounded-none border p-4 text-left transition-colors",
channels.email
? "border-primary bg-primary/5"
: "border-gray-200 opacity-70 hover:opacity-100",
)}
>
<Mail className="h-5 w-5 mb-2 text-gray-700" />
<div className="text-xs font-bold uppercase tracking-wider">
Email
</div>
</button>
</div>
</CardContent>
</Card>
<Card className="border shadow-none rounded-none">
<CardHeader className="border-b">
<CardTitle className="text-sm font-bold uppercase tracking-widest text-gray-400">
Audience
</CardTitle>
</CardHeader>
<CardContent className="pt-6">
<Select
value={audience}
onValueChange={(v) =>
setAudience(
v as
| "all_end_users"
| "system_users_only"
| "everyone_with_access",
)
}
>
<SelectTrigger className="rounded-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all_end_users">
All platform customers
</SelectItem>
<SelectItem value="system_users_only">
Panel users only (support &amp; 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>
)
}

View File

@ -0,0 +1,844 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Search,
ChevronLeft,
ChevronRight,
Filter,
Plus,
Trash2,
Loader2,
Building2,
ListOrdered,
} from "lucide-react";
import { paymentService } from "@/services";
import { cn } from "@/lib/utils";
import { useAdminRole } from "@/hooks/use-admin-role";
import { toast } from "sonner";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area";
export default function PaymentRequestsPage() {
const { canCreateBusinessData } = useAdminRole();
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
// Create Modal State
const [isModalOpen, setIsModalOpen] = useState(false);
const [formData, setFormData] = useState<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>
);
}

View File

@ -0,0 +1,542 @@
import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Search,
Plus,
ChevronLeft,
ChevronRight,
Pencil,
Trash2,
Loader2,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { paymentService } from "@/services";
import { useAdminRole } from "@/hooks/use-admin-role";
import { toast } from "sonner";
import type { Payment } from "@/services/payment.service";
export default function PaymentsListPage() {
const { canCreateBusinessData, canEditBusinessData, canDeleteBusinessData } =
useAdminRole();
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [editingPayment, setEditingPayment] = useState<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>
);
}

View File

@ -1,102 +1,158 @@
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 { Ban, Key, Calendar, User, Zap } from "lucide-react";
import { import { securityService, type ApiKey } from "@/services";
Table, import { toast } from "sonner";
TableBody, import { format } from "date-fns";
TableCell, import type { ApiError } from "@/types/error.types";
TableHead, import { cn } from "@/lib/utils";
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({ queryKey: ['admin', 'security', 'api-keys'] }) queryClient.invalidateQueries({
toast.success("API key revoked successfully") queryKey: ["admin", "security", "api-keys"],
});
toast.success("API access credential revoked");
}, },
onError: (error) => { onError: (error) => {
const apiError = error as ApiError const apiError = error as ApiError;
toast.error(apiError.response?.data?.message || "Failed to revoke API key") toast.error(
apiError.response?.data?.message || "Failed to revoke access",
);
}, },
}) });
return ( return (
<div className="space-y-6"> <div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<h2 className="text-3xl font-bold">API Keys</h2> <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">
API Gateway
</h1>
<p className="text-gray-500 mt-1">
Management of system access credentials and authentication tokens.
</p>
</div>
</div>
<Card> <Card className="border shadow-none rounded-none">
<CardHeader> <CardHeader className="border-b pb-4 flex flex-row items-center justify-between bg-gray-50/30">
<CardTitle>All API Keys</CardTitle> <CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
Credential Registry
</CardTitle>
<Zap className="w-4 h-4 text-gray-300" />
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-0">
{isLoading ? ( <div className="overflow-x-auto">
<div className="text-center py-8">Loading API keys...</div> <table className="w-full text-left">
) : ( <thead className="bg-gray-50 border-b">
<> <tr>
<Table> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<TableHeader> Key Identifier
<TableRow> </th>
<TableHead>Name</TableHead> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<TableHead>User</TableHead> Operator
<TableHead>Last Used</TableHead> </th>
<TableHead>Status</TableHead> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<TableHead>Actions</TableHead> Last Activity
</TableRow> </th>
</TableHeader> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<TableBody> Access Status
{apiKeys?.map((key: ApiKey) => ( </th>
<TableRow key={key.id}> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
<TableCell className="font-medium">{key.name}</TableCell> Actions
<TableCell>{key.userId || 'N/A'}</TableCell> </th>
<TableCell> </tr>
{key.lastUsed ? format(new Date(key.lastUsed), 'MMM dd, yyyy') : 'Never'} </thead>
</TableCell> <tbody className="divide-y text-gray-600">
<TableCell> {isLoading ? (
<Badge variant={key.isActive ? 'default' : 'destructive'}> <tr>
{key.isActive ? 'Active' : 'Revoked'} <td
</Badge> colSpan={5}
</TableCell> className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
<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="icon" 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]"
onClick={() => revokeMutation.mutate(key.id)} onClick={() => revokeMutation.mutate(key.id)}
> >
<Ban className="w-4 h-4" /> <Ban className="w-3 h-3 mr-2" /> Revoke Access
</Button> </Button>
)} )}
</TableCell> </td>
</TableRow> </tr>
))} ))
</TableBody> ) : (
</Table> <tr>
{apiKeys?.length === 0 && ( <td
<div className="text-center py-8 text-muted-foreground"> colSpan={5}
No API keys found className="px-6 py-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]"
</div> >
)} No API access credentials defined.
</> </td>
)} </tr>
)}
</tbody>
</table>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
) );
} }

View File

@ -1,105 +1,177 @@
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 { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button";
import { Button } from "@/components/ui/button" import { Search, Ban, ChevronLeft, ChevronRight, Filter } from "lucide-react";
import { import { securityService, type FailedLogin } from "@/services";
Table, import { format } from "date-fns";
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] = useState(1) const [page, setPage] = useState(1);
const [limit] = useState(50) const [limit] = useState(15);
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-6"> <div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<h2 className="text-3xl font-bold">Failed Login Attempts</h2> <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">
Access Violations
</h1>
<p className="text-gray-500 mt-1">
Audit trail for authentication failures and potential threats.
</p>
</div>
</div>
<Card> <Card className="border shadow-none rounded-none">
<CardHeader> <CardHeader className="border-b pb-4 flex flex-row items-center justify-between space-y-0">
<div className="flex items-center justify-between"> <CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
<CardTitle>Failed Logins</CardTitle> Violation Ledger
<div className="relative"> </CardTitle>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <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 <Input
placeholder="Search by email..." className="pl-10 h-9 rounded-none border-gray-200 text-xs focus-visible:ring-gray-900"
className="pl-10 w-64" placeholder="Search by identifier..."
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> <CardContent className="p-0">
{isLoading ? ( <div className="overflow-x-auto">
<div className="text-center py-8">Loading failed logins...</div> <table className="w-full text-left">
) : ( <thead className="bg-gray-50 border-b">
<> <tr>
<Table> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<TableHeader> Identity
<TableRow> </th>
<TableHead>Email</TableHead> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<TableHead>IP Address</TableHead> Network Source
<TableHead>User Agent</TableHead> </th>
<TableHead>Reason</TableHead> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<TableHead>Attempted At</TableHead> Failure Reason
<TableHead>Blocked</TableHead> </th>
<TableHead>Actions</TableHead> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
</TableRow> Timestamp
</TableHeader> </th>
<TableBody> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
{failedLogins?.data?.map((login: FailedLogin) => ( Actions
<TableRow key={login.id}> </th>
<TableCell className="font-medium">{login.email}</TableCell> </tr>
<TableCell className="font-mono text-sm">{login.ipAddress}</TableCell> </thead>
<TableCell className="max-w-xs truncate">{login.ipAddress}</TableCell> <tbody className="divide-y text-gray-600">
<TableCell>{login.reason || 'N/A'}</TableCell> {isLoading ? (
<TableCell> <tr>
{format(new Date(login.timestamp), 'MMM dd, yyyy HH:mm')} <td
</TableCell> colSpan={5}
<TableCell> className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
<Badge variant="secondary"> >
N/A Synchronizing security data...
</Badge> </td>
</TableCell> </tr>
<TableCell> ) : failedLogins?.data && failedLogins.data.length > 0 ? (
<Button variant="ghost" size="icon"> failedLogins.data.map((login: FailedLogin) => (
<Ban className="w-4 h-4" /> <tr
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>
</TableCell> </td>
</TableRow> </tr>
))} ))
</TableBody> ) : (
</Table> <tr>
{failedLogins?.data?.length === 0 && ( <td
<div className="text-center py-8 text-muted-foreground"> colSpan={5}
No failed login attempts found className="px-6 py-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]"
</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>
) );
} }

View File

@ -1,86 +1,89 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Shield, AlertTriangle, Key, Gauge, Users } from "lucide-react" import {
import { useNavigate } from "react-router-dom" Shield,
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> {
<CardTitle className="flex items-center gap-2"> label: "Failed Logins",
<AlertTriangle className="w-5 h-5" /> description: "View and manage failed login attempts",
Failed Logins icon: AlertTriangle,
</CardTitle> path: "/admin/security/failed-logins",
</CardHeader> color: "text-rose-600",
<CardContent> },
<p className="text-sm text-muted-foreground"> {
View and manage failed login attempts label: "Suspicious Activity",
</p> description: "Monitor suspicious IPs and emails",
</CardContent> icon: Shield,
</Card> path: "/admin/security/suspicious",
color: "text-amber-600",
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/security/suspicious')}> },
<CardHeader> {
<CardTitle className="flex items-center gap-2"> label: "API Keys",
<Shield className="w-5 h-5" /> description: "Manage API keys and tokens",
Suspicious Activity icon: Key,
</CardTitle> path: "/admin/security/api-keys",
</CardHeader> color: "text-blue-600",
<CardContent> },
<p className="text-sm text-muted-foreground"> {
Monitor suspicious IPs and emails label: "Rate Limits",
</p> description: "View rate limit violations",
</CardContent> icon: Gauge,
</Card> path: "/admin/security/rate-limits",
color: "text-purple-600",
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/security/api-keys')}> },
<CardHeader> {
<CardTitle className="flex items-center gap-2"> label: "Active Sessions",
<Key className="w-5 h-5" /> description: "Manage active user sessions",
API Keys icon: Users,
</CardTitle> path: "/admin/security/sessions",
</CardHeader> color: "text-emerald-600",
<CardContent> },
<p className="text-sm text-muted-foreground"> ].map((item) => (
Manage API keys and tokens <Card
</p> key={item.label}
</CardContent> className="group cursor-pointer border-slate-200/60 shadow-sm hover:shadow-md transition-all rounded-none bg-white overflow-hidden"
</Card> onClick={() => navigate(item.path)}
>
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/security/rate-limits')}> <CardHeader className="pb-2 space-y-0 border-b border-slate-50 bg-slate-50/30">
<CardHeader> <div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2"> <span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
<Gauge className="w-5 h-5" /> {item.label}
Rate Limits </span>
</CardTitle> <item.icon
</CardHeader> className={cn("w-4 h-4 transition-colors", item.color)}
<CardContent> />
<p className="text-sm text-muted-foreground"> </div>
View rate limit violations </CardHeader>
</p> <CardContent className="pt-4 flex items-end justify-between">
</CardContent> <div>
</Card> <p className="text-sm font-semibold text-slate-900 tracking-tight mb-1 group-hover:text-primary transition-colors">
{item.label}
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/security/sessions')}> </p>
<CardHeader> <p className="text-xs text-muted-foreground leading-relaxed max-w-[200px]">
<CardTitle className="flex items-center gap-2"> {item.description}
<Users className="w-5 h-5" /> </p>
Active Sessions </div>
</CardTitle> <ChevronRight className="w-4 h-4 text-slate-300 group-hover:translate-x-1 transition-transform" />
</CardHeader> </CardContent>
<CardContent> </Card>
<p className="text-sm text-muted-foreground"> ))}
Manage active user sessions
</p>
</CardContent>
</Card>
</div> </div>
</div> </div>
) );
} }

View File

@ -1,65 +1,136 @@
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 { Gauge, Clock, Activity, AlertTriangle } from "lucide-react";
Table, import { securityService } from "@/services";
TableBody, import type { RateLimitViolation } from "@/types/security.types";
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-6"> <div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<h2 className="text-3xl font-bold">Rate Limit Violations</h2> <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">
Traffic Control
</h1>
<p className="text-gray-500 mt-1">
Audit of rate limit violations and anomalous request frequencies.
</p>
</div>
</div>
<Card> <Card className="border shadow-none rounded-none">
<CardHeader> <CardHeader className="border-b pb-4 flex flex-row items-center justify-between bg-gray-50/30">
<CardTitle>Recent Violations (Last 7 Days)</CardTitle> <CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
Violation Registry
</CardTitle>
<Gauge className="w-4 h-4 text-gray-300" />
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-0">
{isLoading ? ( <div className="overflow-x-auto">
<div className="text-center py-8">Loading violations...</div> <table className="w-full text-left">
) : ( <thead className="bg-gray-50 border-b">
<> <tr>
<Table> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<TableHeader> Protocol Identifier
<TableRow> </th>
<TableHead>User</TableHead> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<TableHead>IP Address</TableHead> Network Origin
<TableHead>Requests</TableHead> </th>
<TableHead>Period</TableHead> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-center">
</TableRow> Velocity
</TableHeader> </th>
<TableBody> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-center">
{violations?.map((violation: RateLimitViolation) => ( Reference Period
<TableRow key={violation.id}> </th>
<TableCell>{violation.userId || 'N/A'}</TableCell> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
<TableCell className="font-mono text-sm">{violation.ipAddress}</TableCell> Severity
<TableCell>{violation.requests}</TableCell> </th>
<TableCell>{violation.period}</TableCell> </tr>
</TableRow> </thead>
))} <tbody className="divide-y text-gray-600">
</TableBody> {isLoading ? (
</Table> <tr>
{violations?.length === 0 && ( <td
<div className="text-center py-8 text-muted-foreground"> colSpan={5}
No rate limit violations found className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
</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>
)
}
<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>
);
}

View File

@ -1,75 +1,132 @@
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 { import { LogOut, Monitor, MapPin, Clock } from "lucide-react";
Table, import { securityService, type ActiveSession } from "@/services";
TableBody, import { format } from "date-fns";
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-6"> <div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<h2 className="text-3xl font-bold">Active Sessions</h2> <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">
Active Sessions
</h1>
<p className="text-gray-500 mt-1">
Live oversight of authenticated system access.
</p>
</div>
</div>
<Card> <Card className="border shadow-none rounded-none">
<CardHeader> <CardHeader className="border-b pb-4 bg-gray-50/30 flex flex-row items-center justify-between space-y-0">
<CardTitle>All Active Sessions</CardTitle> <CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
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> <CardContent className="p-0">
{isLoading ? ( <div className="overflow-x-auto">
<div className="text-center py-8">Loading sessions...</div> <table className="w-full text-left">
) : ( <thead className="bg-gray-50 border-b">
<> <tr>
<Table> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<TableHeader> Operator Identity
<TableRow> </th>
<TableHead>User</TableHead> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<TableHead>IP Address</TableHead> Endpoint
<TableHead>User Agent</TableHead> </th>
<TableHead>Last Activity</TableHead> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<TableHead>Actions</TableHead> Environment
</TableRow> </th>
</TableHeader> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<TableBody> Activity Status
{sessions?.map((session: ActiveSession) => ( </th>
<TableRow key={session.id}> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
<TableCell className="font-medium">{session.userId || 'N/A'}</TableCell> Control
<TableCell className="font-mono text-sm">{session.ipAddress}</TableCell> </th>
<TableCell className="max-w-xs truncate">{session.userAgent}</TableCell> </tr>
<TableCell> </thead>
{format(new Date(session.lastActivity), 'MMM dd, yyyy HH:mm')} <tbody className="divide-y">
</TableCell> {isLoading ? (
<TableCell> <tr>
<Button variant="ghost" size="icon"> <td
<LogOut className="w-4 h-4" /> colSpan={5}
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>
</TableCell> </td>
</TableRow> </tr>
))} ))
</TableBody> ) : (
</Table> <tr>
{sessions?.length === 0 && ( <td
<div className="text-center py-8 text-muted-foreground"> colSpan={5}
No active sessions found className="px-6 py-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]"
</div> >
)} No active authenticated sessions.
</> </td>
)} </tr>
)}
</tbody>
</table>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
) );
} }

View File

@ -1,88 +1,148 @@
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 } from "lucide-react" import { Shield, Ban, Mail, Globe, AlertTriangle } 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-6"> <div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<h2 className="text-3xl font-bold">Suspicious Activity</h2> <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">
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-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<Card> <Card className="border shadow-none rounded-none overflow-hidden">
<CardHeader> <CardHeader className="border-b pb-4 bg-gray-50/30 flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Shield className="w-5 h-5" /> <Globe className="w-4 h-4 text-gray-400" />
Suspicious IP Addresses <CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
</CardTitle> Suspicious Network Ingress
</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> <CardContent className="p-0">
{isLoading ? ( {isLoading ? (
<div className="text-center py-8">Loading...</div> <div className="p-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]">
Interrogating global threats...
</div>
) : (suspicious?.suspiciousIPs?.length ?? 0) > 0 ? ( ) : (suspicious?.suspiciousIPs?.length ?? 0) > 0 ? (
<div className="space-y-2"> <div className="divide-y">
{suspicious?.suspiciousIPs?.map((ip: SuspiciousIP, index: number) => ( {suspicious?.suspiciousIPs?.map(
<div key={index} className="flex items-center justify-between p-2 border rounded"> (ip: SuspiciousIP, index: number) => (
<div> <div
<p className="font-mono font-medium">{ip.ipAddress}</p> key={index}
<p className="text-sm text-muted-foreground">{ip.attempts} attempts</p> className="flex items-center justify-between p-6 hover:bg-gray-50 transition-colors group"
>
<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="text-center py-8 text-muted-foreground"> <div className="p-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]">
No suspicious IPs found No high-risk network sources.
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card className="border shadow-none rounded-none overflow-hidden">
<CardHeader> <CardHeader className="border-b pb-4 bg-gray-50/30 flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Shield className="w-5 h-5" /> <Mail className="w-4 h-4 text-gray-400" />
Suspicious Emails <CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
</CardTitle> Flagged Credentials
</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> <CardContent className="p-0">
{isLoading ? ( {isLoading ? (
<div className="text-center py-8">Loading...</div> <div className="p-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]">
Screening identity registry...
</div>
) : (suspicious?.suspiciousEmails?.length ?? 0) > 0 ? ( ) : (suspicious?.suspiciousEmails?.length ?? 0) > 0 ? (
<div className="space-y-2"> <div className="divide-y">
{suspicious?.suspiciousEmails?.map((email: SuspiciousEmail, index: number) => ( {suspicious?.suspiciousEmails?.map(
<div key={index} className="flex items-center justify-between p-2 border rounded"> (email: SuspiciousEmail, index: number) => (
<div> <div
<p className="font-medium">{email.email}</p> key={index}
<p className="text-sm text-muted-foreground">{email.attempts} attempts</p> className="flex items-center justify-between p-6 hover:bg-gray-50 transition-colors group"
>
<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="text-center py-8 text-muted-foreground"> <div className="p-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]">
No suspicious emails found No suspicious identity triggers.
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div> </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>
);
}

View File

@ -1,211 +1,143 @@
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 { Switch } from "@/components/ui/switch" import { settingsService, type Setting } from "@/services";
import { import { toast } from "sonner";
Dialog, import type { ApiError } from "@/types/error.types";
DialogContent, import { cn } from "@/lib/utils";
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(apiError.response?.data?.message || "Failed to update setting") toast.error(
apiError.response?.data?.message || "Failed to update setting",
);
}, },
}) });
const createSettingMutation = useMutation({
mutationFn: (data: {
key: string
value: string
category: string
description?: string
isPublic?: boolean
}) => settingsService.createSetting(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
toast.success("Setting created successfully")
setCreateDialogOpen(false)
setNewSetting({ key: "", value: "", description: "", isPublic: false })
},
onError: (error) => {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to create setting")
},
})
const handleSave = (key: string, value: string) => { const handleSave = (key: string, value: string) => {
updateSettingMutation.mutate({ key, value }) updateSettingMutation.mutate({ key, value });
} };
const handleCreate = () => { const categories = [
if (!newSetting.key || !newSetting.value) { "GENERAL",
toast.error("Key and value are required") "EMAIL",
return "STORAGE",
} "SECURITY",
createSettingMutation.mutate({ "API",
key: newSetting.key, "FEATURES",
value: newSetting.value, ];
category: selectedCategory,
description: newSetting.description || undefined,
isPublic: newSetting.isPublic,
})
}
return ( return (
<div className="space-y-6"> <div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<div className="flex items-center justify-between"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<h2 className="text-3xl font-bold">System Settings</h2> <div>
<Button onClick={() => setCreateDialogOpen(true)}> <h1 className="text-3xl font-bold text-gray-900 tracking-tight">
<Plus className="w-4 h-4 mr-2" /> System Settings
Create Setting </h1>
</Button> <p className="text-gray-500 mt-1">
Configure global application parameters.
</p>
</div>
<div className="flex items-center gap-2">
{/* View only access: Create Setting button removed */}
</div>
</div> </div>
<Tabs value={selectedCategory} onValueChange={setSelectedCategory}> <Tabs
<TabsList> value={selectedCategory}
<TabsTrigger value="GENERAL">General</TabsTrigger> onValueChange={setSelectedCategory}
<TabsTrigger value="EMAIL">Email</TabsTrigger> className="space-y-6"
<TabsTrigger value="STORAGE">Storage</TabsTrigger> >
<TabsTrigger value="SECURITY">Security</TabsTrigger> <TabsList className="bg-gray-100/50 p-1 rounded-none border border-gray-200">
<TabsTrigger value="API">API</TabsTrigger> {categories.map((cat) => (
<TabsTrigger value="FEATURES">Features</TabsTrigger> <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="space-y-4"> <TabsContent value={selectedCategory} className="outline-none">
<Card> <Card className="border shadow-none rounded-none">
<CardHeader> <CardHeader className="border-b pb-4 bg-gray-50/30">
<CardTitle>{selectedCategory} Settings</CardTitle> <CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
{selectedCategory} Configuration
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="p-0 divide-y">
{isLoading ? ( {isLoading ? (
<div className="text-center py-8">Loading settings...</div> <div className="p-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]">
Fetching system variables...
</div>
) : settings && settings.length > 0 ? ( ) : settings && settings.length > 0 ? (
settings.map((setting: Setting) => ( settings.map((setting: Setting) => (
<div key={setting.key} className="space-y-2"> <div
<Label htmlFor={setting.key}>{setting.key}</Label> key={setting.key}
<div className="flex gap-2"> 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="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="text-center py-8 text-muted-foreground"> <div className="p-20 text-center text-gray-400 italic">
No settings found for this category No variables defined 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>
) );
} }

View File

@ -0,0 +1,484 @@
import { useState } from "react";
import { NavLink } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogFooter,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Search,
Plus,
Pencil,
HelpCircle,
Users,
ShieldCheck,
Globe,
ArrowRight,
Library,
Settings2,
ChevronRight,
} from "lucide-react";
import { faqService } from "@/services";
import type { FaqAudience, FaqEntry } from "@/services/faq.service";
import { useAdminRole } from "@/hooks/use-admin-role";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
export default function FaqSupportPage() {
const { canEditBusinessData: canEdit } = useAdminRole();
const queryClient = useQueryClient();
const [tab, setTab] = useState<"browse" | "manage">("browse");
const [audienceFilter, setAudienceFilter] = useState<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>
);
}

View File

@ -0,0 +1,282 @@
import { useState } from "react"
import { Navigate } from "react-router-dom"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Search, UserPlus } from "lucide-react"
import { systemMemberService } from "@/services"
import { useAdminRole } from "@/hooks/use-admin-role"
import { AdminRole } from "@/lib/admin-roles"
import { toast } from "sonner"
export default function SystemMembersPage() {
const { canAccessSystemMembers, canEdit } = useAdminRole()
const queryClient = useQueryClient()
const [page, setPage] = useState(1)
const [search, setSearch] = useState("")
const [open, setOpen] = useState(false)
const [form, setForm] = useState({
email: "",
firstName: "",
lastName: "",
password: "",
role: AdminRole.CUSTOMER_SUPPORT,
})
const { data, isLoading, error } = useQuery({
queryKey: ["admin", "system-members", page, search],
queryFn: () =>
systemMemberService.list({
page,
limit: 10,
search: search.trim() || undefined,
}),
enabled: canAccessSystemMembers,
})
const createMutation = useMutation({
mutationFn: () => systemMemberService.create(form),
onSuccess: () => {
toast.success("System user created")
queryClient.invalidateQueries({ queryKey: ["admin", "system-members"] })
setOpen(false)
setForm({
email: "",
firstName: "",
lastName: "",
password: "",
role: AdminRole.CUSTOMER_SUPPORT,
})
},
onError: () => toast.error("Failed to create user"),
})
if (!canAccessSystemMembers) {
return <Navigate to="/admin/dashboard" replace />
}
return (
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
System users
</h1>
<p className="text-gray-500 mt-1 max-w-xl">
Internal staff who can access this panel. Actors: System Admin (full
access), Admin (view &amp; 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 &amp; 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>
)
}

View File

@ -0,0 +1,315 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Search,
CheckCircle2,
XCircle,
ArrowRightLeft,
ChevronLeft,
ChevronRight,
Filter,
CreditCard,
User as UserIcon,
} from "lucide-react";
import { subscriptionTransactionService } from "@/services";
import type { SubscriptionPaymentStatus } from "@/services/subscription-transaction.service";
import { cn } from "@/lib/utils";
export default function SubscriptionTransactionsPage() {
const [tab, setTab] = useState<"succeeded" | "failed">("succeeded");
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const status: SubscriptionPaymentStatus =
tab === "succeeded" ? "SUCCEEDED" : "FAILED";
const { data, isLoading, error } = useQuery({
queryKey: ["admin", "subscription-transactions", status, page, search],
queryFn: () =>
subscriptionTransactionService.getTransactions({
status,
page,
limit: 10,
search: search.trim() || undefined,
}),
});
const formatMoney = (amount: number, currency: string) =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency,
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 "succeeded" | "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="succeeded"
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" />
Succeeded
</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 /admin/subscription-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 === "SUCCEEDED"
? "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>
);
}

View File

@ -1,9 +1,9 @@
import { useParams, useNavigate } from "react-router-dom" import { useParams, useNavigate } from "react-router-dom";
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 { 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,81 +11,91 @@ 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 } from "lucide-react" import { ArrowLeft, Edit, Key, Loader2 } from "lucide-react";
import { userService } from "@/services" import { userService } from "@/services";
import { format } from "date-fns" import { useAdminRole } from "@/hooks/use-admin-role";
import { useState } from "react" import { format } from "date-fns";
import { toast } from "sonner" import { useState } from "react";
import { toast } from "sonner";
export default function UserDetailsPage() { export default function UserDetailsPage() {
const { id } = useParams() const { id } = useParams();
const navigate = useNavigate() const navigate = useNavigate();
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) const { canEditUsers } = useAdminRole();
const [isSubmitting, setIsSubmitting] = useState(false) const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [editForm, setEditForm] = useState({ const [editForm, setEditForm] = useState({
firstName: '', firstName: "",
lastName: '', lastName: "",
email: '', email: "",
role: '', role: "",
isActive: true, isActive: true,
}) });
const { data: user, isLoading, refetch } = useQuery({ const {
queryKey: ['admin', 'users', id], data: user,
isLoading,
refetch,
} = useQuery({
queryKey: ["admin", "users", id],
queryFn: () => userService.getUser(id!), queryFn: () => userService.getUser(id!),
enabled: !!id, enabled: !!id,
}) });
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 variant="ghost" size="icon" onClick={() => navigate('/admin/users')}> <Button
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>
@ -95,7 +105,10 @@ export default function UserDetailsPage() {
<TabsList> <TabsList>
<TabsTrigger value="info">Information</TabsTrigger> <TabsTrigger value="info">Information</TabsTrigger>
<TabsTrigger value="statistics">Statistics</TabsTrigger> <TabsTrigger value="statistics">Statistics</TabsTrigger>
<TabsTrigger value="activity" onClick={() => navigate(`/admin/users/${id}/activity`)}> <TabsTrigger
value="activity"
onClick={() => navigate(`/admin/users/${id}/activity`)}
>
Activity Activity
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@ -106,14 +119,27 @@ 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">
<Button variant="outline" size="sm" onClick={handleEditClick}> {canEditUsers && (
<Edit className="w-4 h-4 mr-2" /> <>
Edit <Button
</Button> variant="outline"
<Button variant="outline" size="sm"> size="sm"
<Key className="w-4 h-4 mr-2" /> onClick={handleEditClick}
Reset Password >
</Button> <Edit className="w-4 h-4 mr-2" />
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>
@ -125,7 +151,9 @@ 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">{user.firstName} {user.lastName}</p> <p className="font-medium">
{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>
@ -133,18 +161,22 @@ 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">{format(new Date(user.createdAt), 'PPpp')}</p> <p className="font-medium">
{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 ? format(new Date(user.updatedAt), 'PPpp') : 'N/A'} {user.updatedAt
? format(new Date(user.updatedAt), "PPpp")
: "N/A"}
</p> </p>
</div> </div>
</div> </div>
@ -159,7 +191,9 @@ 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">{user._count?.invoices || 0}</div> <div className="text-2xl font-bold">
{user._count?.invoices || 0}
</div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
@ -167,7 +201,9 @@ 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">{user._count?.reports || 0}</div> <div className="text-2xl font-bold">
{user._count?.reports || 0}
</div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
@ -175,7 +211,9 @@ 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">{user._count?.documents || 0}</div> <div className="text-2xl font-bold">
{user._count?.documents || 0}
</div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
@ -183,7 +221,9 @@ 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">{user._count?.payments || 0}</div> <div className="text-2xl font-bold">
{user._count?.payments || 0}
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@ -204,7 +244,9 @@ export default function UserDetailsPage() {
<Input <Input
id="firstName" id="firstName"
value={editForm.firstName} value={editForm.firstName}
onChange={(e) => setEditForm({ ...editForm, firstName: e.target.value })} onChange={(e) =>
setEditForm({ ...editForm, firstName: e.target.value })
}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -212,7 +254,9 @@ export default function UserDetailsPage() {
<Input <Input
id="lastName" id="lastName"
value={editForm.lastName} value={editForm.lastName}
onChange={(e) => setEditForm({ ...editForm, lastName: e.target.value })} onChange={(e) =>
setEditForm({ ...editForm, lastName: e.target.value })
}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -221,12 +265,19 @@ export default function UserDetailsPage() {
id="email" id="email"
type="email" type="email"
value={editForm.email} value={editForm.email}
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })} onChange={(e) =>
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 value={editForm.role} onValueChange={(value) => setEditForm({ ...editForm, role: value })}> <Select
value={editForm.role}
onValueChange={(value) =>
setEditForm({ ...editForm, role: value })
}
>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select role" /> <SelectValue placeholder="Select role" />
</SelectTrigger> </SelectTrigger>
@ -240,9 +291,11 @@ export default function UserDetailsPage() {
</div> </div>
<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) => setEditForm({ ...editForm, isActive: value === 'active' })} onValueChange={(value) =>
setEditForm({ ...editForm, isActive: value === "active" })
}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select status" /> <SelectValue placeholder="Select status" />
@ -255,17 +308,22 @@ export default function UserDetailsPage() {
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)} disabled={isSubmitting}> <Button
variant="outline"
onClick={() => setIsEditDialogOpen(false)}
disabled={isSubmitting}
>
Cancel Cancel
</Button> </Button>
<Button onClick={handleSaveEdit} disabled={isSubmitting}> <Button onClick={handleSaveEdit} disabled={isSubmitting}>
{isSubmitting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />} {isSubmitting && (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
)}
Save Changes Save Changes
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
) );
} }

View File

@ -1,409 +1,232 @@
import { useState } from "react" import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { useQuery } 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 {
Table, Search,
TableBody, Eye,
TableCell, ChevronLeft,
TableHead, ChevronRight,
TableHeader, Filter,
TableRow, Plus,
} from "@/components/ui/table" } from "lucide-react";
import { import { userService } from "@/services";
Select, import { useAdminRole } from "@/hooks/use-admin-role";
SelectContent, import { format } from "date-fns";
SelectItem, import { cn } from "@/lib/utils";
SelectTrigger, import { toast } from "sonner";
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 queryClient = useQueryClient() const { canCreateUsers } = useAdminRole();
const [page, setPage] = useState(1) const [page, setPage] = useState(1);
const [limit] = useState(20) const [limit] = useState(15);
const [search, setSearch] = useState("") const [search, setSearch] = useState("");
const [roleFilter, setRoleFilter] = useState<string>("all") const [roleFilter] = 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, statusFilter], queryKey: ["admin", "users", page, limit, search, roleFilter],
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;
if (statusFilter !== 'all') params.isActive = statusFilter === 'active' return await userService.getUsers(params);
return await userService.getUsers(params)
}, },
}) });
const deleteUserMutation = useMutation({ const getRoleBadgeColor = (role: string) => {
mutationFn: ({ id, hard }: { id: string; hard: boolean }) =>
userService.deleteUser(id, hard),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
toast.success("User deleted successfully")
setDeleteDialogOpen(false)
},
onError: (error) => {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to delete user")
},
})
const resetPasswordMutation = useMutation({
mutationFn: (id: string) => userService.resetPassword(id),
onSuccess: (data) => {
toast.success(`Password reset. Temporary password: ${data.temporaryPassword}`)
setResetPasswordDialogOpen(false)
},
onError: (error) => {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to reset password")
},
})
const importUsersMutation = useMutation({
mutationFn: (file: File) => userService.importUsers(file),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
toast.success(`Imported ${data.imported} users. ${data.failed} failed.`)
setImportDialogOpen(false)
setImportFile(null)
},
onError: (error) => {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to import users")
},
})
const handleExport = async () => {
try {
const blob = await userService.exportUsers('csv')
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `users-${new Date().toISOString()}.csv`
a.click()
toast.success("Users exported successfully")
} catch (error) {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to export users")
}
}
const handleDelete = () => {
if (selectedUser) {
deleteUserMutation.mutate({ id: selectedUser.id, hard: false })
}
}
const handleResetPassword = () => {
if (selectedUser) {
resetPasswordMutation.mutate(selectedUser.id)
}
}
const handleImport = () => {
if (importFile) {
importUsersMutation.mutate(importFile)
}
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
setImportFile(file)
}
}
const getRoleBadgeVariant = (role: string) => {
switch (role) { switch (role) {
case 'ADMIN': case "ADMIN":
return 'destructive' return "text-rose-600 bg-rose-50 border-rose-100";
case 'USER': case "USER":
return 'default' return "text-blue-600 bg-blue-50 border-blue-100";
case 'VIEWER': case "VIEWER":
return 'secondary' return "text-emerald-600 bg-emerald-50 border-emerald-100";
default: default:
return 'outline' return "text-gray-600 bg-gray-50 border-gray-100";
} }
} };
return ( return (
<div className="space-y-6"> <div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<div className="flex items-center justify-between"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<h2 className="text-3xl font-bold">Users Management</h2> <div>
<div className="flex gap-2"> <h1 className="text-3xl font-bold text-gray-900 tracking-tight">
<Button variant="outline" onClick={() => setImportDialogOpen(true)}> Users
<Upload className="w-4 h-4 mr-2" /> </h1>
Import Users <p className="text-gray-500 mt-1">
</Button> Manage system access and permissions.
<Button> </p>
<UserPlus className="w-4 h-4 mr-2" /> </div>
Add User <div className="flex items-center gap-2">
</Button> {canCreateUsers && (
<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> <Card className="border shadow-none rounded-none">
<CardHeader> <CardHeader className="border-b pb-4 flex flex-row items-center justify-between space-y-0">
<div className="flex items-center justify-between"> <CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
<CardTitle>All Users</CardTitle> User Directory
<div className="flex items-center gap-4"> </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">
<Input <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
placeholder="Search users..." <Input
className="pl-10 w-64" className="pl-10 h-9 rounded-none border-gray-200 text-xs"
value={search} placeholder="Search email or name..."
onChange={(e) => setSearch(e.target.value)} value={search}
/> onChange={(e) => setSearch(e.target.value)}
</div> />
<Select value={roleFilter} onValueChange={setRoleFilter}> </div>
<SelectTrigger className="w-40"> <Button
<SelectValue placeholder="All Roles" /> variant="outline"
</SelectTrigger> size="sm"
<SelectContent> className="h-9 rounded-none border-gray-200"
<SelectItem value="all">All Roles</SelectItem> >
<SelectItem value="ADMIN">Admin</SelectItem> <Filter className="w-4 h-4 mr-2" /> Filter
<SelectItem value="USER">User</SelectItem> </Button>
<SelectItem value="VIEWER">Viewer</SelectItem> </div>
</SelectContent> </CardHeader>
</Select> <CardContent className="p-0">
<Select value={statusFilter} onValueChange={setStatusFilter}> <div className="overflow-x-auto">
<SelectTrigger className="w-40"> <table className="w-full text-left">
<SelectValue placeholder="All Status" /> <thead className="bg-gray-50 border-b">
</SelectTrigger> <tr>
<SelectContent> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<SelectItem value="all">All Status</SelectItem> User
<SelectItem value="active">Active</SelectItem> </th>
<SelectItem value="inactive">Inactive</SelectItem> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
</SelectContent> Role
</Select> </th>
<Button variant="outline" onClick={handleExport}> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<Download className="w-4 h-4 mr-2" /> Status
Export </th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Created
</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 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}
</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",
user.isActive
? "text-emerald-600 bg-emerald-50 border-emerald-100"
: "text-slate-600 bg-slate-50 border-slate-100",
)}
>
{user.isActive ? "Active" : "Inactive"}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{format(new Date(user.createdAt), "MMM dd, yyyy")}
</td>
<td className="px-6 py-4 text-right">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-none"
onClick={() => navigate(`/admin/users/${user.id}`)}
>
<Eye className="w-4 h-4 text-gray-400" />
</Button>
</td>
</tr>
))
) : (
<tr>
<td
colSpan={5}
className="px-6 py-20 text-center text-gray-400 italic"
>
No users found.
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
{usersData && (
<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">
Showing {(page - 1) * limit + 1} to{" "}
{Math.min(page * limit, usersData.total)} of {usersData.total}
</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={page * limit >= usersData.total}
>
<ChevronRight className="w-4 h-4" />
</Button> </Button>
</div> </div>
</div> </div>
</CardHeader> )}
<CardContent>
{isLoading ? (
<div className="text-center py-8">Loading users...</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Name</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created At</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{usersData?.data?.map((user: User) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.email}</TableCell>
<TableCell>{user.firstName} {user.lastName}</TableCell>
<TableCell>
<Badge variant={getRoleBadgeVariant(user.role)}>
{user.role}
</Badge>
</TableCell>
<TableCell>
<Badge variant={user.isActive ? 'default' : 'secondary'}>
{user.isActive ? 'Active' : 'Inactive'}
</Badge>
</TableCell>
<TableCell>
{format(new Date(user.createdAt), 'MMM dd, yyyy')}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/admin/users/${user.id}`)}
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
setSelectedUser(user)
setResetPasswordDialogOpen(true)
}}
>
<Key className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
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}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => p + 1)}
disabled={page * limit >= usersData.total}
>
Next
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card> </Card>
{/* Delete Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete User</DialogTitle>
<DialogDescription>
Are you sure you want to delete {selectedUser?.email}? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Reset Password Dialog */}
<Dialog open={resetPasswordDialogOpen} onOpenChange={setResetPasswordDialogOpen}>
<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>
<DialogFooter>
<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>
) );
} }

View File

@ -7,6 +7,7 @@ import { Label } from "@/components/ui/label"
import { Eye, EyeOff } from "lucide-react" import { Eye, EyeOff } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { authService } from "@/services" import { authService } from "@/services"
import { hasPanelAccess } from "@/lib/admin-roles"
import { errorTracker } from "@/lib/error-tracker" import { errorTracker } from "@/lib/error-tracker"
import type { ApiError, LocationState } from "@/types/error.types" import type { ApiError, LocationState } from "@/types/error.types"
@ -27,9 +28,8 @@ export default function LoginPage() {
try { try {
const response = await authService.login({ email, password }) const response = await authService.login({ email, password })
// Check if user is admin if (!hasPanelAccess(response.user.role)) {
if (response.user.role !== 'ADMIN') { toast.error("Access denied. Staff panel credentials required.")
toast.error("Access denied. Admin privileges required.")
setIsLoading(false) setIsLoading(false)
return return
} }

View File

@ -1,277 +1,567 @@
import { useState, useMemo } from "react" import { useState, useMemo } from "react";
import { useQuery } 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 { 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 {
Table, Dialog,
TableBody, DialogContent,
TableCell, DialogDescription,
TableHead, DialogFooter,
TableHeader, DialogHeader,
TableRow, DialogTitle,
} from "@/components/ui/table" } from "@/components/ui/dialog";
import { Search, Download, Eye, CheckCheck, Bell, Loader2 } from "lucide-react" import {
import { notificationService } from "@/services/notification.service" Search,
import { toast } from "sonner" CheckCheck,
Send,
Plus,
BellRing,
Mail,
MessageSquare,
History,
Target,
ArrowRight,
ChevronRight,
Loader2,
Calendar,
} from "lucide-react";
import { notificationService } from "@/services/notification.service";
import type {
SendPushNotificationRequest,
SendSmsNotificationRequest,
SendEmailNotificationRequest,
} from "@/services/notification.service";
import { useAdminRole } from "@/hooks/use-admin-role";
import { toast } from "sonner";
import { format } from "date-fns";
import { cn } from "@/lib/utils";
type Channel = "PUSH" | "SMS" | "EMAIL";
export default function NotificationsPage() { export default function NotificationsPage() {
const [searchQuery, setSearchQuery] = useState("") const { canSendNotifications } = useAdminRole();
const [typeFilter, setTypeFilter] = useState("") const queryClient = useQueryClient();
const [statusFilter, setStatusFilter] = useState("") const [searchQuery, setSearchQuery] = useState("");
const [activeChannel, setActiveChannel] = useState<Channel>("PUSH");
const [isSendModalOpen, setIsSendModalOpen] = useState(false);
const { data: notifications, isLoading, refetch } = useQuery({ // Combined form state
queryKey: ['notifications'], const [pushForm, setPushForm] = useState<SendPushNotificationRequest>({
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) => {
return notifications.filter((notification) => { if (!searchQuery) return true;
// Type filter const q = searchQuery.toLowerCase();
if (typeFilter && notification.type !== typeFilter) return false return (
n.title?.toLowerCase().includes(q) || n.body.toLowerCase().includes(q)
// Status filter );
if (statusFilter === 'read' && !notification.isRead) return false });
if (statusFilter === 'unread' && notification.isRead) return false }, [notifications, searchQuery]);
// 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 = () => { const handleSend = () => {
try { if (activeChannel === "PUSH") pushMutation.mutate(pushForm);
if (!filteredNotifications || filteredNotifications.length === 0) { else if (activeChannel === "SMS") smsMutation.mutate(smsForm);
toast.error("No notifications to export") else if (activeChannel === "EMAIL") emailMutation.mutate(emailForm);
return };
}
const csvData = [ const isPending =
['Notification ID', 'Title', 'Message', 'Type', 'Recipient', 'Status', 'Created Date', 'Read Date'], pushMutation.isPending || smsMutation.isPending || emailMutation.isPending;
...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-6"> <div className="space-y-8 animate-in fade-in duration-500">
<div className="flex items-center justify-between"> {/* Header Section */}
<div> <div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
<h2 className="text-3xl font-bold">Notifications</h2> <div className="space-y-1">
{unreadCount !== undefined && unreadCount > 0 && ( <div className="flex items-center gap-2 text-primary mb-1">
<p className="text-sm text-muted-foreground mt-1"> <div className="p-2 bg-primary/10 rounded-lg">
You have {unreadCount} unread notification{unreadCount !== 1 ? 's' : ''} <BellRing className="w-5 h-5" />
</p> </div>
)} <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">
{unreadCount !== undefined && unreadCount > 0 && ( <div className="flex items-center gap-3">
<Button variant="outline" onClick={handleMarkAllAsRead}> <Button
<CheckCheck className="w-4 h-4 mr-2" /> variant="ghost"
Mark All as Read 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"
</Button> onClick={() => notificationService.markAllAsRead()}
)} >
<Button> <CheckCheck className="w-4 h-4 mr-2" />
<Bell className="w-4 h-4 mr-2" /> Clear Signal
Settings </Button>
<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>
<Card> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<CardHeader> {/* Stats / Quick Info */}
<div className="flex items-center justify-between"> <div className="lg:col-span-1 space-y-6">
<CardTitle>All Notifications</CardTitle> <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">
<div className="flex items-center gap-4"> <div className="space-y-6">
<div className="relative"> <div className="flex items-center justify-between">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <span className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40">
<Input System Status
placeholder="Search notification..." </span>
className="pl-10 w-64" <Badge className="bg-emerald-500 text-white border-none text-[10px] rounded-full px-2 py-0">
value={searchQuery} Active
onChange={(e) => setSearchQuery(e.target.value)} </Badge>
/> </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>
</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 className="p-8">
<div className="space-y-8">
{/* Channel Selector */}
<div className="space-y-4">
<Label className="text-[10px] font-black uppercase text-slate-400 tracking-widest">
Select Uplink Channels
</Label>
<div className="grid grid-cols-3 gap-4">
{[
{ id: "PUSH", icon: BellRing, label: "Push Notification" },
{ id: "SMS", icon: MessageSquare, label: "SMS Gateway" },
{ id: "EMAIL", icon: Mail, label: "Email Relay" },
].map((c) => (
<button
key={c.id}
type="button"
onClick={() => setActiveChannel(c.id as Channel)}
className={cn(
"flex flex-col items-center justify-center gap-3 p-6 rounded-2xl border-2 transition-all group",
activeChannel === c.id
? "border-primary bg-primary/5 text-primary shadow-lg shadow-primary/10"
: "border-slate-100 bg-white text-slate-400 hover:border-slate-200",
)}
>
<c.icon
className={cn(
"w-6 h-6 transition-transform group-active:scale-90",
activeChannel === c.id
? "text-primary"
: "text-slate-300",
)}
/>
<span className="text-[10px] font-black uppercase tracking-widest">
{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>
<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>
</div> </div>
</CardHeader>
<CardContent> <DialogFooter className="p-8 pt-4 bg-slate-50 border-t border-slate-100 flex items-center justify-between">
{isLoading ? ( <Button
<div className="flex items-center justify-center py-8"> variant="ghost"
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" /> className="h-12 px-6 rounded-xl font-black uppercase text-xs tracking-widest text-slate-400 hover:text-slate-900"
</div> onClick={() => setIsSendModalOpen(false)}
) : filteredNotifications && filteredNotifications.length > 0 ? ( >
<Table> Abort Mission
<TableHeader> </Button>
<TableRow> <Button
<TableHead>Notification ID</TableHead> 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"
<TableHead>Title</TableHead> disabled={isPending}
<TableHead>Message</TableHead> onClick={handleSend}
<TableHead>Type</TableHead> >
<TableHead>Status</TableHead> {isPending ? (
<TableHead>Created Date</TableHead> <>
<TableHead>Read Date</TableHead> <Loader2 className="w-4 h-4 mr-2 animate-spin" />
<TableHead>Action</TableHead> Broadcasting...
</TableRow> </>
</TableHeader> ) : (
<TableBody> <>
{filteredNotifications.map((notification) => ( Commit {activeChannel} Signal
<TableRow key={notification.id} className={!notification.isRead ? 'bg-blue-50' : ''}> <ArrowRight className="w-4 h-4 ml-2" />
<TableCell className="font-medium">{notification.id}</TableCell> </>
<TableCell className="font-medium">{notification.title}</TableCell> )}
<TableCell className="max-w-xs truncate">{notification.message}</TableCell> </Button>
<TableCell> </DialogFooter>
<Badge variant="outline" className="capitalize">{notification.type}</Badge> </DialogContent>
</TableCell> </Dialog>
<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>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="text-center py-8 text-muted-foreground">
{searchQuery || typeFilter || statusFilter
? 'No notifications match your filters'
: 'No notifications found'
}
</div>
)}
</CardContent>
</Card>
</div> </div>
) );
} }

View File

@ -1,87 +1,96 @@
import axios, { type AxiosInstance, type AxiosError, type InternalAxiosRequestConfig } from 'axios' import axios, {
type AxiosInstance,
type AxiosError,
type InternalAxiosRequestConfig,
} from "axios";
const API_BASE_URL = import.meta.env.VITE_BACKEND_API_URL || 'http://localhost:3001/api/v1' const useDevApiProxy =
import.meta.env.DEV && import.meta.env.VITE_USE_API_PROXY === "true";
const API_BASE_URL = useDevApiProxy
? "/api"
: import.meta.env.VITE_BACKEND_API_URL || "http://localhost:3001";
interface RetryableAxiosRequestConfig extends InternalAxiosRequestConfig { 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;

View File

@ -1,4 +1,5 @@
import apiClient from './api/client' import apiClient from './api/client'
import { hasPanelAccess } from '@/lib/admin-roles'
export interface LoginRequest { export interface LoginRequest {
email: string email: string
@ -88,11 +89,11 @@ class AuthService {
} }
/** /**
* Check if user is admin * Legacy: true for any staff panel role
*/ */
isAdmin(): boolean { isAdmin(): boolean {
const user = this.getCurrentUser() const user = this.getCurrentUser()
return user?.role === 'ADMIN' return hasPanelAccess(user?.role)
} }
} }

View File

@ -1,21 +1,57 @@
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 {
@ -23,37 +59,76 @@ 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();

View File

@ -0,0 +1,67 @@
import apiClient from "./api/client"
/** Who can see this FAQ entry in product surfaces */
export type FaqAudience = "END_USER" | "SYSTEM_USER" | "ALL"
export interface FaqEntry {
id: string
question: string
answer: string
audience: FaqAudience
sortOrder: number
isPublished: boolean
createdAt: string
updatedAt?: string
}
export interface PaginatedFaqs {
data: FaqEntry[]
total: number
page: number
limit: number
totalPages: number
}
class FaqService {
async list(params?: {
page?: number
limit?: number
audience?: FaqAudience
search?: string
}): Promise<PaginatedFaqs> {
const response = await apiClient.get<PaginatedFaqs>("/admin/faq", {
params,
})
return response.data
}
async create(data: {
question: string
answer: string
audience: FaqAudience
sortOrder?: number
isPublished?: boolean
}): Promise<FaqEntry> {
const response = await apiClient.post<FaqEntry>("/admin/faq", data)
return response.data
}
async update(
id: string,
data: Partial<
Pick<
FaqEntry,
"question" | "answer" | "audience" | "sortOrder" | "isPublished"
>
>,
): Promise<FaqEntry> {
const response = await apiClient.patch<FaqEntry>(`/admin/faq/${id}`, data)
return response.data
}
async remove(id: string): Promise<void> {
await apiClient.delete(`/admin/faq/${id}`)
}
}
export const faqService = new FaqService()

View File

@ -1,23 +1,75 @@
// 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 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 { OverviewStats, UserGrowthData, RevenueData } from './analytics.service' export type {
export type { SuspiciousActivity, ActiveSession, FailedLogin, ApiKey } from './security.service' OverviewStats,
export type { HealthStatus, SystemInfo, MaintenanceStatus } from './system.service' UserGrowthData,
export type { Announcement, CreateAnnouncementData, UpdateAnnouncementData } from './announcement.service' RevenueData,
export type { AuditLog, GetAuditLogsParams, AuditStats } from './audit.service' } from "./analytics.service";
export type { Setting, CreateSettingData, UpdateSettingData } from './settings.service' export type {
export type { UserDashboardStats, UserProfile } from './dashboard.service' SuspiciousActivity,
export type { Notification, NotificationSettings } from './notification.service' ActiveSession,
FailedLogin,
ApiKey,
} from "./security.service";
export type {
HealthStatus,
SystemInfo,
MaintenanceStatus,
} from "./system.service";
export type {
Announcement,
CreateAnnouncementData,
UpdateAnnouncementData,
} from "./announcement.service";
export type { AuditLog, GetAuditLogsParams, AuditStats } from "./audit.service";
export type {
Setting,
CreateSettingData,
UpdateSettingData,
} from "./settings.service";
export type { UserDashboardStats, UserProfile } from "./dashboard.service";
export type {
Notification,
NotificationSettings,
} from "./notification.service";
export type {
Payment,
PaymentRequest,
PaymentFilters,
PaymentRequestFilters,
} from "./payment.service";
export type {
Invoice,
Proforma,
ProformaRequest,
ProformaRequestItem,
InvoiceFilters,
ProformaFilters,
ProformaRequestFilters,
} from "./invoice.service";
export type {
SubscriptionTransaction,
SubscriptionPaymentStatus,
} from "./subscription-transaction.service";
export type { SystemMember, CreateSystemMemberPayload } from "./system-member.service";
export type { SupportIssue, IssueStatus } from "./issue.service";
export type { FaqEntry, FaqAudience } from "./faq.service";

View File

@ -0,0 +1,272 @@
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();

View File

@ -0,0 +1,63 @@
import apiClient from "./api/client"
export type IssueStatus = "OPEN" | "IN_PROGRESS" | "RESOLVED" | "CLOSED"
export type IssueReporterType = "USER" | "SYSTEM_USER"
export interface SupportIssue {
id: string
title: string
description: string
status: IssueStatus
reporterType: IssueReporterType
reporterEmail: string
reporterUserId?: string
priority: "LOW" | "MEDIUM" | "HIGH"
createdAt: string
updatedAt?: string
}
export interface IssueFilters {
page?: number
limit?: number
status?: IssueStatus
search?: string
}
export interface PaginatedIssues {
data: SupportIssue[]
total: number
page: number
limit: number
totalPages: number
}
class IssueService {
async list(filters: IssueFilters = {}): Promise<PaginatedIssues> {
const response = await apiClient.get<PaginatedIssues>("/admin/issues", {
params: filters,
})
return response.data
}
async create(data: {
title: string
description: string
priority?: SupportIssue["priority"]
}): Promise<SupportIssue> {
const response = await apiClient.post<SupportIssue>("/admin/issues", data)
return response.data
}
async updateStatus(
id: string,
status: IssueStatus,
): Promise<SupportIssue> {
const response = await apiClient.patch<SupportIssue>(
`/admin/issues/${id}`,
{ status },
)
return response.data
}
}
export const issueService = new IssueService()

View File

@ -1,112 +1,175 @@
import apiClient from './api/client' import apiClient from "./api/client";
export interface Notification { export interface Notification {
id: string id: string;
title: string title: string;
message: string body: string;
type: 'system' | 'user' | 'alert' | 'invoice' | 'payment' icon?: string;
recipient: string url?: string;
status: 'sent' | 'delivered' | 'read' | 'unread' sentAt?: string;
isRead: boolean scheduledFor?: string;
createdAt: string isSent: boolean;
sentAt?: string recipientId: string;
readAt?: string data?: Record<string, any>;
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 * Get all notifications for current user (Paginated)
*/ */
async getNotifications(params?: { async getNotifications(params?: {
type?: string page?: number;
status?: string limit?: number;
search?: string type?: 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 }>('/notifications/unread-count') const response = await apiClient.get<{ count: number }>(
return response.data.count "/notifications/unread-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 notification (ADMIN only) * Send push notification (ADMIN only)
*/ */
async sendNotification(data: { async sendPushNotification(
title: string data: SendPushNotificationRequest,
message: string ): Promise<Notification> {
type: string const response = await apiClient.post<Notification>(
recipient?: string "/admin/notifications/send-push",
recipientType?: 'user' | 'all' data,
}): Promise<Notification> { );
const response = await apiClient.post<Notification>('/notifications/send', data) return response.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(`/notifications/unsubscribe/${encodeURIComponent(endpoint)}`) await apiClient.delete(
`/notifications/unsubscribe/${encodeURIComponent(endpoint)}`,
);
} }
/** /**
* Get notification settings * Get notification settings
*/ */
async getSettings(): Promise<NotificationSettings> { async getSettings(): Promise<NotificationSettings> {
const response = await apiClient.get<NotificationSettings>('/notifications/settings') const response = await apiClient.get<NotificationSettings>(
return response.data "/notifications/settings",
);
return response.data;
} }
/** /**
* Update notification settings * Update notification settings
*/ */
async updateSettings(settings: Partial<NotificationSettings>): Promise<NotificationSettings> { async updateSettings(
const response = await apiClient.put<NotificationSettings>('/notifications/settings', settings) settings: Partial<NotificationSettings>,
return response.data ): Promise<NotificationSettings> {
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`);
} }
/** /**
@ -114,20 +177,21 @@ class NotificationService {
*/ */
async exportNotifications(notifications: Notification[]): Promise<Blob> { async exportNotifications(notifications: Notification[]): Promise<Blob> {
const csvContent = [ const csvContent = [
['ID', 'Title', 'Message', 'Type', 'Status', 'Created Date', 'Read Date'], ["ID", "Title", "Body", "Status", "Created Date", "Sent Date"],
...notifications.map(n => [ ...notifications.map((n) => [
n.id, n.id,
n.title, n.title,
n.message, n.body,
n.type, n.isSent ? "Sent" : "Scheduled",
n.status,
n.createdAt, n.createdAt,
n.readAt || '-' n.sentAt || "-",
]) ]),
].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();

View File

@ -0,0 +1,176 @@
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();

View File

@ -0,0 +1,47 @@
import apiClient from "./api/client"
export type SubscriptionPaymentStatus = "SUCCEEDED" | "FAILED" | "PENDING"
export interface SubscriptionTransaction {
id: string
userId: string
userEmail: string
planName: string
amount: number
currency: string
status: SubscriptionPaymentStatus
provider: string
providerRef?: string
failureReason?: string
createdAt: string
}
export interface SubscriptionTransactionFilters {
page?: number
limit?: number
status?: SubscriptionPaymentStatus
search?: string
}
export interface PaginatedSubscriptionTx {
data: SubscriptionTransaction[]
total: number
page: number
limit: number
totalPages: number
}
class SubscriptionTransactionService {
async getTransactions(
filters: SubscriptionTransactionFilters = {},
): Promise<PaginatedSubscriptionTx> {
const response = await apiClient.get<PaginatedSubscriptionTx>(
"/admin/subscription-transactions",
{ params: filters },
)
return response.data
}
}
export const subscriptionTransactionService =
new SubscriptionTransactionService()

View File

@ -0,0 +1,65 @@
import apiClient from "./api/client"
/** Internal staff that operate the admin / support panel */
export interface SystemMember {
id: string
email: string
firstName: string
lastName: string
/** Panel role: SUPER_ADMIN | ADMIN | CUSTOMER_SUPPORT */
role: string
isActive: boolean
createdAt: string
updatedAt?: string
}
export interface CreateSystemMemberPayload {
email: string
firstName: string
lastName: string
password: string
role: string
}
export interface PaginatedSystemMembers {
data: SystemMember[]
total: number
page: number
limit: number
totalPages: number
}
class SystemMemberService {
async list(params?: {
page?: number
limit?: number
search?: string
}): Promise<PaginatedSystemMembers> {
const response = await apiClient.get<PaginatedSystemMembers>(
"/admin/system-members",
{ params },
)
return response.data
}
async create(data: CreateSystemMemberPayload): Promise<SystemMember> {
const response = await apiClient.post<SystemMember>(
"/admin/system-members",
data,
)
return response.data
}
async update(
id: string,
data: Partial<Pick<SystemMember, "firstName" | "lastName" | "role" | "isActive">>,
): Promise<SystemMember> {
const response = await apiClient.patch<SystemMember>(
`/admin/system-members/${id}`,
data,
)
return response.data
}
}
export const systemMemberService = new SystemMemberService()

View File

@ -1,37 +1,61 @@
import { defineConfig } from "vite" import { defineConfig, loadEnv } 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({ export default defineConfig(({ mode }) => {
plugins: [react()], const env = loadEnv(mode, process.cwd(), "")
resolve: { const useApiProxy = env.VITE_USE_API_PROXY === "true"
alias: { const proxyTarget =
"@": path.resolve(__dirname, "./src"), env.VITE_PROXY_TARGET ||
}, env.VITE_BACKEND_API_URL ||
}, "http://localhost:3001"
build: {
sourcemap: false, return {
rollupOptions: { plugins: [react()],
output: { resolve: {
manualChunks: { alias: {
'react-vendor': ['react', 'react-dom', 'react-router-dom'], "@": path.resolve(__dirname, "./src"),
'ui-vendor': ['@radix-ui/react-avatar', '@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu', '@radix-ui/react-select'],
'chart-vendor': ['recharts'],
'query-vendor': ['@tanstack/react-query'],
},
}, },
}, },
chunkSizeWarningLimit: 1000, build: {
}, sourcemap: false,
server: { rollupOptions: {
port: 5173, output: {
strictPort: false, manualChunks: {
host: true, "react-vendor": ["react", "react-dom", "react-router-dom"],
}, "ui-vendor": [
preview: { "@radix-ui/react-avatar",
port: 4173, "@radix-ui/react-dialog",
strictPort: false, "@radix-ui/react-dropdown-menu",
host: true, "@radix-ui/react-select",
}, ],
"chart-vendor": ["recharts"],
"query-vendor": ["@tanstack/react-query"],
},
},
},
chunkSizeWarningLimit: 1000,
},
server: {
port: 5173,
strictPort: false,
host: true,
...(useApiProxy && {
proxy: {
"/api": {
target: proxyTarget.replace(/\/$/, ""),
changeOrigin: true,
secure: true,
rewrite: (p) => p.replace(/^\/api/, "") || "/",
},
},
}),
},
preview: {
port: 4173,
strictPort: false,
host: true,
},
}
}) })