ts
This commit is contained in:
parent
23ab82a726
commit
a15064ce37
77
docs/api-mock-schemas.json
Normal file
77
docs/api-mock-schemas.json
Normal 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
10
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
23
src/components/ui/textarea.tsx
Normal file
23
src/components/ui/textarea.tsx
Normal 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 };
|
||||
|
|
@ -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;
|
||||
|
||||
return {
|
||||
role,
|
||||
roleLabel: roleLabel(role),
|
||||
isSuperAdmin: isSuperAdmin(role),
|
||||
canEdit: canEdit(role),
|
||||
canAccessSystemMembers: canAccessSystemMembers(role),
|
||||
canSendBroadcast: canSendBroadcast(role),
|
||||
hasPanelAccess: hasPanelAccess(role),
|
||||
}
|
||||
}, [])
|
||||
const permissions = useMemo(() => getPermissions(role), [role]);
|
||||
|
||||
return {
|
||||
role,
|
||||
roleLabel: roleLabel(role),
|
||||
hasPanelAccess: hasPanelAccess(role),
|
||||
...permissions,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: CreditCard, label: "Payments", path: "/admin/payments" },
|
||||
{
|
||||
icon: FileClock,
|
||||
label: "Payment Requests",
|
||||
path: "/admin/payment-requests",
|
||||
icon: Folder,
|
||||
label: "Documents",
|
||||
children: [
|
||||
{
|
||||
label: "Invoices",
|
||||
path: "/admin/invoices",
|
||||
icon: Receipt,
|
||||
visible: (role) => getPermissions(role).canViewBusinessData,
|
||||
},
|
||||
{
|
||||
label: "Proforma",
|
||||
icon: FileSearch,
|
||||
children: [
|
||||
{ label: "Records", path: "/admin/proforma" },
|
||||
{ label: "Requests", path: "/admin/proforma-requests" },
|
||||
],
|
||||
visible: (role) => getPermissions(role).canViewBusinessData,
|
||||
},
|
||||
{
|
||||
label: "Payments",
|
||||
icon: CreditCard,
|
||||
children: [
|
||||
{ label: "Records", path: "/admin/payments" },
|
||||
{ label: "Requests", path: "/admin/payment-requests" },
|
||||
],
|
||||
visible: (role) => getPermissions(role).canViewBusinessData,
|
||||
},
|
||||
],
|
||||
visible: (role) => getPermissions(role).canViewBusinessData,
|
||||
},
|
||||
{
|
||||
icon: ArrowRightLeft,
|
||||
label: "Subscription transactions",
|
||||
path: "/admin/transactions/subscriptions",
|
||||
},
|
||||
{ icon: Users, label: "Users", path: "/admin/users" },
|
||||
{
|
||||
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 */}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* View only access: Create and Export buttons removed */}
|
||||
</div>
|
||||
{canCreateBusinessData && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleOpenCreate}
|
||||
className="h-9 rounded-none bg-slate-900 border-none uppercase text-[10px] font-bold tracking-widest"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Invoice
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card className="border shadow-none rounded-none">
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
>
|
||||
{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")}
|
||||
<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)}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,250 +102,436 @@ 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" />
|
||||
<Input
|
||||
className="pl-10 h-9 rounded-none border-gray-200 text-xs"
|
||||
placeholder="Search title or email…"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-8 border-b border-gray-100">
|
||||
<NavLink
|
||||
to="/admin/support/faq"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"pb-4 text-xs font-black uppercase tracking-[0.2em] transition-all border-b-2",
|
||||
isActive
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-slate-400 hover:text-slate-600",
|
||||
)
|
||||
}
|
||||
>
|
||||
FAQ repository
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/admin/issues"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"pb-4 text-xs font-black uppercase tracking-[0.2em] transition-all border-b-2",
|
||||
isActive
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-slate-400 hover:text-slate-600",
|
||||
)
|
||||
}
|
||||
>
|
||||
Support Queue
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<Card className=" border border-gray-100 rounded-[ 6px] overflow-hidden">
|
||||
<CardHeader className="p-8 border-b border-slate-100/50 space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6 uppercase">
|
||||
<h2 className="text-xs font-black tracking-[0.2em] text-slate-400">
|
||||
Support Request Queue
|
||||
</h2>
|
||||
<div className="relative group min-w-[320px]">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 group-focus-within:text-primary transition-colors" />
|
||||
<Input
|
||||
className="pl-11 h-10 bg-slate-50 border-slate-200/60 rounded-[6px] text-sm focus:bg-white transition-all shadow-none placeholder:text-slate-400 font-medium"
|
||||
placeholder="Search ticket titles or reporter..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{error && (
|
||||
<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…
|
||||
</td>
|
||||
</tr>
|
||||
) : data?.data?.length ? (
|
||||
data.data.map((issue) => (
|
||||
<tr key={issue.id} className="hover:bg-gray-50 align-top">
|
||||
<td className="px-6 py-4 text-sm font-semibold text-gray-900 max-w-xs">
|
||||
{issue.title}
|
||||
<p className="text-[11px] text-gray-500 font-normal mt-1 line-clamp-2">
|
||||
{issue.description}
|
||||
</p>
|
||||
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 className="px-6 py-4 text-xs text-gray-600">
|
||||
<div>{issue.reporterEmail}</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="mt-1 text-[10px] rounded-none"
|
||||
>
|
||||
{issue.reporterType}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-xs text-gray-500">—</td>
|
||||
<td className="px-6 py-4 text-xs font-bold text-gray-700">
|
||||
{issue.priority}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-xs text-gray-600">
|
||||
{issue.updatedAt
|
||||
? new Date(issue.updatedAt).toLocaleString()
|
||||
: new Date(issue.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
{canEdit ? (
|
||||
<Select
|
||||
value={issue.status}
|
||||
onValueChange={(v) =>
|
||||
statusMutation.mutate({
|
||||
id: issue.id,
|
||||
status: v as IssueStatus,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[140px] rounded-none text-xs ml-auto">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="OPEN">Open</SelectItem>
|
||||
<SelectItem value="IN_PROGRESS">
|
||||
In progress
|
||||
</SelectItem>
|
||||
<SelectItem value="RESOLVED">Resolved</SelectItem>
|
||||
<SelectItem value="CLOSED">Closed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`rounded-none text-[10px] ${badgeForStatus(issue.status)}`}
|
||||
>
|
||||
{issue.status}
|
||||
</Badge>
|
||||
)}
|
||||
<td colSpan={4} className="px-8 py-6">
|
||||
<div className="h-3 bg-slate-50 rounded-full w-32 ml-auto"></div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : data?.data?.length ? (
|
||||
data.data.map((issue) => {
|
||||
const status = getStatusConfig(issue.status);
|
||||
return (
|
||||
<tr
|
||||
key={issue.id}
|
||||
className="group hover:bg-slate-50/50 transition-colors align-top"
|
||||
>
|
||||
<td className="px-8 py-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-black text-slate-900 tracking-tight leading-snug group-hover:text-primary transition-colors">
|
||||
{issue.title}
|
||||
</span>
|
||||
<p className="text-[11px] text-slate-500 font-medium line-clamp-2 mt-1 leading-relaxed">
|
||||
{issue.description}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-slate-100 flex items-center justify-center text-slate-400">
|
||||
<UserIcon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-bold text-slate-700">
|
||||
{issue.reporterEmail}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="mt-1 text-[9px] font-black uppercase tracking-tighter rounded-md py-0 px-1.5 opacity-60"
|
||||
>
|
||||
{issue.reporterType}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-6">
|
||||
<Badge
|
||||
className={cn(
|
||||
"rounded-lg px-2.5 py-1 text-[10px] font-black uppercase tracking-widest border shadow-none",
|
||||
getPriorityColor(issue.priority),
|
||||
)}
|
||||
>
|
||||
{issue.priority}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-8 py-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-tighter">
|
||||
Updated
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-slate-700">
|
||||
{new Date(
|
||||
issue.updatedAt || issue.createdAt,
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-6 text-right">
|
||||
{canEdit ? (
|
||||
<Select
|
||||
value={issue.status}
|
||||
onValueChange={(v) =>
|
||||
statusMutation.mutate({
|
||||
id: issue.id,
|
||||
status: v as IssueStatus,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-10 w-[160px] rounded-xl text-[10px] font-black uppercase tracking-widest ml-auto bg-white border-slate-200/60 shadow-sm focus:ring-1 focus:ring-primary transition-all">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"w-1.5 h-1.5 rounded-full",
|
||||
status.badge,
|
||||
)}
|
||||
/>
|
||||
<SelectValue />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl border-slate-100 shadow-xl">
|
||||
<SelectItem
|
||||
value="OPEN"
|
||||
className="text-xs font-bold uppercase transition-colors data-[state=checked]:text-primary"
|
||||
>
|
||||
Open Queue
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="IN_PROGRESS"
|
||||
className="text-xs font-bold uppercase transition-colors data-[state=checked]:text-primary"
|
||||
>
|
||||
In Progress
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="RESOLVED"
|
||||
className="text-xs font-bold uppercase transition-colors data-[state=checked]:text-primary text-emerald-600"
|
||||
>
|
||||
Resolved
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="CLOSED"
|
||||
className="text-xs font-bold uppercase transition-colors data-[state=checked]:text-primary italic opacity-50"
|
||||
>
|
||||
Archived
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 ml-auto w-fit px-3 py-1.5 rounded-xl border",
|
||||
status.color,
|
||||
"border-opacity-50",
|
||||
)}
|
||||
>
|
||||
<status.icon className="w-3.5 h-3.5" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest">
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={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>
|
||||
<Input
|
||||
id="iss-title"
|
||||
value={newIssue.title}
|
||||
onChange={(e) =>
|
||||
setNewIssue((n) => ({ ...n, title: e.target.value }))
|
||||
}
|
||||
className="rounded-none"
|
||||
/>
|
||||
<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>
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="iss-desc">Description</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"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<Label>Priority</Label>
|
||||
<Select
|
||||
value={newIssue.priority}
|
||||
onValueChange={(v) =>
|
||||
setNewIssue((n) => ({
|
||||
...n,
|
||||
priority: v as typeof newIssue.priority,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="rounded-none">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="LOW">Low</SelectItem>
|
||||
<SelectItem value="MEDIUM">Medium</SelectItem>
|
||||
<SelectItem value="HIGH">High</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<DialogTitle className="text-3xl font-black italic tracking-tighter uppercase leading-none">
|
||||
Capture <span className="text-primary NOT-italic">Issue</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-400 text-xs font-medium mt-2 leading-relaxed">
|
||||
Document the system anomaly or customer friction point with
|
||||
technical precision. Reports are immediately queued for triage.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label
|
||||
htmlFor="iss-title"
|
||||
className="text-[10px] font-black uppercase text-slate-400 tracking-widest"
|
||||
>
|
||||
Descriptive Headline
|
||||
</Label>
|
||||
<Input
|
||||
id="iss-title"
|
||||
value={newIssue.title}
|
||||
onChange={(e) =>
|
||||
setNewIssue((n) => ({ ...n, title: e.target.value }))
|
||||
}
|
||||
className="h-12 rounded-xl bg-slate-50 border-slate-200/60 focus:bg-white transition-all font-bold text-sm"
|
||||
placeholder="e.g. Authentication loop on mobile safari..."
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label
|
||||
htmlFor="iss-desc"
|
||||
className="text-[10px] font-black uppercase text-slate-400 tracking-widest"
|
||||
>
|
||||
Technical Narrative
|
||||
</Label>
|
||||
<textarea
|
||||
id="iss-desc"
|
||||
value={newIssue.description}
|
||||
onChange={(e) =>
|
||||
setNewIssue((n) => ({ ...n, description: e.target.value }))
|
||||
}
|
||||
className="min-h-[120px] rounded-xl bg-slate-50 border-slate-200/60 p-4 text-sm font-medium focus:bg-white focus:outline-none transition-all resize-none"
|
||||
placeholder="Steps to reproduce, error IDs, and environment context..."
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-[10px] font-black uppercase text-slate-400 tracking-widest">
|
||||
Triage Priority
|
||||
</Label>
|
||||
<Select
|
||||
value={newIssue.priority}
|
||||
onValueChange={(v) =>
|
||||
setNewIssue((n) => ({
|
||||
...n,
|
||||
priority: v as typeof newIssue.priority,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-12 rounded-xl bg-slate-50 border-slate-200/60 font-black text-xs uppercase tracking-widest">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem
|
||||
value="LOW"
|
||||
className="text-[10px] font-black uppercase"
|
||||
>
|
||||
Low / Enhancement
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="MEDIUM"
|
||||
className="text-[10px] font-black uppercase"
|
||||
>
|
||||
Medium / Routine
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="HIGH"
|
||||
className="text-[10px] font-black uppercase text-rose-600"
|
||||
>
|
||||
High / Critical Anomaly
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
||||
<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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</span>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
||||
FAQ & support
|
||||
</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>
|
||||
<div className="space-y-8 mx-auto max-w-7xl mt-10 animate-in fade-in duration-500">
|
||||
{/* Header Section */}
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
||||
FAQ & <span className="text-primary NOT-italic">Support</span>
|
||||
</h1>
|
||||
<p className="text-slate-500 text-sm font-medium max-w-xl leading-relaxed">
|
||||
Curate and manage the central intelligence repository for both
|
||||
standard users and internal system administrators.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-8 border-b border-gray-100">
|
||||
<NavLink
|
||||
to="/admin/support/faq"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"pb-4 text-xs font-black uppercase tracking-[0.2em] transition-all border-b-2",
|
||||
isActive
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-slate-400 hover:text-slate-600",
|
||||
)
|
||||
}
|
||||
>
|
||||
FAQ repository
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/admin/issues"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"pb-4 text-xs font-black uppercase tracking-[0.2em] transition-all border-b-2",
|
||||
isActive
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-slate-400 hover:text-slate-600",
|
||||
)
|
||||
}
|
||||
>
|
||||
Support Queue
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={canEdit ? tab : "browse"}
|
||||
onValueChange={(v) => {
|
||||
if (canEdit) setTab(v as "browse" | "manage")
|
||||
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">
|
||||
<div className="p-3 bg-slate-50 rounded-2xl group-hover:bg-primary/5 transition-colors">
|
||||
<HelpCircle className="w-5 h-5 text-slate-400 group-hover:text-primary transition-colors" />
|
||||
</div>
|
||||
<Badge
|
||||
className={cn(
|
||||
"text-[9px] font-black uppercase tracking-widest rounded-lg px-2 py-0.5 border-none",
|
||||
faq.audience === "ALL"
|
||||
? "bg-slate-100 text-slate-500"
|
||||
: faq.audience === "END_USER"
|
||||
? "bg-emerald-500 text-white"
|
||||
: "bg-primary text-white",
|
||||
)}
|
||||
>
|
||||
{faq.audience === "ALL"
|
||||
? "Everyone"
|
||||
? "Global"
|
||||
: faq.audience === "END_USER"
|
||||
? "End users"
|
||||
: "System users"}
|
||||
? "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">
|
||||
{faq.answer}
|
||||
<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">
|
||||
{faq.question}
|
||||
<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>
|
||||
<Select
|
||||
value={form.audience}
|
||||
onValueChange={(v) =>
|
||||
setForm((f) => ({ ...f, audience: v as FaqAudience }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="rounded-none">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">Everyone (users & system)</SelectItem>
|
||||
<SelectItem value="END_USER">Platform customers only</SelectItem>
|
||||
<SelectItem value="SYSTEM_USER">Panel / support staff only</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="faq-q">Question</Label>
|
||||
<Input
|
||||
id="faq-q"
|
||||
value={form.question}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, question: e.target.value }))
|
||||
}
|
||||
className="rounded-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="faq-a">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"
|
||||
/>
|
||||
<DialogDescription className="text-slate-400 text-xs font-medium mt-2 leading-relaxed">
|
||||
Authoritative content for the platform knowledge base. Ensure
|
||||
semantic clarity and audience alignment.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-6 max-h-[60vh] overflow-y-auto">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-[10px] font-black uppercase text-slate-400 tracking-widest">
|
||||
Visibility Tier
|
||||
</Label>
|
||||
<Select
|
||||
value={form.audience}
|
||||
onValueChange={(v) =>
|
||||
setForm((f) => ({ ...f, audience: v as FaqAudience }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-12 rounded-xl bg-slate-50 border-slate-200/60 font-black text-[10px] uppercase tracking-widest">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem
|
||||
value="ALL"
|
||||
className="text-[10px] font-black uppercase"
|
||||
>
|
||||
Global Visibility
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="END_USER"
|
||||
className="text-[10px] font-black uppercase"
|
||||
>
|
||||
Platform Customers Only
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="SYSTEM_USER"
|
||||
className="text-[10px] font-black uppercase text-primary"
|
||||
>
|
||||
Internal Panel Staff Only
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label
|
||||
htmlFor="faq-q"
|
||||
className="text-[10px] font-black uppercase text-slate-400 tracking-widest"
|
||||
>
|
||||
Article Title / Question
|
||||
</Label>
|
||||
<Input
|
||||
id="faq-q"
|
||||
value={form.question}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, question: e.target.value }))
|
||||
}
|
||||
className="h-12 rounded-xl bg-slate-50 border-slate-200/60 focus:bg-white transition-all font-bold text-sm"
|
||||
placeholder="e.g. How to manage multiple ledgers?"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label
|
||||
htmlFor="faq-a"
|
||||
className="text-[10px] font-black uppercase text-slate-400 tracking-widest"
|
||||
>
|
||||
Authoritative Answer
|
||||
</Label>
|
||||
<textarea
|
||||
id="faq-a"
|
||||
value={form.answer}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, answer: e.target.value }))
|
||||
}
|
||||
className="min-h-[160px] rounded-xl bg-slate-50 border-slate-200/60 p-4 text-sm font-medium focus:bg-white focus:outline-none transition-all resize-none"
|
||||
placeholder="Provide precise, actionable steps..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
||||
<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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,196 +36,280 @@ 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
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
Successful charges and failed attempts for platform subscriptions.
|
||||
</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">
|
||||
<ArrowRightLeft className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-xs font-black uppercase tracking-widest opacity-70">
|
||||
Infrastructure
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-4xl font-black tracking-tighter text-slate-900 uppercase italic">
|
||||
Subscription{" "}
|
||||
<span className="text-primary NOT-italic">Transactions</span>
|
||||
</h1>
|
||||
<p className="text-slate-500 text-sm font-medium max-w-xl leading-relaxed">
|
||||
Monitor real-time platform revenue streams and troubleshoot declined
|
||||
payment attempts across all subscription tiers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => {
|
||||
setTab(v as "succeeded" | "failed")
|
||||
setPage(1)
|
||||
}}
|
||||
className="space-y-6"
|
||||
>
|
||||
<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
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="failed" className="rounded-none gap-2">
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
Failed payments
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="grid grid-cols-1 gap-8">
|
||||
<Card className="border-none shadow-[0_8px_30px_rgb(0,0,0,0.04)] bg-white/50 backdrop-blur-xl rounded-3xl overflow-hidden">
|
||||
<CardHeader className="p-8 border-b border-slate-100/50 space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6">
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => {
|
||||
setTab(v as "succeeded" | "failed");
|
||||
setPage(1);
|
||||
}}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<TabsList className="bg-slate-100/80 p-1 rounded-xl h-11 border border-slate-200/50">
|
||||
<TabsTrigger
|
||||
value="succeeded"
|
||||
className="rounded-lg px-6 gap-2 data-[state=active]:bg-white data-[state=active]:text-emerald-600 data-[state=active]:shadow-sm transition-all font-bold text-xs uppercase"
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Succeeded
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="failed"
|
||||
className="rounded-lg px-6 gap-2 data-[state=active]:bg-white data-[state=active]:text-rose-600 data-[state=active]:shadow-sm transition-all font-bold text-xs uppercase"
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
Failed
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-0">
|
||||
{error && (
|
||||
<div className="m-8 p-6 bg-rose-50 border border-rose-100 rounded-2xl flex items-center gap-4 text-rose-700">
|
||||
<XCircle className="w-6 h-6 flex-shrink-0" />
|
||||
<div className="text-sm font-medium">
|
||||
Failed to synchronize with banking ledger. Verify{" "}
|
||||
<code className="bg-rose-100/50 px-1.5 py-0.5 rounded leading-none">
|
||||
GET /admin/subscription-transactions
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
||||
User
|
||||
</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
||||
Plan
|
||||
</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
||||
Amount
|
||||
</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
||||
Provider / Ref
|
||||
</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
||||
Date
|
||||
</th>
|
||||
{tab === "failed" && (
|
||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
||||
Reason
|
||||
</th>
|
||||
)}
|
||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={tab === "failed" ? 7 : 6}
|
||||
className="px-6 py-16 text-center text-gray-400 animate-pulse"
|
||||
>
|
||||
Loading…
|
||||
</td>
|
||||
</tr>
|
||||
) : data?.data && data.data.length > 0 ? (
|
||||
data.data.map((row) => (
|
||||
<tr key={row.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 text-sm">
|
||||
<div className="font-semibold text-gray-900">
|
||||
{row.userEmail}
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-500">
|
||||
{row.userId}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-700">
|
||||
{row.planName}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm font-bold text-gray-900">
|
||||
{formatMoney(row.amount, row.currency)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-xs text-gray-600">
|
||||
<div>{row.provider}</div>
|
||||
{row.providerRef && (
|
||||
<div className="text-[10px] font-mono text-gray-400 mt-0.5">
|
||||
{row.providerRef}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{new Date(row.createdAt).toLocaleString()}
|
||||
</td>
|
||||
{tab === "failed" && (
|
||||
<td className="px-6 py-4 text-xs text-red-700 max-w-[200px]">
|
||||
{row.failureReason ?? "—"}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-6 py-4 text-right">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="rounded-none text-[10px] uppercase"
|
||||
>
|
||||
{row.status}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={tab === "failed" ? 7 : 6}
|
||||
className="px-6 py-16 text-center text-gray-400 italic text-sm"
|
||||
>
|
||||
No rows for this filter.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{data && data.totalPages > 1 && (
|
||||
<div className="flex justify-between items-center px-6 py-3 border-t text-xs text-gray-600">
|
||||
<span>
|
||||
Page {data.page} of {data.totalPages}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="underline disabled:opacity-40"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="underline disabled:opacity-40"
|
||||
disabled={page >= data.totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</code>{" "}
|
||||
is reachable.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto min-h-[400px]">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-slate-50/50 border-b border-slate-100">
|
||||
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] w-[28%]">
|
||||
Subscriber Details
|
||||
</th>
|
||||
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
||||
Service Plan
|
||||
</th>
|
||||
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
||||
Transaction Value
|
||||
</th>
|
||||
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
||||
Financial Gateway
|
||||
</th>
|
||||
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
||||
Captured At
|
||||
</th>
|
||||
{tab === "failed" && (
|
||||
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
||||
Resolution Logic
|
||||
</th>
|
||||
)}
|
||||
<th className="px-8 py-5 text-right text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
||||
State
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{isLoading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i} className="animate-pulse">
|
||||
<td
|
||||
colSpan={tab === "failed" ? 7 : 6}
|
||||
className="px-8 py-6"
|
||||
>
|
||||
<div className="h-4 bg-slate-100 rounded-full w-3/4 mb-2"></div>
|
||||
<div className="h-3 bg-slate-50 rounded-full w-1/2"></div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : data?.data && data.data.length > 0 ? (
|
||||
data.data.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className="group hover:bg-slate-50/50 transition-colors"
|
||||
>
|
||||
<td className="px-8 py-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-slate-100 flex items-center justify-center text-slate-400">
|
||||
<UserIcon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-black text-slate-900 tracking-tight">
|
||||
{row.userEmail}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
|
||||
{row.userId}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-slate-100 text-slate-700 hover:bg-slate-200 border-none rounded-lg px-2.5 py-0.5 text-[10px] font-black uppercase"
|
||||
>
|
||||
{row.planName}
|
||||
</Badge>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-6">
|
||||
<span className="text-sm font-black text-slate-900 underline decoration-primary/20 underline-offset-4">
|
||||
{formatMoney(row.amount, row.currency)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-8 py-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1.5 font-bold text-[11px] text-slate-700">
|
||||
<CreditCard className="w-3.5 h-3.5 text-slate-400" />
|
||||
{row.provider}
|
||||
</div>
|
||||
{row.providerRef && (
|
||||
<span className="text-[10px] font-mono text-slate-400 bg-slate-100/50 px-1.5 py-0.5 rounded border border-slate-200/50 w-fit">
|
||||
{row.providerRef}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-6">
|
||||
<span className="text-xs font-semibold text-slate-500 italic">
|
||||
{new Date(row.createdAt).toLocaleDateString()}
|
||||
<span className="block text-[10px] not-italic opacity-60 mt-1 uppercase font-bold tracking-tighter">
|
||||
{new Date(row.createdAt).toLocaleTimeString()}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
{tab === "failed" && (
|
||||
<td className="px-8 py-6 max-w-[240px]">
|
||||
<p className="text-xs font-bold text-rose-600 bg-rose-50/50 p-2 rounded-lg border border-rose-100/50">
|
||||
{row.failureReason ?? "Unknown Error Logic"}
|
||||
</p>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-8 py-6 text-right">
|
||||
<Badge
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-1 text-[10px] font-black uppercase tracking-widest border-none shadow-sm",
|
||||
row.status === "SUCCEEDED"
|
||||
? "bg-emerald-500 text-white shadow-emerald-200/50"
|
||||
: row.status === "FAILED"
|
||||
? "bg-rose-500 text-white shadow-rose-200/50"
|
||||
: "bg-amber-500 text-white shadow-amber-200/50",
|
||||
)}
|
||||
>
|
||||
{row.status}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={tab === "failed" ? 7 : 6}
|
||||
className="px-6 py-24 text-center"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center space-y-3 opacity-30 grayscale">
|
||||
<Filter className="w-12 h-12" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-black uppercase tracking-widest">
|
||||
No matching logs
|
||||
</span>
|
||||
<span className="text-xs font-medium">
|
||||
Adjust your criteria or verify live stream.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{data && data.totalPages > 1 && (
|
||||
<div className="p-8 border-t border-slate-100 flex items-center justify-between bg-slate-50/30">
|
||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">
|
||||
Showing{" "}
|
||||
<span className="text-slate-900">{data.data.length}</span> of{" "}
|
||||
<span className="text-slate-900">{data.total}</span> entries
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-10 rounded-xl px-4 font-black uppercase text-[10px] tracking-widest hover:bg-white"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" /> Prev
|
||||
</Button>
|
||||
<div className="flex items-center px-4 text-xs font-black text-primary bg-white rounded-xl shadow-sm border border-slate-200/50">
|
||||
{data.page} / {data.totalPages}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-10 rounded-xl px-4 font-black uppercase text-[10px] tracking-widest hover:bg-white"
|
||||
disabled={page >= data.totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next <ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,14 +119,27 @@ 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}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Key className="w-4 h-4 mr-2" />
|
||||
Reset Password
|
||||
</Button>
|
||||
{canEditUsers && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleEditClick}
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Key className="w-4 h-4 mr-2" />
|
||||
Reset Password
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!canEditUsers && (
|
||||
<span className="text-[10px] font-bold text-slate-300 uppercase italic">
|
||||
Immutable View
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
notification.title.toLowerCase().includes(query) ||
|
||||
notification.message.toLowerCase().includes(query) ||
|
||||
notification.recipient.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
return notifications.filter((n) => {
|
||||
if (!searchQuery) return true;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return (
|
||||
n.title?.toLowerCase().includes(q) || n.body.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [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>
|
||||
<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 gap-2">
|
||||
{unreadCount !== undefined && unreadCount > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleMarkAllAsRead}
|
||||
className="border-slate-200 text-[10px] font-bold uppercase tracking-widest h-9"
|
||||
>
|
||||
<CheckCheck className="w-4 h-4 mr-2" />
|
||||
Mark All as Read
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
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" />
|
||||
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" />
|
||||
<Input
|
||||
placeholder="Filter notifications..."
|
||||
className="pl-9 h-10 border-slate-200/80 focus-visible:ring-slate-900 rounded-none shadow-none"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<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
|
||||
className="pl-11 h-11 bg-slate-50 border-slate-200/60 rounded-xl text-sm focus:bg-white transition-all shadow-none placeholder:text-slate-400 font-medium"
|
||||
placeholder="Search signal history..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto min-h-[400px]">
|
||||
<div className="p-4 space-y-4">
|
||||
{isLoading ? (
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-24 bg-slate-100/50 animate-pulse rounded-2xl"
|
||||
/>
|
||||
))
|
||||
) : filteredNotifications.length ? (
|
||||
filteredNotifications.map((n) => (
|
||||
<div
|
||||
key={n.id}
|
||||
className="p-6 bg-white border border-slate-100 rounded-2xl hover:border-primary/20 hover:shadow-lg hover:shadow-primary/5 transition-all group relative overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-1 p-1 h-full bg-slate-100 group-hover:bg-primary transition-colors" />
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="p-3 bg-slate-50 rounded-xl group-hover:bg-primary/5 transition-colors">
|
||||
<BellRing className="w-5 h-5 text-slate-400 group-hover:text-primary transition-colors" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-black text-slate-900 tracking-tight">
|
||||
{n.title}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[9px] font-black uppercase tracking-tighter opacity-50 px-1.5 py-0 border-slate-200"
|
||||
>
|
||||
Push
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 font-medium leading-relaxed max-w-lg">
|
||||
{n.body}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 shrink-0">
|
||||
<span className="text-[10px] font-bold text-slate-400">
|
||||
{format(
|
||||
new Date(n.createdAt),
|
||||
"HH:mm · MMM d, yyyy",
|
||||
)}
|
||||
</span>
|
||||
<Badge
|
||||
className={cn(
|
||||
"text-[9px] font-black uppercase tracking-widest rounded-lg px-2 border-none",
|
||||
n.isSent
|
||||
? "bg-emerald-500 text-white"
|
||||
: "bg-slate-200 text-slate-500",
|
||||
)}
|
||||
>
|
||||
{n.isSent ? "Delivered" : "Queued"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="py-24 text-center">
|
||||
<div className="flex flex-col items-center justify-center space-y-4 opacity-20 grayscale">
|
||||
<History className="w-16 h-16" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-black uppercase tracking-[0.2em]">
|
||||
Zero Telemetry
|
||||
</span>
|
||||
<span className="text-xs font-medium italic mt-1">
|
||||
No transmissions detected in the current signal
|
||||
range.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dispatch Dialog */}
|
||||
<Dialog open={isSendModalOpen} onOpenChange={setIsSendModalOpen}>
|
||||
<DialogContent className="rounded-3xl max-w-2xl p-0 border-none shadow-2xl overflow-hidden">
|
||||
<div className="p-8 bg-slate-900 text-white overflow-hidden relative">
|
||||
{/* Decorative element */}
|
||||
<div className="absolute -top-12 -right-12 w-48 h-48 bg-primary/20 rounded-full blur-3xl" />
|
||||
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-white/10 rounded-xl">
|
||||
<Target className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] opacity-60 italic">
|
||||
Signal Transmission
|
||||
</span>
|
||||
</div>
|
||||
<DialogTitle className="text-3xl font-black italic tracking-tighter uppercase leading-none">
|
||||
Dispatch{" "}
|
||||
<span className="text-primary NOT-italic">Broadcast</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-400 text-xs font-medium mt-2 leading-relaxed">
|
||||
Authoritative platform-wide signal broadcast. Choose delivery
|
||||
channels and construct the payload with precision.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="space-y-8">
|
||||
{/* Channel Selector */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-[10px] font-black uppercase text-slate-400 tracking-widest">
|
||||
Select Uplink Channels
|
||||
</Label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{ id: "PUSH", icon: BellRing, label: "Push Notification" },
|
||||
{ id: "SMS", icon: MessageSquare, label: "SMS Gateway" },
|
||||
{ id: "EMAIL", icon: Mail, label: "Email Relay" },
|
||||
].map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => setActiveChannel(c.id as Channel)}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-3 p-6 rounded-2xl border-2 transition-all group",
|
||||
activeChannel === c.id
|
||||
? "border-primary bg-primary/5 text-primary shadow-lg shadow-primary/10"
|
||||
: "border-slate-100 bg-white text-slate-400 hover:border-slate-200",
|
||||
)}
|
||||
>
|
||||
<c.icon
|
||||
className={cn(
|
||||
"w-6 h-6 transition-transform group-active:scale-90",
|
||||
activeChannel === c.id
|
||||
? "text-primary"
|
||||
: "text-slate-300",
|
||||
)}
|
||||
/>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest">
|
||||
{c.id}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-slate-100" />
|
||||
|
||||
{/* Dynamic Form Area */}
|
||||
<div className="space-y-6">
|
||||
{activeChannel === "PUSH" && (
|
||||
<div className="space-y-4 animate-in slide-in-from-right-2 duration-300">
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
||||
Notification Title
|
||||
</Label>
|
||||
<Input
|
||||
value={pushForm.title}
|
||||
onChange={(e) =>
|
||||
setPushForm({ ...pushForm, title: e.target.value })
|
||||
}
|
||||
placeholder="Critical System Patch Available"
|
||||
className="h-12 rounded-xl bg-slate-50 border-slate-200/60 font-bold"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
||||
Message Body
|
||||
</Label>
|
||||
<Textarea
|
||||
value={pushForm.body}
|
||||
onChange={(e) =>
|
||||
setPushForm({ ...pushForm, body: e.target.value })
|
||||
}
|
||||
placeholder="Update your client to version 4.2 now..."
|
||||
className="min-h-[100px] rounded-xl bg-slate-50 border-slate-200/60"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeChannel === "SMS" && (
|
||||
<div className="space-y-4 animate-in slide-in-from-right-2 duration-300">
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
||||
Target Phone (Optional)
|
||||
</Label>
|
||||
<Input
|
||||
value={smsForm.recipientPhone}
|
||||
onChange={(e) =>
|
||||
setSmsForm({
|
||||
...smsForm,
|
||||
recipientPhone: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="+1 (555) 000-0000"
|
||||
className="h-12 rounded-xl bg-slate-50 border-slate-200/60 font-bold"
|
||||
/>
|
||||
<p className="text-[9px] text-slate-400 font-medium italic">
|
||||
Leave empty for multi-user broadcast.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
||||
SMS Payload
|
||||
</Label>
|
||||
<Textarea
|
||||
value={smsForm.body}
|
||||
onChange={(e) =>
|
||||
setSmsForm({ ...smsForm, body: e.target.value })
|
||||
}
|
||||
placeholder="Your Yaltopia ticket code is XYZ-123..."
|
||||
className="min-h-[100px] rounded-xl bg-slate-50 border-slate-200/60"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeChannel === "EMAIL" && (
|
||||
<div className="space-y-4 animate-in slide-in-from-right-2 duration-300">
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
||||
Email Subject
|
||||
</Label>
|
||||
<Input
|
||||
value={emailForm.subject}
|
||||
onChange={(e) =>
|
||||
setEmailForm({
|
||||
...emailForm,
|
||||
subject: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Important Account Update"
|
||||
className="h-12 rounded-xl bg-slate-50 border-slate-200/60 font-bold"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
||||
HTML Content
|
||||
</Label>
|
||||
<Textarea
|
||||
value={emailForm.body}
|
||||
onChange={(e) =>
|
||||
setEmailForm({ ...emailForm, body: e.target.value })
|
||||
}
|
||||
placeholder="<h1>Welcome to Yaltopia</h1>..."
|
||||
className="min-h-[160px] rounded-xl bg-slate-50 border-slate-200/60"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<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">
|
||||
{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",
|
||||
)}
|
||||
>
|
||||
<TableCell className="px-6 py-4">
|
||||
<code className="text-[10px] font-bold text-slate-400 font-mono tracking-tighter">
|
||||
{notification.id.substring(0, 12)}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="px-6 py-4">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-black tracking-tight uppercase",
|
||||
notification.isRead
|
||||
? "text-slate-500"
|
||||
: "text-slate-900",
|
||||
)}
|
||||
>
|
||||
{notification.title}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500 line-clamp-1">
|
||||
{notification.message}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-6">
|
||||
<div className="flex items-center text-[10px] font-bold uppercase tracking-widest text-slate-600">
|
||||
{getTypeIcon(notification.type)}
|
||||
{notification.type}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-6">
|
||||
{getStatusBadge(notification.isRead)}
|
||||
</TableCell>
|
||||
<TableCell className="px-6">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-500">
|
||||
<Calendar className="w-3.5 h-3.5 text-slate-300" />
|
||||
{format(
|
||||
new Date(notification.createdAt),
|
||||
"MMM dd, HH:mm",
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right px-6">
|
||||
{!notification.isRead && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-orange-500 transition-colors"
|
||||
onClick={() => handleMarkAsRead(notification.id)}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-center py-24 text-slate-400 font-bold uppercase tracking-widest text-[10px]">
|
||||
{searchQuery || typeFilter || statusFilter
|
||||
? "No matching telemetry records found"
|
||||
: "No notification stream detected"}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user