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",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-is": "^19.2.5",
"react-router-dom": "^7.11.0",
"recharts": "^3.6.0",
"sonner": "^2.0.7",
@ -7053,11 +7054,10 @@
}
},
"node_modules/react-is": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
"license": "MIT",
"peer": true
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz",
"integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==",
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.2.0",

View File

@ -38,6 +38,7 @@
"lucide-react": "^0.561.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-is": "^19.2.5",
"react-router-dom": "^7.11.0",
"recharts": "^3.6.0",
"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 { authService } from "@/services"
import {
canAccessSystemMembers,
canEdit,
canSendBroadcast,
hasPanelAccess,
isSuperAdmin,
roleLabel,
} from "@/lib/admin-roles"
import { useMemo } from "react";
import { authService } from "@/services";
import { getPermissions, hasPanelAccess, roleLabel } from "@/lib/admin-roles";
export function useAdminRole() {
return useMemo(() => {
const user = authService.getCurrentUser() as
| { role?: string; email?: string }
| null
const role = user?.role
const user = authService.getCurrentUser() as {
role?: string;
email?: string;
} | null;
const role = user?.role;
const permissions = useMemo(() => getPermissions(role), [role]);
return {
role,
roleLabel: roleLabel(role),
isSuperAdmin: isSuperAdmin(role),
canEdit: canEdit(role),
canAccessSystemMembers: canAccessSystemMembers(role),
canSendBroadcast: canSendBroadcast(role),
hasPanelAccess: hasPanelAccess(role),
}
}, [])
...permissions,
};
}

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 {
LayoutDashboard,
@ -14,15 +14,16 @@ import {
Bell,
LogOut,
CreditCard,
FileClock,
Receipt,
FileSearch,
ClipboardList,
ArrowRightLeft,
UserCog,
LifeBuoy,
HelpCircle,
Send,
ChevronDown,
ChevronRight,
Folder,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { AdminQuickSearch } from "@/components/admin-quick-search";
@ -36,7 +37,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { roleLabel } from "@/lib/admin-roles";
import { roleLabel, getPermissions } from "@/lib/admin-roles";
import { authService } from "@/services";
interface User {
@ -47,58 +48,198 @@ interface User {
}
type NavItem = {
icon: ComponentType<{ className?: string }>;
icon?: ComponentType<{ className?: string }>;
label: string;
path: string;
path?: string;
children?: NavItem[];
/** Omit = visible to all panel roles */
visible?: (role: string | undefined) => boolean;
};
const adminNavigationItems: NavItem[] = [
{ icon: LayoutDashboard, label: "Dashboard", path: "/admin/dashboard" },
{ icon: Receipt, label: "Invoices", path: "/admin/invoices" },
{ icon: FileSearch, label: "Proforma", path: "/admin/proforma" },
{
icon: ClipboardList,
label: "Proforma Requests",
path: "/admin/proforma-requests",
icon: Folder,
label: "Documents",
children: [
{
label: "Invoices",
path: "/admin/invoices",
icon: Receipt,
visible: (role) => getPermissions(role).canViewBusinessData,
},
{ icon: CreditCard, label: "Payments", path: "/admin/payments" },
{
icon: FileClock,
label: "Payment Requests",
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,
label: "Subscription transactions",
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,
label: "System users",
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: 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: Activity, label: "Audit", path: "/admin/audit" },
{ icon: Shield, label: "Security", path: "/admin/security" },
{ icon: BarChart3, label: "Analytics", path: "/admin/analytics" },
{ icon: Heart, label: "System Health", path: "/admin/health" },
{
icon: Activity,
label: "Audit",
path: "/admin/audit",
visible: (role) => getPermissions(role).canManageSystem,
},
{
icon: Shield,
label: "Security",
path: "/admin/security",
visible: (role) => getPermissions(role).canManageSystem,
},
{
icon: BarChart3,
label: "Analytics",
path: "/admin/analytics",
visible: (role) => getPermissions(role).canManageSystem,
},
{
icon: Heart,
label: "System Health",
path: "/admin/health",
visible: (role) => getPermissions(role).canManageSystem,
},
{
icon: Send,
label: "Send notification",
path: "/admin/notifications/broadcast",
visible: (role) => 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() {
const location = useLocation();
const navigate = useNavigate();
@ -117,12 +258,11 @@ export function AppShell() {
return null;
});
const isActive = (path: string) => {
const isActive = (path?: string) => {
if (!path) return false;
return location.pathname.startsWith(path);
};
// Removed unused getPageTitle as header title is no longer displayed
const handleLogout = async () => {
await authService.logout();
navigate("/login", { replace: true });
@ -170,27 +310,14 @@ export function AppShell() {
{/* Navigation */}
<nav className="flex-1 px-4 py-2 space-y-1 overflow-y-auto">
{adminNavigationItems
.filter((item) =>
item.visible ? item.visible(user?.role) : true,
)
.map((item) => {
const Icon = item.icon;
return (
<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>
);
})}
.filter((item) => (item.visible ? item.visible(user?.role) : true))
.map((item) => (
<SidebarNavItem
key={item.label}
item={item}
isActive={isActive}
/>
))}
</nav>
{/* User Section */}

View File

@ -6,50 +6,110 @@ export const AdminRole = {
SUPER_ADMIN: "SUPER_ADMIN",
ADMIN: "ADMIN",
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>([
AdminRole.SUPER_ADMIN,
AdminRole.ADMIN,
AdminRole.CUSTOMER_SUPPORT,
])
]);
export function hasPanelAccess(role: string | undefined): boolean {
if (!role) return false
return PANEL_ROLES.has(role)
}
/** Full control (all menus + destructive actions) */
export function isSuperAdmin(role: string | undefined): boolean {
return role === AdminRole.SUPER_ADMIN
}
/** Can create/edit content (not read-only support) */
export function canEdit(role: string | undefined): boolean {
return role === AdminRole.SUPER_ADMIN || role === AdminRole.ADMIN
}
/** Internal system users / members management */
export function canAccessSystemMembers(role: string | undefined): boolean {
return role === AdminRole.SUPER_ADMIN || role === AdminRole.ADMIN
}
/** Push / SMS / email broadcast composer */
export function canSendBroadcast(role: string | undefined): boolean {
return role === AdminRole.SUPER_ADMIN || role === AdminRole.ADMIN
if (!role) return false;
return PANEL_ROLES.has(role);
}
export function roleLabel(role: string | undefined): string {
switch (role) {
case AdminRole.SUPER_ADMIN:
return "System Admin"
return "System Admin";
case AdminRole.ADMIN:
return "Admin"
return "Admin";
case AdminRole.CUSTOMER_SUPPORT:
return "Customer Support"
return "Customer Support";
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>
<Card className="border shadow-none rounded-none">
<CardHeader className="border-b pb-4 flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
Bulletin Archive
</CardTitle>
<CardHeader className="border-b pb-4 flex flex-row items-end justify-end space-y-0">
<Button
variant="outline"
size="sm"
@ -223,7 +220,7 @@ export default function AnnouncementsPage() {
colSpan={5}
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
>
Synchronizing broadcast data...
Loading...
</td>
</tr>
) : announcements && announcements.length > 0 ? (
@ -313,7 +310,7 @@ export default function AnnouncementsPage() {
colSpan={5}
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>
</tr>
)}

View File

@ -1,5 +1,5 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@ -8,14 +8,64 @@ import {
ChevronLeft,
ChevronRight,
Filter,
Download,
Plus,
Pencil,
Trash2,
Loader2,
X,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { invoiceService } from "@/services";
import { useAdminRole } from "@/hooks/use-admin-role";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import type { Invoice, InvoiceItem } from "@/services/invoice.service";
export default function InvoicesPage() {
const { canCreateBusinessData, canEditBusinessData, canDeleteBusinessData } =
useAdminRole();
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [editingInvoice, setEditingInvoice] = useState<Invoice | null>(null);
const [invoiceToDelete, setInvoiceToDelete] = useState<string | null>(null);
// Form State
const [formData, setFormData] = useState<Partial<Invoice>>({
invoiceNumber: "",
customerName: "",
customerEmail: "",
customerPhone: "",
amount: 0,
currency: "USD",
type: "SALES",
status: "DRAFT",
issueDate: new Date().toISOString().split("T")[0],
dueDate: new Date().toISOString().split("T")[0],
description: "",
notes: "",
taxAmount: 0,
discountAmount: 0,
items: [] as InvoiceItem[],
});
const { data: invoicesData, isLoading } = useQuery({
queryKey: ["admin", "invoices", page, search],
@ -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 val = typeof amount === "number" ? amount : 0;
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
currency: formData.currency || "USD",
}).format(val);
};
@ -61,9 +252,17 @@ export default function InvoicesPage() {
Manage sales and purchase invoices.
</p>
</div>
{canCreateBusinessData && (
<div className="flex items-center gap-2">
{/* View only access: Create and Export buttons removed */}
<Button
onClick={handleOpenCreate}
className="h-9 rounded-none bg-slate-900 border-none uppercase text-[10px] font-bold tracking-widest"
>
<Plus className="w-4 h-4 mr-2" />
Create Invoice
</Button>
</div>
)}
</div>
<Card className="border shadow-none rounded-none">
@ -110,16 +309,19 @@ export default function InvoicesPage() {
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Status
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Date
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y">
{isLoading ? (
<tr>
<td
colSpan={6}
colSpan={7}
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
>
Synchronizing ledger data...
@ -169,15 +371,47 @@ export default function InvoicesPage() {
{invoice.status}
</span>
</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()}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
{canEditBusinessData && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-900"
onClick={() => handleOpenEdit(invoice)}
>
<Pencil className="w-4 h-4" />
</Button>
)}
{canDeleteBusinessData && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-rose-600"
onClick={() => {
setInvoiceToDelete(invoice.id);
setIsDeleteModalOpen(true);
}}
>
<Trash2 className="w-4 h-4" />
</Button>
)}
{!canEditBusinessData && !canDeleteBusinessData && (
<span className="text-[10px] font-bold text-slate-300 uppercase italic">
View Only
</span>
)}
</div>
</td>
</tr>
))
) : (
<tr>
<td
colSpan={6}
colSpan={7}
className="px-6 py-20 text-center text-gray-400 italic"
>
No invoices found in ledger.
@ -216,6 +450,371 @@ export default function InvoicesPage() {
</div>
)}
</Card>
{/* Create/Edit Modal */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto rounded-none">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle className="text-xl font-bold uppercase tracking-tight">
{editingInvoice ? "Update Invoice" : "Create New Invoice"}
</DialogTitle>
<DialogDescription className="text-xs font-bold uppercase tracking-widest text-slate-400">
Configure administrative ledger entry.
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 py-6">
<div className="space-y-4">
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Invoice Number
</Label>
<Input
value={formData.invoiceNumber}
onChange={(e) =>
setFormData({
...formData,
invoiceNumber: e.target.value,
})
}
className="rounded-none border-slate-200"
/>
</div>
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Customer Name
</Label>
<Input
value={formData.customerName}
onChange={(e) =>
setFormData({ ...formData, customerName: e.target.value })
}
className="rounded-none border-slate-200"
/>
</div>
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Customer Email
</Label>
<Input
type="email"
value={formData.customerEmail}
onChange={(e) =>
setFormData({
...formData,
customerEmail: e.target.value,
})
}
className="rounded-none border-slate-200"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Type
</Label>
<Select
value={formData.type}
onValueChange={(v) =>
setFormData({ ...formData, type: v as any })
}
>
<SelectTrigger className="rounded-none border-slate-200">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SALES">SALES</SelectItem>
<SelectItem value="PURCHASE">PURCHASE</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Status
</Label>
<Select
value={formData.status}
onValueChange={(v) =>
setFormData({ ...formData, status: v as any })
}
>
<SelectTrigger className="rounded-none border-slate-200">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="DRAFT">DRAFT</SelectItem>
<SelectItem value="PENDING">PENDING</SelectItem>
<SelectItem value="PAID">PAID</SelectItem>
<SelectItem value="OVERDUE">OVERDUE</SelectItem>
<SelectItem value="CANCELLED">CANCELLED</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Issue Date
</Label>
<Input
type="date"
value={formData.issueDate}
onChange={(e) =>
setFormData({ ...formData, issueDate: e.target.value })
}
className="rounded-none border-slate-200"
/>
</div>
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Due Date
</Label>
<Input
type="date"
value={formData.dueDate}
onChange={(e) =>
setFormData({ ...formData, dueDate: e.target.value })
}
className="rounded-none border-slate-200"
/>
</div>
</div>
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Description
</Label>
<Textarea
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
className="rounded-none border-slate-200"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Tax Amount
</Label>
<Input
type="number"
value={formData.taxAmount}
onChange={(e) => {
const tax = parseFloat(e.target.value) || 0;
setFormData({
...formData,
taxAmount: tax,
amount: calculateTotals(
formData.items || [],
tax,
formData.discountAmount || 0,
),
});
}}
className="rounded-none border-slate-200"
/>
</div>
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Discount
</Label>
<Input
type="number"
value={formData.discountAmount}
onChange={(e) => {
const discount = parseFloat(e.target.value) || 0;
setFormData({
...formData,
discountAmount: discount,
amount: calculateTotals(
formData.items || [],
formData.taxAmount || 0,
discount,
),
});
}}
className="rounded-none border-slate-200"
/>
</div>
</div>
</div>
</div>
{/* Line Items */}
<div className="border-t pt-6 mt-2">
<div className="flex items-center justify-between mb-4">
<h3 className="text-xs font-black uppercase tracking-[0.2em] text-slate-400">
Line Items
</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddItem}
className="rounded-none h-7 border-slate-200 text-[9px] font-bold uppercase"
>
<Plus className="w-3 h-3 mr-1" /> Add Item
</Button>
</div>
<div className="space-y-4">
{formData.items?.map((item: InvoiceItem, idx: number) => (
<div
key={item.id}
className="flex gap-4 items-end bg-slate-50/50 p-3 border border-slate-100"
>
<div className="flex-1 grid gap-2">
<Label className="text-[9px] font-bold uppercase text-slate-400">
Service Description
</Label>
<Input
value={item.description}
onChange={(e) =>
handleUpdateItem(idx, "description", e.target.value)
}
className="rounded-none border-slate-200 h-8 text-xs"
/>
</div>
<div className="w-20 grid gap-2">
<Label className="text-[9px] font-bold uppercase text-slate-400">
Qty
</Label>
<Input
type="number"
value={item.quantity}
onChange={(e) =>
handleUpdateItem(
idx,
"quantity",
parseInt(e.target.value) || 0,
)
}
className="rounded-none border-slate-200 h-8 text-xs"
/>
</div>
<div className="w-28 grid gap-2">
<Label className="text-[9px] font-bold uppercase text-slate-400">
Unit Price
</Label>
<Input
type="number"
value={item.unitPrice}
onChange={(e) =>
handleUpdateItem(
idx,
"unitPrice",
parseFloat(e.target.value) || 0,
)
}
className="rounded-none border-slate-200 h-8 text-xs"
/>
</div>
<div className="w-24 grid gap-2">
<Label className="text-[9px] font-bold uppercase text-slate-400">
Total
</Label>
<div className="h-8 flex items-center px-3 bg-white border border-slate-100 text-xs font-bold">
{formatCurrency(item.total)}
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveItem(idx)}
className="h-8 w-8 text-slate-300 hover:text-rose-500"
>
<X className="w-4 h-4" />
</Button>
</div>
))}
</div>
</div>
<DialogFooter className="border-t pt-6 mt-6">
<div className="flex items-center justify-between w-full">
<div className="text-right">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
Total Invoice Amount
</p>
<p className="text-2xl font-black tracking-tighter text-slate-900">
{formatCurrency(formData.amount)}
</p>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="ghost"
onClick={() => setIsModalOpen(false)}
className="rounded-none uppercase font-bold text-[10px]"
>
Cancel
</Button>
<Button
type="submit"
disabled={
createMutation.isPending || updateMutation.isPending
}
className="rounded-none bg-slate-900 uppercase font-bold text-[10px] tracking-widest px-8"
>
{createMutation.isPending || updateMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : editingInvoice ? (
"Update Ledger"
) : (
"Commit to Ledger"
)}
</Button>
</div>
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<Dialog open={isDeleteModalOpen} onOpenChange={setIsDeleteModalOpen}>
<DialogContent className="rounded-none">
<DialogHeader>
<DialogTitle className="text-xl font-bold uppercase tracking-tight">
Delete Ledger Entry?
</DialogTitle>
<DialogDescription className="text-xs font-bold uppercase tracking-widest text-rose-500">
This action is permanent and cannot be reversed.
</DialogDescription>
</DialogHeader>
<div className="py-4 text-sm text-slate-600">
Are you sure you want to delete this invoice record? All associated
line item data will be purged.
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setIsDeleteModalOpen(false)}
className="rounded-none uppercase font-bold text-[10px]"
>
Cancel
</Button>
<Button
disabled={deleteMutation.isPending}
onClick={() =>
invoiceToDelete && deleteMutation.mutate(invoiceToDelete)
}
className="rounded-none bg-rose-600 hover:bg-rose-700 uppercase font-bold text-[10px] tracking-widest"
>
{deleteMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
"Confirm Deletion"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -1,5 +1,5 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@ -8,13 +8,62 @@ import {
ChevronLeft,
ChevronRight,
Filter,
Plus,
Pencil,
Trash2,
Loader2,
X,
FileText,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { invoiceService } from "@/services";
import { useAdminRole } from "@/hooks/use-admin-role";
import { toast } from "sonner";
import type { Proforma, InvoiceItem } from "@/services/invoice.service";
export default function ProformaPage() {
const { canCreateBusinessData, canEditBusinessData, canDeleteBusinessData } =
useAdminRole();
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [editingProforma, setEditingProforma] = useState<Proforma | null>(null);
const [proformaToDelete, setProformaToDelete] = useState<string | null>(null);
// Form State
const [formData, setFormData] = useState<Partial<Proforma>>({
proformaNumber: "",
customerName: "",
customerEmail: "",
customerPhone: "",
amount: 0,
currency: "USD",
issueDate: new Date().toISOString().split("T")[0],
dueDate: new Date().toISOString().split("T")[0],
description: "",
notes: "",
taxAmount: 0,
discountAmount: 0,
items: [] as InvoiceItem[],
});
const { data: proformaData, isLoading } = useQuery({
queryKey: ["admin", "proforma", page, search],
@ -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 val = typeof amount === "number" ? amount : 0;
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
currency: formData.currency || "USD",
}).format(val);
};
@ -45,7 +233,17 @@ export default function ProformaPage() {
Manage draft and preliminary invoices.
</p>
</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>
<Card className="border shadow-none rounded-none">
@ -90,7 +288,7 @@ export default function ProformaPage() {
Issue Date
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
Due Date
Actions
</th>
</tr>
</thead>
@ -130,8 +328,37 @@ export default function ProformaPage() {
<td className="px-6 py-4 text-sm text-gray-500">
{new Date(item.issueDate).toLocaleDateString()}
</td>
<td className="px-6 py-4 text-right text-sm text-gray-500">
{new Date(item.dueDate).toLocaleDateString()}
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
{canEditBusinessData && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-900"
onClick={() => handleOpenEdit(item)}
>
<Pencil className="w-4 h-4" />
</Button>
)}
{canDeleteBusinessData && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-rose-600"
onClick={() => {
setProformaToDelete(item.id);
setIsDeleteModalOpen(true);
}}
>
<Trash2 className="w-4 h-4" />
</Button>
)}
{!canEditBusinessData && !canDeleteBusinessData && (
<span className="text-[10px] font-bold text-slate-300 uppercase italic">
View Only
</span>
)}
</div>
</td>
</tr>
))
@ -177,6 +404,348 @@ export default function ProformaPage() {
</div>
)}
</Card>
{/* Create/Edit Modal */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto rounded-none">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle className="text-xl font-bold uppercase tracking-tight">
{editingProforma ? "Update Proforma" : "Create Proforma"}
</DialogTitle>
<DialogDescription className="text-xs font-bold uppercase tracking-widest text-slate-400">
Execute a preliminary billing draft.
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 py-6">
<div className="space-y-4">
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Proforma Number
</Label>
<Input
value={formData.proformaNumber}
onChange={(e) =>
setFormData({
...formData,
proformaNumber: e.target.value,
})
}
className="rounded-none border-slate-200"
/>
</div>
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Customer Name
</Label>
<Input
value={formData.customerName}
onChange={(e) =>
setFormData({ ...formData, customerName: e.target.value })
}
className="rounded-none border-slate-200"
/>
</div>
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Customer Email
</Label>
<Input
type="email"
value={formData.customerEmail}
onChange={(e) =>
setFormData({
...formData,
customerEmail: e.target.value,
})
}
className="rounded-none border-slate-200"
/>
</div>
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Currency
</Label>
<Select
value={formData.currency}
onValueChange={(v) =>
setFormData({ ...formData, currency: v })
}
>
<SelectTrigger className="rounded-none border-slate-200">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="USD">USD</SelectItem>
<SelectItem value="EUR">EUR</SelectItem>
<SelectItem value="GBP">GBP</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Issue Date
</Label>
<Input
type="date"
value={formData.issueDate}
onChange={(e) =>
setFormData({ ...formData, issueDate: e.target.value })
}
className="rounded-none border-slate-200"
/>
</div>
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Due Date
</Label>
<Input
type="date"
value={formData.dueDate}
onChange={(e) =>
setFormData({ ...formData, dueDate: e.target.value })
}
className="rounded-none border-slate-200"
/>
</div>
</div>
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Description
</Label>
<Textarea
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
className="rounded-none border-slate-200"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Tax Amount
</Label>
<Input
type="number"
value={formData.taxAmount}
onChange={(e) => {
const tax = parseFloat(e.target.value) || 0;
setFormData({
...formData,
taxAmount: tax,
amount: calculateTotals(
formData.items || [],
tax,
formData.discountAmount || 0,
),
});
}}
className="rounded-none border-slate-200"
/>
</div>
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Discount
</Label>
<Input
type="number"
value={formData.discountAmount}
onChange={(e) => {
const discount = parseFloat(e.target.value) || 0;
setFormData({
...formData,
discountAmount: discount,
amount: calculateTotals(
formData.items || [],
formData.taxAmount || 0,
discount,
),
});
}}
className="rounded-none border-slate-200"
/>
</div>
</div>
</div>
</div>
{/* Line Items */}
<div className="border-t pt-6 mt-2">
<div className="flex items-center justify-between mb-4">
<h3 className="text-xs font-black uppercase tracking-[0.2em] text-slate-400">
Line Items
</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddItem}
className="rounded-none h-7 border-slate-200 text-[9px] font-bold uppercase"
>
<Plus className="w-3 h-3 mr-1" /> Add Item
</Button>
</div>
<div className="space-y-4">
{formData.items?.map((item: InvoiceItem, idx: number) => (
<div
key={item.id}
className="flex gap-4 items-end bg-slate-50/50 p-3 border border-slate-100"
>
<div className="flex-1 grid gap-2">
<Label className="text-[9px] font-bold uppercase text-slate-400">
Service Description
</Label>
<Input
value={item.description}
onChange={(e) =>
handleUpdateItem(idx, "description", e.target.value)
}
className="rounded-none border-slate-200 h-8 text-xs"
/>
</div>
<div className="w-20 grid gap-2">
<Label className="text-[9px] font-bold uppercase text-slate-400">
Qty
</Label>
<Input
type="number"
value={item.quantity}
onChange={(e) =>
handleUpdateItem(
idx,
"quantity",
parseInt(e.target.value) || 0,
)
}
className="rounded-none border-slate-200 h-8 text-xs"
/>
</div>
<div className="w-28 grid gap-2">
<Label className="text-[9px] font-bold uppercase text-slate-400">
Unit Price
</Label>
<Input
type="number"
value={item.unitPrice}
onChange={(e) =>
handleUpdateItem(
idx,
"unitPrice",
parseFloat(e.target.value) || 0,
)
}
className="rounded-none border-slate-200 h-8 text-xs"
/>
</div>
<div className="w-24 grid gap-2">
<Label className="text-[9px] font-bold uppercase text-slate-400">
Total
</Label>
<div className="h-8 flex items-center px-3 bg-white border border-slate-100 text-xs font-bold">
{formatCurrency(item.total)}
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveItem(idx)}
className="h-8 w-8 text-slate-300 hover:text-rose-500"
>
<X className="w-4 h-4" />
</Button>
</div>
))}
</div>
</div>
<DialogFooter className="border-t pt-6 mt-6">
<div className="flex items-center justify-between w-full">
<div className="text-right">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
Total Proforma Amount
</p>
<p className="text-2xl font-black tracking-tighter text-slate-900">
{formatCurrency(formData.amount)}
</p>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="ghost"
onClick={() => setIsModalOpen(false)}
className="rounded-none uppercase font-bold text-[10px]"
>
Cancel
</Button>
<Button
type="submit"
disabled={
createMutation.isPending || updateMutation.isPending
}
className="rounded-none bg-slate-900 uppercase font-bold text-[10px] tracking-widest px-8"
>
{createMutation.isPending || updateMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : editingProforma ? (
"Update Registry"
) : (
"Commit to Registry"
)}
</Button>
</div>
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<Dialog open={isDeleteModalOpen} onOpenChange={setIsDeleteModalOpen}>
<DialogContent className="rounded-none">
<DialogHeader>
<DialogTitle className="text-xl font-bold uppercase tracking-tight">
Expunge Proforma Entry?
</DialogTitle>
<DialogDescription className="text-xs font-bold uppercase tracking-widest text-rose-500">
This action is permanent and cannot be reversed.
</DialogDescription>
</DialogHeader>
<div className="py-4 text-sm text-slate-600">
Are you sure you want to delete this proforma record? All associated
line item data will be purged.
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setIsDeleteModalOpen(false)}
className="rounded-none uppercase font-bold text-[10px]"
>
Cancel
</Button>
<Button
disabled={deleteMutation.isPending}
onClick={() =>
proformaToDelete && deleteMutation.mutate(proformaToDelete)
}
className="rounded-none bg-rose-600 hover:bg-rose-700 uppercase font-bold text-[10px] tracking-widest"
>
{deleteMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
"Confirm Deletion"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -29,13 +29,70 @@ import {
} from "lucide-react";
import { invoiceService, type ProformaRequest } from "@/services";
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() {
const { canCreateBusinessData, canEditBusinessData } = useAdminRole();
const queryClient = useQueryClient();
// Local State
const [page, setPage] = useState(1);
const [limit] = useState(10);
const [status, setStatus] = useState<string>("all");
const [category, setCategory] = useState<string>("all");
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({
queryKey: [
@ -46,6 +103,8 @@ export default function ProformaRequestsPage() {
status,
category,
search,
deadlineFrom,
deadlineTo,
],
queryFn: () =>
invoiceService.getProformaRequests({
@ -54,6 +113,8 @@ export default function ProformaRequestsPage() {
status: status === "all" ? undefined : status,
category: category === "all" ? undefined : category,
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 (
<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>
<h1 className="text-3xl font-bold tracking-tight">
@ -125,6 +333,15 @@ export default function ProformaRequestsPage() {
Manage and review customer requests for proforma invoices.
</p>
</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>
<Card className="border-slate-200/60 shadow-sm">
@ -165,6 +382,28 @@ export default function ProformaRequestsPage() {
<SelectItem value="MIXED">Mixed</SelectItem>
</SelectContent>
</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>
</CardHeader>
@ -184,11 +423,8 @@ export default function ProformaRequestsPage() {
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
Deadline
</TableHead>
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
Items
</TableHead>
<TableHead className="text-right text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
Timestamp
<TableHead className="text-right text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6 py-4">
Actions
</TableHead>
</TableRow>
</TableHeader>
@ -233,16 +469,76 @@ export default function ProformaRequestsPage() {
)}
</div>
</TableCell>
<TableCell className="px-6">
<Badge
variant="outline"
className="bg-slate-50 border-slate-200 text-slate-600 text-[10px]"
<TableCell className="text-right px-6">
<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(request)}
>
{request.items.length} Items
</Badge>
</TableCell>
<TableCell className="text-right px-6 text-xs text-slate-500 font-medium">
{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>
</TableRow>
))
@ -290,6 +586,603 @@ export default function ProformaRequestsPage() {
</div>
)}
</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>
);
}

View File

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

View File

@ -3,14 +3,76 @@ import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search, ChevronLeft, ChevronRight, Filter } from "lucide-react";
import {
Search,
ChevronLeft,
ChevronRight,
Filter,
Plus,
Trash2,
Loader2,
Building2,
ListOrdered,
} from "lucide-react";
import { paymentService } from "@/services";
import { cn } from "@/lib/utils";
import { useAdminRole } from "@/hooks/use-admin-role";
import { toast } from "sonner";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area";
export default function PaymentRequestsPage() {
const { canCreateBusinessData } = useAdminRole();
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
// Create Modal State
const [isModalOpen, setIsModalOpen] = useState(false);
const [formData, setFormData] = useState<any>({
paymentRequestNumber: `PAYREQ-${new Date().getFullYear()}-${Math.floor(100 + Math.random() * 900)}`,
customerName: "",
customerEmail: "",
customerPhone: "",
amount: 0,
currency: "USD",
issueDate: new Date().toISOString(),
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
description: "",
notes: "",
taxAmount: 0,
discountAmount: 0,
status: "DRAFT",
paymentId: "",
customerId: "",
accounts: [
{
bankName: "Yaltopia Bank",
accountName: "Yaltopia Tech PLC",
accountNumber: "",
currency: "ETB",
},
],
items: [{ description: "", quantity: 1, unitPrice: 0, total: 0 }],
});
const { data: requestsData, isLoading: requestsLoading } = useQuery({
queryKey: ["admin", "payment-requests", page, search],
queryFn: () =>
@ -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 val = typeof amount === "number" ? amount : 0;
return new Intl.NumberFormat("en-US", {
@ -56,7 +190,17 @@ export default function PaymentRequestsPage() {
Manage outbound customer requests.
</p>
</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>
<Card className="border shadow-none rounded-none">
@ -191,6 +335,510 @@ export default function PaymentRequestsPage() {
</div>
)}
</Card>
{/* Create Modal */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden p-0 rounded-none border-slate-200">
<form
onSubmit={handleCreate}
className="flex flex-col h-full bg-slate-50"
>
<DialogHeader className="p-8 pb-6 bg-white border-b border-slate-100">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="px-2 py-0.5 bg-slate-900 text-white text-[9px] font-black uppercase tracking-widest">
Draft
</span>
<DialogTitle className="text-xl font-bold tracking-tight text-slate-900">
Issue Payment Request
</DialogTitle>
</div>
<DialogDescription className="text-xs font-medium text-slate-400">
Draft a formal financial request for outbound settlement.
</DialogDescription>
</div>
</div>
</DialogHeader>
<ScrollArea className="flex-1">
<div className="p-8 space-y-10">
{/* Header Info */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
Reference Number
</Label>
<Input
value={formData.paymentRequestNumber}
onChange={(e) =>
setFormData({
...formData,
paymentRequestNumber: e.target.value,
})
}
className="rounded-none border-slate-200 h-10 font-mono text-xs font-bold"
/>
</div>
<div className="space-y-2">
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
Issue Date
</Label>
<Input
type="date"
value={formData.issueDate.split("T")[0]}
onChange={(e) =>
setFormData({
...formData,
issueDate: new Date(e.target.value).toISOString(),
})
}
className="rounded-none border-slate-200 h-10 text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
Due Date
</Label>
<Input
type="date"
value={formData.dueDate.split("T")[0]}
onChange={(e) =>
setFormData({
...formData,
dueDate: new Date(e.target.value).toISOString(),
})
}
className="rounded-none border-slate-200 h-10 text-xs text-rose-600 font-bold"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-10">
{/* Customer Details */}
<div className="space-y-6">
<div className="flex items-center gap-2 mb-4">
<div className="w-1 h-4 bg-slate-900" />
<h3 className="text-[10px] font-black uppercase tracking-widest text-slate-900">
Recipient Details
</h3>
</div>
<div className="grid gap-4">
<div className="grid gap-2">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-500">
Customer Name
</Label>
<Input
placeholder="e.g. Acme Corp"
value={formData.customerName}
onChange={(e) =>
setFormData({
...formData,
customerName: e.target.value,
})
}
className="rounded-none border-slate-200 h-10 text-xs"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-500">
Email Address
</Label>
<Input
type="email"
placeholder="billing@acme.com"
value={formData.customerEmail}
onChange={(e) =>
setFormData({
...formData,
customerEmail: e.target.value,
})
}
className="rounded-none border-slate-200 h-10 text-xs text-slate-500"
/>
</div>
<div className="grid gap-2">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-500">
Phone Number
</Label>
<Input
placeholder="+1 (555) 000-0000"
value={formData.customerPhone}
onChange={(e) =>
setFormData({
...formData,
customerPhone: e.target.value,
})
}
className="rounded-none border-slate-200 h-10 text-xs"
/>
</div>
</div>
<div className="grid gap-2">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-500">
Customer ID (External)
</Label>
<Input
placeholder="CUST-001"
value={formData.customerId}
onChange={(e) =>
setFormData({
...formData,
customerId: e.target.value,
})
}
className="rounded-none border-slate-200 h-10 text-xs"
/>
</div>
</div>
</div>
{/* Financials & Logic */}
<div className="space-y-6">
<div className="flex items-center gap-2 mb-4">
<div className="w-1 h-4 bg-slate-900" />
<h3 className="text-[10px] font-black uppercase tracking-widest text-slate-900">
Financial Basis
</h3>
</div>
<div className="grid gap-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-500">
Currency
</Label>
<Select
value={formData.currency}
onValueChange={(v) =>
setFormData({ ...formData, currency: v })
}
>
<SelectTrigger className="rounded-none border-slate-200 h-10 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent className="rounded-none">
<SelectItem value="USD">USD - Dollar</SelectItem>
<SelectItem value="ETB">ETB - Birr</SelectItem>
<SelectItem value="EUR">EUR - Euro</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-500">
Description
</Label>
<Input
placeholder="Service payment"
value={formData.description}
onChange={(e) =>
setFormData({
...formData,
description: e.target.value,
})
}
className="rounded-none border-slate-200 h-10 text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-500">
Tax Amount
</Label>
<Input
type="number"
value={formData.taxAmount}
onChange={(e) =>
setFormData({
...formData,
taxAmount: parseFloat(e.target.value) || 0,
})
}
className="rounded-none border-slate-200 h-10 text-xs"
/>
</div>
<div className="grid gap-2">
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-500">
Discount
</Label>
<Input
type="number"
value={formData.discountAmount}
onChange={(e) =>
setFormData({
...formData,
discountAmount: parseFloat(e.target.value) || 0,
})
}
className="rounded-none border-slate-200 h-10 text-xs"
/>
</div>
</div>
<div className="grid gap-2 pt-2">
<div className="bg-slate-900 p-4 flex flex-col items-end justify-center">
<span className="text-[8px] font-black uppercase tracking-widest text-slate-500 mb-1">
Estimated Total
</span>
<span className="text-2xl font-black text-white tabular-nums">
{formatCurrency(
formData.amount +
formData.taxAmount -
formData.discountAmount,
)}
</span>
</div>
</div>
</div>
</div>
</div>
{/* Line Items */}
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ListOrdered className="w-4 h-4 text-slate-400" />
<h3 className="text-[10px] font-black uppercase tracking-widest text-slate-900">
Line Items
</h3>
</div>
<Button
type="button"
variant="ghost"
className="text-[10px] font-black uppercase tracking-widest h-8 text-blue-600 hover:text-blue-700"
onClick={addItem}
>
+ Link Item
</Button>
</div>
<div className="border border-slate-200 divide-y divide-slate-100 bg-white shadow-sm">
{formData.items.map((item: any, idx: number) => (
<div
key={idx}
className="p-4 grid grid-cols-12 gap-4 items-end group"
>
<div className="col-span-6 space-y-2">
<Label className="text-[8px] font-bold uppercase tracking-widest text-slate-400">
Description
</Label>
<Input
value={item.description}
onChange={(e) =>
handleItemChange(
idx,
"description",
e.target.value,
)
}
className="rounded-none border-slate-200 h-8 text-[11px]"
/>
</div>
<div className="col-span-2 space-y-2">
<Label className="text-[8px] font-bold uppercase tracking-widest text-slate-400">
Qty
</Label>
<Input
type="number"
value={item.quantity}
onChange={(e) =>
handleItemChange(
idx,
"quantity",
parseInt(e.target.value) || 0,
)
}
className="rounded-none border-slate-200 h-8 text-xs text-center"
/>
</div>
<div className="col-span-2 space-y-2">
<Label className="text-[8px] font-bold uppercase tracking-widest text-slate-400">
Unit Price
</Label>
<Input
type="number"
value={item.unitPrice}
onChange={(e) =>
handleItemChange(
idx,
"unitPrice",
parseFloat(e.target.value) || 0,
)
}
className="rounded-none border-slate-200 h-8 text-xs"
/>
</div>
<div className="col-span-2 flex items-center justify-end gap-2">
<span className="text-xs font-bold text-slate-900 min-w-16 text-right">
{formatCurrency(item.total)}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-rose-600 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => removeItem(idx)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
))}
</div>
</div>
{/* Settlement Accounts */}
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-slate-400" />
<h3 className="text-[10px] font-black uppercase tracking-widest text-slate-900">
Settlement Accounts
</h3>
</div>
<Button
type="button"
variant="ghost"
className="text-[10px] font-black uppercase tracking-widest h-8 text-blue-600 hover:text-blue-700"
onClick={addAccount}
>
+ Add Target
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{formData.accounts.map((acc: any, idx: number) => (
<div
key={idx}
className="relative p-6 bg-white border border-slate-200 space-y-4 group"
>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-2 top-2 h-7 w-7 text-slate-300 hover:text-rose-600 opacity-0 group-hover:opacity-100"
onClick={() => removeAccount(idx)}
>
<Trash2 className="w-3 h-3" />
</Button>
<div className="space-y-3">
<div className="grid gap-1">
<Label className="text-[8px] font-bold uppercase tracking-widest text-slate-400">
Bank Name
</Label>
<Input
value={acc.bankName}
onChange={(e) =>
handleAccountChange(
idx,
"bankName",
e.target.value,
)
}
className="rounded-none border-slate-200 h-8 text-[11px] font-bold"
/>
</div>
<div className="grid gap-1">
<Label className="text-[8px] font-bold uppercase tracking-widest text-slate-400">
Account Name
</Label>
<Input
value={acc.accountName}
onChange={(e) =>
handleAccountChange(
idx,
"accountName",
e.target.value,
)
}
className="rounded-none border-slate-200 h-8 text-[11px]"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="grid gap-1">
<Label className="text-[8px] font-bold uppercase tracking-widest text-slate-400">
Account #
</Label>
<Input
value={acc.accountNumber}
onChange={(e) =>
handleAccountChange(
idx,
"accountNumber",
e.target.value,
)
}
className="rounded-none border-slate-200 h-8 text-[11px] font-mono"
/>
</div>
<div className="grid gap-1">
<Label className="text-[8px] font-bold uppercase tracking-widest text-slate-400">
Curr
</Label>
<Select
value={acc.currency}
onValueChange={(v) =>
handleAccountChange(idx, "currency", v)
}
>
<SelectTrigger className="rounded-none border-slate-200 h-8 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent className="rounded-none">
<SelectItem value="USD">USD</SelectItem>
<SelectItem value="ETB">ETB</SelectItem>
<SelectItem value="EUR">EUR</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
))}
</div>
</div>
<div className="space-y-4 pt-4">
<div className="grid gap-2">
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
Administrative Notes
</Label>
<Textarea
placeholder="Enter internal notes or customer-facing terms..."
value={formData.notes}
onChange={(e) =>
setFormData({ ...formData, notes: e.target.value })
}
className="rounded-none border-slate-200 min-h-[80px] text-xs resize-none p-4"
/>
</div>
</div>
</div>
</ScrollArea>
<DialogFooter className="p-8 bg-white border-t border-slate-100 flex items-center justify-between sm:justify-between">
<Button
type="button"
variant="ghost"
className="rounded-none uppercase text-[10px] font-black tracking-widest text-slate-400 hover:text-slate-900"
onClick={() => setIsModalOpen(false)}
>
Discard
</Button>
<Button
type="submit"
disabled={createMutation.isPending}
className="rounded-none bg-slate-900 hover:bg-black text-white px-12 h-11 uppercase text-[10px] font-black tracking-widest shadow-lg shadow-slate-200"
>
{createMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
"Authorize & Send Request"
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -1,30 +1,143 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Search,
Plus,
ChevronLeft,
ChevronRight,
Download,
Flag,
Pencil,
Trash2,
Loader2,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { paymentService } from "@/services";
import { useAdminRole } from "@/hooks/use-admin-role";
import { toast } from "sonner";
import type { Payment } from "@/services/payment.service";
export default function PaymentsListPage() {
const { canCreateBusinessData, canEditBusinessData, canDeleteBusinessData } =
useAdminRole();
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [editingPayment, setEditingPayment] = useState<Payment | null>(null);
const [paymentToDelete, setPaymentToDelete] = useState<string | null>(null);
// Form State
const [formData, setFormData] = useState<Partial<Payment>>({
transactionId: "",
amount: 0,
currency: "USD",
get paymentDate() {
return new Date().toISOString().split("T")[0];
},
paymentMethod: "Credit Card",
notes: "",
invoiceId: "",
});
const { data: paymentsData, isLoading: paymentsLoading } = useQuery({
queryKey: ["admin", "payments", page],
queryFn: () => paymentService.getPayments({ page, limit: 10 }),
});
const createMutation = useMutation({
mutationFn: (data: any) => paymentService.createPayment(data),
onSuccess: () => {
toast.success("Payment logged successfully");
setIsModalOpen(false);
queryClient.invalidateQueries({ queryKey: ["admin", "payments"] });
},
onError: (err: any) => {
toast.error(err.response?.data?.message?.[0] || "Failed to log payment");
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) =>
paymentService.updatePayment(id, data),
onSuccess: () => {
toast.success("Payment record updated");
setIsModalOpen(false);
queryClient.invalidateQueries({ queryKey: ["admin", "payments"] });
},
onError: (err: any) => {
toast.error(
err.response?.data?.message?.[0] || "Failed to update payment",
);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => paymentService.deletePayment(id),
onSuccess: () => {
toast.success("Payment record expunged");
setIsDeleteModalOpen(false);
queryClient.invalidateQueries({ queryKey: ["admin", "payments"] });
},
onError: () => {
toast.error("Failed to delete payment");
},
});
const handleOpenCreate = () => {
setEditingPayment(null);
setFormData({
transactionId: `TXN-${Math.floor(100000 + Math.random() * 900000)}`,
amount: 0,
currency: "USD",
paymentDate: new Date().toISOString().split("T")[0],
paymentMethod: "Credit Card",
notes: "",
invoiceId: "",
});
setIsModalOpen(true);
};
const handleOpenEdit = (payment: Payment) => {
setEditingPayment(payment);
setFormData({
...payment,
paymentDate: new Date(payment.paymentDate).toISOString().split("T")[0],
});
setIsModalOpen(true);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (editingPayment) {
updateMutation.mutate({ id: editingPayment.id, data: formData });
} else {
createMutation.mutate(formData);
}
};
const formatCurrency = (amount: number | any) => {
const val = typeof amount === "number" ? amount : 0;
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
currency: formData.currency || "USD",
}).format(val);
};
@ -37,7 +150,17 @@ export default function PaymentsListPage() {
</h1>
<p className="text-gray-500 mt-1">History of settled transactions.</p>
</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>
<Card className="border shadow-none rounded-none">
@ -74,7 +197,7 @@ export default function PaymentsListPage() {
Date
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
Status
Actions
</th>
</tr>
</thead>
@ -83,19 +206,24 @@ export default function PaymentsListPage() {
<tr>
<td
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>
</tr>
) : paymentsData?.data && paymentsData.data.length > 0 ? (
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">
{payment.transactionId}
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{payment.senderName || "Unknown"}
{payment.isFlagged && (
<span className="ml-2 inline-flex items-center gap-1 px-1.5 py-0.5 rounded-none text-[8px] font-black uppercase bg-red-50 text-red-600 border border-red-100 italic">
Flagged
</span>
)}
</td>
<td className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase">
{payment.paymentMethod}
@ -107,11 +235,36 @@ export default function PaymentsListPage() {
{new Date(payment.paymentDate).toLocaleDateString()}
</td>
<td className="px-6 py-4 text-right">
{payment.isFlagged && (
<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">
<Flag className="w-2.5 h-2.5" /> Flagged
<div className="flex items-center justify-end gap-2">
{canEditBusinessData && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-900"
onClick={() => handleOpenEdit(payment)}
>
<Pencil className="w-4 h-4" />
</Button>
)}
{canDeleteBusinessData && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-rose-600"
onClick={() => {
setPaymentToDelete(payment.id);
setIsDeleteModalOpen(true);
}}
>
<Trash2 className="w-4 h-4" />
</Button>
)}
{!canEditBusinessData && !canDeleteBusinessData && (
<span className="text-[10px] font-bold text-slate-300 uppercase italic">
View Only
</span>
)}
</div>
</td>
</tr>
))
@ -121,7 +274,7 @@ export default function PaymentsListPage() {
colSpan={6}
className="px-6 py-20 text-center text-gray-400 italic"
>
No records found.
No records found in transaction history.
</td>
</tr>
)}
@ -157,6 +310,233 @@ export default function PaymentsListPage() {
</div>
)}
</Card>
{/* Create/Edit Modal */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-2xl rounded-none">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle className="text-xl font-bold uppercase tracking-tight">
{editingPayment ? "Update Record" : "Log New Payment"}
</DialogTitle>
<DialogDescription className="text-xs font-bold uppercase tracking-widest text-slate-400">
Official transaction record entry.
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 py-6">
<div className="space-y-4">
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Transaction ID
</Label>
<Input
value={formData.transactionId}
onChange={(e) =>
setFormData({
...formData,
transactionId: e.target.value,
})
}
className="rounded-none border-slate-200"
/>
</div>
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Amount
</Label>
<Input
type="number"
value={formData.amount}
onChange={(e) =>
setFormData({
...formData,
amount: parseFloat(e.target.value) || 0,
})
}
className="rounded-none border-slate-200"
/>
</div>
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Currency
</Label>
<Select
value={formData.currency}
onValueChange={(v) =>
setFormData({ ...formData, currency: v })
}
>
<SelectTrigger className="rounded-none border-slate-200">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="USD">USD</SelectItem>
<SelectItem value="EUR">EUR</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-4">
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Payment Date
</Label>
<Input
type="date"
value={formData.paymentDate}
onChange={(e) =>
setFormData({ ...formData, paymentDate: e.target.value })
}
className="rounded-none border-slate-200"
/>
</div>
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Payment Method
</Label>
<Select
value={formData.paymentMethod}
onValueChange={(v) =>
setFormData({ ...formData, paymentMethod: v })
}
>
<SelectTrigger className="rounded-none border-slate-200">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Bank Transfer">
Bank Transfer
</SelectItem>
<SelectItem value="Credit Card">Credit Card</SelectItem>
<SelectItem value="Cash">Cash</SelectItem>
<SelectItem value="Mobile Money">Mobile Money</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Linked Invoice ID
</Label>
<Input
value={formData.invoiceId}
placeholder="Optional"
onChange={(e) =>
setFormData({ ...formData, invoiceId: e.target.value })
}
className="rounded-none border-slate-200"
/>
</div>
<div className="grid gap-2">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Linked Invoice ID
</Label>
<Input
value={formData.invoiceId}
placeholder="Optional"
onChange={(e) =>
setFormData({ ...formData, invoiceId: e.target.value })
}
className="rounded-none border-slate-200"
/>
</div>
</div>
</div>
<div className="grid gap-2 pb-6">
<Label className="text-[10px] font-bold uppercase tracking-widest">
Notes / Internal Comments
</Label>
<Textarea
value={formData.notes}
onChange={(e) =>
setFormData({ ...formData, notes: e.target.value })
}
className="rounded-none border-slate-200 min-h-[100px]"
/>
</div>
<DialogFooter className="border-t pt-6">
<div className="flex items-center justify-between w-full">
<div className="text-right">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
Ledger Entry Total
</p>
<p className="text-2xl font-black tracking-tighter text-slate-900">
{formatCurrency(formData.amount)}
</p>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="ghost"
onClick={() => setIsModalOpen(false)}
className="rounded-none uppercase font-bold text-[10px]"
>
Cancel
</Button>
<Button
type="submit"
disabled={
createMutation.isPending || updateMutation.isPending
}
className="rounded-none bg-slate-900 uppercase font-bold text-[10px] tracking-widest px-8"
>
{createMutation.isPending || updateMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : editingPayment ? (
"Update Ledger"
) : (
"Commit to Ledger"
)}
</Button>
</div>
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<Dialog open={isDeleteModalOpen} onOpenChange={setIsDeleteModalOpen}>
<DialogContent className="rounded-none">
<DialogHeader>
<DialogTitle className="text-xl font-bold uppercase tracking-tight">
Expunge Payment Record?
</DialogTitle>
<DialogDescription className="text-xs font-bold uppercase tracking-widest text-rose-500">
This action is permanent and cannot be reversed.
</DialogDescription>
</DialogHeader>
<div className="py-4 text-sm text-slate-600">
Are you sure you want to delete this payment record? This will
un-settle any linked invoices.
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setIsDeleteModalOpen(false)}
className="rounded-none uppercase font-bold text-[10px]"
>
Cancel
</Button>
<Button
disabled={deleteMutation.isPending}
onClick={() =>
paymentToDelete && deleteMutation.mutate(paymentToDelete)
}
className="rounded-none bg-rose-600 hover:bg-rose-700 uppercase font-bold text-[10px] tracking-widest"
>
{deleteMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
"Confirm Deletion"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

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

View File

@ -1,19 +1,31 @@
import { useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Search, CheckCircle2, XCircle } from "lucide-react"
import { subscriptionTransactionService } from "@/services"
import type { SubscriptionPaymentStatus } from "@/services/subscription-transaction.service"
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Search,
CheckCircle2,
XCircle,
ArrowRightLeft,
ChevronLeft,
ChevronRight,
Filter,
CreditCard,
User as UserIcon,
} from "lucide-react";
import { subscriptionTransactionService } from "@/services";
import type { SubscriptionPaymentStatus } from "@/services/subscription-transaction.service";
import { cn } from "@/lib/utils";
export default function SubscriptionTransactionsPage() {
const [tab, setTab] = useState<"succeeded" | "failed">("succeeded")
const [page, setPage] = useState(1)
const [search, setSearch] = useState("")
const [tab, setTab] = useState<"succeeded" | "failed">("succeeded");
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const status: SubscriptionPaymentStatus =
tab === "succeeded" ? "SUCCEEDED" : "FAILED"
tab === "succeeded" ? "SUCCEEDED" : "FAILED";
const { data, isLoading, error } = useQuery({
queryKey: ["admin", "subscription-transactions", status, page, search],
@ -24,149 +36,214 @@ export default function SubscriptionTransactionsPage() {
limit: 10,
search: search.trim() || undefined,
}),
})
});
const formatMoney = (amount: number, currency: string) =>
new Intl.NumberFormat("en-US", { style: "currency", currency }).format(
amount,
)
new Intl.NumberFormat("en-US", {
style: "currency",
currency,
minimumFractionDigits: 2,
}).format(amount);
return (
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
Subscription transactions
<div className="space-y-8 animate-in fade-in duration-500">
{/* Header Section */}
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
<div className="space-y-1">
<div className="flex items-center gap-2 text-primary mb-1">
<div className="p-2 bg-primary/10 rounded-lg">
<ArrowRightLeft className="w-5 h-5" />
</div>
<span className="text-xs font-black uppercase tracking-widest opacity-70">
Infrastructure
</span>
</div>
<h1 className="text-4xl font-black tracking-tighter text-slate-900 uppercase italic">
Subscription{" "}
<span className="text-primary NOT-italic">Transactions</span>
</h1>
<p className="text-gray-500 mt-1">
Successful charges and failed attempts for platform subscriptions.
<p className="text-slate-500 text-sm font-medium max-w-xl leading-relaxed">
Monitor real-time platform revenue streams and troubleshoot declined
payment attempts across all subscription tiers.
</p>
</div>
</div>
<div className="grid grid-cols-1 gap-8">
<Card className="border-none shadow-[0_8px_30px_rgb(0,0,0,0.04)] bg-white/50 backdrop-blur-xl rounded-3xl overflow-hidden">
<CardHeader className="p-8 border-b border-slate-100/50 space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6">
<Tabs
value={tab}
onValueChange={(v) => {
setTab(v as "succeeded" | "failed")
setPage(1)
setTab(v as "succeeded" | "failed");
setPage(1);
}}
className="space-y-6"
className="w-full sm:w-auto"
>
<TabsList className="rounded-none bg-gray-100 p-1">
<TabsTrigger value="succeeded" className="rounded-none gap-2">
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
Successful payments
<TabsList className="bg-slate-100/80 p-1 rounded-xl h-11 border border-slate-200/50">
<TabsTrigger
value="succeeded"
className="rounded-lg px-6 gap-2 data-[state=active]:bg-white data-[state=active]:text-emerald-600 data-[state=active]:shadow-sm transition-all font-bold text-xs uppercase"
>
<CheckCircle2 className="h-3.5 w-3.5" />
Succeeded
</TabsTrigger>
<TabsTrigger value="failed" className="rounded-none gap-2">
<XCircle className="h-4 w-4 text-red-600" />
Failed payments
<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">
<CardHeader className="border-b flex flex-row items-center justify-between space-y-0 pb-4">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
{tab === "succeeded"
? "Completed subscription charges"
: "Declined or errored charges"}
</CardTitle>
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<div className="relative group min-w-[300px]">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 group-focus-within:text-primary transition-colors" />
<Input
className="pl-10 h-9 rounded-none border-gray-200 text-xs"
placeholder="Search email, ref, user…"
className="pl-11 h-11 bg-slate-50 border-slate-200/60 rounded-xl text-sm focus:bg-white transition-all shadow-none placeholder:text-slate-400 font-medium"
placeholder="Query by email, ID or reference..."
value={search}
onChange={(e) => {
setSearch(e.target.value)
setPage(1)
setSearch(e.target.value);
setPage(1);
}}
/>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{error && (
<p className="p-6 text-sm text-red-600">
Unable to load transactions. Ensure the API exposes{" "}
<code className="text-xs bg-gray-100 px-1">
<div className="m-8 p-6 bg-rose-50 border border-rose-100 rounded-2xl flex items-center gap-4 text-rose-700">
<XCircle className="w-6 h-6 flex-shrink-0" />
<div className="text-sm font-medium">
Failed to synchronize with banking ledger. Verify{" "}
<code className="bg-rose-100/50 px-1.5 py-0.5 rounded leading-none">
GET /admin/subscription-transactions
</code>
.
</p>
</code>{" "}
is reachable.
</div>
</div>
)}
<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
<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-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Plan
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
Service Plan
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Amount
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
Transaction Value
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Provider / Ref
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
Financial Gateway
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Date
<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-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Reason
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
Resolution Logic
</th>
)}
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
Status
<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">
<tbody className="divide-y divide-slate-100">
{isLoading ? (
<tr>
Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="animate-pulse">
<td
colSpan={tab === "failed" ? 7 : 6}
className="px-6 py-16 text-center text-gray-400 animate-pulse"
className="px-8 py-6"
>
Loading
<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="hover:bg-gray-50">
<td className="px-6 py-4 text-sm">
<div className="font-semibold text-gray-900">
<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}
</div>
<div className="text-[11px] text-gray-500">
</span>
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
{row.userId}
</span>
</div>
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-700">
<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}
</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}
</Badge>
</div>
)}
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{new Date(row.createdAt).toLocaleString()}
<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-6 py-4 text-xs text-red-700 max-w-[200px]">
{row.failureReason ?? "—"}
<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-6 py-4 text-right">
<td className="px-8 py-6 text-right">
<Badge
variant="outline"
className="rounded-none text-[10px] uppercase"
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>
@ -177,43 +254,62 @@ export default function SubscriptionTransactionsPage() {
<tr>
<td
colSpan={tab === "failed" ? 7 : 6}
className="px-6 py-16 text-center text-gray-400 italic text-sm"
className="px-6 py-24 text-center"
>
No rows for this filter.
<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="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="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
type="button"
className="underline disabled:opacity-40"
<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))}
>
Previous
</button>
<button
type="button"
className="underline disabled:opacity-40"
<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
</button>
Next <ChevronRight className="w-4 h-4 ml-1" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</Tabs>
</div>
)
</div>
);
}

View File

@ -1,9 +1,9 @@
import { useParams, useNavigate } from "react-router-dom"
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useParams, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Dialog,
DialogContent,
@ -11,81 +11,91 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { ArrowLeft, Edit, Key, Loader2 } from "lucide-react"
import { userService } from "@/services"
import { format } from "date-fns"
import { useState } from "react"
import { toast } from "sonner"
} from "@/components/ui/select";
import { ArrowLeft, Edit, Key, Loader2 } from "lucide-react";
import { userService } from "@/services";
import { useAdminRole } from "@/hooks/use-admin-role";
import { format } from "date-fns";
import { useState } from "react";
import { toast } from "sonner";
export default function UserDetailsPage() {
const { id } = useParams()
const navigate = useNavigate()
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const { id } = useParams();
const navigate = useNavigate();
const { canEditUsers } = useAdminRole();
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [editForm, setEditForm] = useState({
firstName: '',
lastName: '',
email: '',
role: '',
firstName: "",
lastName: "",
email: "",
role: "",
isActive: true,
})
});
const { data: user, isLoading, refetch } = useQuery({
queryKey: ['admin', 'users', id],
const {
data: user,
isLoading,
refetch,
} = useQuery({
queryKey: ["admin", "users", id],
queryFn: () => userService.getUser(id!),
enabled: !!id,
})
});
const handleEditClick = () => {
if (user) {
setEditForm({
firstName: user.firstName || '',
lastName: user.lastName || '',
firstName: user.firstName || "",
lastName: user.lastName || "",
email: user.email,
role: user.role,
isActive: user.isActive,
})
setIsEditDialogOpen(true)
}
});
setIsEditDialogOpen(true);
}
};
const handleSaveEdit = async () => {
try {
setIsSubmitting(true)
await userService.updateUser(id!, editForm)
toast.success("User updated successfully")
setIsEditDialogOpen(false)
refetch()
setIsSubmitting(true);
await userService.updateUser(id!, editForm);
toast.success("User updated successfully");
setIsEditDialogOpen(false);
refetch();
} catch (error) {
toast.error("Failed to update user")
console.error('Update error:', error)
toast.error("Failed to update user");
console.error("Update error:", error);
} finally {
setIsSubmitting(false)
}
setIsSubmitting(false);
}
};
if (isLoading) {
return <div className="text-center py-8">Loading user details...</div>
return <div className="text-center py-8">Loading user details...</div>;
}
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 (
<div className="space-y-6">
<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" />
</Button>
<h2 className="text-3xl font-bold">User Details</h2>
@ -95,7 +105,10 @@ export default function UserDetailsPage() {
<TabsList>
<TabsTrigger value="info">Information</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
</TabsTrigger>
</TabsList>
@ -106,7 +119,13 @@ export default function UserDetailsPage() {
<div className="flex items-center justify-between">
<CardTitle>User Information</CardTitle>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleEditClick}>
{canEditUsers && (
<>
<Button
variant="outline"
size="sm"
onClick={handleEditClick}
>
<Edit className="w-4 h-4 mr-2" />
Edit
</Button>
@ -114,6 +133,13 @@ export default function UserDetailsPage() {
<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>
</CardHeader>
@ -125,7 +151,9 @@ export default function UserDetailsPage() {
</div>
<div>
<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>
<p className="text-sm text-muted-foreground">Role</p>
@ -133,18 +161,22 @@ export default function UserDetailsPage() {
</div>
<div>
<p className="text-sm text-muted-foreground">Status</p>
<Badge variant={user.isActive ? 'default' : 'secondary'}>
{user.isActive ? 'Active' : 'Inactive'}
<Badge variant={user.isActive ? "default" : "secondary"}>
{user.isActive ? "Active" : "Inactive"}
</Badge>
</div>
<div>
<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>
<p className="text-sm text-muted-foreground">Updated At</p>
<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>
</div>
</div>
@ -159,7 +191,9 @@ export default function UserDetailsPage() {
<CardTitle className="text-sm font-medium">Invoices</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{user._count?.invoices || 0}</div>
<div className="text-2xl font-bold">
{user._count?.invoices || 0}
</div>
</CardContent>
</Card>
<Card>
@ -167,7 +201,9 @@ export default function UserDetailsPage() {
<CardTitle className="text-sm font-medium">Reports</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{user._count?.reports || 0}</div>
<div className="text-2xl font-bold">
{user._count?.reports || 0}
</div>
</CardContent>
</Card>
<Card>
@ -175,7 +211,9 @@ export default function UserDetailsPage() {
<CardTitle className="text-sm font-medium">Documents</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{user._count?.documents || 0}</div>
<div className="text-2xl font-bold">
{user._count?.documents || 0}
</div>
</CardContent>
</Card>
<Card>
@ -183,7 +221,9 @@ export default function UserDetailsPage() {
<CardTitle className="text-sm font-medium">Payments</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{user._count?.payments || 0}</div>
<div className="text-2xl font-bold">
{user._count?.payments || 0}
</div>
</CardContent>
</Card>
</div>
@ -204,7 +244,9 @@ export default function UserDetailsPage() {
<Input
id="firstName"
value={editForm.firstName}
onChange={(e) => setEditForm({ ...editForm, firstName: e.target.value })}
onChange={(e) =>
setEditForm({ ...editForm, firstName: e.target.value })
}
/>
</div>
<div className="space-y-2">
@ -212,7 +254,9 @@ export default function UserDetailsPage() {
<Input
id="lastName"
value={editForm.lastName}
onChange={(e) => setEditForm({ ...editForm, lastName: e.target.value })}
onChange={(e) =>
setEditForm({ ...editForm, lastName: e.target.value })
}
/>
</div>
<div className="space-y-2">
@ -221,12 +265,19 @@ export default function UserDetailsPage() {
id="email"
type="email"
value={editForm.email}
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
onChange={(e) =>
setEditForm({ ...editForm, email: e.target.value })
}
/>
</div>
<div className="space-y-2">
<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>
<SelectValue placeholder="Select role" />
</SelectTrigger>
@ -241,8 +292,10 @@ export default function UserDetailsPage() {
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select
value={editForm.isActive ? 'active' : 'inactive'}
onValueChange={(value) => setEditForm({ ...editForm, isActive: value === 'active' })}
value={editForm.isActive ? "active" : "inactive"}
onValueChange={(value) =>
setEditForm({ ...editForm, isActive: value === "active" })
}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
@ -255,17 +308,22 @@ export default function UserDetailsPage() {
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)} disabled={isSubmitting}>
<Button
variant="outline"
onClick={() => setIsEditDialogOpen(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<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
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
);
}

View File

@ -4,18 +4,27 @@ import { useNavigate } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Search, Eye, ChevronLeft, ChevronRight, Filter } from "lucide-react";
import {
Search,
Eye,
ChevronLeft,
ChevronRight,
Filter,
Plus,
} from "lucide-react";
import { userService } from "@/services";
import { useAdminRole } from "@/hooks/use-admin-role";
import { format } from "date-fns";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
export default function UsersPage() {
const navigate = useNavigate();
const { canCreateUsers } = useAdminRole();
const [page, setPage] = useState(1);
const [limit] = useState(15);
const [search, setSearch] = useState("");
const [roleFilter, setRoleFilter] = useState<string>("all");
const [roleFilter] = useState<string>("all");
const { data: usersData, isLoading } = useQuery({
queryKey: ["admin", "users", page, limit, search, roleFilter],
@ -52,7 +61,17 @@ export default function UsersPage() {
</p>
</div>
<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>

View File

@ -1,41 +1,74 @@
import { useState, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Search,
Eye,
CheckCheck,
Send,
Plus,
BellRing,
Mail,
MessageSquare,
History,
Target,
ArrowRight,
ChevronRight,
Loader2,
Calendar,
Mail,
Tag,
} from "lucide-react";
import { notificationService } from "@/services/notification.service";
import type {
SendPushNotificationRequest,
SendSmsNotificationRequest,
SendEmailNotificationRequest,
} from "@/services/notification.service";
import { useAdminRole } from "@/hooks/use-admin-role";
import { toast } from "sonner";
import { format } from "date-fns";
import { cn } from "@/lib/utils";
export default function NotificationsPage() {
const [searchQuery, setSearchQuery] = useState("");
const [typeFilter, setTypeFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("");
type Channel = "PUSH" | "SMS" | "EMAIL";
const {
data: notifications,
isLoading,
refetch,
} = useQuery({
export default function NotificationsPage() {
const { canSendNotifications } = useAdminRole();
const queryClient = useQueryClient();
const [searchQuery, setSearchQuery] = useState("");
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"],
queryFn: () => notificationService.getNotifications(),
});
@ -45,257 +78,490 @@ export default function NotificationsPage() {
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(() => {
if (!notifications) return [];
return notifications.filter((notification) => {
// Type filter
if (typeFilter && notification.type !== typeFilter) return false;
// Status filter
if (statusFilter === "read" && !notification.isRead) return false;
if (statusFilter === "unread" && notification.isRead) return false;
// Search filter
if (searchQuery) {
const query = searchQuery.toLowerCase();
return notifications.filter((n) => {
if (!searchQuery) return true;
const q = searchQuery.toLowerCase();
return (
notification.title.toLowerCase().includes(query) ||
notification.message.toLowerCase().includes(query) ||
notification.recipient.toLowerCase().includes(query)
n.title?.toLowerCase().includes(q) || n.body.toLowerCase().includes(q)
);
}
return true;
});
}, [notifications, typeFilter, statusFilter, searchQuery]);
}, [notifications, searchQuery]);
const handleMarkAsRead = async (id: string) => {
try {
await notificationService.markAsRead(id);
toast.success("Notification marked as read");
refetch();
} catch (error) {
toast.error("Failed to mark notification as read");
console.error("Mark as read error:", error);
}
const handleSend = () => {
if (activeChannel === "PUSH") pushMutation.mutate(pushForm);
else if (activeChannel === "SMS") smsMutation.mutate(smsForm);
else if (activeChannel === "EMAIL") emailMutation.mutate(emailForm);
};
const handleMarkAllAsRead = async () => {
try {
await notificationService.markAllAsRead();
toast.success("All notifications marked as read");
refetch();
} catch (error) {
toast.error("Failed to mark all as read");
console.error("Mark all as read error:", error);
}
};
const 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" />;
}
};
const isPending =
pushMutation.isPending || smsMutation.isPending || emailMutation.isPending;
return (
<div className="space-y-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">Notifications</h1>
{unreadCount !== undefined && unreadCount > 0 ? (
<p className="text-muted-foreground mt-1">
You have{" "}
<span className="font-bold text-slate-900">{unreadCount}</span>{" "}
unread notification{unreadCount !== 1 ? "s" : ""}
</p>
) : (
<p className="text-muted-foreground mt-1">
All messages been processed.
</p>
)}
<div className="space-y-8 animate-in fade-in duration-500">
{/* Header Section */}
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
<div className="space-y-1">
<div className="flex items-center gap-2 text-primary mb-1">
<div className="p-2 bg-primary/10 rounded-lg">
<BellRing className="w-5 h-5" />
</div>
<div className="flex gap-2">
{unreadCount !== undefined && unreadCount > 0 && (
<span className="text-xs font-black uppercase tracking-widest opacity-70">
Messaging Hub
</span>
</div>
<h1 className="text-4xl font-black tracking-tighter text-slate-900 uppercase italic">
Command <span className="text-primary NOT-italic">Center</span>
</h1>
<p className="text-slate-500 text-sm font-medium max-w-xl leading-relaxed">
Dispatch multi-channel broadcasts and monitor real-time network
telemetry across the Yaltopia mesh.
</p>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
onClick={handleMarkAllAsRead}
className="border-slate-200 text-[10px] font-bold uppercase tracking-widest h-9"
variant="ghost"
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={() => notificationService.markAllAsRead()}
>
<CheckCheck className="w-4 h-4 mr-2" />
Mark All as Read
Clear Signal
</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>
<Card className="border-slate-200/60 shadow-sm rounded-none">
<CardHeader className="pb-3 border-b border-slate-100 bg-slate-50/30">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex flex-1 items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Stats / Quick Info */}
<div className="lg:col-span-1 space-y-6">
<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="space-y-6">
<div className="flex items-center justify-between">
<span className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40">
System Status
</span>
<Badge className="bg-emerald-500 text-white border-none text-[10px] rounded-full px-2 py-0">
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
placeholder="Filter notifications..."
className="pl-9 h-10 border-slate-200/80 focus-visible:ring-slate-900 rounded-none shadow-none"
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>
<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>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto min-h-[400px]">
<div className="p-4 space-y-4">
{isLoading ? (
<div className="flex items-center justify-center py-24">
<Loader2 className="w-8 h-8 animate-spin text-slate-300" />
</div>
) : filteredNotifications && filteredNotifications.length > 0 ? (
<Table>
<TableHeader className="bg-slate-50/50">
<TableRow className="hover:bg-transparent border-slate-100">
<TableHead className="w-[120px] text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6 py-4">
ID Reference
</TableHead>
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
Message Intelligence
</TableHead>
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
Classification
</TableHead>
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
State
</TableHead>
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
Timeline
</TableHead>
<TableHead className="text-right text-[10px] font-bold uppercase tracking-widest text-slate-500 px-10">
Actions
</TableHead>
</TableRow>
</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",
)}
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"
>
<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",
)}
<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"
>
{notification.title}
</span>
<span className="text-xs text-slate-500 line-clamp-1">
{notification.message}
</span>
Push
</Badge>
</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}
<p className="text-xs text-slate-500 font-medium leading-relaxed max-w-lg">
{n.body}
</p>
</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" />
</div>
<div className="flex flex-col items-end gap-2 shrink-0">
<span className="text-[10px] font-bold text-slate-400">
{format(
new Date(notification.createdAt),
"MMM dd, HH:mm",
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",
)}
</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>
{n.isSent ? "Delivered" : "Queued"}
</Badge>
</div>
</div>
</div>
))
) : (
<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 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>
</div>
<DialogFooter className="p-8 pt-4 bg-slate-50 border-t border-slate-100 flex items-center justify-between">
<Button
variant="ghost"
className="h-12 px-6 rounded-xl font-black uppercase text-xs tracking-widest text-slate-400 hover:text-slate-900"
onClick={() => setIsSendModalOpen(false)}
>
Abort Mission
</Button>
<Button
className="h-12 px-10 rounded-xl bg-slate-900 hover:bg-slate-800 text-white font-black uppercase text-xs tracking-[0.1em] shadow-lg shadow-slate-200 transition-all active:scale-95"
disabled={isPending}
onClick={handleSend}
>
{isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Broadcasting...
</>
) : (
<>
Commit {activeChannel} Signal
<ArrowRight className="w-4 h-4 ml-2" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -67,11 +67,13 @@ export interface Proforma {
}
export interface ProformaRequestItem {
id: string;
id?: string;
itemName: string;
itemDescription?: string;
quantity: number;
unitOfMeasure: string;
createdAt: string;
technicalSpecifications?: Record<string, any>;
createdAt?: string;
}
export interface ProformaRequest {
@ -90,7 +92,11 @@ export interface ProformaRequest {
submissionDeadline: string;
allowRevisions: boolean;
paymentTerms: string;
incoterms?: string;
taxIncluded: boolean;
discountStructure?: string;
validityPeriod?: number;
attachments?: { name: string; url: string }[];
items: ProformaRequestItem[];
createdAt: string;
updatedAt: string;
@ -144,6 +150,29 @@ class InvoiceService {
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
*/
@ -159,6 +188,29 @@ class InvoiceService {
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)
*/
@ -173,6 +225,48 @@ class InvoiceService {
);
return response.data;
}
/**
* Get proforma request details (admin view)
*/
async getProformaRequestDetails(id: string): Promise<ProformaRequest> {
const response = await apiClient.get<ProformaRequest>(
`/admin/proforma-requests/${id}`,
);
return response.data;
}
/**
* Create a new proforma request
*/
async createProformaRequest(data: any): Promise<ProformaRequest> {
const response = await apiClient.post<ProformaRequest>(
"/proforma-requests",
data,
);
return response.data;
}
/**
* Update an existing proforma request
*/
async updateProformaRequest(id: string, data: any): Promise<ProformaRequest> {
const response = await apiClient.put<ProformaRequest>(
`/proforma-requests/${id}`,
data,
);
return response.data;
}
/**
* Close a proforma request
*/
async closeProformaRequest(id: string): Promise<void> {
await apiClient.post(`/proforma-requests/${id}/close`);
}
/**
* Cancel a proforma request
*/
async cancelProformaRequest(id: string): Promise<void> {
await apiClient.post(`/proforma-requests/${id}/cancel`);
}
}
export const invoiceService = new InvoiceService();

View File

@ -1,128 +1,175 @@
import apiClient from './api/client'
import apiClient from "./api/client";
export interface Notification {
id: string
title: string
message: string
type: 'system' | 'user' | 'alert' | 'invoice' | 'payment'
recipient: string
status: 'sent' | 'delivered' | 'read' | 'unread'
isRead: boolean
createdAt: string
sentAt?: string
readAt?: string
id: string;
title: string;
body: string;
icon?: string;
url?: string;
sentAt?: string;
scheduledFor?: string;
isSent: boolean;
recipientId: string;
data?: Record<string, any>;
createdAt: string;
updatedAt: string;
}
export interface NotificationSettings {
emailNotifications: boolean
pushNotifications: boolean
invoiceReminders: boolean
paymentAlerts: boolean
systemUpdates: boolean
emailNotifications: boolean;
pushNotifications: boolean;
invoiceReminders: boolean;
paymentAlerts: boolean;
systemUpdates: boolean;
}
export interface SendPushNotificationRequest {
title: string;
body: string;
icon?: string;
url?: string;
recipientId?: string;
scheduledFor?: string;
data?: Record<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 {
/**
* Get all notifications for current user
* Get all notifications for current user (Paginated)
*/
async getNotifications(params?: {
type?: string
status?: string
search?: string
page?: number;
limit?: number;
type?: string;
status?: string;
search?: string;
}): Promise<Notification[]> {
const response = await apiClient.get<Notification[]>('/notifications', {
const response = await apiClient.get<Notification[]>("/notifications", {
params,
})
return response.data
});
return response.data;
}
/**
* Get unread notification count
*/
async getUnreadCount(): Promise<number> {
const response = await apiClient.get<{ count: number }>('/notifications/unread-count')
return response.data.count
const response = await apiClient.get<{ count: number }>(
"/notifications/unread-count",
);
return response.data.count;
}
/**
* Mark notification as read
*/
async markAsRead(id: string): Promise<void> {
await apiClient.post(`/notifications/${id}/read`)
await apiClient.post(`/notifications/${id}/read`);
}
/**
* Mark all notifications as read
*/
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: {
title: string
message: string
channels: ('push' | 'sms' | 'email')[]
audience?: 'all_end_users' | 'system_users_only' | 'everyone_with_access'
}): Promise<{ id: string }> {
const response = await apiClient.post<{ id: string }>(
'/admin/notifications/broadcast',
async sendPushNotification(
data: SendPushNotificationRequest,
): Promise<Notification> {
const response = await apiClient.post<Notification>(
"/admin/notifications/send-push",
data,
)
return response.data
);
return response.data;
}
/**
* Send notification (ADMIN only)
* Send SMS notification (ADMIN only)
*/
async sendNotification(data: {
title: string
message: string
type: string
recipient?: string
recipientType?: 'user' | 'all'
}): Promise<Notification> {
const response = await apiClient.post<Notification>('/notifications/send', data)
return response.data
async sendSmsNotification(
data: SendSmsNotificationRequest,
): Promise<{ success: boolean; messageId: string }> {
const response = await apiClient.post<{
success: boolean;
messageId: string;
}>("/admin/notifications/send-sms", data);
return response.data;
}
/**
* Send Email notification (ADMIN only)
*/
async sendEmailNotification(
data: SendEmailNotificationRequest,
): Promise<{ success: boolean; messageId: string }> {
const response = await apiClient.post<{
success: boolean;
messageId: string;
}>("/admin/notifications/send-email", data);
return response.data;
}
/**
* Subscribe to push notifications
*/
async subscribeToPush(subscription: PushSubscription): Promise<void> {
await apiClient.post('/notifications/subscribe', subscription)
await apiClient.post("/notifications/subscribe", subscription);
}
/**
* Unsubscribe from push notifications
*/
async unsubscribeFromPush(endpoint: string): Promise<void> {
await apiClient.delete(`/notifications/unsubscribe/${encodeURIComponent(endpoint)}`)
await apiClient.delete(
`/notifications/unsubscribe/${encodeURIComponent(endpoint)}`,
);
}
/**
* Get notification settings
*/
async getSettings(): Promise<NotificationSettings> {
const response = await apiClient.get<NotificationSettings>('/notifications/settings')
return response.data
const response = await apiClient.get<NotificationSettings>(
"/notifications/settings",
);
return response.data;
}
/**
* Update notification settings
*/
async updateSettings(settings: Partial<NotificationSettings>): Promise<NotificationSettings> {
const response = await apiClient.put<NotificationSettings>('/notifications/settings', settings)
return response.data
async updateSettings(
settings: Partial<NotificationSettings>,
): Promise<NotificationSettings> {
const response = await apiClient.put<NotificationSettings>(
"/notifications/settings",
settings,
);
return response.data;
}
/**
* Send invoice reminder
*/
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> {
const csvContent = [
['ID', 'Title', 'Message', 'Type', 'Status', 'Created Date', 'Read Date'],
...notifications.map(n => [
["ID", "Title", "Body", "Status", "Created Date", "Sent Date"],
...notifications.map((n) => [
n.id,
n.title,
n.message,
n.type,
n.status,
n.body,
n.isSent ? "Sent" : "Scheduled",
n.createdAt,
n.readAt || '-'
])
].map(row => row.join(',')).join('\n')
n.sentAt || "-",
]),
]
.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;
status: "DRAFT" | "SENT" | "OPENED" | "PAID" | "EXPIRED" | "CANCELLED";
paymentId: string;
accounts: any[];
accounts: {
bankName: string;
accountName: string;
accountNumber: string;
currency: string;
}[];
pdfPath: string;
userId: string;
customerId?: string;
items: {
id: string;
id?: string;
description: string;
quantity: number;
unitPrice: number;
@ -99,6 +105,29 @@ class PaymentService {
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
*/
@ -113,6 +142,35 @@ class PaymentService {
);
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();