This commit is contained in:
elnatansamuel25 2026-05-09 11:24:55 +03:00
parent 23ab82a726
commit a15064ce37
22 changed files with 5545 additions and 1159 deletions

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."
}
}
}

10
package-lock.json generated
View File

@ -29,6 +29,7 @@
"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",
@ -7053,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

@ -38,6 +38,7 @@
"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

@ -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

@ -1,29 +1,20 @@
import { useMemo } from "react" import { useMemo } from "react";
import { authService } from "@/services" import { authService } from "@/services";
import { import { getPermissions, hasPanelAccess, roleLabel } from "@/lib/admin-roles";
canAccessSystemMembers,
canEdit,
canSendBroadcast,
hasPanelAccess,
isSuperAdmin,
roleLabel,
} from "@/lib/admin-roles"
export function useAdminRole() { export function useAdminRole() {
return useMemo(() => { const user = authService.getCurrentUser() as {
const user = authService.getCurrentUser() as role?: string;
| { role?: string; email?: string } email?: string;
| null } | null;
const role = user?.role const role = user?.role;
return { const permissions = useMemo(() => getPermissions(role), [role]);
role,
roleLabel: roleLabel(role), return {
isSuperAdmin: isSuperAdmin(role), role,
canEdit: canEdit(role), roleLabel: roleLabel(role),
canAccessSystemMembers: canAccessSystemMembers(role), hasPanelAccess: hasPanelAccess(role),
canSendBroadcast: canSendBroadcast(role), ...permissions,
hasPanelAccess: hasPanelAccess(role), };
}
}, [])
} }

View File

@ -1,4 +1,4 @@
import { useState, type ComponentType } from "react"; import React, { useState, type ComponentType, useEffect } from "react";
import { Outlet, Link, useLocation, useNavigate } from "react-router-dom"; import { Outlet, Link, useLocation, useNavigate } from "react-router-dom";
import { import {
LayoutDashboard, LayoutDashboard,
@ -14,15 +14,16 @@ import {
Bell, Bell,
LogOut, LogOut,
CreditCard, CreditCard,
FileClock,
Receipt, Receipt,
FileSearch, FileSearch,
ClipboardList,
ArrowRightLeft, ArrowRightLeft,
UserCog, UserCog,
LifeBuoy, LifeBuoy,
HelpCircle, HelpCircle,
Send, Send,
ChevronDown,
ChevronRight,
Folder,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { AdminQuickSearch } from "@/components/admin-quick-search"; import { AdminQuickSearch } from "@/components/admin-quick-search";
@ -36,7 +37,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { roleLabel } from "@/lib/admin-roles"; import { roleLabel, getPermissions } from "@/lib/admin-roles";
import { authService } from "@/services"; import { authService } from "@/services";
interface User { interface User {
@ -47,58 +48,198 @@ interface User {
} }
type NavItem = { type NavItem = {
icon: ComponentType<{ className?: string }>; icon?: ComponentType<{ className?: string }>;
label: string; label: string;
path: string; path?: string;
children?: NavItem[];
/** Omit = visible to all panel roles */ /** Omit = visible to all panel roles */
visible?: (role: string | undefined) => boolean; visible?: (role: string | undefined) => boolean;
}; };
const adminNavigationItems: NavItem[] = [ const adminNavigationItems: NavItem[] = [
{ icon: LayoutDashboard, label: "Dashboard", path: "/admin/dashboard" }, { icon: LayoutDashboard, label: "Dashboard", path: "/admin/dashboard" },
{ icon: Receipt, label: "Invoices", path: "/admin/invoices" },
{ icon: FileSearch, label: "Proforma", path: "/admin/proforma" },
{ {
icon: ClipboardList, icon: Folder,
label: "Proforma Requests", label: "Documents",
path: "/admin/proforma-requests", children: [
}, {
{ icon: CreditCard, label: "Payments", path: "/admin/payments" }, label: "Invoices",
{ path: "/admin/invoices",
icon: FileClock, icon: Receipt,
label: "Payment Requests", visible: (role) => getPermissions(role).canViewBusinessData,
path: "/admin/payment-requests", },
{
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, icon: ArrowRightLeft,
label: "Subscription transactions", label: "Subscription transactions",
path: "/admin/transactions/subscriptions", path: "/admin/transactions/subscriptions",
}, },
{ icon: Users, label: "Users", path: "/admin/users" }, {
icon: Users,
label: "Users",
path: "/admin/users",
visible: (role) => getPermissions(role).canViewUsers,
},
{ {
icon: UserCog, icon: UserCog,
label: "System users", label: "System users",
path: "/admin/system-members", path: "/admin/system-members",
visible: (role) => role === "SUPER_ADMIN" || role === "ADMIN", visible: (role) => getPermissions(role).canManageSystem,
},
{
icon: FileText,
label: "Logs",
path: "/admin/logs",
visible: (role) => getPermissions(role).canViewSystemData,
}, },
{ icon: FileText, label: "Logs", path: "/admin/logs" },
{ icon: LifeBuoy, label: "Issues", path: "/admin/issues" }, { icon: LifeBuoy, label: "Issues", path: "/admin/issues" },
{ icon: HelpCircle, label: "FAQ & support", path: "/admin/support/faq" }, { icon: HelpCircle, label: "FAQ & support", path: "/admin/support/faq" },
{ icon: Settings, label: "Settings", path: "/admin/settings" }, {
{ icon: Wrench, label: "Maintenance", path: "/admin/maintenance" }, 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, icon: Send,
label: "Send notification", label: "Send notification",
path: "/admin/notifications/broadcast", path: "/admin/notifications/broadcast",
visible: (role) => role === "SUPER_ADMIN" || role === "ADMIN", 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();
@ -117,12 +258,11 @@ export function AppShell() {
return null; return null;
}); });
const isActive = (path: string) => { const isActive = (path?: string) => {
if (!path) return false;
return location.pathname.startsWith(path); return location.pathname.startsWith(path);
}; };
// Removed unused getPageTitle as header title is no longer displayed
const handleLogout = async () => { const handleLogout = async () => {
await authService.logout(); await authService.logout();
navigate("/login", { replace: true }); navigate("/login", { replace: true });
@ -170,27 +310,14 @@ export function AppShell() {
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 px-4 py-2 space-y-1 overflow-y-auto"> <nav className="flex-1 px-4 py-2 space-y-1 overflow-y-auto">
{adminNavigationItems {adminNavigationItems
.filter((item) => .filter((item) => (item.visible ? item.visible(user?.role) : true))
item.visible ? item.visible(user?.role) : true, .map((item) => (
) <SidebarNavItem
.map((item) => { key={item.label}
const Icon = item.icon; item={item}
return ( isActive={isActive}
<Link />
key={item.path} ))}
to={item.path}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors",
isActive(item.path)
? "bg-primary text-primary-foreground"
: "text-foreground/70 hover:bg-accent hover:text-foreground",
)}
>
<Icon className="w-5 h-5" />
{item.label}
</Link>
);
})}
</nav> </nav>
{/* User Section */} {/* User Section */}

View File

@ -6,50 +6,110 @@ export const AdminRole = {
SUPER_ADMIN: "SUPER_ADMIN", SUPER_ADMIN: "SUPER_ADMIN",
ADMIN: "ADMIN", ADMIN: "ADMIN",
CUSTOMER_SUPPORT: "CUSTOMER_SUPPORT", CUSTOMER_SUPPORT: "CUSTOMER_SUPPORT",
} as const } as const;
export type AdminRoleValue = (typeof AdminRole)[keyof typeof AdminRole] 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>([ const PANEL_ROLES = new Set<string>([
AdminRole.SUPER_ADMIN, AdminRole.SUPER_ADMIN,
AdminRole.ADMIN, AdminRole.ADMIN,
AdminRole.CUSTOMER_SUPPORT, AdminRole.CUSTOMER_SUPPORT,
]) ]);
export function hasPanelAccess(role: string | undefined): boolean { export function hasPanelAccess(role: string | undefined): boolean {
if (!role) return false if (!role) return false;
return PANEL_ROLES.has(role) return PANEL_ROLES.has(role);
}
/** Full control (all menus + destructive actions) */
export function isSuperAdmin(role: string | undefined): boolean {
return role === AdminRole.SUPER_ADMIN
}
/** Can create/edit content (not read-only support) */
export function canEdit(role: string | undefined): boolean {
return role === AdminRole.SUPER_ADMIN || role === AdminRole.ADMIN
}
/** Internal system users / members management */
export function canAccessSystemMembers(role: string | undefined): boolean {
return role === AdminRole.SUPER_ADMIN || role === AdminRole.ADMIN
}
/** Push / SMS / email broadcast composer */
export function canSendBroadcast(role: string | undefined): boolean {
return role === AdminRole.SUPER_ADMIN || role === AdminRole.ADMIN
} }
export function roleLabel(role: string | undefined): string { export function roleLabel(role: string | undefined): string {
switch (role) { switch (role) {
case AdminRole.SUPER_ADMIN: case AdminRole.SUPER_ADMIN:
return "System Admin" return "System Admin";
case AdminRole.ADMIN: case AdminRole.ADMIN:
return "Admin" return "Admin";
case AdminRole.CUSTOMER_SUPPORT: case AdminRole.CUSTOMER_SUPPORT:
return "Customer Support" return "Customer Support";
default: default:
return role ?? "User" 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

@ -182,10 +182,7 @@ export default function AnnouncementsPage() {
</div> </div>
<Card className="border shadow-none rounded-none"> <Card className="border shadow-none rounded-none">
<CardHeader className="border-b pb-4 flex flex-row items-center justify-between space-y-0"> <CardHeader className="border-b pb-4 flex flex-row items-end justify-end space-y-0">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
Bulletin Archive
</CardTitle>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -223,7 +220,7 @@ export default function AnnouncementsPage() {
colSpan={5} colSpan={5}
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]" className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
> >
Synchronizing broadcast data... Loading...
</td> </td>
</tr> </tr>
) : announcements && announcements.length > 0 ? ( ) : announcements && announcements.length > 0 ? (
@ -313,7 +310,7 @@ export default function AnnouncementsPage() {
colSpan={5} colSpan={5}
className="px-6 py-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]" className="px-6 py-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]"
> >
No active broadcasts. No active announcements.
</td> </td>
</tr> </tr>
)} )}

View File

@ -1,5 +1,5 @@
import { useState } from "react"; import React, { useState } 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 { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -8,14 +8,64 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Filter, Filter,
Download, Plus,
Pencil,
Trash2,
Loader2,
X,
} from "lucide-react"; } 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 { invoiceService } from "@/services";
import { useAdminRole } from "@/hooks/use-admin-role";
import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Invoice, InvoiceItem } from "@/services/invoice.service";
export default function InvoicesPage() { export default function InvoicesPage() {
const { canCreateBusinessData, canEditBusinessData, canDeleteBusinessData } =
useAdminRole();
const queryClient = useQueryClient();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [search, setSearch] = useState(""); 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({ const { data: invoicesData, isLoading } = useQuery({
queryKey: ["admin", "invoices", page, search], queryKey: ["admin", "invoices", page, search],
@ -27,11 +77,152 @@ export default function InvoicesPage() {
}), }),
}); });
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 formatCurrency = (amount: number | any) => {
const val = typeof amount === "number" ? amount : 0; const val = typeof amount === "number" ? amount : 0;
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency: "USD", currency: formData.currency || "USD",
}).format(val); }).format(val);
}; };
@ -61,9 +252,17 @@ export default function InvoicesPage() {
Manage sales and purchase invoices. Manage sales and purchase invoices.
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> {canCreateBusinessData && (
{/* View only access: Create and Export buttons removed */} <div className="flex items-center gap-2">
</div> <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> </div>
<Card className="border shadow-none rounded-none"> <Card className="border shadow-none rounded-none">
@ -110,16 +309,19 @@ export default function InvoicesPage() {
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest"> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Status Status
</th> </th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right"> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Date Date
</th> </th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
Actions
</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y"> <tbody className="divide-y">
{isLoading ? ( {isLoading ? (
<tr> <tr>
<td <td
colSpan={6} colSpan={7}
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]" className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
> >
Synchronizing ledger data... Synchronizing ledger data...
@ -169,15 +371,47 @@ export default function InvoicesPage() {
{invoice.status} {invoice.status}
</span> </span>
</td> </td>
<td className="px-6 py-4 text-right text-sm text-gray-500"> <td className="px-6 py-4 text-sm text-gray-500">
{new Date(invoice.issueDate).toLocaleDateString()} {new Date(invoice.issueDate).toLocaleDateString()}
</td> </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>
)) ))
) : ( ) : (
<tr> <tr>
<td <td
colSpan={6} colSpan={7}
className="px-6 py-20 text-center text-gray-400 italic" className="px-6 py-20 text-center text-gray-400 italic"
> >
No invoices found in ledger. No invoices found in ledger.
@ -216,6 +450,371 @@ export default function InvoicesPage() {
</div> </div>
)} )}
</Card> </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> </div>
); );
} }

View File

@ -1,5 +1,5 @@
import { useState } from "react"; import React, { useState } 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 { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -8,13 +8,62 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Filter, Filter,
Plus,
Pencil,
Trash2,
Loader2,
X,
FileText, FileText,
} from "lucide-react"; } 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 { 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() { export default function ProformaPage() {
const { canCreateBusinessData, canEditBusinessData, canDeleteBusinessData } =
useAdminRole();
const queryClient = useQueryClient();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [search, setSearch] = useState(""); 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({ const { data: proformaData, isLoading } = useQuery({
queryKey: ["admin", "proforma", page, search], queryKey: ["admin", "proforma", page, search],
@ -26,11 +75,150 @@ export default function ProformaPage() {
}), }),
}); });
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 formatCurrency = (amount: number | any) => {
const val = typeof amount === "number" ? amount : 0; const val = typeof amount === "number" ? amount : 0;
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency: "USD", currency: formData.currency || "USD",
}).format(val); }).format(val);
}; };
@ -45,7 +233,17 @@ export default function ProformaPage() {
Manage draft and preliminary invoices. Manage draft and preliminary invoices.
</p> </p>
</div> </div>
{/* View only access: Create button removed */} {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> </div>
<Card className="border shadow-none rounded-none"> <Card className="border shadow-none rounded-none">
@ -90,7 +288,7 @@ export default function ProformaPage() {
Issue Date Issue Date
</th> </th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right"> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
Due Date Actions
</th> </th>
</tr> </tr>
</thead> </thead>
@ -130,8 +328,37 @@ export default function ProformaPage() {
<td className="px-6 py-4 text-sm text-gray-500"> <td className="px-6 py-4 text-sm text-gray-500">
{new Date(item.issueDate).toLocaleDateString()} {new Date(item.issueDate).toLocaleDateString()}
</td> </td>
<td className="px-6 py-4 text-right text-sm text-gray-500"> <td className="px-6 py-4 text-right">
{new Date(item.dueDate).toLocaleDateString()} <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> </td>
</tr> </tr>
)) ))
@ -177,6 +404,348 @@ export default function ProformaPage() {
</div> </div>
)} )}
</Card> </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> </div>
); );
} }

View File

@ -29,13 +29,70 @@ import {
} from "lucide-react"; } from "lucide-react";
import { invoiceService, type ProformaRequest } from "@/services"; import { invoiceService, type ProformaRequest } from "@/services";
import { format } from "date-fns"; import { format } from "date-fns";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useAdminRole } from "@/hooks/use-admin-role";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Plus, Trash2, Lock, Ban, Loader2, MoreVertical } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
export default function ProformaRequestsPage() { export default function ProformaRequestsPage() {
const { canCreateBusinessData, canEditBusinessData } = useAdminRole();
const queryClient = useQueryClient();
// Local State
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [limit] = useState(10); const [limit] = useState(10);
const [status, setStatus] = useState<string>("all"); const [status, setStatus] = useState<string>("all");
const [category, setCategory] = useState<string>("all"); const [category, setCategory] = useState<string>("all");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [deadlineFrom, setDeadlineFrom] = useState("");
const [deadlineTo, setDeadlineTo] = useState("");
// CRUD State
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
const [editingRequest, setEditingRequest] = useState<ProformaRequest | null>(
null,
);
const [isStatusModalOpen, setIsStatusModalOpen] = useState(false);
const [statusAction, setStatusAction] = useState<"CLOSE" | "CANCEL" | null>(
null,
);
const [selectedRequest, setSelectedRequest] =
useState<ProformaRequest | null>(null);
// Form State
const [formData, setFormData] = useState<any>({
title: "",
description: "",
category: "EQUIPMENT",
submissionDeadline: new Date().toISOString().split("T")[0],
allowRevisions: true,
paymentTerms: "Net 30 days",
incoterms: "EXW",
taxIncluded: false,
discountStructure: "",
validityPeriod: 30,
attachments: [],
items: [],
});
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: [ queryKey: [
@ -46,6 +103,8 @@ export default function ProformaRequestsPage() {
status, status,
category, category,
search, search,
deadlineFrom,
deadlineTo,
], ],
queryFn: () => queryFn: () =>
invoiceService.getProformaRequests({ invoiceService.getProformaRequests({
@ -54,6 +113,8 @@ export default function ProformaRequestsPage() {
status: status === "all" ? undefined : status, status: status === "all" ? undefined : status,
category: category === "all" ? undefined : category, category: category === "all" ? undefined : category,
search: search || undefined, search: search || undefined,
deadlineFrom: deadlineFrom || undefined,
deadlineTo: deadlineTo || undefined,
}), }),
}); });
@ -114,8 +175,155 @@ export default function ProformaRequestsPage() {
} }
}; };
const createMutation = useMutation({
mutationFn: (data: any) => invoiceService.createProformaRequest(data),
onSuccess: () => {
toast.success("Request created successfully");
setIsModalOpen(false);
queryClient.invalidateQueries({
queryKey: ["admin", "proforma-requests"],
});
},
onError: () => toast.error("Failed to create request"),
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) =>
invoiceService.updateProformaRequest(id, data),
onSuccess: () => {
toast.success("Request updated successfully");
setIsModalOpen(false);
queryClient.invalidateQueries({
queryKey: ["admin", "proforma-requests"],
});
},
onError: () => toast.error("Failed to update request"),
});
const closeMutation = useMutation({
mutationFn: (id: string) => invoiceService.closeProformaRequest(id),
onSuccess: () => {
toast.success("Request closed");
setIsStatusModalOpen(false);
queryClient.invalidateQueries({
queryKey: ["admin", "proforma-requests"],
});
},
onError: () => toast.error("Failed to close request"),
});
const cancelMutation = useMutation({
mutationFn: (id: string) => invoiceService.cancelProformaRequest(id),
onSuccess: () => {
toast.success("Request cancelled");
setIsStatusModalOpen(false);
queryClient.invalidateQueries({
queryKey: ["admin", "proforma-requests"],
});
},
onError: () => toast.error("Failed to cancel request"),
});
const handleOpenCreate = () => {
setEditingRequest(null);
setFormData({
title: "",
description: "",
category: "EQUIPMENT",
submissionDeadline: new Date().toISOString().split("T")[0],
allowRevisions: true,
paymentTerms: "Net 30 days",
incoterms: "EXW",
taxIncluded: false,
discountStructure: "",
validityPeriod: 30,
attachments: [],
items: [
{
itemName: "",
itemDescription: "",
quantity: 1,
unitOfMeasure: "unit",
technicalSpecifications: {},
},
],
});
setIsModalOpen(true);
};
const handleOpenEdit = async (request: ProformaRequest) => {
try {
const fullDetails = await invoiceService.getProformaRequestDetails(
request.id,
);
setEditingRequest(fullDetails);
setFormData({
...fullDetails,
submissionDeadline: new Date(fullDetails.submissionDeadline)
.toISOString()
.split("T")[0],
});
setIsModalOpen(true);
} catch (err) {
toast.error("Failed to load request details");
}
};
const handleOpenDetails = async (request: ProformaRequest) => {
try {
const fullDetails = await invoiceService.getProformaRequestDetails(
request.id,
);
setSelectedRequest(fullDetails);
setIsDetailsOpen(true);
} catch (err) {
toast.error("Failed to load request details");
}
};
const addItem = () => {
setFormData({
...formData,
items: [
...formData.items,
{
itemName: "",
itemDescription: "",
quantity: 1,
unitOfMeasure: "unit",
technicalSpecifications: {},
},
],
});
};
const removeItem = (index: number) => {
setFormData({
...formData,
items: formData.items.filter((_: any, i: number) => i !== index),
});
};
const handleItemChange = (index: number, field: string, value: any) => {
const newItems = [...formData.items];
newItems[index] = { ...newItems[index], [field]: value };
setFormData({ ...formData, items: newItems });
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (editingRequest) {
updateMutation.mutate({
id: editingRequest.id,
data: { ...formData, status: editingRequest.status },
});
} else {
createMutation.mutate(formData);
}
};
return ( return (
<div className="space-y-6"> <div className="space-y-6 max-w-7xl mx-auto mt-10">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight"> <h1 className="text-3xl font-bold tracking-tight">
@ -125,6 +333,15 @@ export default function ProformaRequestsPage() {
Manage and review customer requests for proforma invoices. Manage and review customer requests for proforma invoices.
</p> </p>
</div> </div>
{canCreateBusinessData && (
<Button
onClick={handleOpenCreate}
className="h-10 rounded-md bg-slate-900 hover:bg-slate-800 text-white font-bold px-6 shadow-sm flex items-center gap-2"
>
<Plus className="w-4 h-4" />
New Request
</Button>
)}
</div> </div>
<Card className="border-slate-200/60 shadow-sm"> <Card className="border-slate-200/60 shadow-sm">
@ -165,6 +382,28 @@ export default function ProformaRequestsPage() {
<SelectItem value="MIXED">Mixed</SelectItem> <SelectItem value="MIXED">Mixed</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<div className="flex flex-col gap-1 mb-4">
<Label className="text-[8px] font-bold uppercase text-slate-400">
Deadline From
</Label>
<Input
type="date"
value={deadlineFrom}
onChange={(e) => setDeadlineFrom(e.target.value)}
className="w-[170px] h-9 border-slate-200/80 text-[10px]"
/>
</div>
<div className="flex flex-col gap-1 mb-4">
<Label className="text-[8px] font-bold uppercase text-slate-400">
Deadline To
</Label>
<Input
type="date"
value={deadlineTo}
onChange={(e) => setDeadlineTo(e.target.value)}
className="w-[170px] h-9 border-slate-200/80 text-[10px]"
/>
</div>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
@ -184,11 +423,8 @@ export default function ProformaRequestsPage() {
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6"> <TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
Deadline Deadline
</TableHead> </TableHead>
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6"> <TableHead className="text-right text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6 py-4">
Items Actions
</TableHead>
<TableHead className="text-right text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
Timestamp
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@ -233,16 +469,76 @@ export default function ProformaRequestsPage() {
)} )}
</div> </div>
</TableCell> </TableCell>
<TableCell className="px-6"> <TableCell className="text-right px-6">
<Badge <div className="flex items-center justify-end gap-2">
variant="outline" {canEditBusinessData && (
className="bg-slate-50 border-slate-200 text-slate-600 text-[10px]" <>
> <Button
{request.items.length} Items variant="ghost"
</Badge> size="icon"
</TableCell> className="h-8 w-8 text-slate-400 hover:text-slate-900"
<TableCell className="text-right px-6 text-xs text-slate-500 font-medium"> onClick={() => handleOpenEdit(request)}
{format(new Date(request.createdAt), "HH:mm, MMM dd")} >
<MoreVertical className="w-4 h-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
>
<MoreVertical className="w-4 h-4 text-slate-400" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-40 rounded-md shadow-lg border-slate-200"
>
<DropdownMenuItem
onClick={() => handleOpenDetails(request)}
className="text-xs font-semibold py-2 cursor-pointer"
>
View Details
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleOpenEdit(request)}
className="text-xs font-semibold py-2 cursor-pointer"
>
Edit Request
</DropdownMenuItem>
{request.status !== "CLOSED" && (
<DropdownMenuItem
onClick={() => {
setSelectedRequest(request);
setStatusAction("CLOSE");
setIsStatusModalOpen(true);
}}
className="text-xs font-semibold py-2 text-emerald-600 cursor-pointer"
>
Close Request
</DropdownMenuItem>
)}
{request.status !== "CANCELLED" && (
<DropdownMenuItem
onClick={() => {
setSelectedRequest(request);
setStatusAction("CANCEL");
setIsStatusModalOpen(true);
}}
className="text-xs font-semibold py-2 text-rose-600 cursor-pointer"
>
Cancel Request
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</>
)}
{!canEditBusinessData && (
<Lock className="w-4 h-4 text-slate-200" />
)}
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
@ -290,6 +586,603 @@ export default function ProformaRequestsPage() {
</div> </div>
)} )}
</Card> </Card>
{/* Create/Edit Modal */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto rounded-xl shadow-2xl border-none p-0">
<form onSubmit={handleSubmit} className="flex flex-col h-full">
<DialogHeader className="p-8 border-b border-slate-100 bg-slate-50/30 rounded-t-xl">
<div className="flex items-center justify-between">
<div>
<DialogTitle className="text-2xl font-black tracking-tight text-slate-900">
{editingRequest
? "Edit Proforma Request"
: "New Proforma Request"}
</DialogTitle>
<DialogDescription className="text-xs font-bold uppercase tracking-widest text-slate-400 mt-1">
Complete all technical specifications below.
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="p-8 space-y-8">
{/* Basic Info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-4">
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
Request Title
</Label>
<Input
value={formData.title}
onChange={(e) =>
setFormData({ ...formData, title: e.target.value })
}
placeholder="e.g. Office Equipment Procurement 2024"
className="h-11 rounded-lg border-slate-200"
required
/>
</div>
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
Category
</Label>
<Select
value={formData.category}
onValueChange={(v) =>
setFormData({ ...formData, category: v })
}
>
<SelectTrigger className="h-11 rounded-lg border-slate-200">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EQUIPMENT">EQUIPMENT</SelectItem>
<SelectItem value="SERVICE">SERVICE</SelectItem>
<SelectItem value="MIXED">MIXED</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
Description
</Label>
<Textarea
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder="Provide a detailed overview of the request..."
className="min-h-[110px] rounded-lg border-slate-200 resize-none"
required
/>
</div>
</div>
{/* Terms & Deadlines */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 pt-4 border-t border-slate-50">
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
Submission Deadline
</Label>
<Input
type="date"
value={formData.submissionDeadline}
onChange={(e) =>
setFormData({
...formData,
submissionDeadline: e.target.value,
})
}
className="h-10 rounded-lg border-slate-200"
required
/>
</div>
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
Payment Terms
</Label>
<Input
value={formData.paymentTerms}
onChange={(e) =>
setFormData({ ...formData, paymentTerms: e.target.value })
}
placeholder="e.g. Net 30 days"
className="h-10 rounded-lg border-slate-200"
/>
</div>
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
Incoterms
</Label>
<Input
value={formData.incoterms}
onChange={(e) =>
setFormData({ ...formData, incoterms: e.target.value })
}
placeholder="e.g. EXW, FOB"
className="h-10 rounded-lg border-slate-200"
/>
</div>
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
Discount Structure
</Label>
<Input
value={formData.discountStructure}
onChange={(e) =>
setFormData({
...formData,
discountStructure: e.target.value,
})
}
placeholder="e.g. 5% for >100 units"
className="h-10 rounded-lg border-slate-200"
/>
</div>
</div>
{/* Toggles & Other */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-slate-100">
<div className="space-y-0.5">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Allow Revisions
</Label>
<p className="text-[9px] text-slate-400">
Can customers revise offers?
</p>
</div>
<Switch
checked={formData.allowRevisions}
onCheckedChange={(v) =>
setFormData({ ...formData, allowRevisions: v })
}
/>
</div>
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-slate-100">
<div className="space-y-0.5">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Tax Included
</Label>
<p className="text-[9px] text-slate-400">
Are prices gross or net?
</p>
</div>
<Switch
checked={formData.taxIncluded}
onCheckedChange={(v) =>
setFormData({ ...formData, taxIncluded: v })
}
/>
</div>
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
Validity Period (Days)
</Label>
<Input
type="number"
value={formData.validityPeriod}
onChange={(e) =>
setFormData({
...formData,
validityPeriod: parseInt(e.target.value) || 0,
})
}
className="h-10 rounded-lg border-slate-200"
/>
</div>
</div>
{/* Items Section */}
<div className="space-y-4 pt-4 border-t border-slate-50">
<div className="flex items-center justify-between">
<h3 className="text-sm font-black uppercase tracking-widest text-slate-900 flex items-center gap-2">
<ClipboardList className="w-4 h-4 text-slate-400" />
Technical Items
</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={addItem}
className="h-8 rounded-lg border-slate-200 text-xs font-bold"
>
<Plus className="w-3.5 h-3.5 mr-1" /> Add Item
</Button>
</div>
<div className="space-y-3">
{formData.items.map((item: any, idx: number) => (
<div
key={idx}
className="group relative p-6 bg-slate-50/50 rounded-xl border border-slate-100 space-y-4"
>
<div className="grid grid-cols-1 md:grid-cols-12 gap-4">
<div className="md:col-span-5 grid gap-2">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400">
Item Name
</Label>
<Input
value={item.itemName}
onChange={(e) =>
handleItemChange(idx, "itemName", e.target.value)
}
className="h-9 rounded-lg border-slate-200 bg-white"
/>
</div>
<div className="md:col-span-2 grid gap-2">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400">
Quantity
</Label>
<Input
type="number"
value={item.quantity}
onChange={(e) =>
handleItemChange(
idx,
"quantity",
parseInt(e.target.value) || 0,
)
}
className="h-9 rounded-lg border-slate-200 bg-white"
/>
</div>
<div className="md:col-span-3 grid gap-2">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400">
Unit of Measure
</Label>
<Input
value={item.unitOfMeasure}
onChange={(e) =>
handleItemChange(
idx,
"unitOfMeasure",
e.target.value,
)
}
className="h-9 rounded-lg border-slate-200 bg-white"
/>
</div>
<div className="md:col-span-2 flex items-end justify-end">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeItem(idx)}
className="h-9 w-9 text-slate-400 hover:text-rose-500 hover:bg-rose-50"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
<div className="grid gap-2">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400">
Item Description
</Label>
<Input
value={item.itemDescription}
onChange={(e) =>
handleItemChange(
idx,
"itemDescription",
e.target.value,
)
}
className="h-9 rounded-lg border-slate-200 bg-white"
placeholder="Technical details..."
/>
</div>
<div className="grid gap-2">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400">
Technical Specs (JSON)
</Label>
<Input
value={JSON.stringify(
item.technicalSpecifications || {},
)}
onChange={(e) => {
try {
const specs = JSON.parse(e.target.value);
handleItemChange(
idx,
"technicalSpecifications",
specs,
);
} catch (err) {}
}}
className="h-9 rounded-lg border-slate-200 bg-white font-mono text-[10px]"
placeholder='{"processor": "i7"}'
/>
</div>
</div>
))}
</div>
</div>
</div>
<DialogFooter className="p-8 border-t border-slate-100 bg-slate-50/30 rounded-b-xl mt-auto">
<div className="flex gap-3">
<Button
type="button"
variant="ghost"
onClick={() => setIsModalOpen(false)}
className="rounded-lg font-bold text-xs uppercase"
>
Cancel
</Button>
<Button
type="submit"
disabled={
createMutation.isPending || updateMutation.isPending
}
className="rounded-lg bg-slate-900 hover:bg-slate-800 text-white font-bold text-xs uppercase px-12"
>
{createMutation.isPending || updateMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : editingRequest ? (
"Update Request"
) : (
"Commit Request"
)}
</Button>
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Action Confirmation Modal */}
<Dialog open={isStatusModalOpen} onOpenChange={setIsStatusModalOpen}>
<DialogContent className="max-w-md rounded-xl p-0 shadow-2xl overflow-hidden border-none text-center">
<div
className={`p-8 ${statusAction === "CLOSE" ? "bg-emerald-500" : "bg-rose-500"} text-white`}
>
<div className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center mx-auto mb-4">
{statusAction === "CLOSE" ? (
<Lock className="w-8 h-8" />
) : (
<Ban className="w-8 h-8" />
)}
</div>
<DialogTitle className="text-2xl font-black tracking-tight text-white mb-2">
{statusAction === "CLOSE" ? "Close Request?" : "Cancel Request?"}
</DialogTitle>
<DialogDescription className="text-white/80 font-medium text-sm">
{statusAction === "CLOSE"
? "Closing this request will prevent further quotations from being submitted."
: "Cancelling this request will permanently stop the procurement process."}
</DialogDescription>
</div>
<div className="p-8 space-y-4">
<p className="text-xs font-bold uppercase tracking-widest text-slate-400">
Target:{" "}
<span className="text-slate-900">{selectedRequest?.title}</span>
</p>
<div className="flex flex-col gap-2">
<Button
onClick={() => {
if (selectedRequest) {
if (statusAction === "CLOSE")
closeMutation.mutate(selectedRequest.id);
else cancelMutation.mutate(selectedRequest.id);
}
}}
disabled={closeMutation.isPending || cancelMutation.isPending}
className={`h-11 rounded-lg font-black uppercase text-xs tracking-widest ${statusAction === "CLOSE" ? "bg-emerald-600 hover:bg-emerald-700" : "bg-rose-600 hover:bg-rose-700"}`}
>
{closeMutation.isPending || cancelMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
"Confirm Action"
)}
</Button>
<Button
variant="ghost"
onClick={() => setIsStatusModalOpen(false)}
className="h-11 rounded-lg font-bold text-slate-500 hover:text-slate-900"
>
Go Back
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Details Dialog */}
<Dialog open={isDetailsOpen} onOpenChange={setIsDetailsOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto rounded-xl shadow-2xl border-none p-0">
{selectedRequest && (
<div className="flex flex-col">
<DialogHeader className="p-8 border-b border-slate-100 bg-slate-50/30 rounded-t-xl">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2 mb-2">
{getStatusBadge(selectedRequest.status)}
<span className="text-xs font-bold text-slate-400">
ID: {selectedRequest.id}
</span>
</div>
<DialogTitle className="text-2xl font-black tracking-tight text-slate-900">
{selectedRequest.title}
</DialogTitle>
</div>
</div>
</DialogHeader>
<div className="p-8 space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-4">
<div>
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
Description
</Label>
<p className="text-sm text-slate-600 mt-1 whitespace-pre-wrap">
{selectedRequest.description}
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
Category
</Label>
<div className="flex items-center text-xs font-semibold text-slate-900 mt-1 uppercase tracking-tight">
{getCategoryIcon(selectedRequest.category)}
{selectedRequest.category}
</div>
</div>
<div>
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
Deadline
</Label>
<p className="text-xs font-semibold text-slate-900 mt-1">
{format(
new Date(selectedRequest.submissionDeadline),
"MMMM dd, yyyy",
)}
</p>
</div>
</div>
</div>
<div className="space-y-4 p-6 bg-slate-50 rounded-xl border border-slate-100">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-[9px] font-black uppercase text-slate-400">
Payment Terms
</Label>
<p className="text-xs font-bold text-slate-900">
{selectedRequest.paymentTerms || "N/A"}
</p>
</div>
<div>
<Label className="text-[9px] font-black uppercase text-slate-400">
Incoterms
</Label>
<p className="text-xs font-bold text-slate-900">
{selectedRequest.incoterms || "N/A"}
</p>
</div>
<div>
<Label className="text-[9px] font-black uppercase text-slate-400">
Tax Included
</Label>
<p className="text-xs font-bold text-slate-900">
{selectedRequest.taxIncluded ? "Yes" : "No"}
</p>
</div>
<div>
<Label className="text-[9px] font-black uppercase text-slate-400">
Validity Period
</Label>
<p className="text-xs font-bold text-slate-900">
{selectedRequest.validityPeriod} Days
</p>
</div>
</div>
{selectedRequest.discountStructure && (
<div>
<Label className="text-[9px] font-black uppercase text-slate-400">
Discount Structure
</Label>
<p className="text-xs font-bold text-slate-900 mt-0.5">
{selectedRequest.discountStructure}
</p>
</div>
)}
</div>
</div>
<Separator />
<div className="space-y-4">
<h3 className="text-sm font-black uppercase tracking-widest text-slate-900">
Technical Items
</h3>
<div className="grid gap-4">
{selectedRequest.items.map((item, idx) => (
<div
key={idx}
className="p-6 bg-white border border-slate-200 rounded-xl shadow-sm"
>
<div className="flex items-start justify-between">
<div>
<h4 className="text-sm font-bold text-slate-900">
{item.itemName}
</h4>
<p className="text-xs text-slate-500 mt-1">
{item.itemDescription}
</p>
</div>
<div className="text-right">
<span className="text-[10px] font-black uppercase text-slate-400 block">
Quantity
</span>
<span className="text-sm font-black text-slate-900">
{item.quantity} {item.unitOfMeasure}
</span>
</div>
</div>
{item.technicalSpecifications &&
Object.keys(item.technicalSpecifications).length >
0 && (
<div className="mt-4 pt-4 border-t border-slate-100 grid grid-cols-2 md:grid-cols-3 gap-4">
{Object.entries(item.technicalSpecifications).map(
([key, val]) => (
<div key={key}>
<span className="text-[9px] font-bold uppercase text-slate-400 block">
{key}
</span>
<span className="text-[11px] font-semibold text-slate-700">
{val as string}
</span>
</div>
),
)}
</div>
)}
</div>
))}
</div>
</div>
{selectedRequest.attachments &&
selectedRequest.attachments.length > 0 && (
<div className="space-y-4">
<h3 className="text-sm font-black uppercase tracking-widest text-slate-900">
Attachments
</h3>
<div className="flex flex-wrap gap-2">
{selectedRequest.attachments.map((file: any, idx) => (
<a
key={idx}
href={file.url}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 bg-slate-900 text-white text-[10px] font-bold uppercase tracking-widest rounded-lg hover:bg-slate-800 transition-colors"
>
{file.name}
</a>
))}
</div>
</div>
)}
</div>
<DialogFooter className="p-8 border-t border-slate-100 bg-slate-50/30 rounded-b-xl mt-auto">
<Button
variant="ghost"
onClick={() => setIsDetailsOpen(false)}
className="rounded-lg font-bold text-xs uppercase"
>
Close View
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@ -1,51 +1,98 @@
import { useState } from "react" import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { NavLink } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label" import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge" import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogFooter, DialogFooter,
DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" DialogDescription,
} from "@/components/ui/dialog";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select";
import { Search, Plus } from "lucide-react" import {
import { issueService } from "@/services" Search,
import type { IssueStatus } from "@/services/issue.service" Plus,
import { useAdminRole } from "@/hooks/use-admin-role" LifeBuoy,
import { toast } from "sonner" 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 badgeForStatus = (s: IssueStatus) => { const getPriorityColor = (p: string) => {
const map: Record<IssueStatus, string> = { switch (p) {
OPEN: "bg-orange-100 text-orange-900 border-orange-200", case "HIGH":
IN_PROGRESS: "bg-blue-100 text-blue-900 border-blue-200", return "text-rose-600 bg-rose-50 border-rose-100";
RESOLVED: "bg-emerald-100 text-emerald-900 border-emerald-200", case "MEDIUM":
CLOSED: "bg-gray-100 text-gray-700 border-gray-200", return "text-amber-600 bg-amber-50 border-amber-100";
default:
return "text-slate-600 bg-slate-50 border-slate-100";
} }
return map[s] ?? "" };
}
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() { export default function IssuesPage() {
const { canEdit } = useAdminRole() const { canEditBusinessData: canEdit } = useAdminRole();
const queryClient = useQueryClient() const queryClient = useQueryClient();
const [page, setPage] = useState(1) const [page, setPage] = useState(1);
const [search, setSearch] = useState("") const [search, setSearch] = useState("");
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false);
const [newIssue, setNewIssue] = useState({ const [newIssue, setNewIssue] = useState({
title: "", title: "",
description: "", description: "",
priority: "MEDIUM" as "LOW" | "MEDIUM" | "HIGH", priority: "MEDIUM" as "LOW" | "MEDIUM" | "HIGH",
}) });
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["admin", "issues", page, search], queryKey: ["admin", "issues", page, search],
@ -55,250 +102,436 @@ export default function IssuesPage() {
limit: 12, limit: 12,
search: search.trim() || undefined, search: search.trim() || undefined,
}), }),
}) });
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: () => issueService.create(newIssue), mutationFn: () => issueService.create(newIssue),
onSuccess: () => { onSuccess: () => {
toast.success("Issue reported") toast.success("Issue reported and logged into system");
queryClient.invalidateQueries({ queryKey: ["admin", "issues"] }) queryClient.invalidateQueries({ queryKey: ["admin", "issues"] });
setOpen(false) setOpen(false);
setNewIssue({ setNewIssue({ title: "", description: "", priority: "MEDIUM" });
title: "",
description: "",
priority: "MEDIUM",
})
}, },
onError: () => toast.error("Could not create issue"), onError: () => toast.error("Critical failure during issue report creation"),
}) });
const statusMutation = useMutation({ const statusMutation = useMutation({
mutationFn: ({ id, status }: { id: string; status: IssueStatus }) => mutationFn: ({ id, status }: { id: string; status: IssueStatus }) =>
issueService.updateStatus(id, status), issueService.updateStatus(id, status),
onSuccess: () => { onSuccess: () => {
toast.success("Status updated") toast.success("Workflow status transition successful");
queryClient.invalidateQueries({ queryKey: ["admin", "issues"] }) queryClient.invalidateQueries({ queryKey: ["admin", "issues"] });
}, },
onError: () => toast.error("Update failed"), onError: () => toast.error("Transition refused by system"),
}) });
return ( return (
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen"> <div className="space-y-8 mx-auto max-w-7xl mt-10 animate-in fade-in duration-500">
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4"> {/* Header Section */}
<div> <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"> <h1 className="text-3xl font-bold text-gray-900 tracking-tight">
Issues Issue Tracking
</h1> </h1>
<p className="text-gray-500 mt-1 max-w-xl">
Customers and internal system users can report problems here. Support
staff with edit access can move tickets through the workflow.
</p>
</div> </div>
<Button <Button
className="rounded-none gap-2" 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)} onClick={() => setOpen(true)}
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4 mr-2" />
Report issue Report Issue
</Button> </Button>
</div> </div>
<Card className="border shadow-none rounded-none"> <div className="flex gap-8 border-b border-gray-100">
<CardHeader className="border-b flex flex-row items-center justify-between space-y-0 pb-4"> <NavLink
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400"> to="/admin/support/faq"
Queue className={({ isActive }) =>
</CardTitle> cn(
<div className="relative w-64"> "pb-4 text-xs font-black uppercase tracking-[0.2em] transition-all border-b-2",
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" /> isActive
<Input ? "border-primary text-primary"
className="pl-10 h-9 rounded-none border-gray-200 text-xs" : "border-transparent text-slate-400 hover:text-slate-600",
placeholder="Search title or email…" )
value={search} }
onChange={(e) => { >
setSearch(e.target.value) FAQ repository
setPage(1) </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> </div>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
{error && ( {error && (
<p className="p-6 text-sm text-amber-700 bg-amber-50 border-b"> <div className="m-8 p-2 bg-amber-50 border border-amber-100 rounded-[6px] flex items-center gap-4 text-amber-700">
Wire up <code className="text-xs">GET /admin/issues</code> on your API <AlertCircle className="w-6 h-6 flex-shrink-0" />
to list tickets. <div className="text-sm font-medium">
</p> 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">
<table className="w-full text-left"> <div className="overflow-x-auto min-h-[450px]">
<thead className="bg-gray-50 border-b"> <table className="w-full">
<tr> <thead>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest"> <tr className="bg-slate-50/50 border-b border-slate-100">
Title <th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] w-[35%]">
Objective / Details
</th> </th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest"> <th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
Reporter Reporter Profile
</th> </th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest"> <th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
Type
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Priority Priority
</th> </th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest"> <th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
Updated Timeline
</th> </th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right"> <th className="px-8 py-5 text-right text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
Status Governance
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y"> <tbody className="divide-y divide-slate-100">
{isLoading ? ( {isLoading ? (
<tr> Array.from({ length: 6 }).map((_, i) => (
<td <tr key={i} className="animate-pulse">
colSpan={6} <td className="px-8 py-6">
className="px-6 py-16 text-center text-gray-400 animate-pulse" <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>
Loading
</td>
</tr>
) : data?.data?.length ? (
data.data.map((issue) => (
<tr key={issue.id} className="hover:bg-gray-50 align-top">
<td className="px-6 py-4 text-sm font-semibold text-gray-900 max-w-xs">
{issue.title}
<p className="text-[11px] text-gray-500 font-normal mt-1 line-clamp-2">
{issue.description}
</p>
</td> </td>
<td className="px-6 py-4 text-xs text-gray-600"> <td colSpan={4} className="px-8 py-6">
<div>{issue.reporterEmail}</div> <div className="h-3 bg-slate-50 rounded-full w-32 ml-auto"></div>
<Badge
variant="outline"
className="mt-1 text-[10px] rounded-none"
>
{issue.reporterType}
</Badge>
</td>
<td className="px-6 py-4 text-xs text-gray-500"></td>
<td className="px-6 py-4 text-xs font-bold text-gray-700">
{issue.priority}
</td>
<td className="px-6 py-4 text-xs text-gray-600">
{issue.updatedAt
? new Date(issue.updatedAt).toLocaleString()
: new Date(issue.createdAt).toLocaleString()}
</td>
<td className="px-6 py-4 text-right">
{canEdit ? (
<Select
value={issue.status}
onValueChange={(v) =>
statusMutation.mutate({
id: issue.id,
status: v as IssueStatus,
})
}
>
<SelectTrigger className="h-8 w-[140px] rounded-none text-xs ml-auto">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="OPEN">Open</SelectItem>
<SelectItem value="IN_PROGRESS">
In progress
</SelectItem>
<SelectItem value="RESOLVED">Resolved</SelectItem>
<SelectItem value="CLOSED">Closed</SelectItem>
</SelectContent>
</Select>
) : (
<Badge
variant="outline"
className={`rounded-none text-[10px] ${badgeForStatus(issue.status)}`}
>
{issue.status}
</Badge>
)}
</td> </td>
</tr> </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> <tr>
<td <td colSpan={5} className="px-8 py-24 text-center">
colSpan={6} <div className="flex flex-col items-center justify-center space-y-4 opacity-20 grayscale">
className="px-6 py-16 text-center text-gray-400 italic text-sm" <LifeBuoy className="w-16 h-16" />
> <div className="flex flex-col">
No issues loaded. <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> </td>
</tr> </tr>
)} )}
</tbody> </tbody>
</table> </table>
</div> </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> </CardContent>
</Card> </Card>
{/* Report Modal */}
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="rounded-none max-w-lg"> <DialogContent className="rounded-3xl max-w-lg p-0 border-none shadow-2xl overflow-hidden">
<DialogHeader> <div className="p-8 bg-slate-900 text-white">
<DialogTitle>Report an issue</DialogTitle> <div className="flex items-center gap-3 mb-2">
</DialogHeader> <div className="p-2 bg-white/10 rounded-xl">
<div className="grid gap-3 py-2"> <LifeBuoy className="w-5 h-5 text-primary" />
<div className="grid gap-1"> </div>
<Label htmlFor="iss-title">Title</Label> <span className="text-[10px] font-black uppercase tracking-[0.2em] opacity-60 italic">
<Input Report Anomaly
id="iss-title" </span>
value={newIssue.title}
onChange={(e) =>
setNewIssue((n) => ({ ...n, title: e.target.value }))
}
className="rounded-none"
/>
</div> </div>
<div className="grid gap-1"> <DialogTitle className="text-3xl font-black italic tracking-tighter uppercase leading-none">
<Label htmlFor="iss-desc">Description</Label> Capture <span className="text-primary NOT-italic">Issue</span>
<textarea </DialogTitle>
id="iss-desc" <DialogDescription className="text-slate-400 text-xs font-medium mt-2 leading-relaxed">
value={newIssue.description} Document the system anomaly or customer friction point with
onChange={(e) => technical precision. Reports are immediately queued for triage.
setNewIssue((n) => ({ ...n, description: e.target.value })) </DialogDescription>
} </div>
className="flex min-h-[100px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/> <div className="p-8 space-y-6">
</div> <div className="space-y-4">
<div className="grid gap-1"> <div className="grid gap-2">
<Label>Priority</Label> <Label
<Select htmlFor="iss-title"
value={newIssue.priority} className="text-[10px] font-black uppercase text-slate-400 tracking-widest"
onValueChange={(v) => >
setNewIssue((n) => ({ Descriptive Headline
...n, </Label>
priority: v as typeof newIssue.priority, <Input
})) id="iss-title"
} value={newIssue.title}
> onChange={(e) =>
<SelectTrigger className="rounded-none"> setNewIssue((n) => ({ ...n, title: e.target.value }))
<SelectValue /> }
</SelectTrigger> className="h-12 rounded-xl bg-slate-50 border-slate-200/60 focus:bg-white transition-all font-bold text-sm"
<SelectContent> placeholder="e.g. Authentication loop on mobile safari..."
<SelectItem value="LOW">Low</SelectItem> />
<SelectItem value="MEDIUM">Medium</SelectItem> </div>
<SelectItem value="HIGH">High</SelectItem> <div className="grid gap-2">
</SelectContent> <Label
</Select> 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>
</div> </div>
<DialogFooter>
<DialogFooter className="p-8 pt-4 bg-slate-50 border-t border-slate-100 flex items-center justify-between">
<Button <Button
variant="outline" variant="ghost"
className="rounded-none" className="h-12 px-6 rounded-xl font-black uppercase text-xs tracking-widest text-slate-400 hover:text-slate-900"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
> >
Cancel Discard
</Button> </Button>
<Button <Button
className="rounded-none" 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={ disabled={
createMutation.isPending || createMutation.isPending ||
!newIssue.title.trim() || !newIssue.title.trim() ||
@ -306,11 +539,12 @@ export default function IssuesPage() {
} }
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
> >
Submit {createMutation.isPending ? "Queuing..." : "Commit Report"}
<ArrowRight className="w-4 h-4 ml-2" />
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
) );
} }

View File

@ -3,14 +3,76 @@ import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Search, ChevronLeft, ChevronRight, Filter } from "lucide-react"; import {
Search,
ChevronLeft,
ChevronRight,
Filter,
Plus,
Trash2,
Loader2,
Building2,
ListOrdered,
} from "lucide-react";
import { paymentService } from "@/services"; import { paymentService } from "@/services";
import { cn } from "@/lib/utils"; 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() { export default function PaymentRequestsPage() {
const { canCreateBusinessData } = useAdminRole();
const queryClient = useQueryClient();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [search, setSearch] = useState(""); 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({ const { data: requestsData, isLoading: requestsLoading } = useQuery({
queryKey: ["admin", "payment-requests", page, search], queryKey: ["admin", "payment-requests", page, search],
queryFn: () => queryFn: () =>
@ -21,6 +83,78 @@ export default function PaymentRequestsPage() {
}), }),
}); });
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 formatCurrency = (amount: number | any) => {
const val = typeof amount === "number" ? amount : 0; const val = typeof amount === "number" ? amount : 0;
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {
@ -56,7 +190,17 @@ export default function PaymentRequestsPage() {
Manage outbound customer requests. Manage outbound customer requests.
</p> </p>
</div> </div>
{/* View only access: New Request button removed */} {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> </div>
<Card className="border shadow-none rounded-none"> <Card className="border shadow-none rounded-none">
@ -191,6 +335,510 @@ export default function PaymentRequestsPage() {
</div> </div>
)} )}
</Card> </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> </div>
); );
} }

View File

@ -1,30 +1,143 @@
import { useState } from "react"; import React, { useState } 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 { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Search, Search,
Plus,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Download, Pencil,
Flag, Trash2,
Loader2,
} from "lucide-react"; } 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 { 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() { export default function PaymentsListPage() {
const { canCreateBusinessData, canEditBusinessData, canDeleteBusinessData } =
useAdminRole();
const queryClient = useQueryClient();
const [page, setPage] = useState(1); 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({ const { data: paymentsData, isLoading: paymentsLoading } = useQuery({
queryKey: ["admin", "payments", page], queryKey: ["admin", "payments", page],
queryFn: () => paymentService.getPayments({ page, limit: 10 }), 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 formatCurrency = (amount: number | any) => {
const val = typeof amount === "number" ? amount : 0; const val = typeof amount === "number" ? amount : 0;
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency: "USD", currency: formData.currency || "USD",
}).format(val); }).format(val);
}; };
@ -37,7 +150,17 @@ export default function PaymentsListPage() {
</h1> </h1>
<p className="text-gray-500 mt-1">History of settled transactions.</p> <p className="text-gray-500 mt-1">History of settled transactions.</p>
</div> </div>
{/* View only access: Export button removed */} {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> </div>
<Card className="border shadow-none rounded-none"> <Card className="border shadow-none rounded-none">
@ -74,7 +197,7 @@ export default function PaymentsListPage() {
Date Date
</th> </th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right"> <th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
Status Actions
</th> </th>
</tr> </tr>
</thead> </thead>
@ -83,19 +206,24 @@ export default function PaymentsListPage() {
<tr> <tr>
<td <td
colSpan={6} colSpan={6}
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium" className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
> >
Loading payments... Synchronizing ledger...
</td> </td>
</tr> </tr>
) : paymentsData?.data && paymentsData.data.length > 0 ? ( ) : paymentsData?.data && paymentsData.data.length > 0 ? (
paymentsData.data.map((payment) => ( paymentsData.data.map((payment) => (
<tr key={payment.id} className="hover:bg-gray-50"> <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"> <td className="px-6 py-4 text-sm font-bold text-gray-900 uppercase tracking-tighter">
{payment.transactionId} {payment.transactionId}
</td> </td>
<td className="px-6 py-4 text-sm text-gray-600"> <td className="px-6 py-4 text-sm text-gray-600">
{payment.senderName || "Unknown"} {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>
<td className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase"> <td className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase">
{payment.paymentMethod} {payment.paymentMethod}
@ -107,11 +235,36 @@ export default function PaymentsListPage() {
{new Date(payment.paymentDate).toLocaleDateString()} {new Date(payment.paymentDate).toLocaleDateString()}
</td> </td>
<td className="px-6 py-4 text-right"> <td className="px-6 py-4 text-right">
{payment.isFlagged && ( <div className="flex items-center justify-end gap-2">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-none text-[9px] font-bold uppercase bg-red-50 text-red-600 border border-red-100"> {canEditBusinessData && (
<Flag className="w-2.5 h-2.5" /> Flagged <Button
</span> 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> </td>
</tr> </tr>
)) ))
@ -121,7 +274,7 @@ export default function PaymentsListPage() {
colSpan={6} colSpan={6}
className="px-6 py-20 text-center text-gray-400 italic" className="px-6 py-20 text-center text-gray-400 italic"
> >
No records found. No records found in transaction history.
</td> </td>
</tr> </tr>
)} )}
@ -157,6 +310,233 @@ export default function PaymentsListPage() {
</div> </div>
)} )}
</Card> </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> </div>
); );
} }

View File

@ -1,46 +1,60 @@
import { useState } from "react" import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { NavLink } from "react-router-dom";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label" import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge" import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogFooter, DialogFooter,
DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" DialogDescription,
} from "@/components/ui/dialog";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select";
import { Search, Plus, Pencil } from "lucide-react" import {
import { faqService } from "@/services" Search,
import type { FaqAudience, FaqEntry } from "@/services/faq.service" Plus,
import { useAdminRole } from "@/hooks/use-admin-role" Pencil,
import { toast } from "sonner" 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() { export default function FaqSupportPage() {
const { canEdit } = useAdminRole() const { canEditBusinessData: canEdit } = useAdminRole();
const queryClient = useQueryClient() const queryClient = useQueryClient();
const [tab, setTab] = useState<"browse" | "manage">("browse") const [tab, setTab] = useState<"browse" | "manage">("browse");
const [audienceFilter, setAudienceFilter] = useState<FaqAudience | "ALL">( const [audienceFilter, setAudienceFilter] = useState<FaqAudience | "ALL">(
"ALL", "ALL",
) );
const [search, setSearch] = useState("") const [search, setSearch] = useState("");
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<FaqEntry | null>(null) const [editing, setEditing] = useState<FaqEntry | null>(null);
const [form, setForm] = useState({ const [form, setForm] = useState({
question: "", question: "",
answer: "", answer: "",
audience: "ALL" as FaqAudience, audience: "ALL" as FaqAudience,
}) });
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["admin", "faq", search, audienceFilter], queryKey: ["admin", "faq", search, audienceFilter],
@ -48,10 +62,9 @@ export default function FaqSupportPage() {
faqService.list({ faqService.list({
limit: 100, limit: 100,
search: search.trim() || undefined, search: search.trim() || undefined,
audience: audience: audienceFilter === "ALL" ? undefined : audienceFilter,
audienceFilter === "ALL" ? undefined : audienceFilter,
}), }),
}) });
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
@ -60,193 +73,280 @@ export default function FaqSupportPage() {
question: form.question, question: form.question,
answer: form.answer, answer: form.answer,
audience: form.audience, audience: form.audience,
}) });
} }
return faqService.create({ return faqService.create({
question: form.question, question: form.question,
answer: form.answer, answer: form.answer,
audience: form.audience, audience: form.audience,
isPublished: true, isPublished: true,
}) });
}, },
onSuccess: () => { onSuccess: () => {
toast.success(editing ? "FAQ updated" : "FAQ published") toast.success(
queryClient.invalidateQueries({ queryKey: ["admin", "faq"] }) editing ? "FAQ entry updated" : "FAQ entry published successfully",
setOpen(false) );
setEditing(null) queryClient.invalidateQueries({ queryKey: ["admin", "faq"] });
setForm({ setOpen(false);
question: "", setEditing(null);
answer: "", setForm({ question: "", answer: "", audience: "ALL" });
audience: "ALL",
})
}, },
onError: () => toast.error("Save failed"), onError: () =>
}) toast.error("Failure while committing FAQ data to repository"),
});
const browseItems = const browseItems = data?.data?.filter((e) => e.isPublished !== false) ?? [];
data?.data?.filter((e) => e.isPublished !== false) ?? []
return ( return (
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen"> <div className="space-y-8 mx-auto max-w-7xl mt-10 animate-in fade-in duration-500">
<div> {/* Header Section */}
<h1 className="text-3xl font-bold text-gray-900 tracking-tight"> <div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
FAQ &amp; support <div className="space-y-1">
</h1> <h1 className="text-3xl font-bold text-gray-900 tracking-tight">
<p className="text-gray-500 mt-1 max-w-2xl"> FAQ & <span className="text-primary NOT-italic">Support</span>
Browse answers for end users and internal system users. Editors can </h1>
publish entries and control which audience sees each question. <p className="text-slate-500 text-sm font-medium max-w-xl leading-relaxed">
</p> 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> </div>
<Tabs <Tabs
value={canEdit ? tab : "browse"} value={canEdit ? tab : "browse"}
onValueChange={(v) => { onValueChange={(v) => {
if (canEdit) setTab(v as "browse" | "manage") if (canEdit) setTab(v as "browse" | "manage");
}} }}
className="space-y-6" className="space-y-8"
> >
<TabsList className="rounded-none bg-gray-100 p-1"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6 backdrop-blur-xl p-2 rounded-[6px] ">
<TabsTrigger value="browse" className="rounded-none"> <div className="flex items-center gap-3 w-full sm:w-auto">
Browse <div className="relative group flex-1 sm:min-w-[280px]">
</TabsTrigger> <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" />
{canEdit && (
<TabsTrigger value="manage" className="rounded-none">
Manage content
</TabsTrigger>
)}
</TabsList>
<TabsContent value="browse" className="space-y-4 mt-2">
<div className="flex flex-wrap gap-3 items-center">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input <Input
className="pl-10 h-9 rounded-none border-gray-200 text-xs" 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 questions…" placeholder="Search articles..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
</div> </div>
<Select <Select
value={audienceFilter} value={audienceFilter}
onValueChange={(v) => onValueChange={(v) => setAudienceFilter(v as FaqAudience | "ALL")}
setAudienceFilter(v as FaqAudience | "ALL")
}
> >
<SelectTrigger className="w-[200px] rounded-none h-9 text-xs"> <SelectTrigger className="w-[180px] h-10 rounded-[6px] bg-white border-slate-200/60 font-black text-[10px] uppercase tracking-widest">
<SelectValue placeholder="Audience" /> <SelectValue placeholder="Audience" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="rounded-xl border-slate-100 shadow-xl">
<SelectItem value="ALL">All audiences</SelectItem> <SelectItem
<SelectItem value="END_USER">End users</SelectItem> value="ALL"
<SelectItem value="SYSTEM_USER">System users</SelectItem> 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> </SelectContent>
</Select> </Select>
</div> </div>
</div>
<TabsContent value="browse" className="mt-0 space-y-6">
{error && ( {error && (
<p className="text-sm text-amber-700 bg-amber-50 border px-4 py-3"> <div className="p-6 bg-rose-50 border border-rose-100 rounded-2xl flex items-center gap-4 text-rose-700">
Connect <code className="text-xs">GET /admin/faq</code> to load entries. <HelpCircle className="w-6 h-6 flex-shrink-0" />
</p> <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="space-y-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{isLoading ? ( {isLoading ? (
<p className="text-gray-400 animate-pulse py-12 text-center"> Array.from({ length: 4 }).map((_, i) => (
Loading <div
</p> key={i}
className="h-[200px] bg-slate-100/50 animate-pulse rounded-3xl"
/>
))
) : browseItems.length ? ( ) : browseItems.length ? (
browseItems.map((faq) => ( browseItems.map((faq) => (
<Card <Card
key={faq.id} key={faq.id}
className="border shadow-none rounded-none" 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="pb-2 border-b border-gray-100"> <CardHeader className="p-8 pb-4">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<CardTitle className="text-base font-bold text-gray-900"> <div className="p-3 bg-slate-50 rounded-2xl group-hover:bg-primary/5 transition-colors">
{faq.question} <HelpCircle className="w-5 h-5 text-slate-400 group-hover:text-primary transition-colors" />
</CardTitle> </div>
<Badge variant="outline" className="text-[10px] rounded-none shrink-0"> <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" {faq.audience === "ALL"
? "Everyone" ? "Global"
: faq.audience === "END_USER" : faq.audience === "END_USER"
? "End users" ? "Customer"
: "System users"} : "Internal"}
</Badge> </Badge>
</div> </div>
<CardTitle className="text-xl font-black text-slate-900 tracking-tight mt-4 leading-tight">
{faq.question}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-4 text-sm text-gray-700 leading-relaxed whitespace-pre-wrap"> <CardContent className="px-8 pb-8 pt-0">
{faq.answer} <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> </CardContent>
</Card> </Card>
)) ))
) : ( ) : (
<p className="text-center text-gray-400 py-12 italic text-sm"> <div className="col-span-full py-24 text-center">
No FAQs to display. <div className="flex flex-col items-center justify-center space-y-4 opacity-20 grayscale">
</p> <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> </div>
</TabsContent> </TabsContent>
{canEdit && ( {canEdit && (
<TabsContent value="manage" className="mt-2 space-y-4"> <TabsContent value="manage" className="mt-0 space-y-6">
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
className="rounded-none gap-2" 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={() => { onClick={() => {
setEditing(null) setEditing(null);
setForm({ setForm({ question: "", answer: "", audience: "ALL" });
question: "", setOpen(true);
answer: "",
audience: "ALL",
})
setOpen(true)
}} }}
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4 mr-2" />
New question Publish Entry
</Button> </Button>
</div> </div>
<Card className="border shadow-none rounded-none">
<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"> <CardContent className="p-0">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-left"> <table className="w-full">
<thead className="bg-gray-50 border-b"> <thead>
<tr> <tr className="bg-slate-50/50 border-b border-slate-100">
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest"> <th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] w-[60%]">
Question Intelligence Subject
</th> </th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest"> <th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
Audience Target Audience
</th> </th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right"> <th className="px-8 py-5 text-right text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
Actions Operations
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y"> <tbody className="divide-y divide-slate-100">
{data?.data?.map((faq) => ( {data?.data?.map((faq) => (
<tr key={faq.id}> <tr
<td className="px-6 py-3 text-sm font-medium max-w-md line-clamp-2"> key={faq.id}
{faq.question} 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>
<td className="px-6 py-3 text-xs">{faq.audience}</td> <td className="px-8 py-6">
<td className="px-6 py-3 text-right"> <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 <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="rounded-none h-8" className="h-9 w-9 p-0 rounded-xl hover:bg-white hover:shadow-md transition-all"
onClick={() => { onClick={() => {
setEditing(faq) setEditing(faq);
setForm({ setForm({
question: faq.question, question: faq.question,
answer: faq.answer, answer: faq.answer,
audience: faq.audience, audience: faq.audience,
}) });
setOpen(true) setOpen(true);
}} }}
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4 text-slate-400" />
</Button> </Button>
</td> </td>
</tr> </tr>
@ -260,65 +360,112 @@ export default function FaqSupportPage() {
)} )}
</Tabs> </Tabs>
{/* FAQ Creation/Edit Modal */}
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="rounded-none max-w-lg max-h-[90vh] overflow-y-auto"> <DialogContent className="rounded-3xl max-w-lg p-0 border-none shadow-2xl overflow-hidden">
<DialogHeader> <div className="p-8 bg-slate-900 text-white">
<DialogTitle> <div className="flex items-center gap-3 mb-2">
{editing ? "Edit FAQ" : "New FAQ entry"} <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> </DialogTitle>
</DialogHeader> <DialogDescription className="text-slate-400 text-xs font-medium mt-2 leading-relaxed">
<div className="grid gap-3 py-2"> Authoritative content for the platform knowledge base. Ensure
<div className="grid gap-1"> semantic clarity and audience alignment.
<Label>Audience</Label> </DialogDescription>
<Select </div>
value={form.audience}
onValueChange={(v) => <div className="p-8 space-y-6 max-h-[60vh] overflow-y-auto">
setForm((f) => ({ ...f, audience: v as FaqAudience })) <div className="space-y-4">
} <div className="grid gap-2">
> <Label className="text-[10px] font-black uppercase text-slate-400 tracking-widest">
<SelectTrigger className="rounded-none"> Visibility Tier
<SelectValue /> </Label>
</SelectTrigger> <Select
<SelectContent> value={form.audience}
<SelectItem value="ALL">Everyone (users &amp; system)</SelectItem> onValueChange={(v) =>
<SelectItem value="END_USER">Platform customers only</SelectItem> setForm((f) => ({ ...f, audience: v as FaqAudience }))
<SelectItem value="SYSTEM_USER">Panel / support staff only</SelectItem> }
</SelectContent> >
</Select> <SelectTrigger className="h-12 rounded-xl bg-slate-50 border-slate-200/60 font-black text-[10px] uppercase tracking-widest">
</div> <SelectValue />
<div className="grid gap-1"> </SelectTrigger>
<Label htmlFor="faq-q">Question</Label> <SelectContent className="rounded-xl">
<Input <SelectItem
id="faq-q" value="ALL"
value={form.question} className="text-[10px] font-black uppercase"
onChange={(e) => >
setForm((f) => ({ ...f, question: e.target.value })) Global Visibility
} </SelectItem>
className="rounded-none" <SelectItem
/> value="END_USER"
</div> className="text-[10px] font-black uppercase"
<div className="grid gap-1"> >
<Label htmlFor="faq-a">Answer</Label> Platform Customers Only
<textarea </SelectItem>
id="faq-a" <SelectItem
value={form.answer} value="SYSTEM_USER"
onChange={(e) => className="text-[10px] font-black uppercase text-primary"
setForm((f) => ({ ...f, answer: e.target.value })) >
} Internal Panel Staff Only
className="flex min-h-[140px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" </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>
</div> </div>
<DialogFooter>
<DialogFooter className="p-8 pt-4 bg-slate-50 border-t border-slate-100 flex items-center justify-between">
<Button <Button
variant="outline" variant="ghost"
className="rounded-none" className="h-12 px-6 rounded-xl font-black uppercase text-xs tracking-widest text-slate-400 hover:text-slate-900"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
> >
Cancel Discard
</Button> </Button>
<Button <Button
className="rounded-none" 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={ disabled={
saveMutation.isPending || saveMutation.isPending ||
!form.question.trim() || !form.question.trim() ||
@ -326,11 +473,12 @@ export default function FaqSupportPage() {
} }
onClick={() => saveMutation.mutate()} onClick={() => saveMutation.mutate()}
> >
Save {saveMutation.isPending ? "Syncing..." : "Publish Intelligence"}
<ArrowRight className="w-4 h-4 ml-2" />
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
) );
} }

View File

@ -1,19 +1,31 @@
import { useState } from "react" import { useState } from "react";
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge" import { Input } from "@/components/ui/input";
import { Search, CheckCircle2, XCircle } from "lucide-react" import { Badge } from "@/components/ui/badge";
import { subscriptionTransactionService } from "@/services" import {
import type { SubscriptionPaymentStatus } from "@/services/subscription-transaction.service" 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() { export default function SubscriptionTransactionsPage() {
const [tab, setTab] = useState<"succeeded" | "failed">("succeeded") const [tab, setTab] = useState<"succeeded" | "failed">("succeeded");
const [page, setPage] = useState(1) const [page, setPage] = useState(1);
const [search, setSearch] = useState("") const [search, setSearch] = useState("");
const status: SubscriptionPaymentStatus = const status: SubscriptionPaymentStatus =
tab === "succeeded" ? "SUCCEEDED" : "FAILED" tab === "succeeded" ? "SUCCEEDED" : "FAILED";
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["admin", "subscription-transactions", status, page, search], queryKey: ["admin", "subscription-transactions", status, page, search],
@ -24,196 +36,280 @@ export default function SubscriptionTransactionsPage() {
limit: 10, limit: 10,
search: search.trim() || undefined, search: search.trim() || undefined,
}), }),
}) });
const formatMoney = (amount: number, currency: string) => const formatMoney = (amount: number, currency: string) =>
new Intl.NumberFormat("en-US", { style: "currency", currency }).format( new Intl.NumberFormat("en-US", {
amount, style: "currency",
) currency,
minimumFractionDigits: 2,
}).format(amount);
return ( return (
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen"> <div className="space-y-8 animate-in fade-in duration-500">
<div> {/* Header Section */}
<h1 className="text-3xl font-bold text-gray-900 tracking-tight"> <div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
Subscription transactions <div className="space-y-1">
</h1> <div className="flex items-center gap-2 text-primary mb-1">
<p className="text-gray-500 mt-1"> <div className="p-2 bg-primary/10 rounded-lg">
Successful charges and failed attempts for platform subscriptions. <ArrowRightLeft className="w-5 h-5" />
</p> </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>
<Tabs <div className="grid grid-cols-1 gap-8">
value={tab} <Card className="border-none shadow-[0_8px_30px_rgb(0,0,0,0.04)] bg-white/50 backdrop-blur-xl rounded-3xl overflow-hidden">
onValueChange={(v) => { <CardHeader className="p-8 border-b border-slate-100/50 space-y-6">
setTab(v as "succeeded" | "failed") <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6">
setPage(1) <Tabs
}} value={tab}
className="space-y-6" onValueChange={(v) => {
> setTab(v as "succeeded" | "failed");
<TabsList className="rounded-none bg-gray-100 p-1"> setPage(1);
<TabsTrigger value="succeeded" className="rounded-none gap-2"> }}
<CheckCircle2 className="h-4 w-4 text-emerald-600" /> className="w-full sm:w-auto"
Successful payments >
</TabsTrigger> <TabsList className="bg-slate-100/80 p-1 rounded-xl h-11 border border-slate-200/50">
<TabsTrigger value="failed" className="rounded-none gap-2"> <TabsTrigger
<XCircle className="h-4 w-4 text-red-600" /> value="succeeded"
Failed payments 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"
</TabsTrigger> >
</TabsList> <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>
<Card className="border shadow-none rounded-none"> <div className="relative group min-w-[300px]">
<CardHeader className="border-b flex flex-row items-center justify-between space-y-0 pb-4"> <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" />
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
{tab === "succeeded"
? "Completed subscription charges"
: "Declined or errored charges"}
</CardTitle>
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input <Input
className="pl-10 h-9 rounded-none border-gray-200 text-xs" 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 email, ref, user…" placeholder="Query by email, ID or reference..."
value={search} value={search}
onChange={(e) => { onChange={(e) => {
setSearch(e.target.value) setSearch(e.target.value);
setPage(1) setPage(1);
}} }}
/> />
</div> </div>
</CardHeader> </div>
<CardContent className="p-0"> </CardHeader>
{error && (
<p className="p-6 text-sm text-red-600"> <CardContent className="p-0">
Unable to load transactions. Ensure the API exposes{" "} {error && (
<code className="text-xs bg-gray-100 px-1"> <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 GET /admin/subscription-transactions
</code> </code>{" "}
. is reachable.
</p>
)}
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
User
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Plan
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Amount
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Provider / Ref
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Date
</th>
{tab === "failed" && (
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Reason
</th>
)}
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
Status
</th>
</tr>
</thead>
<tbody className="divide-y">
{isLoading ? (
<tr>
<td
colSpan={tab === "failed" ? 7 : 6}
className="px-6 py-16 text-center text-gray-400 animate-pulse"
>
Loading
</td>
</tr>
) : data?.data && data.data.length > 0 ? (
data.data.map((row) => (
<tr key={row.id} className="hover:bg-gray-50">
<td className="px-6 py-4 text-sm">
<div className="font-semibold text-gray-900">
{row.userEmail}
</div>
<div className="text-[11px] text-gray-500">
{row.userId}
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-700">
{row.planName}
</td>
<td className="px-6 py-4 text-sm font-bold text-gray-900">
{formatMoney(row.amount, row.currency)}
</td>
<td className="px-6 py-4 text-xs text-gray-600">
<div>{row.provider}</div>
{row.providerRef && (
<div className="text-[10px] font-mono text-gray-400 mt-0.5">
{row.providerRef}
</div>
)}
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{new Date(row.createdAt).toLocaleString()}
</td>
{tab === "failed" && (
<td className="px-6 py-4 text-xs text-red-700 max-w-[200px]">
{row.failureReason ?? "—"}
</td>
)}
<td className="px-6 py-4 text-right">
<Badge
variant="outline"
className="rounded-none text-[10px] uppercase"
>
{row.status}
</Badge>
</td>
</tr>
))
) : (
<tr>
<td
colSpan={tab === "failed" ? 7 : 6}
className="px-6 py-16 text-center text-gray-400 italic text-sm"
>
No rows for this filter.
</td>
</tr>
)}
</tbody>
</table>
</div>
{data && data.totalPages > 1 && (
<div className="flex justify-between items-center px-6 py-3 border-t text-xs text-gray-600">
<span>
Page {data.page} of {data.totalPages}
</span>
<div className="flex gap-2">
<button
type="button"
className="underline disabled:opacity-40"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Previous
</button>
<button
type="button"
className="underline disabled:opacity-40"
disabled={page >= data.totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
</div> </div>
)} </div>
</CardContent> )}
</Card>
</Tabs> <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> </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

@ -4,18 +4,27 @@ 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 {
import { Search, Eye, ChevronLeft, ChevronRight, Filter } from "lucide-react"; Search,
Eye,
ChevronLeft,
ChevronRight,
Filter,
Plus,
} from "lucide-react";
import { userService } from "@/services"; import { userService } from "@/services";
import { useAdminRole } from "@/hooks/use-admin-role";
import { format } from "date-fns"; import { format } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toast } from "sonner";
export default function UsersPage() { export default function UsersPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { canCreateUsers } = useAdminRole();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [limit] = useState(15); const [limit] = useState(15);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [roleFilter, setRoleFilter] = useState<string>("all"); const [roleFilter] = useState<string>("all");
const { data: usersData, isLoading } = useQuery({ const { data: usersData, isLoading } = useQuery({
queryKey: ["admin", "users", page, limit, search, roleFilter], queryKey: ["admin", "users", page, limit, search, roleFilter],
@ -52,7 +61,17 @@ export default function UsersPage() {
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* View only access: Add User and Import buttons removed */} {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>

View File

@ -1,41 +1,74 @@
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 } 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 { import {
Search, Search,
Eye,
CheckCheck, CheckCheck,
Send,
Plus,
BellRing,
Mail,
MessageSquare,
History,
Target,
ArrowRight,
ChevronRight,
Loader2, Loader2,
Calendar, Calendar,
Mail,
Tag,
} from "lucide-react"; } from "lucide-react";
import { notificationService } from "@/services/notification.service"; 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 { toast } from "sonner";
import { format } from "date-fns"; import { format } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export default function NotificationsPage() { type Channel = "PUSH" | "SMS" | "EMAIL";
const [searchQuery, setSearchQuery] = useState("");
const [typeFilter, setTypeFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const { export default function NotificationsPage() {
data: notifications, const { canSendNotifications } = useAdminRole();
isLoading, const queryClient = useQueryClient();
refetch, const [searchQuery, setSearchQuery] = useState("");
} = useQuery({ const [activeChannel, setActiveChannel] = useState<Channel>("PUSH");
const [isSendModalOpen, setIsSendModalOpen] = useState(false);
// Combined form state
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"], queryKey: ["notifications"],
queryFn: () => notificationService.getNotifications(), queryFn: () => notificationService.getNotifications(),
}); });
@ -45,257 +78,490 @@ export default function NotificationsPage() {
queryFn: () => notificationService.getUnreadCount(), queryFn: () => notificationService.getUnreadCount(),
}); });
// Client-side filtering 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"] });
},
});
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;
// 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]); }, [notifications, searchQuery]);
const handleMarkAsRead = async (id: string) => { const handleSend = () => {
try { if (activeChannel === "PUSH") pushMutation.mutate(pushForm);
await notificationService.markAsRead(id); else if (activeChannel === "SMS") smsMutation.mutate(smsForm);
toast.success("Notification marked as read"); else if (activeChannel === "EMAIL") emailMutation.mutate(emailForm);
refetch();
} catch (error) {
toast.error("Failed to mark notification as read");
console.error("Mark as read error:", error);
}
}; };
const handleMarkAllAsRead = async () => { const isPending =
try { pushMutation.isPending || smsMutation.isPending || emailMutation.isPending;
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 getStatusBadge = (isRead: boolean) => {
return isRead ? (
<Badge
variant="outline"
className="text-[10px] font-bold uppercase tracking-widest text-slate-400 border-slate-200"
>
Read
</Badge>
) : (
<Badge
variant="default"
className="text-[10px] font-bold uppercase tracking-widest bg-orange-500 hover:bg-orange-600"
>
Unread
</Badge>
);
};
const getTypeIcon = (type: string) => {
switch (type.toLowerCase()) {
case "system":
return <Tag className="w-3.5 h-3.5 mr-1.5" />;
case "alert":
return <CheckCheck className="w-3.5 h-3.5 mr-1.5" />;
case "invoice":
return <Mail className="w-3.5 h-3.5 mr-1.5" />;
default:
return <Tag className="w-3.5 h-3.5 mr-1.5" />;
}
};
return ( return (
<div className="space-y-6"> <div className="space-y-8 animate-in fade-in duration-500">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4"> {/* Header Section */}
<div> <div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
<h1 className="text-3xl font-bold tracking-tight">Notifications</h1> <div className="space-y-1">
{unreadCount !== undefined && unreadCount > 0 ? ( <div className="flex items-center gap-2 text-primary mb-1">
<p className="text-muted-foreground mt-1"> <div className="p-2 bg-primary/10 rounded-lg">
You have{" "} <BellRing className="w-5 h-5" />
<span className="font-bold text-slate-900">{unreadCount}</span>{" "} </div>
unread notification{unreadCount !== 1 ? "s" : ""} <span className="text-xs font-black uppercase tracking-widest opacity-70">
</p> Messaging Hub
) : ( </span>
<p className="text-muted-foreground mt-1"> </div>
All messages been processed. <h1 className="text-4xl font-black tracking-tighter text-slate-900 uppercase italic">
</p> 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 <Button
variant="outline" variant="ghost"
size="sm" 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"
onClick={handleMarkAllAsRead} onClick={() => notificationService.markAllAsRead()}
className="border-slate-200 text-[10px] font-bold uppercase tracking-widest h-9" >
> <CheckCheck className="w-4 h-4 mr-2" />
<CheckCheck className="w-4 h-4 mr-2" /> Clear Signal
Mark All as Read </Button>
</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>
</div> </div>
</div> </div>
<Card className="border-slate-200/60 shadow-sm rounded-none"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<CardHeader className="pb-3 border-b border-slate-100 bg-slate-50/30"> {/* Stats / Quick Info */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4"> <div className="lg:col-span-1 space-y-6">
<div className="flex flex-1 items-center gap-4"> <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="relative flex-1 max-w-sm"> <div className="space-y-6">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <div className="flex items-center justify-between">
<Input <span className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40">
placeholder="Filter notifications..." System Status
className="pl-9 h-10 border-slate-200/80 focus-visible:ring-slate-900 rounded-none shadow-none" </span>
value={searchQuery} <Badge className="bg-emerald-500 text-white border-none text-[10px] rounded-full px-2 py-0">
onChange={(e) => setSearchQuery(e.target.value)} Active
/> </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="h-10 px-3 bg-white border border-slate-200 text-xs font-bold uppercase tracking-widest rounded-none focus:outline-none focus:ring-1 focus:ring-slate-900"
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="h-10 px-3 bg-white border border-slate-200 text-xs font-bold uppercase tracking-widest rounded-none focus:outline-none focus:ring-1 focus:ring-slate-900"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="">All Status</option>
<option value="read">Read</option>
<option value="unread">Unread</option>
</select>
</div> </div>
</div> </div>
</CardHeader>
<CardContent className="p-0"> <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-24"> variant="ghost"
<Loader2 className="w-8 h-8 animate-spin text-slate-300" /> 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 className="bg-slate-50/50"> </Button>
<TableRow className="hover:bg-transparent border-slate-100"> <Button
<TableHead className="w-[120px] text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6 py-4"> 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"
ID Reference disabled={isPending}
</TableHead> onClick={handleSend}
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6"> >
Message Intelligence {isPending ? (
</TableHead> <>
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6"> <Loader2 className="w-4 h-4 mr-2 animate-spin" />
Classification Broadcasting...
</TableHead> </>
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6"> ) : (
State <>
</TableHead> Commit {activeChannel} Signal
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6"> <ArrowRight className="w-4 h-4 ml-2" />
Timeline </>
</TableHead> )}
<TableHead className="text-right text-[10px] font-bold uppercase tracking-widest text-slate-500 px-10"> </Button>
Actions </DialogFooter>
</TableHead> </DialogContent>
</TableRow> </Dialog>
</TableHeader>
<TableBody>
{filteredNotifications.map((notification) => (
<TableRow
key={notification.id}
className={cn(
"group hover:bg-slate-50 transition-colors border-slate-100",
!notification.isRead && "bg-slate-50/20",
)}
>
<TableCell className="px-6 py-4">
<code className="text-[10px] font-bold text-slate-400 font-mono tracking-tighter">
{notification.id.substring(0, 12)}
</code>
</TableCell>
<TableCell className="px-6 py-4">
<div className="flex flex-col gap-0.5">
<span
className={cn(
"text-xs font-black tracking-tight uppercase",
notification.isRead
? "text-slate-500"
: "text-slate-900",
)}
>
{notification.title}
</span>
<span className="text-xs text-slate-500 line-clamp-1">
{notification.message}
</span>
</div>
</TableCell>
<TableCell className="px-6">
<div className="flex items-center text-[10px] font-bold uppercase tracking-widest text-slate-600">
{getTypeIcon(notification.type)}
{notification.type}
</div>
</TableCell>
<TableCell className="px-6">
{getStatusBadge(notification.isRead)}
</TableCell>
<TableCell className="px-6">
<div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-500">
<Calendar className="w-3.5 h-3.5 text-slate-300" />
{format(
new Date(notification.createdAt),
"MMM dd, HH:mm",
)}
</div>
</TableCell>
<TableCell className="text-right px-6">
{!notification.isRead && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-orange-500 transition-colors"
onClick={() => handleMarkAsRead(notification.id)}
>
<Eye className="w-4 h-4" />
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="text-center py-24 text-slate-400 font-bold uppercase tracking-widest text-[10px]">
{searchQuery || typeFilter || statusFilter
? "No matching telemetry records found"
: "No notification stream detected"}
</div>
)}
</CardContent>
</Card>
</div> </div>
); );
} }

View File

@ -67,11 +67,13 @@ export interface Proforma {
} }
export interface ProformaRequestItem { export interface ProformaRequestItem {
id: string; id?: string;
itemName: string; itemName: string;
itemDescription?: string;
quantity: number; quantity: number;
unitOfMeasure: string; unitOfMeasure: string;
createdAt: string; technicalSpecifications?: Record<string, any>;
createdAt?: string;
} }
export interface ProformaRequest { export interface ProformaRequest {
@ -90,7 +92,11 @@ export interface ProformaRequest {
submissionDeadline: string; submissionDeadline: string;
allowRevisions: boolean; allowRevisions: boolean;
paymentTerms: string; paymentTerms: string;
incoterms?: string;
taxIncluded: boolean; taxIncluded: boolean;
discountStructure?: string;
validityPeriod?: number;
attachments?: { name: string; url: string }[];
items: ProformaRequestItem[]; items: ProformaRequestItem[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@ -144,6 +150,29 @@ class InvoiceService {
return response.data; 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 * Get all proforma invoices
*/ */
@ -159,6 +188,29 @@ class InvoiceService {
return response.data; 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) * Get all proforma requests (admin view)
*/ */
@ -173,6 +225,48 @@ class InvoiceService {
); );
return response.data; 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(); export const invoiceService = new InvoiceService();

View File

@ -1,128 +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");
} }
/** /**
* Broadcast push, SMS, and/or email (Super Admin & Admin only) * Send push notification (ADMIN only)
*/ */
async sendBroadcast(data: { async sendPushNotification(
title: string data: SendPushNotificationRequest,
message: string ): Promise<Notification> {
channels: ('push' | 'sms' | 'email')[] const response = await apiClient.post<Notification>(
audience?: 'all_end_users' | 'system_users_only' | 'everyone_with_access' "/admin/notifications/send-push",
}): Promise<{ id: string }> {
const response = await apiClient.post<{ id: string }>(
'/admin/notifications/broadcast',
data, data,
) );
return response.data return response.data;
} }
/** /**
* Send notification (ADMIN only) * Send SMS notification (ADMIN only)
*/ */
async sendNotification(data: { async sendSmsNotification(
title: string data: SendSmsNotificationRequest,
message: string ): Promise<{ success: boolean; messageId: string }> {
type: string const response = await apiClient.post<{
recipient?: string success: boolean;
recipientType?: 'user' | 'all' messageId: string;
}): Promise<Notification> { }>("/admin/notifications/send-sms", data);
const response = await apiClient.post<Notification>('/notifications/send', data) return response.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`);
} }
/** /**
@ -130,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

@ -40,11 +40,17 @@ export interface PaymentRequest {
copiedAccountCount: number; copiedAccountCount: number;
status: "DRAFT" | "SENT" | "OPENED" | "PAID" | "EXPIRED" | "CANCELLED"; status: "DRAFT" | "SENT" | "OPENED" | "PAID" | "EXPIRED" | "CANCELLED";
paymentId: string; paymentId: string;
accounts: any[]; accounts: {
bankName: string;
accountName: string;
accountNumber: string;
currency: string;
}[];
pdfPath: string; pdfPath: string;
userId: string; userId: string;
customerId?: string;
items: { items: {
id: string; id?: string;
description: string; description: string;
quantity: number; quantity: number;
unitPrice: number; unitPrice: number;
@ -99,6 +105,29 @@ class PaymentService {
return response.data; 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 * Get payment requests
*/ */
@ -113,6 +142,35 @@ class PaymentService {
); );
return response.data; 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(); export const paymentService = new PaymentService();