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",
|
"lucide-react": "^0.561.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-is": "^19.2.5",
|
||||||
"react-router-dom": "^7.11.0",
|
"react-router-dom": "^7.11.0",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
|
@ -7053,11 +7054,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "19.2.4",
|
"version": "19.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz",
|
||||||
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
|
"integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/react-redux": {
|
"node_modules/react-redux": {
|
||||||
"version": "9.2.0",
|
"version": "9.2.0",
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@
|
||||||
"lucide-react": "^0.561.0",
|
"lucide-react": "^0.561.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-is": "^19.2.5",
|
||||||
"react-router-dom": "^7.11.0",
|
"react-router-dom": "^7.11.0",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
|
|
||||||
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 { useMemo } from "react";
|
||||||
import { authService } from "@/services"
|
import { authService } from "@/services";
|
||||||
import {
|
import { getPermissions, hasPanelAccess, roleLabel } from "@/lib/admin-roles";
|
||||||
canAccessSystemMembers,
|
|
||||||
canEdit,
|
|
||||||
canSendBroadcast,
|
|
||||||
hasPanelAccess,
|
|
||||||
isSuperAdmin,
|
|
||||||
roleLabel,
|
|
||||||
} from "@/lib/admin-roles"
|
|
||||||
|
|
||||||
export function useAdminRole() {
|
export function useAdminRole() {
|
||||||
return useMemo(() => {
|
const user = authService.getCurrentUser() as {
|
||||||
const user = authService.getCurrentUser() as
|
role?: string;
|
||||||
| { role?: string; email?: string }
|
email?: string;
|
||||||
| null
|
} | null;
|
||||||
const role = user?.role
|
const role = user?.role;
|
||||||
|
|
||||||
return {
|
const permissions = useMemo(() => getPermissions(role), [role]);
|
||||||
role,
|
|
||||||
roleLabel: roleLabel(role),
|
return {
|
||||||
isSuperAdmin: isSuperAdmin(role),
|
role,
|
||||||
canEdit: canEdit(role),
|
roleLabel: roleLabel(role),
|
||||||
canAccessSystemMembers: canAccessSystemMembers(role),
|
hasPanelAccess: hasPanelAccess(role),
|
||||||
canSendBroadcast: canSendBroadcast(role),
|
...permissions,
|
||||||
hasPanelAccess: hasPanelAccess(role),
|
};
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, type ComponentType } from "react";
|
import React, { useState, type ComponentType, useEffect } from "react";
|
||||||
import { Outlet, Link, useLocation, useNavigate } from "react-router-dom";
|
import { Outlet, Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
|
@ -14,15 +14,16 @@ import {
|
||||||
Bell,
|
Bell,
|
||||||
LogOut,
|
LogOut,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
FileClock,
|
|
||||||
Receipt,
|
Receipt,
|
||||||
FileSearch,
|
FileSearch,
|
||||||
ClipboardList,
|
|
||||||
ArrowRightLeft,
|
ArrowRightLeft,
|
||||||
UserCog,
|
UserCog,
|
||||||
LifeBuoy,
|
LifeBuoy,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
Send,
|
Send,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Folder,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AdminQuickSearch } from "@/components/admin-quick-search";
|
import { AdminQuickSearch } from "@/components/admin-quick-search";
|
||||||
|
|
@ -36,7 +37,7 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { roleLabel } from "@/lib/admin-roles";
|
import { roleLabel, getPermissions } from "@/lib/admin-roles";
|
||||||
import { authService } from "@/services";
|
import { authService } from "@/services";
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
|
|
@ -47,58 +48,198 @@ interface User {
|
||||||
}
|
}
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
icon: ComponentType<{ className?: string }>;
|
icon?: ComponentType<{ className?: string }>;
|
||||||
label: string;
|
label: string;
|
||||||
path: string;
|
path?: string;
|
||||||
|
children?: NavItem[];
|
||||||
/** Omit = visible to all panel roles */
|
/** Omit = visible to all panel roles */
|
||||||
visible?: (role: string | undefined) => boolean;
|
visible?: (role: string | undefined) => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const adminNavigationItems: NavItem[] = [
|
const adminNavigationItems: NavItem[] = [
|
||||||
{ icon: LayoutDashboard, label: "Dashboard", path: "/admin/dashboard" },
|
{ icon: LayoutDashboard, label: "Dashboard", path: "/admin/dashboard" },
|
||||||
{ icon: Receipt, label: "Invoices", path: "/admin/invoices" },
|
|
||||||
{ icon: FileSearch, label: "Proforma", path: "/admin/proforma" },
|
|
||||||
{
|
{
|
||||||
icon: ClipboardList,
|
icon: Folder,
|
||||||
label: "Proforma Requests",
|
label: "Documents",
|
||||||
path: "/admin/proforma-requests",
|
children: [
|
||||||
},
|
{
|
||||||
{ icon: CreditCard, label: "Payments", path: "/admin/payments" },
|
label: "Invoices",
|
||||||
{
|
path: "/admin/invoices",
|
||||||
icon: FileClock,
|
icon: Receipt,
|
||||||
label: "Payment Requests",
|
visible: (role) => getPermissions(role).canViewBusinessData,
|
||||||
path: "/admin/payment-requests",
|
},
|
||||||
|
{
|
||||||
|
label: "Proforma",
|
||||||
|
icon: FileSearch,
|
||||||
|
children: [
|
||||||
|
{ label: "Records", path: "/admin/proforma" },
|
||||||
|
{ label: "Requests", path: "/admin/proforma-requests" },
|
||||||
|
],
|
||||||
|
visible: (role) => getPermissions(role).canViewBusinessData,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Payments",
|
||||||
|
icon: CreditCard,
|
||||||
|
children: [
|
||||||
|
{ label: "Records", path: "/admin/payments" },
|
||||||
|
{ label: "Requests", path: "/admin/payment-requests" },
|
||||||
|
],
|
||||||
|
visible: (role) => getPermissions(role).canViewBusinessData,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
visible: (role) => getPermissions(role).canViewBusinessData,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: ArrowRightLeft,
|
icon: ArrowRightLeft,
|
||||||
label: "Subscription transactions",
|
label: "Subscription transactions",
|
||||||
path: "/admin/transactions/subscriptions",
|
path: "/admin/transactions/subscriptions",
|
||||||
},
|
},
|
||||||
{ icon: Users, label: "Users", path: "/admin/users" },
|
{
|
||||||
|
icon: Users,
|
||||||
|
label: "Users",
|
||||||
|
path: "/admin/users",
|
||||||
|
visible: (role) => getPermissions(role).canViewUsers,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: UserCog,
|
icon: UserCog,
|
||||||
label: "System users",
|
label: "System users",
|
||||||
path: "/admin/system-members",
|
path: "/admin/system-members",
|
||||||
visible: (role) => role === "SUPER_ADMIN" || role === "ADMIN",
|
visible: (role) => getPermissions(role).canManageSystem,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: FileText,
|
||||||
|
label: "Logs",
|
||||||
|
path: "/admin/logs",
|
||||||
|
visible: (role) => getPermissions(role).canViewSystemData,
|
||||||
},
|
},
|
||||||
{ icon: FileText, label: "Logs", path: "/admin/logs" },
|
|
||||||
{ icon: LifeBuoy, label: "Issues", path: "/admin/issues" },
|
{ icon: LifeBuoy, label: "Issues", path: "/admin/issues" },
|
||||||
{ icon: HelpCircle, label: "FAQ & support", path: "/admin/support/faq" },
|
{ icon: HelpCircle, label: "FAQ & support", path: "/admin/support/faq" },
|
||||||
{ icon: Settings, label: "Settings", path: "/admin/settings" },
|
{
|
||||||
{ icon: Wrench, label: "Maintenance", path: "/admin/maintenance" },
|
icon: Settings,
|
||||||
|
label: "Settings",
|
||||||
|
path: "/admin/settings",
|
||||||
|
visible: (role) => getPermissions(role).canManageSystem,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Wrench,
|
||||||
|
label: "Maintenance",
|
||||||
|
path: "/admin/maintenance",
|
||||||
|
visible: (role) => getPermissions(role).canManageSystem,
|
||||||
|
},
|
||||||
{ icon: Megaphone, label: "Announcements", path: "/admin/announcements" },
|
{ icon: Megaphone, label: "Announcements", path: "/admin/announcements" },
|
||||||
{ icon: Activity, label: "Audit", path: "/admin/audit" },
|
{
|
||||||
{ icon: Shield, label: "Security", path: "/admin/security" },
|
icon: Activity,
|
||||||
{ icon: BarChart3, label: "Analytics", path: "/admin/analytics" },
|
label: "Audit",
|
||||||
{ icon: Heart, label: "System Health", path: "/admin/health" },
|
path: "/admin/audit",
|
||||||
|
visible: (role) => getPermissions(role).canManageSystem,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Shield,
|
||||||
|
label: "Security",
|
||||||
|
path: "/admin/security",
|
||||||
|
visible: (role) => getPermissions(role).canManageSystem,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: BarChart3,
|
||||||
|
label: "Analytics",
|
||||||
|
path: "/admin/analytics",
|
||||||
|
visible: (role) => getPermissions(role).canManageSystem,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Heart,
|
||||||
|
label: "System Health",
|
||||||
|
path: "/admin/health",
|
||||||
|
visible: (role) => getPermissions(role).canManageSystem,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: Send,
|
icon: Send,
|
||||||
label: "Send notification",
|
label: "Send notification",
|
||||||
path: "/admin/notifications/broadcast",
|
path: "/admin/notifications/broadcast",
|
||||||
visible: (role) => role === "SUPER_ADMIN" || role === "ADMIN",
|
visible: (role) => getPermissions(role).canSendNotifications,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const SidebarNavItem = ({
|
||||||
|
item,
|
||||||
|
depth = 0,
|
||||||
|
isActive,
|
||||||
|
}: {
|
||||||
|
item: NavItem;
|
||||||
|
depth?: number;
|
||||||
|
isActive: (path?: string) => boolean;
|
||||||
|
}) => {
|
||||||
|
const hasChildren = item.children && item.children.length > 0;
|
||||||
|
const isCurrentlyActive =
|
||||||
|
isActive(item.path) ||
|
||||||
|
(hasChildren && item.children?.some((child) => isActive(child.path)));
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(isCurrentlyActive);
|
||||||
|
|
||||||
|
// Keep open if it becomes active from external navigation (e.g. breadcrumbs or search)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCurrentlyActive) {
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
}, [isCurrentlyActive]);
|
||||||
|
|
||||||
|
const Icon = item.icon;
|
||||||
|
|
||||||
|
if (hasChildren) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center justify-between px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-accent hover:text-foreground",
|
||||||
|
isCurrentlyActive
|
||||||
|
? "text-primary font-semibold"
|
||||||
|
: "text-foreground/70",
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: `${depth * 1 + 0.75}rem` }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{Icon && <Icon className="w-5 h-5" />}
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
{isOpen ? (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="space-y-1 ml-4 border-l border-slate-200">
|
||||||
|
{item.children?.map((child) => (
|
||||||
|
<SidebarNavItem
|
||||||
|
key={child.label}
|
||||||
|
item={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
isActive={isActive}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={item.path || "#"}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
||||||
|
isActive(item.path)
|
||||||
|
? "bg-primary text-primary-foreground shadow-sm"
|
||||||
|
: "text-foreground/70 hover:bg-accent hover:text-foreground",
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: `${depth * 1 + 0.75}rem` }}
|
||||||
|
>
|
||||||
|
{Icon && <Icon className="w-5 h-5" />}
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -117,12 +258,11 @@ export function AppShell() {
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActive = (path?: string) => {
|
||||||
|
if (!path) return false;
|
||||||
return location.pathname.startsWith(path);
|
return location.pathname.startsWith(path);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Removed unused getPageTitle as header title is no longer displayed
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await authService.logout();
|
await authService.logout();
|
||||||
navigate("/login", { replace: true });
|
navigate("/login", { replace: true });
|
||||||
|
|
@ -170,27 +310,14 @@ export function AppShell() {
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 px-4 py-2 space-y-1 overflow-y-auto">
|
<nav className="flex-1 px-4 py-2 space-y-1 overflow-y-auto">
|
||||||
{adminNavigationItems
|
{adminNavigationItems
|
||||||
.filter((item) =>
|
.filter((item) => (item.visible ? item.visible(user?.role) : true))
|
||||||
item.visible ? item.visible(user?.role) : true,
|
.map((item) => (
|
||||||
)
|
<SidebarNavItem
|
||||||
.map((item) => {
|
key={item.label}
|
||||||
const Icon = item.icon;
|
item={item}
|
||||||
return (
|
isActive={isActive}
|
||||||
<Link
|
/>
|
||||||
key={item.path}
|
))}
|
||||||
to={item.path}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
|
||||||
isActive(item.path)
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "text-foreground/70 hover:bg-accent hover:text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="w-5 h-5" />
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User Section */}
|
{/* User Section */}
|
||||||
|
|
|
||||||
|
|
@ -6,50 +6,110 @@ export const AdminRole = {
|
||||||
SUPER_ADMIN: "SUPER_ADMIN",
|
SUPER_ADMIN: "SUPER_ADMIN",
|
||||||
ADMIN: "ADMIN",
|
ADMIN: "ADMIN",
|
||||||
CUSTOMER_SUPPORT: "CUSTOMER_SUPPORT",
|
CUSTOMER_SUPPORT: "CUSTOMER_SUPPORT",
|
||||||
} as const
|
} as const;
|
||||||
|
|
||||||
export type AdminRoleValue = (typeof AdminRole)[keyof typeof AdminRole]
|
export type AdminRoleValue = (typeof AdminRole)[keyof typeof AdminRole];
|
||||||
|
|
||||||
|
export interface RolePermissions {
|
||||||
|
canManageSystem: boolean; // Settings, Maintenance, Health, Security
|
||||||
|
canViewSystemData: boolean; // Audit logs, etc.
|
||||||
|
|
||||||
|
// App Users
|
||||||
|
canViewUsers: boolean;
|
||||||
|
canCreateUsers: boolean;
|
||||||
|
canEditUsers: boolean;
|
||||||
|
canDeleteUsers: boolean;
|
||||||
|
|
||||||
|
// Business Data (Invoices, Proforma, Payments)
|
||||||
|
canViewBusinessData: boolean;
|
||||||
|
canCreateBusinessData: boolean;
|
||||||
|
canEditBusinessData: boolean;
|
||||||
|
canDeleteBusinessData: boolean;
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
canViewNotifications: boolean;
|
||||||
|
canSendNotifications: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERMISSIONS: Record<AdminRoleValue, RolePermissions> = {
|
||||||
|
[AdminRole.SUPER_ADMIN]: {
|
||||||
|
canManageSystem: true,
|
||||||
|
canViewSystemData: true,
|
||||||
|
canViewUsers: true,
|
||||||
|
canCreateUsers: true,
|
||||||
|
canEditUsers: true,
|
||||||
|
canDeleteUsers: true,
|
||||||
|
canViewBusinessData: true,
|
||||||
|
canCreateBusinessData: true,
|
||||||
|
canEditBusinessData: true,
|
||||||
|
canDeleteBusinessData: true,
|
||||||
|
canViewNotifications: true,
|
||||||
|
canSendNotifications: true,
|
||||||
|
},
|
||||||
|
[AdminRole.ADMIN]: {
|
||||||
|
canManageSystem: false,
|
||||||
|
canViewSystemData: true,
|
||||||
|
canViewUsers: true,
|
||||||
|
canCreateUsers: false,
|
||||||
|
canEditUsers: true,
|
||||||
|
canDeleteUsers: false,
|
||||||
|
canViewBusinessData: true,
|
||||||
|
canCreateBusinessData: false,
|
||||||
|
canEditBusinessData: true,
|
||||||
|
canDeleteBusinessData: false,
|
||||||
|
canViewNotifications: true,
|
||||||
|
canSendNotifications: true,
|
||||||
|
},
|
||||||
|
[AdminRole.CUSTOMER_SUPPORT]: {
|
||||||
|
canManageSystem: false,
|
||||||
|
canViewSystemData: true,
|
||||||
|
canViewUsers: true,
|
||||||
|
canCreateUsers: true,
|
||||||
|
canEditUsers: true,
|
||||||
|
canDeleteUsers: true,
|
||||||
|
canViewBusinessData: true,
|
||||||
|
canCreateBusinessData: false,
|
||||||
|
canEditBusinessData: false,
|
||||||
|
canDeleteBusinessData: false,
|
||||||
|
canViewNotifications: true,
|
||||||
|
canSendNotifications: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getPermissions(role: string | undefined): RolePermissions {
|
||||||
|
const r = (role as AdminRoleValue) || AdminRole.CUSTOMER_SUPPORT;
|
||||||
|
return PERMISSIONS[r] || PERMISSIONS[AdminRole.CUSTOMER_SUPPORT];
|
||||||
|
}
|
||||||
|
|
||||||
const PANEL_ROLES = new Set<string>([
|
const PANEL_ROLES = new Set<string>([
|
||||||
AdminRole.SUPER_ADMIN,
|
AdminRole.SUPER_ADMIN,
|
||||||
AdminRole.ADMIN,
|
AdminRole.ADMIN,
|
||||||
AdminRole.CUSTOMER_SUPPORT,
|
AdminRole.CUSTOMER_SUPPORT,
|
||||||
])
|
]);
|
||||||
|
|
||||||
export function hasPanelAccess(role: string | undefined): boolean {
|
export function hasPanelAccess(role: string | undefined): boolean {
|
||||||
if (!role) return false
|
if (!role) return false;
|
||||||
return PANEL_ROLES.has(role)
|
return PANEL_ROLES.has(role);
|
||||||
}
|
|
||||||
|
|
||||||
/** Full control (all menus + destructive actions) */
|
|
||||||
export function isSuperAdmin(role: string | undefined): boolean {
|
|
||||||
return role === AdminRole.SUPER_ADMIN
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Can create/edit content (not read-only support) */
|
|
||||||
export function canEdit(role: string | undefined): boolean {
|
|
||||||
return role === AdminRole.SUPER_ADMIN || role === AdminRole.ADMIN
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Internal system users / members management */
|
|
||||||
export function canAccessSystemMembers(role: string | undefined): boolean {
|
|
||||||
return role === AdminRole.SUPER_ADMIN || role === AdminRole.ADMIN
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Push / SMS / email broadcast composer */
|
|
||||||
export function canSendBroadcast(role: string | undefined): boolean {
|
|
||||||
return role === AdminRole.SUPER_ADMIN || role === AdminRole.ADMIN
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function roleLabel(role: string | undefined): string {
|
export function roleLabel(role: string | undefined): string {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case AdminRole.SUPER_ADMIN:
|
case AdminRole.SUPER_ADMIN:
|
||||||
return "System Admin"
|
return "System Admin";
|
||||||
case AdminRole.ADMIN:
|
case AdminRole.ADMIN:
|
||||||
return "Admin"
|
return "Admin";
|
||||||
case AdminRole.CUSTOMER_SUPPORT:
|
case AdminRole.CUSTOMER_SUPPORT:
|
||||||
return "Customer Support"
|
return "Customer Support";
|
||||||
default:
|
default:
|
||||||
return role ?? "User"
|
return role ?? "User";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Legacy helpers maintained for compatibility but using new logic */
|
||||||
|
export function isSuperAdmin(role: string | undefined): boolean {
|
||||||
|
return role === AdminRole.SUPER_ADMIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canEdit(role: string | undefined): boolean {
|
||||||
|
return getPermissions(role).canEditBusinessData;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -182,10 +182,7 @@ export default function AnnouncementsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none">
|
<Card className="border shadow-none rounded-none">
|
||||||
<CardHeader className="border-b pb-4 flex flex-row items-center justify-between space-y-0">
|
<CardHeader className="border-b pb-4 flex flex-row items-end justify-end space-y-0">
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
|
||||||
Bulletin Archive
|
|
||||||
</CardTitle>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -223,7 +220,7 @@ export default function AnnouncementsPage() {
|
||||||
colSpan={5}
|
colSpan={5}
|
||||||
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
|
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
|
||||||
>
|
>
|
||||||
Synchronizing broadcast data...
|
Loading...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : announcements && announcements.length > 0 ? (
|
) : announcements && announcements.length > 0 ? (
|
||||||
|
|
@ -313,7 +310,7 @@ export default function AnnouncementsPage() {
|
||||||
colSpan={5}
|
colSpan={5}
|
||||||
className="px-6 py-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]"
|
className="px-6 py-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]"
|
||||||
>
|
>
|
||||||
No active broadcasts.
|
No active announcements.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -8,14 +8,64 @@ import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Filter,
|
Filter,
|
||||||
Download,
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Loader2,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { invoiceService } from "@/services";
|
import { invoiceService } from "@/services";
|
||||||
|
import { useAdminRole } from "@/hooks/use-admin-role";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { Invoice, InvoiceItem } from "@/services/invoice.service";
|
||||||
|
|
||||||
export default function InvoicesPage() {
|
export default function InvoicesPage() {
|
||||||
|
const { canCreateBusinessData, canEditBusinessData, canDeleteBusinessData } =
|
||||||
|
useAdminRole();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [editingInvoice, setEditingInvoice] = useState<Invoice | null>(null);
|
||||||
|
const [invoiceToDelete, setInvoiceToDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Form State
|
||||||
|
const [formData, setFormData] = useState<Partial<Invoice>>({
|
||||||
|
invoiceNumber: "",
|
||||||
|
customerName: "",
|
||||||
|
customerEmail: "",
|
||||||
|
customerPhone: "",
|
||||||
|
amount: 0,
|
||||||
|
currency: "USD",
|
||||||
|
type: "SALES",
|
||||||
|
status: "DRAFT",
|
||||||
|
issueDate: new Date().toISOString().split("T")[0],
|
||||||
|
dueDate: new Date().toISOString().split("T")[0],
|
||||||
|
description: "",
|
||||||
|
notes: "",
|
||||||
|
taxAmount: 0,
|
||||||
|
discountAmount: 0,
|
||||||
|
items: [] as InvoiceItem[],
|
||||||
|
});
|
||||||
|
|
||||||
const { data: invoicesData, isLoading } = useQuery({
|
const { data: invoicesData, isLoading } = useQuery({
|
||||||
queryKey: ["admin", "invoices", page, search],
|
queryKey: ["admin", "invoices", page, search],
|
||||||
|
|
@ -27,11 +77,152 @@ export default function InvoicesPage() {
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => invoiceService.createInvoice(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Invoice created successfully");
|
||||||
|
setIsModalOpen(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["admin", "invoices"] });
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast.error(
|
||||||
|
err.response?.data?.message?.[0] || "Failed to create invoice",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: any }) =>
|
||||||
|
invoiceService.updateInvoice(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Invoice updated successfully");
|
||||||
|
setIsModalOpen(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["admin", "invoices"] });
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast.error(
|
||||||
|
err.response?.data?.message?.[0] || "Failed to update invoice",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => invoiceService.deleteInvoice(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Invoice deleted successfully");
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["admin", "invoices"] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to delete invoice");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setEditingInvoice(null);
|
||||||
|
setFormData({
|
||||||
|
invoiceNumber: `INV-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`,
|
||||||
|
customerName: "",
|
||||||
|
customerEmail: "",
|
||||||
|
customerPhone: "",
|
||||||
|
amount: 0,
|
||||||
|
currency: "USD",
|
||||||
|
type: "SALES",
|
||||||
|
status: "DRAFT",
|
||||||
|
issueDate: new Date().toISOString().split("T")[0],
|
||||||
|
dueDate: new Date().toISOString().split("T")[0],
|
||||||
|
description: "",
|
||||||
|
notes: "",
|
||||||
|
taxAmount: 0,
|
||||||
|
discountAmount: 0,
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEdit = (invoice: Invoice) => {
|
||||||
|
setEditingInvoice(invoice);
|
||||||
|
setFormData({
|
||||||
|
...invoice,
|
||||||
|
issueDate: new Date(invoice.issueDate).toISOString().split("T")[0],
|
||||||
|
dueDate: new Date(invoice.dueDate).toISOString().split("T")[0],
|
||||||
|
});
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (editingInvoice) {
|
||||||
|
updateMutation.mutate({ id: editingInvoice.id, data: formData });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(formData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddItem = () => {
|
||||||
|
const newItem: InvoiceItem = {
|
||||||
|
id: Math.random().toString(36).substring(7),
|
||||||
|
description: "",
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: 0,
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
items: [...(formData.items || []), newItem],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateTotals = (
|
||||||
|
items: InvoiceItem[],
|
||||||
|
tax: number,
|
||||||
|
discount: number,
|
||||||
|
) => {
|
||||||
|
const subtotal = items.reduce(
|
||||||
|
(acc: number, item: InvoiceItem) => acc + item.total,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return subtotal + tax - discount;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateItem = (
|
||||||
|
index: number,
|
||||||
|
field: keyof InvoiceItem,
|
||||||
|
value: string | number,
|
||||||
|
) => {
|
||||||
|
const newItems = [...(formData.items || [])];
|
||||||
|
newItems[index] = { ...newItems[index], [field]: value } as InvoiceItem;
|
||||||
|
|
||||||
|
if (field === "quantity" || field === "unitPrice") {
|
||||||
|
newItems[index].total =
|
||||||
|
Number(newItems[index].quantity) * Number(newItems[index].unitPrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAmount = calculateTotals(
|
||||||
|
newItems,
|
||||||
|
formData.taxAmount || 0,
|
||||||
|
formData.discountAmount || 0,
|
||||||
|
);
|
||||||
|
setFormData({ ...formData, items: newItems, amount: newAmount });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveItem = (index: number) => {
|
||||||
|
const newItems = (formData.items || []).filter(
|
||||||
|
(_, i: number) => i !== index,
|
||||||
|
);
|
||||||
|
const newAmount = calculateTotals(
|
||||||
|
newItems,
|
||||||
|
formData.taxAmount || 0,
|
||||||
|
formData.discountAmount || 0,
|
||||||
|
);
|
||||||
|
setFormData({ ...formData, items: newItems, amount: newAmount });
|
||||||
|
};
|
||||||
|
|
||||||
const formatCurrency = (amount: number | any) => {
|
const formatCurrency = (amount: number | any) => {
|
||||||
const val = typeof amount === "number" ? amount : 0;
|
const val = typeof amount === "number" ? amount : 0;
|
||||||
return new Intl.NumberFormat("en-US", {
|
return new Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "USD",
|
currency: formData.currency || "USD",
|
||||||
}).format(val);
|
}).format(val);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -61,9 +252,17 @@ export default function InvoicesPage() {
|
||||||
Manage sales and purchase invoices.
|
Manage sales and purchase invoices.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
{canCreateBusinessData && (
|
||||||
{/* View only access: Create and Export buttons removed */}
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<Button
|
||||||
|
onClick={handleOpenCreate}
|
||||||
|
className="h-9 rounded-none bg-slate-900 border-none uppercase text-[10px] font-bold tracking-widest"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Create Invoice
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none">
|
<Card className="border shadow-none rounded-none">
|
||||||
|
|
@ -110,16 +309,19 @@ export default function InvoicesPage() {
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
||||||
Status
|
Status
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
|
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
||||||
Date
|
Date
|
||||||
</th>
|
</th>
|
||||||
|
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y">
|
<tbody className="divide-y">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={6}
|
colSpan={7}
|
||||||
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
|
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
|
||||||
>
|
>
|
||||||
Synchronizing ledger data...
|
Synchronizing ledger data...
|
||||||
|
|
@ -169,15 +371,47 @@ export default function InvoicesPage() {
|
||||||
{invoice.status}
|
{invoice.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-right text-sm text-gray-500">
|
<td className="px-6 py-4 text-sm text-gray-500">
|
||||||
{new Date(invoice.issueDate).toLocaleDateString()}
|
{new Date(invoice.issueDate).toLocaleDateString()}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{canEditBusinessData && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-slate-400 hover:text-slate-900"
|
||||||
|
onClick={() => handleOpenEdit(invoice)}
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canDeleteBusinessData && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-slate-400 hover:text-rose-600"
|
||||||
|
onClick={() => {
|
||||||
|
setInvoiceToDelete(invoice.id);
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!canEditBusinessData && !canDeleteBusinessData && (
|
||||||
|
<span className="text-[10px] font-bold text-slate-300 uppercase italic">
|
||||||
|
View Only
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={6}
|
colSpan={7}
|
||||||
className="px-6 py-20 text-center text-gray-400 italic"
|
className="px-6 py-20 text-center text-gray-400 italic"
|
||||||
>
|
>
|
||||||
No invoices found in ledger.
|
No invoices found in ledger.
|
||||||
|
|
@ -216,6 +450,371 @@ export default function InvoicesPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Create/Edit Modal */}
|
||||||
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto rounded-none">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl font-bold uppercase tracking-tight">
|
||||||
|
{editingInvoice ? "Update Invoice" : "Create New Invoice"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs font-bold uppercase tracking-widest text-slate-400">
|
||||||
|
Configure administrative ledger entry.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 py-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Invoice Number
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.invoiceNumber}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
invoiceNumber: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Customer Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.customerName}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, customerName: e.target.value })
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Customer Email
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={formData.customerEmail}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
customerEmail: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Type
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.type}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setFormData({ ...formData, type: v as any })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="rounded-none border-slate-200">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="SALES">SALES</SelectItem>
|
||||||
|
<SelectItem value="PURCHASE">PURCHASE</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Status
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.status}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setFormData({ ...formData, status: v as any })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="rounded-none border-slate-200">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="DRAFT">DRAFT</SelectItem>
|
||||||
|
<SelectItem value="PENDING">PENDING</SelectItem>
|
||||||
|
<SelectItem value="PAID">PAID</SelectItem>
|
||||||
|
<SelectItem value="OVERDUE">OVERDUE</SelectItem>
|
||||||
|
<SelectItem value="CANCELLED">CANCELLED</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Issue Date
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formData.issueDate}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, issueDate: e.target.value })
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Due Date
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formData.dueDate}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, dueDate: e.target.value })
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Description
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, description: e.target.value })
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Tax Amount
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.taxAmount}
|
||||||
|
onChange={(e) => {
|
||||||
|
const tax = parseFloat(e.target.value) || 0;
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
taxAmount: tax,
|
||||||
|
amount: calculateTotals(
|
||||||
|
formData.items || [],
|
||||||
|
tax,
|
||||||
|
formData.discountAmount || 0,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="rounded-none border-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Discount
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.discountAmount}
|
||||||
|
onChange={(e) => {
|
||||||
|
const discount = parseFloat(e.target.value) || 0;
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
discountAmount: discount,
|
||||||
|
amount: calculateTotals(
|
||||||
|
formData.items || [],
|
||||||
|
formData.taxAmount || 0,
|
||||||
|
discount,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="rounded-none border-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Line Items */}
|
||||||
|
<div className="border-t pt-6 mt-2">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-xs font-black uppercase tracking-[0.2em] text-slate-400">
|
||||||
|
Line Items
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAddItem}
|
||||||
|
className="rounded-none h-7 border-slate-200 text-[9px] font-bold uppercase"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3 mr-1" /> Add Item
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{formData.items?.map((item: InvoiceItem, idx: number) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="flex gap-4 items-end bg-slate-50/50 p-3 border border-slate-100"
|
||||||
|
>
|
||||||
|
<div className="flex-1 grid gap-2">
|
||||||
|
<Label className="text-[9px] font-bold uppercase text-slate-400">
|
||||||
|
Service Description
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={item.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdateItem(idx, "description", e.target.value)
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-20 grid gap-2">
|
||||||
|
<Label className="text-[9px] font-bold uppercase text-slate-400">
|
||||||
|
Qty
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={item.quantity}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdateItem(
|
||||||
|
idx,
|
||||||
|
"quantity",
|
||||||
|
parseInt(e.target.value) || 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-28 grid gap-2">
|
||||||
|
<Label className="text-[9px] font-bold uppercase text-slate-400">
|
||||||
|
Unit Price
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={item.unitPrice}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdateItem(
|
||||||
|
idx,
|
||||||
|
"unitPrice",
|
||||||
|
parseFloat(e.target.value) || 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-24 grid gap-2">
|
||||||
|
<Label className="text-[9px] font-bold uppercase text-slate-400">
|
||||||
|
Total
|
||||||
|
</Label>
|
||||||
|
<div className="h-8 flex items-center px-3 bg-white border border-slate-100 text-xs font-bold">
|
||||||
|
{formatCurrency(item.total)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleRemoveItem(idx)}
|
||||||
|
className="h-8 w-8 text-slate-300 hover:text-rose-500"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="border-t pt-6 mt-6">
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
||||||
|
Total Invoice Amount
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-black tracking-tighter text-slate-900">
|
||||||
|
{formatCurrency(formData.amount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="rounded-none uppercase font-bold text-[10px]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
createMutation.isPending || updateMutation.isPending
|
||||||
|
}
|
||||||
|
className="rounded-none bg-slate-900 uppercase font-bold text-[10px] tracking-widest px-8"
|
||||||
|
>
|
||||||
|
{createMutation.isPending || updateMutation.isPending ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : editingInvoice ? (
|
||||||
|
"Update Ledger"
|
||||||
|
) : (
|
||||||
|
"Commit to Ledger"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
<Dialog open={isDeleteModalOpen} onOpenChange={setIsDeleteModalOpen}>
|
||||||
|
<DialogContent className="rounded-none">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl font-bold uppercase tracking-tight">
|
||||||
|
Delete Ledger Entry?
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs font-bold uppercase tracking-widest text-rose-500">
|
||||||
|
This action is permanent and cannot be reversed.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4 text-sm text-slate-600">
|
||||||
|
Are you sure you want to delete this invoice record? All associated
|
||||||
|
line item data will be purged.
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setIsDeleteModalOpen(false)}
|
||||||
|
className="rounded-none uppercase font-bold text-[10px]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
invoiceToDelete && deleteMutation.mutate(invoiceToDelete)
|
||||||
|
}
|
||||||
|
className="rounded-none bg-rose-600 hover:bg-rose-700 uppercase font-bold text-[10px] tracking-widest"
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Confirm Deletion"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -8,13 +8,62 @@ import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Filter,
|
Filter,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Loader2,
|
||||||
|
X,
|
||||||
FileText,
|
FileText,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { invoiceService } from "@/services";
|
import { invoiceService } from "@/services";
|
||||||
|
import { useAdminRole } from "@/hooks/use-admin-role";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { Proforma, InvoiceItem } from "@/services/invoice.service";
|
||||||
|
|
||||||
export default function ProformaPage() {
|
export default function ProformaPage() {
|
||||||
|
const { canCreateBusinessData, canEditBusinessData, canDeleteBusinessData } =
|
||||||
|
useAdminRole();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [editingProforma, setEditingProforma] = useState<Proforma | null>(null);
|
||||||
|
const [proformaToDelete, setProformaToDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Form State
|
||||||
|
const [formData, setFormData] = useState<Partial<Proforma>>({
|
||||||
|
proformaNumber: "",
|
||||||
|
customerName: "",
|
||||||
|
customerEmail: "",
|
||||||
|
customerPhone: "",
|
||||||
|
amount: 0,
|
||||||
|
currency: "USD",
|
||||||
|
issueDate: new Date().toISOString().split("T")[0],
|
||||||
|
dueDate: new Date().toISOString().split("T")[0],
|
||||||
|
description: "",
|
||||||
|
notes: "",
|
||||||
|
taxAmount: 0,
|
||||||
|
discountAmount: 0,
|
||||||
|
items: [] as InvoiceItem[],
|
||||||
|
});
|
||||||
|
|
||||||
const { data: proformaData, isLoading } = useQuery({
|
const { data: proformaData, isLoading } = useQuery({
|
||||||
queryKey: ["admin", "proforma", page, search],
|
queryKey: ["admin", "proforma", page, search],
|
||||||
|
|
@ -26,11 +75,150 @@ export default function ProformaPage() {
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => invoiceService.createProforma(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Proforma invoice created");
|
||||||
|
setIsModalOpen(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["admin", "proforma"] });
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast.error(
|
||||||
|
err.response?.data?.message?.[0] || "Failed to create proforma",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: any }) =>
|
||||||
|
invoiceService.updateProforma(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Proforma updated");
|
||||||
|
setIsModalOpen(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["admin", "proforma"] });
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast.error(
|
||||||
|
err.response?.data?.message?.[0] || "Failed to update proforma",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => invoiceService.deleteProforma(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Proforma deleted");
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["admin", "proforma"] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to delete proforma");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setEditingProforma(null);
|
||||||
|
setFormData({
|
||||||
|
proformaNumber: `PRO-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`,
|
||||||
|
customerName: "",
|
||||||
|
customerEmail: "",
|
||||||
|
customerPhone: "",
|
||||||
|
amount: 0,
|
||||||
|
currency: "USD",
|
||||||
|
issueDate: new Date().toISOString().split("T")[0],
|
||||||
|
dueDate: new Date().toISOString().split("T")[0],
|
||||||
|
description: "",
|
||||||
|
notes: "",
|
||||||
|
taxAmount: 0,
|
||||||
|
discountAmount: 0,
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEdit = (item: Proforma) => {
|
||||||
|
setEditingProforma(item);
|
||||||
|
setFormData({
|
||||||
|
...item,
|
||||||
|
issueDate: new Date(item.issueDate).toISOString().split("T")[0],
|
||||||
|
dueDate: new Date(item.dueDate).toISOString().split("T")[0],
|
||||||
|
});
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (editingProforma) {
|
||||||
|
updateMutation.mutate({ id: editingProforma.id, data: formData });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(formData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateTotals = (
|
||||||
|
items: InvoiceItem[],
|
||||||
|
tax: number,
|
||||||
|
discount: number,
|
||||||
|
) => {
|
||||||
|
const subtotal = items.reduce(
|
||||||
|
(acc: number, item: InvoiceItem) => acc + item.total,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return subtotal + tax - discount;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddItem = () => {
|
||||||
|
const newItem: InvoiceItem = {
|
||||||
|
id: Math.random().toString(36).substring(7),
|
||||||
|
description: "",
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: 0,
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
items: [...(formData.items || []), newItem],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateItem = (
|
||||||
|
index: number,
|
||||||
|
field: keyof InvoiceItem,
|
||||||
|
value: string | number,
|
||||||
|
) => {
|
||||||
|
const newItems = [...(formData.items || [])];
|
||||||
|
newItems[index] = { ...newItems[index], [field]: value } as InvoiceItem;
|
||||||
|
|
||||||
|
if (field === "quantity" || field === "unitPrice") {
|
||||||
|
newItems[index].total =
|
||||||
|
Number(newItems[index].quantity) * Number(newItems[index].unitPrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAmount = calculateTotals(
|
||||||
|
newItems,
|
||||||
|
formData.taxAmount || 0,
|
||||||
|
formData.discountAmount || 0,
|
||||||
|
);
|
||||||
|
setFormData({ ...formData, items: newItems, amount: newAmount });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveItem = (index: number) => {
|
||||||
|
const newItems = (formData.items || []).filter(
|
||||||
|
(_, i: number) => i !== index,
|
||||||
|
);
|
||||||
|
const newAmount = calculateTotals(
|
||||||
|
newItems,
|
||||||
|
formData.taxAmount || 0,
|
||||||
|
formData.discountAmount || 0,
|
||||||
|
);
|
||||||
|
setFormData({ ...formData, items: newItems, amount: newAmount });
|
||||||
|
};
|
||||||
|
|
||||||
const formatCurrency = (amount: number | any) => {
|
const formatCurrency = (amount: number | any) => {
|
||||||
const val = typeof amount === "number" ? amount : 0;
|
const val = typeof amount === "number" ? amount : 0;
|
||||||
return new Intl.NumberFormat("en-US", {
|
return new Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "USD",
|
currency: formData.currency || "USD",
|
||||||
}).format(val);
|
}).format(val);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -45,7 +233,17 @@ export default function ProformaPage() {
|
||||||
Manage draft and preliminary invoices.
|
Manage draft and preliminary invoices.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/* View only access: Create button removed */}
|
{canCreateBusinessData && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleOpenCreate}
|
||||||
|
className="h-9 rounded-none bg-slate-900 border-none uppercase text-[10px] font-bold tracking-widest"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
New Proforma
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none">
|
<Card className="border shadow-none rounded-none">
|
||||||
|
|
@ -90,7 +288,7 @@ export default function ProformaPage() {
|
||||||
Issue Date
|
Issue Date
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
|
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
|
||||||
Due Date
|
Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -130,8 +328,37 @@ export default function ProformaPage() {
|
||||||
<td className="px-6 py-4 text-sm text-gray-500">
|
<td className="px-6 py-4 text-sm text-gray-500">
|
||||||
{new Date(item.issueDate).toLocaleDateString()}
|
{new Date(item.issueDate).toLocaleDateString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-right text-sm text-gray-500">
|
<td className="px-6 py-4 text-right">
|
||||||
{new Date(item.dueDate).toLocaleDateString()}
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{canEditBusinessData && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-slate-400 hover:text-slate-900"
|
||||||
|
onClick={() => handleOpenEdit(item)}
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canDeleteBusinessData && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-slate-400 hover:text-rose-600"
|
||||||
|
onClick={() => {
|
||||||
|
setProformaToDelete(item.id);
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!canEditBusinessData && !canDeleteBusinessData && (
|
||||||
|
<span className="text-[10px] font-bold text-slate-300 uppercase italic">
|
||||||
|
View Only
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
|
|
@ -177,6 +404,348 @@ export default function ProformaPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Create/Edit Modal */}
|
||||||
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto rounded-none">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl font-bold uppercase tracking-tight">
|
||||||
|
{editingProforma ? "Update Proforma" : "Create Proforma"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs font-bold uppercase tracking-widest text-slate-400">
|
||||||
|
Execute a preliminary billing draft.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 py-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Proforma Number
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.proformaNumber}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
proformaNumber: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Customer Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.customerName}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, customerName: e.target.value })
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Customer Email
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={formData.customerEmail}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
customerEmail: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Currency
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.currency}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setFormData({ ...formData, currency: v })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="rounded-none border-slate-200">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="USD">USD</SelectItem>
|
||||||
|
<SelectItem value="EUR">EUR</SelectItem>
|
||||||
|
<SelectItem value="GBP">GBP</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Issue Date
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formData.issueDate}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, issueDate: e.target.value })
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Due Date
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formData.dueDate}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, dueDate: e.target.value })
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Description
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, description: e.target.value })
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Tax Amount
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.taxAmount}
|
||||||
|
onChange={(e) => {
|
||||||
|
const tax = parseFloat(e.target.value) || 0;
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
taxAmount: tax,
|
||||||
|
amount: calculateTotals(
|
||||||
|
formData.items || [],
|
||||||
|
tax,
|
||||||
|
formData.discountAmount || 0,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="rounded-none border-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Discount
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.discountAmount}
|
||||||
|
onChange={(e) => {
|
||||||
|
const discount = parseFloat(e.target.value) || 0;
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
discountAmount: discount,
|
||||||
|
amount: calculateTotals(
|
||||||
|
formData.items || [],
|
||||||
|
formData.taxAmount || 0,
|
||||||
|
discount,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="rounded-none border-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Line Items */}
|
||||||
|
<div className="border-t pt-6 mt-2">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-xs font-black uppercase tracking-[0.2em] text-slate-400">
|
||||||
|
Line Items
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAddItem}
|
||||||
|
className="rounded-none h-7 border-slate-200 text-[9px] font-bold uppercase"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3 mr-1" /> Add Item
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{formData.items?.map((item: InvoiceItem, idx: number) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="flex gap-4 items-end bg-slate-50/50 p-3 border border-slate-100"
|
||||||
|
>
|
||||||
|
<div className="flex-1 grid gap-2">
|
||||||
|
<Label className="text-[9px] font-bold uppercase text-slate-400">
|
||||||
|
Service Description
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={item.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdateItem(idx, "description", e.target.value)
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-20 grid gap-2">
|
||||||
|
<Label className="text-[9px] font-bold uppercase text-slate-400">
|
||||||
|
Qty
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={item.quantity}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdateItem(
|
||||||
|
idx,
|
||||||
|
"quantity",
|
||||||
|
parseInt(e.target.value) || 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-28 grid gap-2">
|
||||||
|
<Label className="text-[9px] font-bold uppercase text-slate-400">
|
||||||
|
Unit Price
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={item.unitPrice}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdateItem(
|
||||||
|
idx,
|
||||||
|
"unitPrice",
|
||||||
|
parseFloat(e.target.value) || 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-24 grid gap-2">
|
||||||
|
<Label className="text-[9px] font-bold uppercase text-slate-400">
|
||||||
|
Total
|
||||||
|
</Label>
|
||||||
|
<div className="h-8 flex items-center px-3 bg-white border border-slate-100 text-xs font-bold">
|
||||||
|
{formatCurrency(item.total)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleRemoveItem(idx)}
|
||||||
|
className="h-8 w-8 text-slate-300 hover:text-rose-500"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="border-t pt-6 mt-6">
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
||||||
|
Total Proforma Amount
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-black tracking-tighter text-slate-900">
|
||||||
|
{formatCurrency(formData.amount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="rounded-none uppercase font-bold text-[10px]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
createMutation.isPending || updateMutation.isPending
|
||||||
|
}
|
||||||
|
className="rounded-none bg-slate-900 uppercase font-bold text-[10px] tracking-widest px-8"
|
||||||
|
>
|
||||||
|
{createMutation.isPending || updateMutation.isPending ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : editingProforma ? (
|
||||||
|
"Update Registry"
|
||||||
|
) : (
|
||||||
|
"Commit to Registry"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
<Dialog open={isDeleteModalOpen} onOpenChange={setIsDeleteModalOpen}>
|
||||||
|
<DialogContent className="rounded-none">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl font-bold uppercase tracking-tight">
|
||||||
|
Expunge Proforma Entry?
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs font-bold uppercase tracking-widest text-rose-500">
|
||||||
|
This action is permanent and cannot be reversed.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4 text-sm text-slate-600">
|
||||||
|
Are you sure you want to delete this proforma record? All associated
|
||||||
|
line item data will be purged.
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setIsDeleteModalOpen(false)}
|
||||||
|
className="rounded-none uppercase font-bold text-[10px]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
proformaToDelete && deleteMutation.mutate(proformaToDelete)
|
||||||
|
}
|
||||||
|
className="rounded-none bg-rose-600 hover:bg-rose-700 uppercase font-bold text-[10px] tracking-widest"
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Confirm Deletion"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,13 +29,70 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { invoiceService, type ProformaRequest } from "@/services";
|
import { invoiceService, type ProformaRequest } from "@/services";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useAdminRole } from "@/hooks/use-admin-role";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Plus, Trash2, Lock, Ban, Loader2, MoreVertical } from "lucide-react";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
export default function ProformaRequestsPage() {
|
export default function ProformaRequestsPage() {
|
||||||
|
const { canCreateBusinessData, canEditBusinessData } = useAdminRole();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Local State
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [limit] = useState(10);
|
const [limit] = useState(10);
|
||||||
const [status, setStatus] = useState<string>("all");
|
const [status, setStatus] = useState<string>("all");
|
||||||
const [category, setCategory] = useState<string>("all");
|
const [category, setCategory] = useState<string>("all");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [deadlineFrom, setDeadlineFrom] = useState("");
|
||||||
|
const [deadlineTo, setDeadlineTo] = useState("");
|
||||||
|
|
||||||
|
// CRUD State
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
|
||||||
|
const [editingRequest, setEditingRequest] = useState<ProformaRequest | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [isStatusModalOpen, setIsStatusModalOpen] = useState(false);
|
||||||
|
const [statusAction, setStatusAction] = useState<"CLOSE" | "CANCEL" | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [selectedRequest, setSelectedRequest] =
|
||||||
|
useState<ProformaRequest | null>(null);
|
||||||
|
|
||||||
|
// Form State
|
||||||
|
const [formData, setFormData] = useState<any>({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
category: "EQUIPMENT",
|
||||||
|
submissionDeadline: new Date().toISOString().split("T")[0],
|
||||||
|
allowRevisions: true,
|
||||||
|
paymentTerms: "Net 30 days",
|
||||||
|
incoterms: "EXW",
|
||||||
|
taxIncluded: false,
|
||||||
|
discountStructure: "",
|
||||||
|
validityPeriod: 30,
|
||||||
|
attachments: [],
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
|
|
@ -46,6 +103,8 @@ export default function ProformaRequestsPage() {
|
||||||
status,
|
status,
|
||||||
category,
|
category,
|
||||||
search,
|
search,
|
||||||
|
deadlineFrom,
|
||||||
|
deadlineTo,
|
||||||
],
|
],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
invoiceService.getProformaRequests({
|
invoiceService.getProformaRequests({
|
||||||
|
|
@ -54,6 +113,8 @@ export default function ProformaRequestsPage() {
|
||||||
status: status === "all" ? undefined : status,
|
status: status === "all" ? undefined : status,
|
||||||
category: category === "all" ? undefined : category,
|
category: category === "all" ? undefined : category,
|
||||||
search: search || undefined,
|
search: search || undefined,
|
||||||
|
deadlineFrom: deadlineFrom || undefined,
|
||||||
|
deadlineTo: deadlineTo || undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -114,8 +175,155 @@ export default function ProformaRequestsPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => invoiceService.createProformaRequest(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Request created successfully");
|
||||||
|
setIsModalOpen(false);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["admin", "proforma-requests"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to create request"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: any }) =>
|
||||||
|
invoiceService.updateProformaRequest(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Request updated successfully");
|
||||||
|
setIsModalOpen(false);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["admin", "proforma-requests"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to update request"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => invoiceService.closeProformaRequest(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Request closed");
|
||||||
|
setIsStatusModalOpen(false);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["admin", "proforma-requests"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to close request"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const cancelMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => invoiceService.cancelProformaRequest(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Request cancelled");
|
||||||
|
setIsStatusModalOpen(false);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["admin", "proforma-requests"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to cancel request"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setEditingRequest(null);
|
||||||
|
setFormData({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
category: "EQUIPMENT",
|
||||||
|
submissionDeadline: new Date().toISOString().split("T")[0],
|
||||||
|
allowRevisions: true,
|
||||||
|
paymentTerms: "Net 30 days",
|
||||||
|
incoterms: "EXW",
|
||||||
|
taxIncluded: false,
|
||||||
|
discountStructure: "",
|
||||||
|
validityPeriod: 30,
|
||||||
|
attachments: [],
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
itemName: "",
|
||||||
|
itemDescription: "",
|
||||||
|
quantity: 1,
|
||||||
|
unitOfMeasure: "unit",
|
||||||
|
technicalSpecifications: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEdit = async (request: ProformaRequest) => {
|
||||||
|
try {
|
||||||
|
const fullDetails = await invoiceService.getProformaRequestDetails(
|
||||||
|
request.id,
|
||||||
|
);
|
||||||
|
setEditingRequest(fullDetails);
|
||||||
|
setFormData({
|
||||||
|
...fullDetails,
|
||||||
|
submissionDeadline: new Date(fullDetails.submissionDeadline)
|
||||||
|
.toISOString()
|
||||||
|
.split("T")[0],
|
||||||
|
});
|
||||||
|
setIsModalOpen(true);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Failed to load request details");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenDetails = async (request: ProformaRequest) => {
|
||||||
|
try {
|
||||||
|
const fullDetails = await invoiceService.getProformaRequestDetails(
|
||||||
|
request.id,
|
||||||
|
);
|
||||||
|
setSelectedRequest(fullDetails);
|
||||||
|
setIsDetailsOpen(true);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Failed to load request details");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addItem = () => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
items: [
|
||||||
|
...formData.items,
|
||||||
|
{
|
||||||
|
itemName: "",
|
||||||
|
itemDescription: "",
|
||||||
|
quantity: 1,
|
||||||
|
unitOfMeasure: "unit",
|
||||||
|
technicalSpecifications: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (index: number) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
items: formData.items.filter((_: any, i: number) => i !== index),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemChange = (index: number, field: string, value: any) => {
|
||||||
|
const newItems = [...formData.items];
|
||||||
|
newItems[index] = { ...newItems[index], [field]: value };
|
||||||
|
setFormData({ ...formData, items: newItems });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (editingRequest) {
|
||||||
|
updateMutation.mutate({
|
||||||
|
id: editingRequest.id,
|
||||||
|
data: { ...formData, status: editingRequest.status },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(formData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 max-w-7xl mx-auto mt-10">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
<h1 className="text-3xl font-bold tracking-tight">
|
||||||
|
|
@ -125,6 +333,15 @@ export default function ProformaRequestsPage() {
|
||||||
Manage and review customer requests for proforma invoices.
|
Manage and review customer requests for proforma invoices.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{canCreateBusinessData && (
|
||||||
|
<Button
|
||||||
|
onClick={handleOpenCreate}
|
||||||
|
className="h-10 rounded-md bg-slate-900 hover:bg-slate-800 text-white font-bold px-6 shadow-sm flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
New Request
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border-slate-200/60 shadow-sm">
|
<Card className="border-slate-200/60 shadow-sm">
|
||||||
|
|
@ -165,6 +382,28 @@ export default function ProformaRequestsPage() {
|
||||||
<SelectItem value="MIXED">Mixed</SelectItem>
|
<SelectItem value="MIXED">Mixed</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<div className="flex flex-col gap-1 mb-4">
|
||||||
|
<Label className="text-[8px] font-bold uppercase text-slate-400">
|
||||||
|
Deadline From
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={deadlineFrom}
|
||||||
|
onChange={(e) => setDeadlineFrom(e.target.value)}
|
||||||
|
className="w-[170px] h-9 border-slate-200/80 text-[10px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 mb-4">
|
||||||
|
<Label className="text-[8px] font-bold uppercase text-slate-400">
|
||||||
|
Deadline To
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={deadlineTo}
|
||||||
|
onChange={(e) => setDeadlineTo(e.target.value)}
|
||||||
|
className="w-[170px] h-9 border-slate-200/80 text-[10px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -184,11 +423,8 @@ export default function ProformaRequestsPage() {
|
||||||
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
|
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
|
||||||
Deadline
|
Deadline
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
|
<TableHead className="text-right text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6 py-4">
|
||||||
Items
|
Actions
|
||||||
</TableHead>
|
|
||||||
<TableHead className="text-right text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
|
|
||||||
Timestamp
|
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
@ -233,16 +469,76 @@ export default function ProformaRequestsPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-6">
|
<TableCell className="text-right px-6">
|
||||||
<Badge
|
<div className="flex items-center justify-end gap-2">
|
||||||
variant="outline"
|
{canEditBusinessData && (
|
||||||
className="bg-slate-50 border-slate-200 text-slate-600 text-[10px]"
|
<>
|
||||||
>
|
<Button
|
||||||
{request.items.length} Items
|
variant="ghost"
|
||||||
</Badge>
|
size="icon"
|
||||||
</TableCell>
|
className="h-8 w-8 text-slate-400 hover:text-slate-900"
|
||||||
<TableCell className="text-right px-6 text-xs text-slate-500 font-medium">
|
onClick={() => handleOpenEdit(request)}
|
||||||
{format(new Date(request.createdAt), "HH:mm, MMM dd")}
|
>
|
||||||
|
<MoreVertical className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
>
|
||||||
|
<MoreVertical className="w-4 h-4 text-slate-400" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
className="w-40 rounded-md shadow-lg border-slate-200"
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleOpenDetails(request)}
|
||||||
|
className="text-xs font-semibold py-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
View Details
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleOpenEdit(request)}
|
||||||
|
className="text-xs font-semibold py-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
Edit Request
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{request.status !== "CLOSED" && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedRequest(request);
|
||||||
|
setStatusAction("CLOSE");
|
||||||
|
setIsStatusModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="text-xs font-semibold py-2 text-emerald-600 cursor-pointer"
|
||||||
|
>
|
||||||
|
Close Request
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{request.status !== "CANCELLED" && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedRequest(request);
|
||||||
|
setStatusAction("CANCEL");
|
||||||
|
setIsStatusModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="text-xs font-semibold py-2 text-rose-600 cursor-pointer"
|
||||||
|
>
|
||||||
|
Cancel Request
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!canEditBusinessData && (
|
||||||
|
<Lock className="w-4 h-4 text-slate-200" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
|
|
@ -290,6 +586,603 @@ export default function ProformaRequestsPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Create/Edit Modal */}
|
||||||
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto rounded-xl shadow-2xl border-none p-0">
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col h-full">
|
||||||
|
<DialogHeader className="p-8 border-b border-slate-100 bg-slate-50/30 rounded-t-xl">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<DialogTitle className="text-2xl font-black tracking-tight text-slate-900">
|
||||||
|
{editingRequest
|
||||||
|
? "Edit Proforma Request"
|
||||||
|
: "New Proforma Request"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs font-bold uppercase tracking-widest text-slate-400 mt-1">
|
||||||
|
Complete all technical specifications below.
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="p-8 space-y-8">
|
||||||
|
{/* Basic Info */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
|
||||||
|
Request Title
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, title: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="e.g. Office Equipment Procurement 2024"
|
||||||
|
className="h-11 rounded-lg border-slate-200"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
|
||||||
|
Category
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.category}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setFormData({ ...formData, category: v })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-11 rounded-lg border-slate-200">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="EQUIPMENT">EQUIPMENT</SelectItem>
|
||||||
|
<SelectItem value="SERVICE">SERVICE</SelectItem>
|
||||||
|
<SelectItem value="MIXED">MIXED</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
|
||||||
|
Description
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, description: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Provide a detailed overview of the request..."
|
||||||
|
className="min-h-[110px] rounded-lg border-slate-200 resize-none"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terms & Deadlines */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 pt-4 border-t border-slate-50">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
|
||||||
|
Submission Deadline
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formData.submissionDeadline}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
submissionDeadline: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-10 rounded-lg border-slate-200"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
|
||||||
|
Payment Terms
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.paymentTerms}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, paymentTerms: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="e.g. Net 30 days"
|
||||||
|
className="h-10 rounded-lg border-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
|
||||||
|
Incoterms
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.incoterms}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, incoterms: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="e.g. EXW, FOB"
|
||||||
|
className="h-10 rounded-lg border-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
|
||||||
|
Discount Structure
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.discountStructure}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
discountStructure: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="e.g. 5% for >100 units"
|
||||||
|
className="h-10 rounded-lg border-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggles & Other */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-slate-100">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Allow Revisions
|
||||||
|
</Label>
|
||||||
|
<p className="text-[9px] text-slate-400">
|
||||||
|
Can customers revise offers?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={formData.allowRevisions}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
setFormData({ ...formData, allowRevisions: v })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-slate-100">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Tax Included
|
||||||
|
</Label>
|
||||||
|
<p className="text-[9px] text-slate-400">
|
||||||
|
Are prices gross or net?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={formData.taxIncluded}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
setFormData({ ...formData, taxIncluded: v })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
|
||||||
|
Validity Period (Days)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.validityPeriod}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
validityPeriod: parseInt(e.target.value) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-10 rounded-lg border-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items Section */}
|
||||||
|
<div className="space-y-4 pt-4 border-t border-slate-50">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-black uppercase tracking-widest text-slate-900 flex items-center gap-2">
|
||||||
|
<ClipboardList className="w-4 h-4 text-slate-400" />
|
||||||
|
Technical Items
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addItem}
|
||||||
|
className="h-8 rounded-lg border-slate-200 text-xs font-bold"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5 mr-1" /> Add Item
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{formData.items.map((item: any, idx: number) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="group relative p-6 bg-slate-50/50 rounded-xl border border-slate-100 space-y-4"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-4">
|
||||||
|
<div className="md:col-span-5 grid gap-2">
|
||||||
|
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400">
|
||||||
|
Item Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={item.itemName}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleItemChange(idx, "itemName", e.target.value)
|
||||||
|
}
|
||||||
|
className="h-9 rounded-lg border-slate-200 bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2 grid gap-2">
|
||||||
|
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400">
|
||||||
|
Quantity
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={item.quantity}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleItemChange(
|
||||||
|
idx,
|
||||||
|
"quantity",
|
||||||
|
parseInt(e.target.value) || 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-9 rounded-lg border-slate-200 bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-3 grid gap-2">
|
||||||
|
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400">
|
||||||
|
Unit of Measure
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={item.unitOfMeasure}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleItemChange(
|
||||||
|
idx,
|
||||||
|
"unitOfMeasure",
|
||||||
|
e.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-9 rounded-lg border-slate-200 bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2 flex items-end justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => removeItem(idx)}
|
||||||
|
className="h-9 w-9 text-slate-400 hover:text-rose-500 hover:bg-rose-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400">
|
||||||
|
Item Description
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={item.itemDescription}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleItemChange(
|
||||||
|
idx,
|
||||||
|
"itemDescription",
|
||||||
|
e.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-9 rounded-lg border-slate-200 bg-white"
|
||||||
|
placeholder="Technical details..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-400">
|
||||||
|
Technical Specs (JSON)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={JSON.stringify(
|
||||||
|
item.technicalSpecifications || {},
|
||||||
|
)}
|
||||||
|
onChange={(e) => {
|
||||||
|
try {
|
||||||
|
const specs = JSON.parse(e.target.value);
|
||||||
|
handleItemChange(
|
||||||
|
idx,
|
||||||
|
"technicalSpecifications",
|
||||||
|
specs,
|
||||||
|
);
|
||||||
|
} catch (err) {}
|
||||||
|
}}
|
||||||
|
className="h-9 rounded-lg border-slate-200 bg-white font-mono text-[10px]"
|
||||||
|
placeholder='{"processor": "i7"}'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="p-8 border-t border-slate-100 bg-slate-50/30 rounded-b-xl mt-auto">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="rounded-lg font-bold text-xs uppercase"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
createMutation.isPending || updateMutation.isPending
|
||||||
|
}
|
||||||
|
className="rounded-lg bg-slate-900 hover:bg-slate-800 text-white font-bold text-xs uppercase px-12"
|
||||||
|
>
|
||||||
|
{createMutation.isPending || updateMutation.isPending ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : editingRequest ? (
|
||||||
|
"Update Request"
|
||||||
|
) : (
|
||||||
|
"Commit Request"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Action Confirmation Modal */}
|
||||||
|
<Dialog open={isStatusModalOpen} onOpenChange={setIsStatusModalOpen}>
|
||||||
|
<DialogContent className="max-w-md rounded-xl p-0 shadow-2xl overflow-hidden border-none text-center">
|
||||||
|
<div
|
||||||
|
className={`p-8 ${statusAction === "CLOSE" ? "bg-emerald-500" : "bg-rose-500"} text-white`}
|
||||||
|
>
|
||||||
|
<div className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
{statusAction === "CLOSE" ? (
|
||||||
|
<Lock className="w-8 h-8" />
|
||||||
|
) : (
|
||||||
|
<Ban className="w-8 h-8" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogTitle className="text-2xl font-black tracking-tight text-white mb-2">
|
||||||
|
{statusAction === "CLOSE" ? "Close Request?" : "Cancel Request?"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-white/80 font-medium text-sm">
|
||||||
|
{statusAction === "CLOSE"
|
||||||
|
? "Closing this request will prevent further quotations from being submitted."
|
||||||
|
: "Cancelling this request will permanently stop the procurement process."}
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
<div className="p-8 space-y-4">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-slate-400">
|
||||||
|
Target:{" "}
|
||||||
|
<span className="text-slate-900">{selectedRequest?.title}</span>
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedRequest) {
|
||||||
|
if (statusAction === "CLOSE")
|
||||||
|
closeMutation.mutate(selectedRequest.id);
|
||||||
|
else cancelMutation.mutate(selectedRequest.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={closeMutation.isPending || cancelMutation.isPending}
|
||||||
|
className={`h-11 rounded-lg font-black uppercase text-xs tracking-widest ${statusAction === "CLOSE" ? "bg-emerald-600 hover:bg-emerald-700" : "bg-rose-600 hover:bg-rose-700"}`}
|
||||||
|
>
|
||||||
|
{closeMutation.isPending || cancelMutation.isPending ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Confirm Action"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setIsStatusModalOpen(false)}
|
||||||
|
className="h-11 rounded-lg font-bold text-slate-500 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Details Dialog */}
|
||||||
|
<Dialog open={isDetailsOpen} onOpenChange={setIsDetailsOpen}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto rounded-xl shadow-2xl border-none p-0">
|
||||||
|
{selectedRequest && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<DialogHeader className="p-8 border-b border-slate-100 bg-slate-50/30 rounded-t-xl">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
{getStatusBadge(selectedRequest.status)}
|
||||||
|
<span className="text-xs font-bold text-slate-400">
|
||||||
|
ID: {selectedRequest.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<DialogTitle className="text-2xl font-black tracking-tight text-slate-900">
|
||||||
|
{selectedRequest.title}
|
||||||
|
</DialogTitle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="p-8 space-y-8">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
|
||||||
|
Description
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-slate-600 mt-1 whitespace-pre-wrap">
|
||||||
|
{selectedRequest.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
|
||||||
|
Category
|
||||||
|
</Label>
|
||||||
|
<div className="flex items-center text-xs font-semibold text-slate-900 mt-1 uppercase tracking-tight">
|
||||||
|
{getCategoryIcon(selectedRequest.category)}
|
||||||
|
{selectedRequest.category}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
|
||||||
|
Deadline
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs font-semibold text-slate-900 mt-1">
|
||||||
|
{format(
|
||||||
|
new Date(selectedRequest.submissionDeadline),
|
||||||
|
"MMMM dd, yyyy",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 p-6 bg-slate-50 rounded-xl border border-slate-100">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[9px] font-black uppercase text-slate-400">
|
||||||
|
Payment Terms
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs font-bold text-slate-900">
|
||||||
|
{selectedRequest.paymentTerms || "N/A"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[9px] font-black uppercase text-slate-400">
|
||||||
|
Incoterms
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs font-bold text-slate-900">
|
||||||
|
{selectedRequest.incoterms || "N/A"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[9px] font-black uppercase text-slate-400">
|
||||||
|
Tax Included
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs font-bold text-slate-900">
|
||||||
|
{selectedRequest.taxIncluded ? "Yes" : "No"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[9px] font-black uppercase text-slate-400">
|
||||||
|
Validity Period
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs font-bold text-slate-900">
|
||||||
|
{selectedRequest.validityPeriod} Days
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedRequest.discountStructure && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-[9px] font-black uppercase text-slate-400">
|
||||||
|
Discount Structure
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs font-bold text-slate-900 mt-0.5">
|
||||||
|
{selectedRequest.discountStructure}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-black uppercase tracking-widest text-slate-900">
|
||||||
|
Technical Items
|
||||||
|
</h3>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{selectedRequest.items.map((item, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="p-6 bg-white border border-slate-200 rounded-xl shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-slate-900">
|
||||||
|
{item.itemName}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
{item.itemDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="text-[10px] font-black uppercase text-slate-400 block">
|
||||||
|
Quantity
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-black text-slate-900">
|
||||||
|
{item.quantity} {item.unitOfMeasure}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{item.technicalSpecifications &&
|
||||||
|
Object.keys(item.technicalSpecifications).length >
|
||||||
|
0 && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-slate-100 grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
{Object.entries(item.technicalSpecifications).map(
|
||||||
|
([key, val]) => (
|
||||||
|
<div key={key}>
|
||||||
|
<span className="text-[9px] font-bold uppercase text-slate-400 block">
|
||||||
|
{key}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] font-semibold text-slate-700">
|
||||||
|
{val as string}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedRequest.attachments &&
|
||||||
|
selectedRequest.attachments.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-black uppercase tracking-widest text-slate-900">
|
||||||
|
Attachments
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{selectedRequest.attachments.map((file: any, idx) => (
|
||||||
|
<a
|
||||||
|
key={idx}
|
||||||
|
href={file.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="px-4 py-2 bg-slate-900 text-white text-[10px] font-bold uppercase tracking-widest rounded-lg hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
{file.name}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="p-8 border-t border-slate-100 bg-slate-50/30 rounded-b-xl mt-auto">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setIsDetailsOpen(false)}
|
||||||
|
className="rounded-lg font-bold text-xs uppercase"
|
||||||
|
>
|
||||||
|
Close View
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,98 @@
|
||||||
import { useState } from "react"
|
import { useState } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
import { NavLink } from "react-router-dom";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input"
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label"
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
DialogDescription,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select";
|
||||||
import { Search, Plus } from "lucide-react"
|
import {
|
||||||
import { issueService } from "@/services"
|
Search,
|
||||||
import type { IssueStatus } from "@/services/issue.service"
|
Plus,
|
||||||
import { useAdminRole } from "@/hooks/use-admin-role"
|
LifeBuoy,
|
||||||
import { toast } from "sonner"
|
AlertCircle,
|
||||||
|
Clock,
|
||||||
|
CheckCircle2,
|
||||||
|
User as UserIcon,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ArrowRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { issueService } from "@/services";
|
||||||
|
import type { IssueStatus } from "@/services/issue.service";
|
||||||
|
import { useAdminRole } from "@/hooks/use-admin-role";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const badgeForStatus = (s: IssueStatus) => {
|
const getPriorityColor = (p: string) => {
|
||||||
const map: Record<IssueStatus, string> = {
|
switch (p) {
|
||||||
OPEN: "bg-orange-100 text-orange-900 border-orange-200",
|
case "HIGH":
|
||||||
IN_PROGRESS: "bg-blue-100 text-blue-900 border-blue-200",
|
return "text-rose-600 bg-rose-50 border-rose-100";
|
||||||
RESOLVED: "bg-emerald-100 text-emerald-900 border-emerald-200",
|
case "MEDIUM":
|
||||||
CLOSED: "bg-gray-100 text-gray-700 border-gray-200",
|
return "text-amber-600 bg-amber-50 border-amber-100";
|
||||||
|
default:
|
||||||
|
return "text-slate-600 bg-slate-50 border-slate-100";
|
||||||
}
|
}
|
||||||
return map[s] ?? ""
|
};
|
||||||
}
|
|
||||||
|
const getStatusConfig = (s: IssueStatus) => {
|
||||||
|
switch (s) {
|
||||||
|
case "OPEN":
|
||||||
|
return {
|
||||||
|
label: "Open Queue",
|
||||||
|
icon: AlertCircle,
|
||||||
|
color: "text-orange-600 bg-orange-50",
|
||||||
|
badge: "bg-orange-500",
|
||||||
|
};
|
||||||
|
case "IN_PROGRESS":
|
||||||
|
return {
|
||||||
|
label: "In Progress",
|
||||||
|
icon: Clock,
|
||||||
|
color: "text-blue-600 bg-blue-50",
|
||||||
|
badge: "bg-blue-500",
|
||||||
|
};
|
||||||
|
case "RESOLVED":
|
||||||
|
return {
|
||||||
|
label: "Resolved",
|
||||||
|
icon: CheckCircle2,
|
||||||
|
color: "text-emerald-600 bg-emerald-50",
|
||||||
|
badge: "bg-emerald-500",
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
label: "Archived",
|
||||||
|
icon: CheckCircle2,
|
||||||
|
color: "text-slate-500 bg-slate-50",
|
||||||
|
badge: "bg-slate-400",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default function IssuesPage() {
|
export default function IssuesPage() {
|
||||||
const { canEdit } = useAdminRole()
|
const { canEditBusinessData: canEdit } = useAdminRole();
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient();
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("");
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false);
|
||||||
const [newIssue, setNewIssue] = useState({
|
const [newIssue, setNewIssue] = useState({
|
||||||
title: "",
|
title: "",
|
||||||
description: "",
|
description: "",
|
||||||
priority: "MEDIUM" as "LOW" | "MEDIUM" | "HIGH",
|
priority: "MEDIUM" as "LOW" | "MEDIUM" | "HIGH",
|
||||||
})
|
});
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ["admin", "issues", page, search],
|
queryKey: ["admin", "issues", page, search],
|
||||||
|
|
@ -55,250 +102,436 @@ export default function IssuesPage() {
|
||||||
limit: 12,
|
limit: 12,
|
||||||
search: search.trim() || undefined,
|
search: search.trim() || undefined,
|
||||||
}),
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: () => issueService.create(newIssue),
|
mutationFn: () => issueService.create(newIssue),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Issue reported")
|
toast.success("Issue reported and logged into system");
|
||||||
queryClient.invalidateQueries({ queryKey: ["admin", "issues"] })
|
queryClient.invalidateQueries({ queryKey: ["admin", "issues"] });
|
||||||
setOpen(false)
|
setOpen(false);
|
||||||
setNewIssue({
|
setNewIssue({ title: "", description: "", priority: "MEDIUM" });
|
||||||
title: "",
|
|
||||||
description: "",
|
|
||||||
priority: "MEDIUM",
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Could not create issue"),
|
onError: () => toast.error("Critical failure during issue report creation"),
|
||||||
})
|
});
|
||||||
|
|
||||||
const statusMutation = useMutation({
|
const statusMutation = useMutation({
|
||||||
mutationFn: ({ id, status }: { id: string; status: IssueStatus }) =>
|
mutationFn: ({ id, status }: { id: string; status: IssueStatus }) =>
|
||||||
issueService.updateStatus(id, status),
|
issueService.updateStatus(id, status),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Status updated")
|
toast.success("Workflow status transition successful");
|
||||||
queryClient.invalidateQueries({ queryKey: ["admin", "issues"] })
|
queryClient.invalidateQueries({ queryKey: ["admin", "issues"] });
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Update failed"),
|
onError: () => toast.error("Transition refused by system"),
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
|
<div className="space-y-8 mx-auto max-w-7xl mt-10 animate-in fade-in duration-500">
|
||||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
|
{/* Header Section */}
|
||||||
<div>
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 text-primary mb-1"></div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
||||||
Issues
|
Issue Tracking
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-500 mt-1 max-w-xl">
|
|
||||||
Customers and internal system users can report problems here. Support
|
|
||||||
staff with edit access can move tickets through the workflow.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="rounded-none gap-2"
|
className="h-10 px-8 rounded-[6px] bg-slate-900 hover:bg-slate-800 text-white font-black uppercase text-xs tracking-[0.1em] shadow-xl shadow-slate-200 transition-all hover:-translate-y-0.5"
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Report issue
|
Report Issue
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none">
|
<div className="flex gap-8 border-b border-gray-100">
|
||||||
<CardHeader className="border-b flex flex-row items-center justify-between space-y-0 pb-4">
|
<NavLink
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
to="/admin/support/faq"
|
||||||
Queue
|
className={({ isActive }) =>
|
||||||
</CardTitle>
|
cn(
|
||||||
<div className="relative w-64">
|
"pb-4 text-xs font-black uppercase tracking-[0.2em] transition-all border-b-2",
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
isActive
|
||||||
<Input
|
? "border-primary text-primary"
|
||||||
className="pl-10 h-9 rounded-none border-gray-200 text-xs"
|
: "border-transparent text-slate-400 hover:text-slate-600",
|
||||||
placeholder="Search title or email…"
|
)
|
||||||
value={search}
|
}
|
||||||
onChange={(e) => {
|
>
|
||||||
setSearch(e.target.value)
|
FAQ repository
|
||||||
setPage(1)
|
</NavLink>
|
||||||
}}
|
<NavLink
|
||||||
/>
|
to="/admin/issues"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
"pb-4 text-xs font-black uppercase tracking-[0.2em] transition-all border-b-2",
|
||||||
|
isActive
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-slate-400 hover:text-slate-600",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Support Queue
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className=" border border-gray-100 rounded-[ 6px] overflow-hidden">
|
||||||
|
<CardHeader className="p-8 border-b border-slate-100/50 space-y-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6 uppercase">
|
||||||
|
<h2 className="text-xs font-black tracking-[0.2em] text-slate-400">
|
||||||
|
Support Request Queue
|
||||||
|
</h2>
|
||||||
|
<div className="relative group min-w-[320px]">
|
||||||
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 group-focus-within:text-primary transition-colors" />
|
||||||
|
<Input
|
||||||
|
className="pl-11 h-10 bg-slate-50 border-slate-200/60 rounded-[6px] text-sm focus:bg-white transition-all shadow-none placeholder:text-slate-400 font-medium"
|
||||||
|
placeholder="Search ticket titles or reporter..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{error && (
|
{error && (
|
||||||
<p className="p-6 text-sm text-amber-700 bg-amber-50 border-b">
|
<div className="m-8 p-2 bg-amber-50 border border-amber-100 rounded-[6px] flex items-center gap-4 text-amber-700">
|
||||||
Wire up <code className="text-xs">GET /admin/issues</code> on your API
|
<AlertCircle className="w-6 h-6 flex-shrink-0" />
|
||||||
to list tickets.
|
<div className="text-sm font-medium">
|
||||||
</p>
|
Synchronization error. Local queue out of sync with{" "}
|
||||||
|
<code className="bg-amber-100/50 px-1.5 py-0.5 rounded leading-none">
|
||||||
|
GET /admin/issues
|
||||||
|
</code>{" "}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-left">
|
<div className="overflow-x-auto min-h-[450px]">
|
||||||
<thead className="bg-gray-50 border-b">
|
<table className="w-full">
|
||||||
<tr>
|
<thead>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<tr className="bg-slate-50/50 border-b border-slate-100">
|
||||||
Title
|
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] w-[35%]">
|
||||||
|
Objective / Details
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
||||||
Reporter
|
Reporter Profile
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
||||||
Type
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Priority
|
Priority
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
||||||
Updated
|
Timeline
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
|
<th className="px-8 py-5 text-right text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
||||||
Status
|
Governance
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y">
|
<tbody className="divide-y divide-slate-100">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<tr>
|
Array.from({ length: 6 }).map((_, i) => (
|
||||||
<td
|
<tr key={i} className="animate-pulse">
|
||||||
colSpan={6}
|
<td className="px-8 py-6">
|
||||||
className="px-6 py-16 text-center text-gray-400 animate-pulse"
|
<div className="h-4 bg-slate-100 rounded-full w-3/4 mb-2"></div>
|
||||||
>
|
<div className="h-3 bg-slate-50 rounded-full w-1/2"></div>
|
||||||
Loading…
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : data?.data?.length ? (
|
|
||||||
data.data.map((issue) => (
|
|
||||||
<tr key={issue.id} className="hover:bg-gray-50 align-top">
|
|
||||||
<td className="px-6 py-4 text-sm font-semibold text-gray-900 max-w-xs">
|
|
||||||
{issue.title}
|
|
||||||
<p className="text-[11px] text-gray-500 font-normal mt-1 line-clamp-2">
|
|
||||||
{issue.description}
|
|
||||||
</p>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-xs text-gray-600">
|
<td colSpan={4} className="px-8 py-6">
|
||||||
<div>{issue.reporterEmail}</div>
|
<div className="h-3 bg-slate-50 rounded-full w-32 ml-auto"></div>
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="mt-1 text-[10px] rounded-none"
|
|
||||||
>
|
|
||||||
{issue.reporterType}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-xs text-gray-500">—</td>
|
|
||||||
<td className="px-6 py-4 text-xs font-bold text-gray-700">
|
|
||||||
{issue.priority}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-xs text-gray-600">
|
|
||||||
{issue.updatedAt
|
|
||||||
? new Date(issue.updatedAt).toLocaleString()
|
|
||||||
: new Date(issue.createdAt).toLocaleString()}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-right">
|
|
||||||
{canEdit ? (
|
|
||||||
<Select
|
|
||||||
value={issue.status}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
statusMutation.mutate({
|
|
||||||
id: issue.id,
|
|
||||||
status: v as IssueStatus,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-[140px] rounded-none text-xs ml-auto">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="OPEN">Open</SelectItem>
|
|
||||||
<SelectItem value="IN_PROGRESS">
|
|
||||||
In progress
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="RESOLVED">Resolved</SelectItem>
|
|
||||||
<SelectItem value="CLOSED">Closed</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={`rounded-none text-[10px] ${badgeForStatus(issue.status)}`}
|
|
||||||
>
|
|
||||||
{issue.status}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
|
) : data?.data?.length ? (
|
||||||
|
data.data.map((issue) => {
|
||||||
|
const status = getStatusConfig(issue.status);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={issue.id}
|
||||||
|
className="group hover:bg-slate-50/50 transition-colors align-top"
|
||||||
|
>
|
||||||
|
<td className="px-8 py-6">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-sm font-black text-slate-900 tracking-tight leading-snug group-hover:text-primary transition-colors">
|
||||||
|
{issue.title}
|
||||||
|
</span>
|
||||||
|
<p className="text-[11px] text-slate-500 font-medium line-clamp-2 mt-1 leading-relaxed">
|
||||||
|
{issue.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-8 py-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-slate-100 flex items-center justify-center text-slate-400">
|
||||||
|
<UserIcon className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs font-bold text-slate-700">
|
||||||
|
{issue.reporterEmail}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="mt-1 text-[9px] font-black uppercase tracking-tighter rounded-md py-0 px-1.5 opacity-60"
|
||||||
|
>
|
||||||
|
{issue.reporterType}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-8 py-6">
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg px-2.5 py-1 text-[10px] font-black uppercase tracking-widest border shadow-none",
|
||||||
|
getPriorityColor(issue.priority),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{issue.priority}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-8 py-6">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-[10px] font-black text-slate-400 uppercase tracking-tighter">
|
||||||
|
Updated
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold text-slate-700">
|
||||||
|
{new Date(
|
||||||
|
issue.updatedAt || issue.createdAt,
|
||||||
|
).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-8 py-6 text-right">
|
||||||
|
{canEdit ? (
|
||||||
|
<Select
|
||||||
|
value={issue.status}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
statusMutation.mutate({
|
||||||
|
id: issue.id,
|
||||||
|
status: v as IssueStatus,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-10 w-[160px] rounded-xl text-[10px] font-black uppercase tracking-widest ml-auto bg-white border-slate-200/60 shadow-sm focus:ring-1 focus:ring-primary transition-all">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-1.5 h-1.5 rounded-full",
|
||||||
|
status.badge,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SelectValue />
|
||||||
|
</div>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl border-slate-100 shadow-xl">
|
||||||
|
<SelectItem
|
||||||
|
value="OPEN"
|
||||||
|
className="text-xs font-bold uppercase transition-colors data-[state=checked]:text-primary"
|
||||||
|
>
|
||||||
|
Open Queue
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
value="IN_PROGRESS"
|
||||||
|
className="text-xs font-bold uppercase transition-colors data-[state=checked]:text-primary"
|
||||||
|
>
|
||||||
|
In Progress
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
value="RESOLVED"
|
||||||
|
className="text-xs font-bold uppercase transition-colors data-[state=checked]:text-primary text-emerald-600"
|
||||||
|
>
|
||||||
|
Resolved
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
value="CLOSED"
|
||||||
|
className="text-xs font-bold uppercase transition-colors data-[state=checked]:text-primary italic opacity-50"
|
||||||
|
>
|
||||||
|
Archived
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 ml-auto w-fit px-3 py-1.5 rounded-xl border",
|
||||||
|
status.color,
|
||||||
|
"border-opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<status.icon className="w-3.5 h-3.5" />
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest">
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
) : (
|
) : (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td colSpan={5} className="px-8 py-24 text-center">
|
||||||
colSpan={6}
|
<div className="flex flex-col items-center justify-center space-y-4 opacity-20 grayscale">
|
||||||
className="px-6 py-16 text-center text-gray-400 italic text-sm"
|
<LifeBuoy className="w-16 h-16" />
|
||||||
>
|
<div className="flex flex-col">
|
||||||
No issues loaded.
|
<span className="text-sm font-black uppercase tracking-[0.2em]">
|
||||||
|
Support empty
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium italic mt-1">
|
||||||
|
No active support report.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{data && data.totalPages > 1 && (
|
||||||
|
<div className="p-8 border-t border-slate-100 flex items-center justify-between bg-slate-50/30">
|
||||||
|
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest leading-none">
|
||||||
|
Page <span className="text-slate-900">{data.page}</span>{" "}
|
||||||
|
<span className="mx-1 opacity-20 text-[6px]">|</span>{" "}
|
||||||
|
<span className="text-slate-400 font-medium tracking-normal text-[11px] capitalize">
|
||||||
|
Showing {data.data.length} items
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-10 rounded-xl px-4 font-black uppercase text-[10px] tracking-widest hover:bg-white"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center px-4 text-xs font-black text-primary bg-white rounded-xl shadow-sm border border-slate-200/50">
|
||||||
|
{data.page} / {data.totalPages}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-10 rounded-xl px-4 font-black uppercase text-[10px] tracking-widest hover:bg-white"
|
||||||
|
disabled={page >= data.totalPages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Report Modal */}
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent className="rounded-none max-w-lg">
|
<DialogContent className="rounded-3xl max-w-lg p-0 border-none shadow-2xl overflow-hidden">
|
||||||
<DialogHeader>
|
<div className="p-8 bg-slate-900 text-white">
|
||||||
<DialogTitle>Report an issue</DialogTitle>
|
<div className="flex items-center gap-3 mb-2">
|
||||||
</DialogHeader>
|
<div className="p-2 bg-white/10 rounded-xl">
|
||||||
<div className="grid gap-3 py-2">
|
<LifeBuoy className="w-5 h-5 text-primary" />
|
||||||
<div className="grid gap-1">
|
</div>
|
||||||
<Label htmlFor="iss-title">Title</Label>
|
<span className="text-[10px] font-black uppercase tracking-[0.2em] opacity-60 italic">
|
||||||
<Input
|
Report Anomaly
|
||||||
id="iss-title"
|
</span>
|
||||||
value={newIssue.title}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNewIssue((n) => ({ ...n, title: e.target.value }))
|
|
||||||
}
|
|
||||||
className="rounded-none"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-1">
|
<DialogTitle className="text-3xl font-black italic tracking-tighter uppercase leading-none">
|
||||||
<Label htmlFor="iss-desc">Description</Label>
|
Capture <span className="text-primary NOT-italic">Issue</span>
|
||||||
<textarea
|
</DialogTitle>
|
||||||
id="iss-desc"
|
<DialogDescription className="text-slate-400 text-xs font-medium mt-2 leading-relaxed">
|
||||||
value={newIssue.description}
|
Document the system anomaly or customer friction point with
|
||||||
onChange={(e) =>
|
technical precision. Reports are immediately queued for triage.
|
||||||
setNewIssue((n) => ({ ...n, description: e.target.value }))
|
</DialogDescription>
|
||||||
}
|
</div>
|
||||||
className="flex min-h-[100px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
||||||
/>
|
<div className="p-8 space-y-6">
|
||||||
</div>
|
<div className="space-y-4">
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-2">
|
||||||
<Label>Priority</Label>
|
<Label
|
||||||
<Select
|
htmlFor="iss-title"
|
||||||
value={newIssue.priority}
|
className="text-[10px] font-black uppercase text-slate-400 tracking-widest"
|
||||||
onValueChange={(v) =>
|
>
|
||||||
setNewIssue((n) => ({
|
Descriptive Headline
|
||||||
...n,
|
</Label>
|
||||||
priority: v as typeof newIssue.priority,
|
<Input
|
||||||
}))
|
id="iss-title"
|
||||||
}
|
value={newIssue.title}
|
||||||
>
|
onChange={(e) =>
|
||||||
<SelectTrigger className="rounded-none">
|
setNewIssue((n) => ({ ...n, title: e.target.value }))
|
||||||
<SelectValue />
|
}
|
||||||
</SelectTrigger>
|
className="h-12 rounded-xl bg-slate-50 border-slate-200/60 focus:bg-white transition-all font-bold text-sm"
|
||||||
<SelectContent>
|
placeholder="e.g. Authentication loop on mobile safari..."
|
||||||
<SelectItem value="LOW">Low</SelectItem>
|
/>
|
||||||
<SelectItem value="MEDIUM">Medium</SelectItem>
|
</div>
|
||||||
<SelectItem value="HIGH">High</SelectItem>
|
<div className="grid gap-2">
|
||||||
</SelectContent>
|
<Label
|
||||||
</Select>
|
htmlFor="iss-desc"
|
||||||
|
className="text-[10px] font-black uppercase text-slate-400 tracking-widest"
|
||||||
|
>
|
||||||
|
Technical Narrative
|
||||||
|
</Label>
|
||||||
|
<textarea
|
||||||
|
id="iss-desc"
|
||||||
|
value={newIssue.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewIssue((n) => ({ ...n, description: e.target.value }))
|
||||||
|
}
|
||||||
|
className="min-h-[120px] rounded-xl bg-slate-50 border-slate-200/60 p-4 text-sm font-medium focus:bg-white focus:outline-none transition-all resize-none"
|
||||||
|
placeholder="Steps to reproduce, error IDs, and environment context..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-black uppercase text-slate-400 tracking-widest">
|
||||||
|
Triage Priority
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={newIssue.priority}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setNewIssue((n) => ({
|
||||||
|
...n,
|
||||||
|
priority: v as typeof newIssue.priority,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-12 rounded-xl bg-slate-50 border-slate-200/60 font-black text-xs uppercase tracking-widest">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl">
|
||||||
|
<SelectItem
|
||||||
|
value="LOW"
|
||||||
|
className="text-[10px] font-black uppercase"
|
||||||
|
>
|
||||||
|
Low / Enhancement
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
value="MEDIUM"
|
||||||
|
className="text-[10px] font-black uppercase"
|
||||||
|
>
|
||||||
|
Medium / Routine
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
value="HIGH"
|
||||||
|
className="text-[10px] font-black uppercase text-rose-600"
|
||||||
|
>
|
||||||
|
High / Critical Anomaly
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
|
||||||
|
<DialogFooter className="p-8 pt-4 bg-slate-50 border-t border-slate-100 flex items-center justify-between">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
className="rounded-none"
|
className="h-12 px-6 rounded-xl font-black uppercase text-xs tracking-widest text-slate-400 hover:text-slate-900"
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
Cancel
|
Discard
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="rounded-none"
|
className="h-12 px-10 rounded-xl bg-primary hover:bg-primary/90 text-primary-foreground font-black uppercase text-xs tracking-[0.1em] shadow-lg shadow-primary/20 transition-all active:scale-95"
|
||||||
disabled={
|
disabled={
|
||||||
createMutation.isPending ||
|
createMutation.isPending ||
|
||||||
!newIssue.title.trim() ||
|
!newIssue.title.trim() ||
|
||||||
|
|
@ -306,11 +539,12 @@ export default function IssuesPage() {
|
||||||
}
|
}
|
||||||
onClick={() => createMutation.mutate()}
|
onClick={() => createMutation.mutate()}
|
||||||
>
|
>
|
||||||
Submit
|
{createMutation.isPending ? "Queuing..." : "Commit Report"}
|
||||||
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,76 @@ import { useQuery } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Search, ChevronLeft, ChevronRight, Filter } from "lucide-react";
|
import {
|
||||||
|
Search,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Filter,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Loader2,
|
||||||
|
Building2,
|
||||||
|
ListOrdered,
|
||||||
|
} from "lucide-react";
|
||||||
import { paymentService } from "@/services";
|
import { paymentService } from "@/services";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useAdminRole } from "@/hooks/use-admin-role";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
|
||||||
export default function PaymentRequestsPage() {
|
export default function PaymentRequestsPage() {
|
||||||
|
const { canCreateBusinessData } = useAdminRole();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
// Create Modal State
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [formData, setFormData] = useState<any>({
|
||||||
|
paymentRequestNumber: `PAYREQ-${new Date().getFullYear()}-${Math.floor(100 + Math.random() * 900)}`,
|
||||||
|
customerName: "",
|
||||||
|
customerEmail: "",
|
||||||
|
customerPhone: "",
|
||||||
|
amount: 0,
|
||||||
|
currency: "USD",
|
||||||
|
issueDate: new Date().toISOString(),
|
||||||
|
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
description: "",
|
||||||
|
notes: "",
|
||||||
|
taxAmount: 0,
|
||||||
|
discountAmount: 0,
|
||||||
|
status: "DRAFT",
|
||||||
|
paymentId: "",
|
||||||
|
customerId: "",
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
bankName: "Yaltopia Bank",
|
||||||
|
accountName: "Yaltopia Tech PLC",
|
||||||
|
accountNumber: "",
|
||||||
|
currency: "ETB",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
items: [{ description: "", quantity: 1, unitPrice: 0, total: 0 }],
|
||||||
|
});
|
||||||
|
|
||||||
const { data: requestsData, isLoading: requestsLoading } = useQuery({
|
const { data: requestsData, isLoading: requestsLoading } = useQuery({
|
||||||
queryKey: ["admin", "payment-requests", page, search],
|
queryKey: ["admin", "payment-requests", page, search],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
|
|
@ -21,6 +83,78 @@ export default function PaymentRequestsPage() {
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => paymentService.createPaymentRequest(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Payment request created successfully");
|
||||||
|
setIsModalOpen(false);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["admin", "payment-requests"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to create payment request");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCreate = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
createMutation.mutate(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addItem = () => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
items: [
|
||||||
|
...formData.items,
|
||||||
|
{ description: "", quantity: 1, unitPrice: 0, total: 0 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (idx: number) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
items: formData.items.filter((_: any, i: number) => i !== idx),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemChange = (idx: number, field: string, value: any) => {
|
||||||
|
const newItems = [...formData.items];
|
||||||
|
newItems[idx] = { ...newItems[idx], [field]: value };
|
||||||
|
|
||||||
|
// Auto-calculate total
|
||||||
|
if (field === "quantity" || field === "unitPrice") {
|
||||||
|
newItems[idx].total = newItems[idx].quantity * newItems[idx].unitPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAmount = newItems.reduce((sum, item) => sum + item.total, 0);
|
||||||
|
setFormData({ ...formData, items: newItems, amount: newAmount });
|
||||||
|
};
|
||||||
|
|
||||||
|
const addAccount = () => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
accounts: [
|
||||||
|
...formData.accounts,
|
||||||
|
{ bankName: "", accountName: "", accountNumber: "", currency: "ETB" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAccount = (idx: number) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
accounts: formData.accounts.filter((_: any, i: number) => i !== idx),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAccountChange = (idx: number, field: string, value: any) => {
|
||||||
|
const newAccounts = [...formData.accounts];
|
||||||
|
newAccounts[idx] = { ...newAccounts[idx], [field]: value };
|
||||||
|
setFormData({ ...formData, accounts: newAccounts });
|
||||||
|
};
|
||||||
|
|
||||||
const formatCurrency = (amount: number | any) => {
|
const formatCurrency = (amount: number | any) => {
|
||||||
const val = typeof amount === "number" ? amount : 0;
|
const val = typeof amount === "number" ? amount : 0;
|
||||||
return new Intl.NumberFormat("en-US", {
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
|
@ -56,7 +190,17 @@ export default function PaymentRequestsPage() {
|
||||||
Manage outbound customer requests.
|
Manage outbound customer requests.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/* View only access: New Request button removed */}
|
{canCreateBusinessData && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
className="h-9 rounded-none bg-slate-900 border-none uppercase text-[10px] font-bold tracking-widest px-6"
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
New Request
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none">
|
<Card className="border shadow-none rounded-none">
|
||||||
|
|
@ -191,6 +335,510 @@ export default function PaymentRequestsPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Create Modal */}
|
||||||
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden p-0 rounded-none border-slate-200">
|
||||||
|
<form
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
className="flex flex-col h-full bg-slate-50"
|
||||||
|
>
|
||||||
|
<DialogHeader className="p-8 pb-6 bg-white border-b border-slate-100">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="px-2 py-0.5 bg-slate-900 text-white text-[9px] font-black uppercase tracking-widest">
|
||||||
|
Draft
|
||||||
|
</span>
|
||||||
|
<DialogTitle className="text-xl font-bold tracking-tight text-slate-900">
|
||||||
|
Issue Payment Request
|
||||||
|
</DialogTitle>
|
||||||
|
</div>
|
||||||
|
<DialogDescription className="text-xs font-medium text-slate-400">
|
||||||
|
Draft a formal financial request for outbound settlement.
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-8 space-y-10">
|
||||||
|
{/* Header Info */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
||||||
|
Reference Number
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.paymentRequestNumber}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
paymentRequestNumber: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200 h-10 font-mono text-xs font-bold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
||||||
|
Issue Date
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formData.issueDate.split("T")[0]}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
issueDate: new Date(e.target.value).toISOString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200 h-10 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
||||||
|
Due Date
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formData.dueDate.split("T")[0]}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
dueDate: new Date(e.target.value).toISOString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200 h-10 text-xs text-rose-600 font-bold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-10">
|
||||||
|
{/* Customer Details */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<div className="w-1 h-4 bg-slate-900" />
|
||||||
|
<h3 className="text-[10px] font-black uppercase tracking-widest text-slate-900">
|
||||||
|
Recipient Details
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-500">
|
||||||
|
Customer Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. Acme Corp"
|
||||||
|
value={formData.customerName}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
customerName: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200 h-10 text-xs"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-500">
|
||||||
|
Email Address
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="billing@acme.com"
|
||||||
|
value={formData.customerEmail}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
customerEmail: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200 h-10 text-xs text-slate-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-500">
|
||||||
|
Phone Number
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="+1 (555) 000-0000"
|
||||||
|
value={formData.customerPhone}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
customerPhone: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200 h-10 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-500">
|
||||||
|
Customer ID (External)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="CUST-001"
|
||||||
|
value={formData.customerId}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
customerId: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200 h-10 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Financials & Logic */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<div className="w-1 h-4 bg-slate-900" />
|
||||||
|
<h3 className="text-[10px] font-black uppercase tracking-widest text-slate-900">
|
||||||
|
Financial Basis
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-500">
|
||||||
|
Currency
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.currency}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setFormData({ ...formData, currency: v })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="rounded-none border-slate-200 h-10 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-none">
|
||||||
|
<SelectItem value="USD">USD - Dollar</SelectItem>
|
||||||
|
<SelectItem value="ETB">ETB - Birr</SelectItem>
|
||||||
|
<SelectItem value="EUR">EUR - Euro</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-500">
|
||||||
|
Description
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="Service payment"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
description: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200 h-10 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-500">
|
||||||
|
Tax Amount
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.taxAmount}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
taxAmount: parseFloat(e.target.value) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200 h-10 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[9px] font-bold uppercase tracking-widest text-slate-500">
|
||||||
|
Discount
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.discountAmount}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
discountAmount: parseFloat(e.target.value) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200 h-10 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 pt-2">
|
||||||
|
<div className="bg-slate-900 p-4 flex flex-col items-end justify-center">
|
||||||
|
<span className="text-[8px] font-black uppercase tracking-widest text-slate-500 mb-1">
|
||||||
|
Estimated Total
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl font-black text-white tabular-nums">
|
||||||
|
{formatCurrency(
|
||||||
|
formData.amount +
|
||||||
|
formData.taxAmount -
|
||||||
|
formData.discountAmount,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Line Items */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ListOrdered className="w-4 h-4 text-slate-400" />
|
||||||
|
<h3 className="text-[10px] font-black uppercase tracking-widest text-slate-900">
|
||||||
|
Line Items
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-[10px] font-black uppercase tracking-widest h-8 text-blue-600 hover:text-blue-700"
|
||||||
|
onClick={addItem}
|
||||||
|
>
|
||||||
|
+ Link Item
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="border border-slate-200 divide-y divide-slate-100 bg-white shadow-sm">
|
||||||
|
{formData.items.map((item: any, idx: number) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="p-4 grid grid-cols-12 gap-4 items-end group"
|
||||||
|
>
|
||||||
|
<div className="col-span-6 space-y-2">
|
||||||
|
<Label className="text-[8px] font-bold uppercase tracking-widest text-slate-400">
|
||||||
|
Description
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={item.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleItemChange(
|
||||||
|
idx,
|
||||||
|
"description",
|
||||||
|
e.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200 h-8 text-[11px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 space-y-2">
|
||||||
|
<Label className="text-[8px] font-bold uppercase tracking-widest text-slate-400">
|
||||||
|
Qty
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={item.quantity}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleItemChange(
|
||||||
|
idx,
|
||||||
|
"quantity",
|
||||||
|
parseInt(e.target.value) || 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200 h-8 text-xs text-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 space-y-2">
|
||||||
|
<Label className="text-[8px] font-bold uppercase tracking-widest text-slate-400">
|
||||||
|
Unit Price
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={item.unitPrice}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleItemChange(
|
||||||
|
idx,
|
||||||
|
"unitPrice",
|
||||||
|
parseFloat(e.target.value) || 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 flex items-center justify-end gap-2">
|
||||||
|
<span className="text-xs font-bold text-slate-900 min-w-16 text-right">
|
||||||
|
{formatCurrency(item.total)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-slate-400 hover:text-rose-600 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={() => removeItem(idx)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settlement Accounts */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="w-4 h-4 text-slate-400" />
|
||||||
|
<h3 className="text-[10px] font-black uppercase tracking-widest text-slate-900">
|
||||||
|
Settlement Accounts
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-[10px] font-black uppercase tracking-widest h-8 text-blue-600 hover:text-blue-700"
|
||||||
|
onClick={addAccount}
|
||||||
|
>
|
||||||
|
+ Add Target
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{formData.accounts.map((acc: any, idx: number) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="relative p-6 bg-white border border-slate-200 space-y-4 group"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-2 top-2 h-7 w-7 text-slate-300 hover:text-rose-600 opacity-0 group-hover:opacity-100"
|
||||||
|
onClick={() => removeAccount(idx)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label className="text-[8px] font-bold uppercase tracking-widest text-slate-400">
|
||||||
|
Bank Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={acc.bankName}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleAccountChange(
|
||||||
|
idx,
|
||||||
|
"bankName",
|
||||||
|
e.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200 h-8 text-[11px] font-bold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label className="text-[8px] font-bold uppercase tracking-widest text-slate-400">
|
||||||
|
Account Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={acc.accountName}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleAccountChange(
|
||||||
|
idx,
|
||||||
|
"accountName",
|
||||||
|
e.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200 h-8 text-[11px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label className="text-[8px] font-bold uppercase tracking-widest text-slate-400">
|
||||||
|
Account #
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={acc.accountNumber}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleAccountChange(
|
||||||
|
idx,
|
||||||
|
"accountNumber",
|
||||||
|
e.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200 h-8 text-[11px] font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label className="text-[8px] font-bold uppercase tracking-widest text-slate-400">
|
||||||
|
Curr
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={acc.currency}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
handleAccountChange(idx, "currency", v)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="rounded-none border-slate-200 h-8 text-[10px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-none">
|
||||||
|
<SelectItem value="USD">USD</SelectItem>
|
||||||
|
<SelectItem value="ETB">ETB</SelectItem>
|
||||||
|
<SelectItem value="EUR">EUR</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 pt-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
||||||
|
Administrative Notes
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Enter internal notes or customer-facing terms..."
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, notes: e.target.value })
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200 min-h-[80px] text-xs resize-none p-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<DialogFooter className="p-8 bg-white border-t border-slate-100 flex items-center justify-between sm:justify-between">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="rounded-none uppercase text-[10px] font-black tracking-widest text-slate-400 hover:text-slate-900"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
>
|
||||||
|
Discard
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
className="rounded-none bg-slate-900 hover:bg-black text-white px-12 h-11 uppercase text-[10px] font-black tracking-widest shadow-lg shadow-slate-200"
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Authorize & Send Request"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,143 @@
|
||||||
import { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
|
Plus,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Download,
|
Pencil,
|
||||||
Flag,
|
Trash2,
|
||||||
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { paymentService } from "@/services";
|
import { paymentService } from "@/services";
|
||||||
|
import { useAdminRole } from "@/hooks/use-admin-role";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { Payment } from "@/services/payment.service";
|
||||||
|
|
||||||
export default function PaymentsListPage() {
|
export default function PaymentsListPage() {
|
||||||
|
const { canCreateBusinessData, canEditBusinessData, canDeleteBusinessData } =
|
||||||
|
useAdminRole();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [editingPayment, setEditingPayment] = useState<Payment | null>(null);
|
||||||
|
const [paymentToDelete, setPaymentToDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Form State
|
||||||
|
const [formData, setFormData] = useState<Partial<Payment>>({
|
||||||
|
transactionId: "",
|
||||||
|
amount: 0,
|
||||||
|
currency: "USD",
|
||||||
|
get paymentDate() {
|
||||||
|
return new Date().toISOString().split("T")[0];
|
||||||
|
},
|
||||||
|
paymentMethod: "Credit Card",
|
||||||
|
notes: "",
|
||||||
|
invoiceId: "",
|
||||||
|
});
|
||||||
|
|
||||||
const { data: paymentsData, isLoading: paymentsLoading } = useQuery({
|
const { data: paymentsData, isLoading: paymentsLoading } = useQuery({
|
||||||
queryKey: ["admin", "payments", page],
|
queryKey: ["admin", "payments", page],
|
||||||
queryFn: () => paymentService.getPayments({ page, limit: 10 }),
|
queryFn: () => paymentService.getPayments({ page, limit: 10 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => paymentService.createPayment(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Payment logged successfully");
|
||||||
|
setIsModalOpen(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["admin", "payments"] });
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast.error(err.response?.data?.message?.[0] || "Failed to log payment");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: any }) =>
|
||||||
|
paymentService.updatePayment(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Payment record updated");
|
||||||
|
setIsModalOpen(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["admin", "payments"] });
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast.error(
|
||||||
|
err.response?.data?.message?.[0] || "Failed to update payment",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => paymentService.deletePayment(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Payment record expunged");
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["admin", "payments"] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to delete payment");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setEditingPayment(null);
|
||||||
|
setFormData({
|
||||||
|
transactionId: `TXN-${Math.floor(100000 + Math.random() * 900000)}`,
|
||||||
|
amount: 0,
|
||||||
|
currency: "USD",
|
||||||
|
paymentDate: new Date().toISOString().split("T")[0],
|
||||||
|
paymentMethod: "Credit Card",
|
||||||
|
notes: "",
|
||||||
|
invoiceId: "",
|
||||||
|
});
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEdit = (payment: Payment) => {
|
||||||
|
setEditingPayment(payment);
|
||||||
|
setFormData({
|
||||||
|
...payment,
|
||||||
|
paymentDate: new Date(payment.paymentDate).toISOString().split("T")[0],
|
||||||
|
});
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (editingPayment) {
|
||||||
|
updateMutation.mutate({ id: editingPayment.id, data: formData });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(formData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formatCurrency = (amount: number | any) => {
|
const formatCurrency = (amount: number | any) => {
|
||||||
const val = typeof amount === "number" ? amount : 0;
|
const val = typeof amount === "number" ? amount : 0;
|
||||||
return new Intl.NumberFormat("en-US", {
|
return new Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "USD",
|
currency: formData.currency || "USD",
|
||||||
}).format(val);
|
}).format(val);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -37,7 +150,17 @@ export default function PaymentsListPage() {
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-500 mt-1">History of settled transactions.</p>
|
<p className="text-gray-500 mt-1">History of settled transactions.</p>
|
||||||
</div>
|
</div>
|
||||||
{/* View only access: Export button removed */}
|
{canCreateBusinessData && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleOpenCreate}
|
||||||
|
className="h-9 rounded-none bg-slate-900 border-none uppercase text-[10px] font-bold tracking-widest px-6"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Log Payment
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none">
|
<Card className="border shadow-none rounded-none">
|
||||||
|
|
@ -74,7 +197,7 @@ export default function PaymentsListPage() {
|
||||||
Date
|
Date
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
|
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
|
||||||
Status
|
Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -83,19 +206,24 @@ export default function PaymentsListPage() {
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={6}
|
colSpan={6}
|
||||||
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium"
|
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
|
||||||
>
|
>
|
||||||
Loading payments...
|
Synchronizing ledger...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : paymentsData?.data && paymentsData.data.length > 0 ? (
|
) : paymentsData?.data && paymentsData.data.length > 0 ? (
|
||||||
paymentsData.data.map((payment) => (
|
paymentsData.data.map((payment) => (
|
||||||
<tr key={payment.id} className="hover:bg-gray-50">
|
<tr key={payment.id} className="hover:bg-gray-50 group">
|
||||||
<td className="px-6 py-4 text-sm font-bold text-gray-900 uppercase tracking-tighter">
|
<td className="px-6 py-4 text-sm font-bold text-gray-900 uppercase tracking-tighter">
|
||||||
{payment.transactionId}
|
{payment.transactionId}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm text-gray-600">
|
<td className="px-6 py-4 text-sm text-gray-600">
|
||||||
{payment.senderName || "Unknown"}
|
{payment.senderName || "Unknown"}
|
||||||
|
{payment.isFlagged && (
|
||||||
|
<span className="ml-2 inline-flex items-center gap-1 px-1.5 py-0.5 rounded-none text-[8px] font-black uppercase bg-red-50 text-red-600 border border-red-100 italic">
|
||||||
|
Flagged
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase">
|
<td className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase">
|
||||||
{payment.paymentMethod}
|
{payment.paymentMethod}
|
||||||
|
|
@ -107,11 +235,36 @@ export default function PaymentsListPage() {
|
||||||
{new Date(payment.paymentDate).toLocaleDateString()}
|
{new Date(payment.paymentDate).toLocaleDateString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-right">
|
<td className="px-6 py-4 text-right">
|
||||||
{payment.isFlagged && (
|
<div className="flex items-center justify-end gap-2">
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-none text-[9px] font-bold uppercase bg-red-50 text-red-600 border border-red-100">
|
{canEditBusinessData && (
|
||||||
<Flag className="w-2.5 h-2.5" /> Flagged
|
<Button
|
||||||
</span>
|
variant="ghost"
|
||||||
)}
|
size="icon"
|
||||||
|
className="h-8 w-8 text-slate-400 hover:text-slate-900"
|
||||||
|
onClick={() => handleOpenEdit(payment)}
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canDeleteBusinessData && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-slate-400 hover:text-rose-600"
|
||||||
|
onClick={() => {
|
||||||
|
setPaymentToDelete(payment.id);
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!canEditBusinessData && !canDeleteBusinessData && (
|
||||||
|
<span className="text-[10px] font-bold text-slate-300 uppercase italic">
|
||||||
|
View Only
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
|
|
@ -121,7 +274,7 @@ export default function PaymentsListPage() {
|
||||||
colSpan={6}
|
colSpan={6}
|
||||||
className="px-6 py-20 text-center text-gray-400 italic"
|
className="px-6 py-20 text-center text-gray-400 italic"
|
||||||
>
|
>
|
||||||
No records found.
|
No records found in transaction history.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|
@ -157,6 +310,233 @@ export default function PaymentsListPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Create/Edit Modal */}
|
||||||
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
|
<DialogContent className="max-w-2xl rounded-none">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl font-bold uppercase tracking-tight">
|
||||||
|
{editingPayment ? "Update Record" : "Log New Payment"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs font-bold uppercase tracking-widest text-slate-400">
|
||||||
|
Official transaction record entry.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 py-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Transaction ID
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.transactionId}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
transactionId: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Amount
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.amount}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
amount: parseFloat(e.target.value) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Currency
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.currency}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setFormData({ ...formData, currency: v })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="rounded-none border-slate-200">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="USD">USD</SelectItem>
|
||||||
|
<SelectItem value="EUR">EUR</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Payment Date
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formData.paymentDate}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, paymentDate: e.target.value })
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Payment Method
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.paymentMethod}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setFormData({ ...formData, paymentMethod: v })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="rounded-none border-slate-200">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Bank Transfer">
|
||||||
|
Bank Transfer
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="Credit Card">Credit Card</SelectItem>
|
||||||
|
<SelectItem value="Cash">Cash</SelectItem>
|
||||||
|
<SelectItem value="Mobile Money">Mobile Money</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Linked Invoice ID
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.invoiceId}
|
||||||
|
placeholder="Optional"
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, invoiceId: e.target.value })
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Linked Invoice ID
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.invoiceId}
|
||||||
|
placeholder="Optional"
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, invoiceId: e.target.value })
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2 pb-6">
|
||||||
|
<Label className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
Notes / Internal Comments
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, notes: e.target.value })
|
||||||
|
}
|
||||||
|
className="rounded-none border-slate-200 min-h-[100px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="border-t pt-6">
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
||||||
|
Ledger Entry Total
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-black tracking-tighter text-slate-900">
|
||||||
|
{formatCurrency(formData.amount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="rounded-none uppercase font-bold text-[10px]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
createMutation.isPending || updateMutation.isPending
|
||||||
|
}
|
||||||
|
className="rounded-none bg-slate-900 uppercase font-bold text-[10px] tracking-widest px-8"
|
||||||
|
>
|
||||||
|
{createMutation.isPending || updateMutation.isPending ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : editingPayment ? (
|
||||||
|
"Update Ledger"
|
||||||
|
) : (
|
||||||
|
"Commit to Ledger"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
<Dialog open={isDeleteModalOpen} onOpenChange={setIsDeleteModalOpen}>
|
||||||
|
<DialogContent className="rounded-none">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl font-bold uppercase tracking-tight">
|
||||||
|
Expunge Payment Record?
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs font-bold uppercase tracking-widest text-rose-500">
|
||||||
|
This action is permanent and cannot be reversed.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4 text-sm text-slate-600">
|
||||||
|
Are you sure you want to delete this payment record? This will
|
||||||
|
un-settle any linked invoices.
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setIsDeleteModalOpen(false)}
|
||||||
|
className="rounded-none uppercase font-bold text-[10px]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
paymentToDelete && deleteMutation.mutate(paymentToDelete)
|
||||||
|
}
|
||||||
|
className="rounded-none bg-rose-600 hover:bg-rose-700 uppercase font-bold text-[10px] tracking-widest"
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Confirm Deletion"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,60 @@
|
||||||
import { useState } from "react"
|
import { useState } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
import { NavLink } from "react-router-dom";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input"
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label"
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
DialogDescription,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select";
|
||||||
import { Search, Plus, Pencil } from "lucide-react"
|
import {
|
||||||
import { faqService } from "@/services"
|
Search,
|
||||||
import type { FaqAudience, FaqEntry } from "@/services/faq.service"
|
Plus,
|
||||||
import { useAdminRole } from "@/hooks/use-admin-role"
|
Pencil,
|
||||||
import { toast } from "sonner"
|
HelpCircle,
|
||||||
|
Users,
|
||||||
|
ShieldCheck,
|
||||||
|
Globe,
|
||||||
|
ArrowRight,
|
||||||
|
Library,
|
||||||
|
Settings2,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { faqService } from "@/services";
|
||||||
|
import type { FaqAudience, FaqEntry } from "@/services/faq.service";
|
||||||
|
import { useAdminRole } from "@/hooks/use-admin-role";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export default function FaqSupportPage() {
|
export default function FaqSupportPage() {
|
||||||
const { canEdit } = useAdminRole()
|
const { canEditBusinessData: canEdit } = useAdminRole();
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient();
|
||||||
const [tab, setTab] = useState<"browse" | "manage">("browse")
|
const [tab, setTab] = useState<"browse" | "manage">("browse");
|
||||||
const [audienceFilter, setAudienceFilter] = useState<FaqAudience | "ALL">(
|
const [audienceFilter, setAudienceFilter] = useState<FaqAudience | "ALL">(
|
||||||
"ALL",
|
"ALL",
|
||||||
)
|
);
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("");
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<FaqEntry | null>(null)
|
const [editing, setEditing] = useState<FaqEntry | null>(null);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
question: "",
|
question: "",
|
||||||
answer: "",
|
answer: "",
|
||||||
audience: "ALL" as FaqAudience,
|
audience: "ALL" as FaqAudience,
|
||||||
})
|
});
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ["admin", "faq", search, audienceFilter],
|
queryKey: ["admin", "faq", search, audienceFilter],
|
||||||
|
|
@ -48,10 +62,9 @@ export default function FaqSupportPage() {
|
||||||
faqService.list({
|
faqService.list({
|
||||||
limit: 100,
|
limit: 100,
|
||||||
search: search.trim() || undefined,
|
search: search.trim() || undefined,
|
||||||
audience:
|
audience: audienceFilter === "ALL" ? undefined : audienceFilter,
|
||||||
audienceFilter === "ALL" ? undefined : audienceFilter,
|
|
||||||
}),
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
|
|
@ -60,193 +73,280 @@ export default function FaqSupportPage() {
|
||||||
question: form.question,
|
question: form.question,
|
||||||
answer: form.answer,
|
answer: form.answer,
|
||||||
audience: form.audience,
|
audience: form.audience,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
return faqService.create({
|
return faqService.create({
|
||||||
question: form.question,
|
question: form.question,
|
||||||
answer: form.answer,
|
answer: form.answer,
|
||||||
audience: form.audience,
|
audience: form.audience,
|
||||||
isPublished: true,
|
isPublished: true,
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(editing ? "FAQ updated" : "FAQ published")
|
toast.success(
|
||||||
queryClient.invalidateQueries({ queryKey: ["admin", "faq"] })
|
editing ? "FAQ entry updated" : "FAQ entry published successfully",
|
||||||
setOpen(false)
|
);
|
||||||
setEditing(null)
|
queryClient.invalidateQueries({ queryKey: ["admin", "faq"] });
|
||||||
setForm({
|
setOpen(false);
|
||||||
question: "",
|
setEditing(null);
|
||||||
answer: "",
|
setForm({ question: "", answer: "", audience: "ALL" });
|
||||||
audience: "ALL",
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Save failed"),
|
onError: () =>
|
||||||
})
|
toast.error("Failure while committing FAQ data to repository"),
|
||||||
|
});
|
||||||
|
|
||||||
const browseItems =
|
const browseItems = data?.data?.filter((e) => e.isPublished !== false) ?? [];
|
||||||
data?.data?.filter((e) => e.isPublished !== false) ?? []
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
|
<div className="space-y-8 mx-auto max-w-7xl mt-10 animate-in fade-in duration-500">
|
||||||
<div>
|
{/* Header Section */}
|
||||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
|
||||||
FAQ & support
|
<div className="space-y-1">
|
||||||
</h1>
|
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
||||||
<p className="text-gray-500 mt-1 max-w-2xl">
|
FAQ & <span className="text-primary NOT-italic">Support</span>
|
||||||
Browse answers for end users and internal system users. Editors can
|
</h1>
|
||||||
publish entries and control which audience sees each question.
|
<p className="text-slate-500 text-sm font-medium max-w-xl leading-relaxed">
|
||||||
</p>
|
Curate and manage the central intelligence repository for both
|
||||||
|
standard users and internal system administrators.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-8 border-b border-gray-100">
|
||||||
|
<NavLink
|
||||||
|
to="/admin/support/faq"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
"pb-4 text-xs font-black uppercase tracking-[0.2em] transition-all border-b-2",
|
||||||
|
isActive
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-slate-400 hover:text-slate-600",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
FAQ repository
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/admin/issues"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
"pb-4 text-xs font-black uppercase tracking-[0.2em] transition-all border-b-2",
|
||||||
|
isActive
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-slate-400 hover:text-slate-600",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Support Queue
|
||||||
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={canEdit ? tab : "browse"}
|
value={canEdit ? tab : "browse"}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
if (canEdit) setTab(v as "browse" | "manage")
|
if (canEdit) setTab(v as "browse" | "manage");
|
||||||
}}
|
}}
|
||||||
className="space-y-6"
|
className="space-y-8"
|
||||||
>
|
>
|
||||||
<TabsList className="rounded-none bg-gray-100 p-1">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6 backdrop-blur-xl p-2 rounded-[6px] ">
|
||||||
<TabsTrigger value="browse" className="rounded-none">
|
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||||
Browse
|
<div className="relative group flex-1 sm:min-w-[280px]">
|
||||||
</TabsTrigger>
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 group-focus-within:text-primary transition-colors" />
|
||||||
{canEdit && (
|
|
||||||
<TabsTrigger value="manage" className="rounded-none">
|
|
||||||
Manage content
|
|
||||||
</TabsTrigger>
|
|
||||||
)}
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="browse" className="space-y-4 mt-2">
|
|
||||||
<div className="flex flex-wrap gap-3 items-center">
|
|
||||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
||||||
<Input
|
<Input
|
||||||
className="pl-10 h-9 rounded-none border-gray-200 text-xs"
|
className="pl-11 h-10 bg-white border-slate-200/60 rounded-[6px] text-sm focus:bg-white transition-all shadow-none placeholder:text-slate-400 font-medium"
|
||||||
placeholder="Search questions…"
|
placeholder="Search articles..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<Select
|
||||||
value={audienceFilter}
|
value={audienceFilter}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) => setAudienceFilter(v as FaqAudience | "ALL")}
|
||||||
setAudienceFilter(v as FaqAudience | "ALL")
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[200px] rounded-none h-9 text-xs">
|
<SelectTrigger className="w-[180px] h-10 rounded-[6px] bg-white border-slate-200/60 font-black text-[10px] uppercase tracking-widest">
|
||||||
<SelectValue placeholder="Audience" />
|
<SelectValue placeholder="Audience" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent className="rounded-xl border-slate-100 shadow-xl">
|
||||||
<SelectItem value="ALL">All audiences</SelectItem>
|
<SelectItem
|
||||||
<SelectItem value="END_USER">End users</SelectItem>
|
value="ALL"
|
||||||
<SelectItem value="SYSTEM_USER">System users</SelectItem>
|
className="text-[10px] font-black uppercase"
|
||||||
|
>
|
||||||
|
All Audiences
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
value="END_USER"
|
||||||
|
className="text-[10px] font-black uppercase"
|
||||||
|
>
|
||||||
|
End Users
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
value="SYSTEM_USER"
|
||||||
|
className="text-[10px] font-black uppercase"
|
||||||
|
>
|
||||||
|
System Staff
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="browse" className="mt-0 space-y-6">
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-sm text-amber-700 bg-amber-50 border px-4 py-3">
|
<div className="p-6 bg-rose-50 border border-rose-100 rounded-2xl flex items-center gap-4 text-rose-700">
|
||||||
Connect <code className="text-xs">GET /admin/faq</code> to load entries.
|
<HelpCircle className="w-6 h-6 flex-shrink-0" />
|
||||||
</p>
|
<div className="text-sm font-medium">
|
||||||
|
Library unreachable. Verify{" "}
|
||||||
|
<code className="bg-rose-100/50 px-1.5 py-0.5 rounded leading-none">
|
||||||
|
GET /admin/faq
|
||||||
|
</code>{" "}
|
||||||
|
endpoint integrity.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="text-gray-400 animate-pulse py-12 text-center">
|
Array.from({ length: 4 }).map((_, i) => (
|
||||||
Loading…
|
<div
|
||||||
</p>
|
key={i}
|
||||||
|
className="h-[200px] bg-slate-100/50 animate-pulse rounded-3xl"
|
||||||
|
/>
|
||||||
|
))
|
||||||
) : browseItems.length ? (
|
) : browseItems.length ? (
|
||||||
browseItems.map((faq) => (
|
browseItems.map((faq) => (
|
||||||
<Card
|
<Card
|
||||||
key={faq.id}
|
key={faq.id}
|
||||||
className="border shadow-none rounded-none"
|
className="border-none shadow-[0_4px_20px_rgb(0,0,0,0.03)] bg-white rounded-3xl overflow-hidden hover:shadow-[0_8px_30px_rgb(0,0,0,0.06)] transition-all group"
|
||||||
>
|
>
|
||||||
<CardHeader className="pb-2 border-b border-gray-100">
|
<CardHeader className="p-8 pb-4">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<CardTitle className="text-base font-bold text-gray-900">
|
<div className="p-3 bg-slate-50 rounded-2xl group-hover:bg-primary/5 transition-colors">
|
||||||
{faq.question}
|
<HelpCircle className="w-5 h-5 text-slate-400 group-hover:text-primary transition-colors" />
|
||||||
</CardTitle>
|
</div>
|
||||||
<Badge variant="outline" className="text-[10px] rounded-none shrink-0">
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
"text-[9px] font-black uppercase tracking-widest rounded-lg px-2 py-0.5 border-none",
|
||||||
|
faq.audience === "ALL"
|
||||||
|
? "bg-slate-100 text-slate-500"
|
||||||
|
: faq.audience === "END_USER"
|
||||||
|
? "bg-emerald-500 text-white"
|
||||||
|
: "bg-primary text-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{faq.audience === "ALL"
|
{faq.audience === "ALL"
|
||||||
? "Everyone"
|
? "Global"
|
||||||
: faq.audience === "END_USER"
|
: faq.audience === "END_USER"
|
||||||
? "End users"
|
? "Customer"
|
||||||
: "System users"}
|
: "Internal"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
<CardTitle className="text-xl font-black text-slate-900 tracking-tight mt-4 leading-tight">
|
||||||
|
{faq.question}
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-4 text-sm text-gray-700 leading-relaxed whitespace-pre-wrap">
|
<CardContent className="px-8 pb-8 pt-0">
|
||||||
{faq.answer}
|
<p className="text-slate-500 text-sm font-medium leading-relaxed line-clamp-3">
|
||||||
|
{faq.answer}
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 flex items-center text-[10px] font-black uppercase tracking-wider text-primary opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
Read Documentation{" "}
|
||||||
|
<ChevronRight className="w-3 h-3 ml-1" />
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className="text-center text-gray-400 py-12 italic text-sm">
|
<div className="col-span-full py-24 text-center">
|
||||||
No FAQs to display.
|
<div className="flex flex-col items-center justify-center space-y-4 opacity-20 grayscale">
|
||||||
</p>
|
<Library className="w-16 h-16" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-black uppercase tracking-[0.2em]">
|
||||||
|
FAQ Empty
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium italic mt-1">
|
||||||
|
No matches found in the current FAQ repository.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<TabsContent value="manage" className="mt-2 space-y-4">
|
<TabsContent value="manage" className="mt-0 space-y-6">
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
className="rounded-none gap-2"
|
className="h-12 px-8 rounded-2xl bg-slate-900 hover:bg-slate-800 text-white font-black uppercase text-xs tracking-[0.1em] shadow-xl shadow-slate-200 transition-all hover:-translate-y-0.5"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditing(null)
|
setEditing(null);
|
||||||
setForm({
|
setForm({ question: "", answer: "", audience: "ALL" });
|
||||||
question: "",
|
setOpen(true);
|
||||||
answer: "",
|
|
||||||
audience: "ALL",
|
|
||||||
})
|
|
||||||
setOpen(true)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
New question
|
Publish Entry
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Card className="border shadow-none rounded-none">
|
|
||||||
|
<Card className="border-none shadow-[0_8px_30px_rgb(0,0,0,0.04)] bg-white/50 backdrop-blur-xl rounded-3xl overflow-hidden">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left">
|
<table className="w-full">
|
||||||
<thead className="bg-gray-50 border-b">
|
<thead>
|
||||||
<tr>
|
<tr className="bg-slate-50/50 border-b border-slate-100">
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] w-[60%]">
|
||||||
Question
|
Intelligence Subject
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
||||||
Audience
|
Target Audience
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
|
<th className="px-8 py-5 text-right text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
||||||
Actions
|
Operations
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y">
|
<tbody className="divide-y divide-slate-100">
|
||||||
{data?.data?.map((faq) => (
|
{data?.data?.map((faq) => (
|
||||||
<tr key={faq.id}>
|
<tr
|
||||||
<td className="px-6 py-3 text-sm font-medium max-w-md line-clamp-2">
|
key={faq.id}
|
||||||
{faq.question}
|
className="group hover:bg-slate-50/50 transition-colors uppercase"
|
||||||
|
>
|
||||||
|
<td className="px-8 py-6">
|
||||||
|
<span className="text-sm font-black text-slate-900 line-clamp-1 tracking-tight">
|
||||||
|
{faq.question}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-3 text-xs">{faq.audience}</td>
|
<td className="px-8 py-6">
|
||||||
<td className="px-6 py-3 text-right">
|
<div className="flex items-center gap-2">
|
||||||
|
{faq.audience === "ALL" ? (
|
||||||
|
<Globe className="w-3.5 h-3.5 text-slate-400" />
|
||||||
|
) : faq.audience === "END_USER" ? (
|
||||||
|
<Users className="w-3.5 h-3.5 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<ShieldCheck className="w-3.5 h-3.5 text-primary" />
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] font-bold text-slate-600">
|
||||||
|
{faq.audience}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-8 py-6 text-right">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="rounded-none h-8"
|
className="h-9 w-9 p-0 rounded-xl hover:bg-white hover:shadow-md transition-all"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditing(faq)
|
setEditing(faq);
|
||||||
setForm({
|
setForm({
|
||||||
question: faq.question,
|
question: faq.question,
|
||||||
answer: faq.answer,
|
answer: faq.answer,
|
||||||
audience: faq.audience,
|
audience: faq.audience,
|
||||||
})
|
});
|
||||||
setOpen(true)
|
setOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4 text-slate-400" />
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -260,65 +360,112 @@ export default function FaqSupportPage() {
|
||||||
)}
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
{/* FAQ Creation/Edit Modal */}
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent className="rounded-none max-w-lg max-h-[90vh] overflow-y-auto">
|
<DialogContent className="rounded-3xl max-w-lg p-0 border-none shadow-2xl overflow-hidden">
|
||||||
<DialogHeader>
|
<div className="p-8 bg-slate-900 text-white">
|
||||||
<DialogTitle>
|
<div className="flex items-center gap-3 mb-2">
|
||||||
{editing ? "Edit FAQ" : "New FAQ entry"}
|
<div className="p-2 bg-white/10 rounded-xl">
|
||||||
|
<HelpCircle className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-[0.2em] opacity-60 italic">
|
||||||
|
Intelligence Editor
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<DialogTitle className="text-3xl font-black italic tracking-tighter uppercase leading-none">
|
||||||
|
{editing ? "Refine" : "Commit"}{" "}
|
||||||
|
<span className="text-primary NOT-italic">Intelligence</span>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
<DialogDescription className="text-slate-400 text-xs font-medium mt-2 leading-relaxed">
|
||||||
<div className="grid gap-3 py-2">
|
Authoritative content for the platform knowledge base. Ensure
|
||||||
<div className="grid gap-1">
|
semantic clarity and audience alignment.
|
||||||
<Label>Audience</Label>
|
</DialogDescription>
|
||||||
<Select
|
</div>
|
||||||
value={form.audience}
|
|
||||||
onValueChange={(v) =>
|
<div className="p-8 space-y-6 max-h-[60vh] overflow-y-auto">
|
||||||
setForm((f) => ({ ...f, audience: v as FaqAudience }))
|
<div className="space-y-4">
|
||||||
}
|
<div className="grid gap-2">
|
||||||
>
|
<Label className="text-[10px] font-black uppercase text-slate-400 tracking-widest">
|
||||||
<SelectTrigger className="rounded-none">
|
Visibility Tier
|
||||||
<SelectValue />
|
</Label>
|
||||||
</SelectTrigger>
|
<Select
|
||||||
<SelectContent>
|
value={form.audience}
|
||||||
<SelectItem value="ALL">Everyone (users & system)</SelectItem>
|
onValueChange={(v) =>
|
||||||
<SelectItem value="END_USER">Platform customers only</SelectItem>
|
setForm((f) => ({ ...f, audience: v as FaqAudience }))
|
||||||
<SelectItem value="SYSTEM_USER">Panel / support staff only</SelectItem>
|
}
|
||||||
</SelectContent>
|
>
|
||||||
</Select>
|
<SelectTrigger className="h-12 rounded-xl bg-slate-50 border-slate-200/60 font-black text-[10px] uppercase tracking-widest">
|
||||||
</div>
|
<SelectValue />
|
||||||
<div className="grid gap-1">
|
</SelectTrigger>
|
||||||
<Label htmlFor="faq-q">Question</Label>
|
<SelectContent className="rounded-xl">
|
||||||
<Input
|
<SelectItem
|
||||||
id="faq-q"
|
value="ALL"
|
||||||
value={form.question}
|
className="text-[10px] font-black uppercase"
|
||||||
onChange={(e) =>
|
>
|
||||||
setForm((f) => ({ ...f, question: e.target.value }))
|
Global Visibility
|
||||||
}
|
</SelectItem>
|
||||||
className="rounded-none"
|
<SelectItem
|
||||||
/>
|
value="END_USER"
|
||||||
</div>
|
className="text-[10px] font-black uppercase"
|
||||||
<div className="grid gap-1">
|
>
|
||||||
<Label htmlFor="faq-a">Answer</Label>
|
Platform Customers Only
|
||||||
<textarea
|
</SelectItem>
|
||||||
id="faq-a"
|
<SelectItem
|
||||||
value={form.answer}
|
value="SYSTEM_USER"
|
||||||
onChange={(e) =>
|
className="text-[10px] font-black uppercase text-primary"
|
||||||
setForm((f) => ({ ...f, answer: e.target.value }))
|
>
|
||||||
}
|
Internal Panel Staff Only
|
||||||
className="flex min-h-[140px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
</SelectItem>
|
||||||
/>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="faq-q"
|
||||||
|
className="text-[10px] font-black uppercase text-slate-400 tracking-widest"
|
||||||
|
>
|
||||||
|
Article Title / Question
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="faq-q"
|
||||||
|
value={form.question}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, question: e.target.value }))
|
||||||
|
}
|
||||||
|
className="h-12 rounded-xl bg-slate-50 border-slate-200/60 focus:bg-white transition-all font-bold text-sm"
|
||||||
|
placeholder="e.g. How to manage multiple ledgers?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="faq-a"
|
||||||
|
className="text-[10px] font-black uppercase text-slate-400 tracking-widest"
|
||||||
|
>
|
||||||
|
Authoritative Answer
|
||||||
|
</Label>
|
||||||
|
<textarea
|
||||||
|
id="faq-a"
|
||||||
|
value={form.answer}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, answer: e.target.value }))
|
||||||
|
}
|
||||||
|
className="min-h-[160px] rounded-xl bg-slate-50 border-slate-200/60 p-4 text-sm font-medium focus:bg-white focus:outline-none transition-all resize-none"
|
||||||
|
placeholder="Provide precise, actionable steps..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
|
||||||
|
<DialogFooter className="p-8 pt-4 bg-slate-50 border-t border-slate-100 flex items-center justify-between">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
className="rounded-none"
|
className="h-12 px-6 rounded-xl font-black uppercase text-xs tracking-widest text-slate-400 hover:text-slate-900"
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
Cancel
|
Discard
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="rounded-none"
|
className="h-12 px-10 rounded-xl bg-primary hover:bg-primary/90 text-primary-foreground font-black uppercase text-xs tracking-[0.1em] shadow-lg shadow-primary/20 transition-all active:scale-95"
|
||||||
disabled={
|
disabled={
|
||||||
saveMutation.isPending ||
|
saveMutation.isPending ||
|
||||||
!form.question.trim() ||
|
!form.question.trim() ||
|
||||||
|
|
@ -326,11 +473,12 @@ export default function FaqSupportPage() {
|
||||||
}
|
}
|
||||||
onClick={() => saveMutation.mutate()}
|
onClick={() => saveMutation.mutate()}
|
||||||
>
|
>
|
||||||
Save
|
{saveMutation.isPending ? "Syncing..." : "Publish Intelligence"}
|
||||||
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,31 @@
|
||||||
import { useState } from "react"
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input"
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Input } from "@/components/ui/input";
|
||||||
import { Search, CheckCircle2, XCircle } from "lucide-react"
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { subscriptionTransactionService } from "@/services"
|
import {
|
||||||
import type { SubscriptionPaymentStatus } from "@/services/subscription-transaction.service"
|
Search,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
ArrowRightLeft,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Filter,
|
||||||
|
CreditCard,
|
||||||
|
User as UserIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { subscriptionTransactionService } from "@/services";
|
||||||
|
import type { SubscriptionPaymentStatus } from "@/services/subscription-transaction.service";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export default function SubscriptionTransactionsPage() {
|
export default function SubscriptionTransactionsPage() {
|
||||||
const [tab, setTab] = useState<"succeeded" | "failed">("succeeded")
|
const [tab, setTab] = useState<"succeeded" | "failed">("succeeded");
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("");
|
||||||
const status: SubscriptionPaymentStatus =
|
const status: SubscriptionPaymentStatus =
|
||||||
tab === "succeeded" ? "SUCCEEDED" : "FAILED"
|
tab === "succeeded" ? "SUCCEEDED" : "FAILED";
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ["admin", "subscription-transactions", status, page, search],
|
queryKey: ["admin", "subscription-transactions", status, page, search],
|
||||||
|
|
@ -24,196 +36,280 @@ export default function SubscriptionTransactionsPage() {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
search: search.trim() || undefined,
|
search: search.trim() || undefined,
|
||||||
}),
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
const formatMoney = (amount: number, currency: string) =>
|
const formatMoney = (amount: number, currency: string) =>
|
||||||
new Intl.NumberFormat("en-US", { style: "currency", currency }).format(
|
new Intl.NumberFormat("en-US", {
|
||||||
amount,
|
style: "currency",
|
||||||
)
|
currency,
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
}).format(amount);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
|
<div className="space-y-8 animate-in fade-in duration-500">
|
||||||
<div>
|
{/* Header Section */}
|
||||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
|
||||||
Subscription transactions
|
<div className="space-y-1">
|
||||||
</h1>
|
<div className="flex items-center gap-2 text-primary mb-1">
|
||||||
<p className="text-gray-500 mt-1">
|
<div className="p-2 bg-primary/10 rounded-lg">
|
||||||
Successful charges and failed attempts for platform subscriptions.
|
<ArrowRightLeft className="w-5 h-5" />
|
||||||
</p>
|
</div>
|
||||||
|
<span className="text-xs font-black uppercase tracking-widest opacity-70">
|
||||||
|
Infrastructure
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-black tracking-tighter text-slate-900 uppercase italic">
|
||||||
|
Subscription{" "}
|
||||||
|
<span className="text-primary NOT-italic">Transactions</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-500 text-sm font-medium max-w-xl leading-relaxed">
|
||||||
|
Monitor real-time platform revenue streams and troubleshoot declined
|
||||||
|
payment attempts across all subscription tiers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs
|
<div className="grid grid-cols-1 gap-8">
|
||||||
value={tab}
|
<Card className="border-none shadow-[0_8px_30px_rgb(0,0,0,0.04)] bg-white/50 backdrop-blur-xl rounded-3xl overflow-hidden">
|
||||||
onValueChange={(v) => {
|
<CardHeader className="p-8 border-b border-slate-100/50 space-y-6">
|
||||||
setTab(v as "succeeded" | "failed")
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6">
|
||||||
setPage(1)
|
<Tabs
|
||||||
}}
|
value={tab}
|
||||||
className="space-y-6"
|
onValueChange={(v) => {
|
||||||
>
|
setTab(v as "succeeded" | "failed");
|
||||||
<TabsList className="rounded-none bg-gray-100 p-1">
|
setPage(1);
|
||||||
<TabsTrigger value="succeeded" className="rounded-none gap-2">
|
}}
|
||||||
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
|
className="w-full sm:w-auto"
|
||||||
Successful payments
|
>
|
||||||
</TabsTrigger>
|
<TabsList className="bg-slate-100/80 p-1 rounded-xl h-11 border border-slate-200/50">
|
||||||
<TabsTrigger value="failed" className="rounded-none gap-2">
|
<TabsTrigger
|
||||||
<XCircle className="h-4 w-4 text-red-600" />
|
value="succeeded"
|
||||||
Failed payments
|
className="rounded-lg px-6 gap-2 data-[state=active]:bg-white data-[state=active]:text-emerald-600 data-[state=active]:shadow-sm transition-all font-bold text-xs uppercase"
|
||||||
</TabsTrigger>
|
>
|
||||||
</TabsList>
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
|
Succeeded
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="failed"
|
||||||
|
className="rounded-lg px-6 gap-2 data-[state=active]:bg-white data-[state=active]:text-rose-600 data-[state=active]:shadow-sm transition-all font-bold text-xs uppercase"
|
||||||
|
>
|
||||||
|
<XCircle className="h-3.5 w-3.5" />
|
||||||
|
Failed
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none">
|
<div className="relative group min-w-[300px]">
|
||||||
<CardHeader className="border-b flex flex-row items-center justify-between space-y-0 pb-4">
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 group-focus-within:text-primary transition-colors" />
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
|
||||||
{tab === "succeeded"
|
|
||||||
? "Completed subscription charges"
|
|
||||||
: "Declined or errored charges"}
|
|
||||||
</CardTitle>
|
|
||||||
<div className="relative w-64">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
||||||
<Input
|
<Input
|
||||||
className="pl-10 h-9 rounded-none border-gray-200 text-xs"
|
className="pl-11 h-11 bg-slate-50 border-slate-200/60 rounded-xl text-sm focus:bg-white transition-all shadow-none placeholder:text-slate-400 font-medium"
|
||||||
placeholder="Search email, ref, user…"
|
placeholder="Query by email, ID or reference..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSearch(e.target.value)
|
setSearch(e.target.value);
|
||||||
setPage(1)
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="p-0">
|
</CardHeader>
|
||||||
{error && (
|
|
||||||
<p className="p-6 text-sm text-red-600">
|
<CardContent className="p-0">
|
||||||
Unable to load transactions. Ensure the API exposes{" "}
|
{error && (
|
||||||
<code className="text-xs bg-gray-100 px-1">
|
<div className="m-8 p-6 bg-rose-50 border border-rose-100 rounded-2xl flex items-center gap-4 text-rose-700">
|
||||||
|
<XCircle className="w-6 h-6 flex-shrink-0" />
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
Failed to synchronize with banking ledger. Verify{" "}
|
||||||
|
<code className="bg-rose-100/50 px-1.5 py-0.5 rounded leading-none">
|
||||||
GET /admin/subscription-transactions
|
GET /admin/subscription-transactions
|
||||||
</code>
|
</code>{" "}
|
||||||
.
|
is reachable.
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-left">
|
|
||||||
<thead className="bg-gray-50 border-b">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
User
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Plan
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Amount
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Provider / Ref
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Date
|
|
||||||
</th>
|
|
||||||
{tab === "failed" && (
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
Reason
|
|
||||||
</th>
|
|
||||||
)}
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
|
|
||||||
Status
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{isLoading ? (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={tab === "failed" ? 7 : 6}
|
|
||||||
className="px-6 py-16 text-center text-gray-400 animate-pulse"
|
|
||||||
>
|
|
||||||
Loading…
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : data?.data && data.data.length > 0 ? (
|
|
||||||
data.data.map((row) => (
|
|
||||||
<tr key={row.id} className="hover:bg-gray-50">
|
|
||||||
<td className="px-6 py-4 text-sm">
|
|
||||||
<div className="font-semibold text-gray-900">
|
|
||||||
{row.userEmail}
|
|
||||||
</div>
|
|
||||||
<div className="text-[11px] text-gray-500">
|
|
||||||
{row.userId}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-gray-700">
|
|
||||||
{row.planName}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm font-bold text-gray-900">
|
|
||||||
{formatMoney(row.amount, row.currency)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-xs text-gray-600">
|
|
||||||
<div>{row.provider}</div>
|
|
||||||
{row.providerRef && (
|
|
||||||
<div className="text-[10px] font-mono text-gray-400 mt-0.5">
|
|
||||||
{row.providerRef}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-gray-600">
|
|
||||||
{new Date(row.createdAt).toLocaleString()}
|
|
||||||
</td>
|
|
||||||
{tab === "failed" && (
|
|
||||||
<td className="px-6 py-4 text-xs text-red-700 max-w-[200px]">
|
|
||||||
{row.failureReason ?? "—"}
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
<td className="px-6 py-4 text-right">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="rounded-none text-[10px] uppercase"
|
|
||||||
>
|
|
||||||
{row.status}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={tab === "failed" ? 7 : 6}
|
|
||||||
className="px-6 py-16 text-center text-gray-400 italic text-sm"
|
|
||||||
>
|
|
||||||
No rows for this filter.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{data && data.totalPages > 1 && (
|
|
||||||
<div className="flex justify-between items-center px-6 py-3 border-t text-xs text-gray-600">
|
|
||||||
<span>
|
|
||||||
Page {data.page} of {data.totalPages}
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="underline disabled:opacity-40"
|
|
||||||
disabled={page <= 1}
|
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="underline disabled:opacity-40"
|
|
||||||
disabled={page >= data.totalPages}
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</CardContent>
|
)}
|
||||||
</Card>
|
|
||||||
</Tabs>
|
<div className="overflow-x-auto min-h-[400px]">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-50/50 border-b border-slate-100">
|
||||||
|
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] w-[28%]">
|
||||||
|
Subscriber Details
|
||||||
|
</th>
|
||||||
|
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
||||||
|
Service Plan
|
||||||
|
</th>
|
||||||
|
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
||||||
|
Transaction Value
|
||||||
|
</th>
|
||||||
|
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
||||||
|
Financial Gateway
|
||||||
|
</th>
|
||||||
|
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
||||||
|
Captured At
|
||||||
|
</th>
|
||||||
|
{tab === "failed" && (
|
||||||
|
<th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
||||||
|
Resolution Logic
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
<th className="px-8 py-5 text-right text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
||||||
|
State
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{isLoading ? (
|
||||||
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<tr key={i} className="animate-pulse">
|
||||||
|
<td
|
||||||
|
colSpan={tab === "failed" ? 7 : 6}
|
||||||
|
className="px-8 py-6"
|
||||||
|
>
|
||||||
|
<div className="h-4 bg-slate-100 rounded-full w-3/4 mb-2"></div>
|
||||||
|
<div className="h-3 bg-slate-50 rounded-full w-1/2"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : data?.data && data.data.length > 0 ? (
|
||||||
|
data.data.map((row) => (
|
||||||
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
className="group hover:bg-slate-50/50 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-8 py-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-slate-100 flex items-center justify-center text-slate-400">
|
||||||
|
<UserIcon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-black text-slate-900 tracking-tight">
|
||||||
|
{row.userEmail}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
|
||||||
|
{row.userId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-8 py-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-slate-100 text-slate-700 hover:bg-slate-200 border-none rounded-lg px-2.5 py-0.5 text-[10px] font-black uppercase"
|
||||||
|
>
|
||||||
|
{row.planName}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-8 py-6">
|
||||||
|
<span className="text-sm font-black text-slate-900 underline decoration-primary/20 underline-offset-4">
|
||||||
|
{formatMoney(row.amount, row.currency)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-8 py-6">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-1.5 font-bold text-[11px] text-slate-700">
|
||||||
|
<CreditCard className="w-3.5 h-3.5 text-slate-400" />
|
||||||
|
{row.provider}
|
||||||
|
</div>
|
||||||
|
{row.providerRef && (
|
||||||
|
<span className="text-[10px] font-mono text-slate-400 bg-slate-100/50 px-1.5 py-0.5 rounded border border-slate-200/50 w-fit">
|
||||||
|
{row.providerRef}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-8 py-6">
|
||||||
|
<span className="text-xs font-semibold text-slate-500 italic">
|
||||||
|
{new Date(row.createdAt).toLocaleDateString()}
|
||||||
|
<span className="block text-[10px] not-italic opacity-60 mt-1 uppercase font-bold tracking-tighter">
|
||||||
|
{new Date(row.createdAt).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{tab === "failed" && (
|
||||||
|
<td className="px-8 py-6 max-w-[240px]">
|
||||||
|
<p className="text-xs font-bold text-rose-600 bg-rose-50/50 p-2 rounded-lg border border-rose-100/50">
|
||||||
|
{row.failureReason ?? "Unknown Error Logic"}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td className="px-8 py-6 text-right">
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg px-3 py-1 text-[10px] font-black uppercase tracking-widest border-none shadow-sm",
|
||||||
|
row.status === "SUCCEEDED"
|
||||||
|
? "bg-emerald-500 text-white shadow-emerald-200/50"
|
||||||
|
: row.status === "FAILED"
|
||||||
|
? "bg-rose-500 text-white shadow-rose-200/50"
|
||||||
|
: "bg-amber-500 text-white shadow-amber-200/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.status}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={tab === "failed" ? 7 : 6}
|
||||||
|
className="px-6 py-24 text-center"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center justify-center space-y-3 opacity-30 grayscale">
|
||||||
|
<Filter className="w-12 h-12" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-black uppercase tracking-widest">
|
||||||
|
No matching logs
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
Adjust your criteria or verify live stream.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination Controls */}
|
||||||
|
{data && data.totalPages > 1 && (
|
||||||
|
<div className="p-8 border-t border-slate-100 flex items-center justify-between bg-slate-50/30">
|
||||||
|
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">
|
||||||
|
Showing{" "}
|
||||||
|
<span className="text-slate-900">{data.data.length}</span> of{" "}
|
||||||
|
<span className="text-slate-900">{data.total}</span> entries
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-10 rounded-xl px-4 font-black uppercase text-[10px] tracking-widest hover:bg-white"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4 mr-1" /> Prev
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center px-4 text-xs font-black text-primary bg-white rounded-xl shadow-sm border border-slate-200/50">
|
||||||
|
{data.page} / {data.totalPages}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-10 rounded-xl px-4 font-black uppercase text-[10px] tracking-widest hover:bg-white"
|
||||||
|
disabled={page >= data.totalPages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
Next <ChevronRight className="w-4 h-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { useParams, useNavigate } from "react-router-dom"
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -11,81 +11,91 @@ import {
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select";
|
||||||
import { ArrowLeft, Edit, Key, Loader2 } from "lucide-react"
|
import { ArrowLeft, Edit, Key, Loader2 } from "lucide-react";
|
||||||
import { userService } from "@/services"
|
import { userService } from "@/services";
|
||||||
import { format } from "date-fns"
|
import { useAdminRole } from "@/hooks/use-admin-role";
|
||||||
import { useState } from "react"
|
import { format } from "date-fns";
|
||||||
import { toast } from "sonner"
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export default function UserDetailsPage() {
|
export default function UserDetailsPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams();
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
const { canEditUsers } = useAdminRole();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [editForm, setEditForm] = useState({
|
const [editForm, setEditForm] = useState({
|
||||||
firstName: '',
|
firstName: "",
|
||||||
lastName: '',
|
lastName: "",
|
||||||
email: '',
|
email: "",
|
||||||
role: '',
|
role: "",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
})
|
});
|
||||||
|
|
||||||
const { data: user, isLoading, refetch } = useQuery({
|
const {
|
||||||
queryKey: ['admin', 'users', id],
|
data: user,
|
||||||
|
isLoading,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["admin", "users", id],
|
||||||
queryFn: () => userService.getUser(id!),
|
queryFn: () => userService.getUser(id!),
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
})
|
});
|
||||||
|
|
||||||
const handleEditClick = () => {
|
const handleEditClick = () => {
|
||||||
if (user) {
|
if (user) {
|
||||||
setEditForm({
|
setEditForm({
|
||||||
firstName: user.firstName || '',
|
firstName: user.firstName || "",
|
||||||
lastName: user.lastName || '',
|
lastName: user.lastName || "",
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
isActive: user.isActive,
|
isActive: user.isActive,
|
||||||
})
|
});
|
||||||
setIsEditDialogOpen(true)
|
setIsEditDialogOpen(true);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleSaveEdit = async () => {
|
const handleSaveEdit = async () => {
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true);
|
||||||
await userService.updateUser(id!, editForm)
|
await userService.updateUser(id!, editForm);
|
||||||
toast.success("User updated successfully")
|
toast.success("User updated successfully");
|
||||||
setIsEditDialogOpen(false)
|
setIsEditDialogOpen(false);
|
||||||
refetch()
|
refetch();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Failed to update user")
|
toast.error("Failed to update user");
|
||||||
console.error('Update error:', error)
|
console.error("Update error:", error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="text-center py-8">Loading user details...</div>
|
return <div className="text-center py-8">Loading user details...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <div className="text-center py-8">User not found</div>
|
return <div className="text-center py-8">User not found</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate('/admin/users')}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => navigate("/admin/users")}
|
||||||
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<h2 className="text-3xl font-bold">User Details</h2>
|
<h2 className="text-3xl font-bold">User Details</h2>
|
||||||
|
|
@ -95,7 +105,10 @@ export default function UserDetailsPage() {
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="info">Information</TabsTrigger>
|
<TabsTrigger value="info">Information</TabsTrigger>
|
||||||
<TabsTrigger value="statistics">Statistics</TabsTrigger>
|
<TabsTrigger value="statistics">Statistics</TabsTrigger>
|
||||||
<TabsTrigger value="activity" onClick={() => navigate(`/admin/users/${id}/activity`)}>
|
<TabsTrigger
|
||||||
|
value="activity"
|
||||||
|
onClick={() => navigate(`/admin/users/${id}/activity`)}
|
||||||
|
>
|
||||||
Activity
|
Activity
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
@ -106,14 +119,27 @@ export default function UserDetailsPage() {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle>User Information</CardTitle>
|
<CardTitle>User Information</CardTitle>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={handleEditClick}>
|
{canEditUsers && (
|
||||||
<Edit className="w-4 h-4 mr-2" />
|
<>
|
||||||
Edit
|
<Button
|
||||||
</Button>
|
variant="outline"
|
||||||
<Button variant="outline" size="sm">
|
size="sm"
|
||||||
<Key className="w-4 h-4 mr-2" />
|
onClick={handleEditClick}
|
||||||
Reset Password
|
>
|
||||||
</Button>
|
<Edit className="w-4 h-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Key className="w-4 h-4 mr-2" />
|
||||||
|
Reset Password
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!canEditUsers && (
|
||||||
|
<span className="text-[10px] font-bold text-slate-300 uppercase italic">
|
||||||
|
Immutable View
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -125,7 +151,9 @@ export default function UserDetailsPage() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Name</p>
|
<p className="text-sm text-muted-foreground">Name</p>
|
||||||
<p className="font-medium">{user.firstName} {user.lastName}</p>
|
<p className="font-medium">
|
||||||
|
{user.firstName} {user.lastName}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Role</p>
|
<p className="text-sm text-muted-foreground">Role</p>
|
||||||
|
|
@ -133,18 +161,22 @@ export default function UserDetailsPage() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Status</p>
|
<p className="text-sm text-muted-foreground">Status</p>
|
||||||
<Badge variant={user.isActive ? 'default' : 'secondary'}>
|
<Badge variant={user.isActive ? "default" : "secondary"}>
|
||||||
{user.isActive ? 'Active' : 'Inactive'}
|
{user.isActive ? "Active" : "Inactive"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Created At</p>
|
<p className="text-sm text-muted-foreground">Created At</p>
|
||||||
<p className="font-medium">{format(new Date(user.createdAt), 'PPpp')}</p>
|
<p className="font-medium">
|
||||||
|
{format(new Date(user.createdAt), "PPpp")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Updated At</p>
|
<p className="text-sm text-muted-foreground">Updated At</p>
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{user.updatedAt ? format(new Date(user.updatedAt), 'PPpp') : 'N/A'}
|
{user.updatedAt
|
||||||
|
? format(new Date(user.updatedAt), "PPpp")
|
||||||
|
: "N/A"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -159,7 +191,9 @@ export default function UserDetailsPage() {
|
||||||
<CardTitle className="text-sm font-medium">Invoices</CardTitle>
|
<CardTitle className="text-sm font-medium">Invoices</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{user._count?.invoices || 0}</div>
|
<div className="text-2xl font-bold">
|
||||||
|
{user._count?.invoices || 0}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -167,7 +201,9 @@ export default function UserDetailsPage() {
|
||||||
<CardTitle className="text-sm font-medium">Reports</CardTitle>
|
<CardTitle className="text-sm font-medium">Reports</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{user._count?.reports || 0}</div>
|
<div className="text-2xl font-bold">
|
||||||
|
{user._count?.reports || 0}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -175,7 +211,9 @@ export default function UserDetailsPage() {
|
||||||
<CardTitle className="text-sm font-medium">Documents</CardTitle>
|
<CardTitle className="text-sm font-medium">Documents</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{user._count?.documents || 0}</div>
|
<div className="text-2xl font-bold">
|
||||||
|
{user._count?.documents || 0}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -183,7 +221,9 @@ export default function UserDetailsPage() {
|
||||||
<CardTitle className="text-sm font-medium">Payments</CardTitle>
|
<CardTitle className="text-sm font-medium">Payments</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{user._count?.payments || 0}</div>
|
<div className="text-2xl font-bold">
|
||||||
|
{user._count?.payments || 0}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -204,7 +244,9 @@ export default function UserDetailsPage() {
|
||||||
<Input
|
<Input
|
||||||
id="firstName"
|
id="firstName"
|
||||||
value={editForm.firstName}
|
value={editForm.firstName}
|
||||||
onChange={(e) => setEditForm({ ...editForm, firstName: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setEditForm({ ...editForm, firstName: e.target.value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -212,7 +254,9 @@ export default function UserDetailsPage() {
|
||||||
<Input
|
<Input
|
||||||
id="lastName"
|
id="lastName"
|
||||||
value={editForm.lastName}
|
value={editForm.lastName}
|
||||||
onChange={(e) => setEditForm({ ...editForm, lastName: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setEditForm({ ...editForm, lastName: e.target.value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -221,12 +265,19 @@ export default function UserDetailsPage() {
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
value={editForm.email}
|
value={editForm.email}
|
||||||
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setEditForm({ ...editForm, email: e.target.value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="role">Role</Label>
|
<Label htmlFor="role">Role</Label>
|
||||||
<Select value={editForm.role} onValueChange={(value) => setEditForm({ ...editForm, role: value })}>
|
<Select
|
||||||
|
value={editForm.role}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setEditForm({ ...editForm, role: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select role" />
|
<SelectValue placeholder="Select role" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
@ -241,8 +292,10 @@ export default function UserDetailsPage() {
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="status">Status</Label>
|
<Label htmlFor="status">Status</Label>
|
||||||
<Select
|
<Select
|
||||||
value={editForm.isActive ? 'active' : 'inactive'}
|
value={editForm.isActive ? "active" : "inactive"}
|
||||||
onValueChange={(value) => setEditForm({ ...editForm, isActive: value === 'active' })}
|
onValueChange={(value) =>
|
||||||
|
setEditForm({ ...editForm, isActive: value === "active" })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select status" />
|
<SelectValue placeholder="Select status" />
|
||||||
|
|
@ -255,17 +308,22 @@ export default function UserDetailsPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)} disabled={isSubmitting}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsEditDialogOpen(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSaveEdit} disabled={isSubmitting}>
|
<Button onClick={handleSaveEdit} disabled={isSubmitting}>
|
||||||
{isSubmitting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
{isSubmitting && (
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
)}
|
||||||
Save Changes
|
Save Changes
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,27 @@ import { useNavigate } from "react-router-dom";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import {
|
||||||
import { Search, Eye, ChevronLeft, ChevronRight, Filter } from "lucide-react";
|
Search,
|
||||||
|
Eye,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Filter,
|
||||||
|
Plus,
|
||||||
|
} from "lucide-react";
|
||||||
import { userService } from "@/services";
|
import { userService } from "@/services";
|
||||||
|
import { useAdminRole } from "@/hooks/use-admin-role";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { canCreateUsers } = useAdminRole();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [limit] = useState(15);
|
const [limit] = useState(15);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [roleFilter, setRoleFilter] = useState<string>("all");
|
const [roleFilter] = useState<string>("all");
|
||||||
|
|
||||||
const { data: usersData, isLoading } = useQuery({
|
const { data: usersData, isLoading } = useQuery({
|
||||||
queryKey: ["admin", "users", page, limit, search, roleFilter],
|
queryKey: ["admin", "users", page, limit, search, roleFilter],
|
||||||
|
|
@ -52,7 +61,17 @@ export default function UsersPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* View only access: Add User and Import buttons removed */}
|
{canCreateUsers && (
|
||||||
|
<Button
|
||||||
|
className="h-9 rounded-none bg-slate-900 border-none uppercase text-[10px] font-bold tracking-widest px-6"
|
||||||
|
onClick={() =>
|
||||||
|
toast.info("User creation module is being synchronized.")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add User
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,74 @@
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
Table,
|
Dialog,
|
||||||
TableBody,
|
DialogContent,
|
||||||
TableCell,
|
DialogDescription,
|
||||||
TableHead,
|
DialogFooter,
|
||||||
TableHeader,
|
DialogHeader,
|
||||||
TableRow,
|
DialogTitle,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
Eye,
|
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
|
Send,
|
||||||
|
Plus,
|
||||||
|
BellRing,
|
||||||
|
Mail,
|
||||||
|
MessageSquare,
|
||||||
|
History,
|
||||||
|
Target,
|
||||||
|
ArrowRight,
|
||||||
|
ChevronRight,
|
||||||
Loader2,
|
Loader2,
|
||||||
Calendar,
|
Calendar,
|
||||||
Mail,
|
|
||||||
Tag,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { notificationService } from "@/services/notification.service";
|
import { notificationService } from "@/services/notification.service";
|
||||||
|
import type {
|
||||||
|
SendPushNotificationRequest,
|
||||||
|
SendSmsNotificationRequest,
|
||||||
|
SendEmailNotificationRequest,
|
||||||
|
} from "@/services/notification.service";
|
||||||
|
import { useAdminRole } from "@/hooks/use-admin-role";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export default function NotificationsPage() {
|
type Channel = "PUSH" | "SMS" | "EMAIL";
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [typeFilter, setTypeFilter] = useState("");
|
|
||||||
const [statusFilter, setStatusFilter] = useState("");
|
|
||||||
|
|
||||||
const {
|
export default function NotificationsPage() {
|
||||||
data: notifications,
|
const { canSendNotifications } = useAdminRole();
|
||||||
isLoading,
|
const queryClient = useQueryClient();
|
||||||
refetch,
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
} = useQuery({
|
const [activeChannel, setActiveChannel] = useState<Channel>("PUSH");
|
||||||
|
const [isSendModalOpen, setIsSendModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Combined form state
|
||||||
|
const [pushForm, setPushForm] = useState<SendPushNotificationRequest>({
|
||||||
|
title: "",
|
||||||
|
body: "",
|
||||||
|
recipientId: "",
|
||||||
|
url: "",
|
||||||
|
icon: "/assets/icon.png",
|
||||||
|
});
|
||||||
|
const [smsForm, setSmsForm] = useState<SendSmsNotificationRequest>({
|
||||||
|
body: "",
|
||||||
|
recipientPhone: "",
|
||||||
|
});
|
||||||
|
const [emailForm, setEmailForm] = useState<SendEmailNotificationRequest>({
|
||||||
|
subject: "",
|
||||||
|
body: "",
|
||||||
|
recipientEmail: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: notifications, isLoading } = useQuery({
|
||||||
queryKey: ["notifications"],
|
queryKey: ["notifications"],
|
||||||
queryFn: () => notificationService.getNotifications(),
|
queryFn: () => notificationService.getNotifications(),
|
||||||
});
|
});
|
||||||
|
|
@ -45,257 +78,490 @@ export default function NotificationsPage() {
|
||||||
queryFn: () => notificationService.getUnreadCount(),
|
queryFn: () => notificationService.getUnreadCount(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Client-side filtering
|
const pushMutation = useMutation({
|
||||||
|
mutationFn: (data: SendPushNotificationRequest) =>
|
||||||
|
notificationService.sendPushNotification(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Network transmission: Push packet delivered to gateway");
|
||||||
|
setIsSendModalOpen(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const smsMutation = useMutation({
|
||||||
|
mutationFn: (data: SendSmsNotificationRequest) =>
|
||||||
|
notificationService.sendSmsNotification(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Cellular uplink: SMS payload queued for broadcast");
|
||||||
|
setIsSendModalOpen(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emailMutation = useMutation({
|
||||||
|
mutationFn: (data: SendEmailNotificationRequest) =>
|
||||||
|
notificationService.sendEmailNotification(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("SMTP Handshake: Email broadcast initiated");
|
||||||
|
setIsSendModalOpen(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const filteredNotifications = useMemo(() => {
|
const filteredNotifications = useMemo(() => {
|
||||||
if (!notifications) return [];
|
if (!notifications) return [];
|
||||||
|
return notifications.filter((n) => {
|
||||||
return notifications.filter((notification) => {
|
if (!searchQuery) return true;
|
||||||
// Type filter
|
const q = searchQuery.toLowerCase();
|
||||||
if (typeFilter && notification.type !== typeFilter) return false;
|
return (
|
||||||
|
n.title?.toLowerCase().includes(q) || n.body.toLowerCase().includes(q)
|
||||||
// Status filter
|
);
|
||||||
if (statusFilter === "read" && !notification.isRead) return false;
|
|
||||||
if (statusFilter === "unread" && notification.isRead) return false;
|
|
||||||
|
|
||||||
// Search filter
|
|
||||||
if (searchQuery) {
|
|
||||||
const query = searchQuery.toLowerCase();
|
|
||||||
return (
|
|
||||||
notification.title.toLowerCase().includes(query) ||
|
|
||||||
notification.message.toLowerCase().includes(query) ||
|
|
||||||
notification.recipient.toLowerCase().includes(query)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
});
|
||||||
}, [notifications, typeFilter, statusFilter, searchQuery]);
|
}, [notifications, searchQuery]);
|
||||||
|
|
||||||
const handleMarkAsRead = async (id: string) => {
|
const handleSend = () => {
|
||||||
try {
|
if (activeChannel === "PUSH") pushMutation.mutate(pushForm);
|
||||||
await notificationService.markAsRead(id);
|
else if (activeChannel === "SMS") smsMutation.mutate(smsForm);
|
||||||
toast.success("Notification marked as read");
|
else if (activeChannel === "EMAIL") emailMutation.mutate(emailForm);
|
||||||
refetch();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Failed to mark notification as read");
|
|
||||||
console.error("Mark as read error:", error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMarkAllAsRead = async () => {
|
const isPending =
|
||||||
try {
|
pushMutation.isPending || smsMutation.isPending || emailMutation.isPending;
|
||||||
await notificationService.markAllAsRead();
|
|
||||||
toast.success("All notifications marked as read");
|
|
||||||
refetch();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Failed to mark all as read");
|
|
||||||
console.error("Mark all as read error:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusBadge = (isRead: boolean) => {
|
|
||||||
return isRead ? (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-[10px] font-bold uppercase tracking-widest text-slate-400 border-slate-200"
|
|
||||||
>
|
|
||||||
Read
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge
|
|
||||||
variant="default"
|
|
||||||
className="text-[10px] font-bold uppercase tracking-widest bg-orange-500 hover:bg-orange-600"
|
|
||||||
>
|
|
||||||
Unread
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTypeIcon = (type: string) => {
|
|
||||||
switch (type.toLowerCase()) {
|
|
||||||
case "system":
|
|
||||||
return <Tag className="w-3.5 h-3.5 mr-1.5" />;
|
|
||||||
case "alert":
|
|
||||||
return <CheckCheck className="w-3.5 h-3.5 mr-1.5" />;
|
|
||||||
case "invoice":
|
|
||||||
return <Mail className="w-3.5 h-3.5 mr-1.5" />;
|
|
||||||
default:
|
|
||||||
return <Tag className="w-3.5 h-3.5 mr-1.5" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8 animate-in fade-in duration-500">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
{/* Header Section */}
|
||||||
<div>
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Notifications</h1>
|
<div className="space-y-1">
|
||||||
{unreadCount !== undefined && unreadCount > 0 ? (
|
<div className="flex items-center gap-2 text-primary mb-1">
|
||||||
<p className="text-muted-foreground mt-1">
|
<div className="p-2 bg-primary/10 rounded-lg">
|
||||||
You have{" "}
|
<BellRing className="w-5 h-5" />
|
||||||
<span className="font-bold text-slate-900">{unreadCount}</span>{" "}
|
</div>
|
||||||
unread notification{unreadCount !== 1 ? "s" : ""}
|
<span className="text-xs font-black uppercase tracking-widest opacity-70">
|
||||||
</p>
|
Messaging Hub
|
||||||
) : (
|
</span>
|
||||||
<p className="text-muted-foreground mt-1">
|
</div>
|
||||||
All messages been processed.
|
<h1 className="text-4xl font-black tracking-tighter text-slate-900 uppercase italic">
|
||||||
</p>
|
Command <span className="text-primary NOT-italic">Center</span>
|
||||||
)}
|
</h1>
|
||||||
|
<p className="text-slate-500 text-sm font-medium max-w-xl leading-relaxed">
|
||||||
|
Dispatch multi-channel broadcasts and monitor real-time network
|
||||||
|
telemetry across the Yaltopia mesh.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
|
||||||
{unreadCount !== undefined && unreadCount > 0 && (
|
<div className="flex items-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
className="h-12 px-6 rounded-2xl text-slate-400 hover:text-slate-900 hover:bg-slate-100 font-black uppercase text-[10px] tracking-widest transition-all"
|
||||||
onClick={handleMarkAllAsRead}
|
onClick={() => notificationService.markAllAsRead()}
|
||||||
className="border-slate-200 text-[10px] font-bold uppercase tracking-widest h-9"
|
>
|
||||||
>
|
<CheckCheck className="w-4 h-4 mr-2" />
|
||||||
<CheckCheck className="w-4 h-4 mr-2" />
|
Clear Signal
|
||||||
Mark All as Read
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
)}
|
className="h-12 px-8 rounded-2xl bg-slate-900 hover:bg-slate-800 text-white font-black uppercase text-xs tracking-[0.1em] shadow-xl shadow-slate-200 transition-all hover:-translate-y-0.5"
|
||||||
|
onClick={() => setIsSendModalOpen(true)}
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4 mr-2" />
|
||||||
|
New Broadcast
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border-slate-200/60 shadow-sm rounded-none">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
<CardHeader className="pb-3 border-b border-slate-100 bg-slate-50/30">
|
{/* Stats / Quick Info */}
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div className="lg:col-span-1 space-y-6">
|
||||||
<div className="flex flex-1 items-center gap-4">
|
<Card className="border-none shadow-[0_8px_30px_rgb(0,0,0,0.04)] bg-slate-900 text-white rounded-3xl overflow-hidden p-8">
|
||||||
<div className="relative flex-1 max-w-sm">
|
<div className="space-y-6">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<div className="flex items-center justify-between">
|
||||||
<Input
|
<span className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40">
|
||||||
placeholder="Filter notifications..."
|
System Status
|
||||||
className="pl-9 h-10 border-slate-200/80 focus-visible:ring-slate-900 rounded-none shadow-none"
|
</span>
|
||||||
value={searchQuery}
|
<Badge className="bg-emerald-500 text-white border-none text-[10px] rounded-full px-2 py-0">
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
Active
|
||||||
/>
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-5xl font-black italic tracking-tighter leading-none">
|
||||||
|
{unreadCount ?? 0}
|
||||||
|
</span>
|
||||||
|
<p className="text-slate-400 text-xs font-medium mt-2">
|
||||||
|
Active notifications in current window.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4 border-t border-white/10 pt-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-black tracking-tight">
|
||||||
|
{notifications?.length ?? 0}
|
||||||
|
</div>
|
||||||
|
<div className="text-[8px] font-black uppercase tracking-widest opacity-40 mt-1 uppercase">
|
||||||
|
Push
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center border-x border-white/10 px-2">
|
||||||
|
<div className="text-lg font-black tracking-tight">—</div>
|
||||||
|
<div className="text-[8px] font-black uppercase tracking-widest opacity-40 mt-1 uppercase">
|
||||||
|
SMS
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-black tracking-tight">—</div>
|
||||||
|
<div className="text-[8px] font-black uppercase tracking-widest opacity-40 mt-1 uppercase">
|
||||||
|
Mail
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-none shadow-[0_8px_30px_rgb(0,0,0,0.04)] bg-white/50 backdrop-blur-xl rounded-3xl p-6">
|
||||||
|
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 mb-4 px-2">
|
||||||
|
Operator Directives
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
icon: Target,
|
||||||
|
label: "Audience Segmentation",
|
||||||
|
desc: "Filter by role",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Calendar,
|
||||||
|
label: "Scheduled Dispatch",
|
||||||
|
desc: "Queue for later",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: History,
|
||||||
|
label: "Audit Integrity",
|
||||||
|
desc: "Full log access",
|
||||||
|
},
|
||||||
|
].map((item, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="flex items-center gap-4 p-3 rounded-2xl hover:bg-slate-50 transition-colors group cursor-default"
|
||||||
|
>
|
||||||
|
<div className="p-2.5 bg-slate-100 rounded-xl group-hover:bg-primary/10 transition-colors">
|
||||||
|
<item.icon className="w-4 h-4 text-slate-400 group-hover:text-primary transition-colors" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-black text-slate-900 tracking-tight">
|
||||||
|
{item.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-slate-400 font-medium">
|
||||||
|
{item.desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* History Feed */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<Card className="border-none shadow-[0_8px_30px_rgb(0,0,0,0.04)] bg-white/50 backdrop-blur-xl rounded-3xl overflow-hidden">
|
||||||
|
<CardHeader className="p-8 border-b border-slate-100/50 space-y-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6 uppercase">
|
||||||
|
<h2 className="text-xs font-black tracking-[0.2em] text-slate-400">
|
||||||
|
Transmission Log
|
||||||
|
</h2>
|
||||||
|
<div className="relative group min-w-[280px]">
|
||||||
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 group-focus-within:text-primary transition-colors" />
|
||||||
|
<Input
|
||||||
|
className="pl-11 h-11 bg-slate-50 border-slate-200/60 rounded-xl text-sm focus:bg-white transition-all shadow-none placeholder:text-slate-400 font-medium"
|
||||||
|
placeholder="Search signal history..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto min-h-[400px]">
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{isLoading ? (
|
||||||
|
Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-24 bg-slate-100/50 animate-pulse rounded-2xl"
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : filteredNotifications.length ? (
|
||||||
|
filteredNotifications.map((n) => (
|
||||||
|
<div
|
||||||
|
key={n.id}
|
||||||
|
className="p-6 bg-white border border-slate-100 rounded-2xl hover:border-primary/20 hover:shadow-lg hover:shadow-primary/5 transition-all group relative overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="absolute top-0 right-0 w-1 p-1 h-full bg-slate-100 group-hover:bg-primary transition-colors" />
|
||||||
|
<div className="flex items-start justify-between gap-6">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="p-3 bg-slate-50 rounded-xl group-hover:bg-primary/5 transition-colors">
|
||||||
|
<BellRing className="w-5 h-5 text-slate-400 group-hover:text-primary transition-colors" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-black text-slate-900 tracking-tight">
|
||||||
|
{n.title}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[9px] font-black uppercase tracking-tighter opacity-50 px-1.5 py-0 border-slate-200"
|
||||||
|
>
|
||||||
|
Push
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 font-medium leading-relaxed max-w-lg">
|
||||||
|
{n.body}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-2 shrink-0">
|
||||||
|
<span className="text-[10px] font-bold text-slate-400">
|
||||||
|
{format(
|
||||||
|
new Date(n.createdAt),
|
||||||
|
"HH:mm · MMM d, yyyy",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
"text-[9px] font-black uppercase tracking-widest rounded-lg px-2 border-none",
|
||||||
|
n.isSent
|
||||||
|
? "bg-emerald-500 text-white"
|
||||||
|
: "bg-slate-200 text-slate-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{n.isSent ? "Delivered" : "Queued"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="py-24 text-center">
|
||||||
|
<div className="flex flex-col items-center justify-center space-y-4 opacity-20 grayscale">
|
||||||
|
<History className="w-16 h-16" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-black uppercase tracking-[0.2em]">
|
||||||
|
Zero Telemetry
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium italic mt-1">
|
||||||
|
No transmissions detected in the current signal
|
||||||
|
range.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dispatch Dialog */}
|
||||||
|
<Dialog open={isSendModalOpen} onOpenChange={setIsSendModalOpen}>
|
||||||
|
<DialogContent className="rounded-3xl max-w-2xl p-0 border-none shadow-2xl overflow-hidden">
|
||||||
|
<div className="p-8 bg-slate-900 text-white overflow-hidden relative">
|
||||||
|
{/* Decorative element */}
|
||||||
|
<div className="absolute -top-12 -right-12 w-48 h-48 bg-primary/20 rounded-full blur-3xl" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="p-2 bg-white/10 rounded-xl">
|
||||||
|
<Target className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-[0.2em] opacity-60 italic">
|
||||||
|
Signal Transmission
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<DialogTitle className="text-3xl font-black italic tracking-tighter uppercase leading-none">
|
||||||
|
Dispatch{" "}
|
||||||
|
<span className="text-primary NOT-italic">Broadcast</span>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-400 text-xs font-medium mt-2 leading-relaxed">
|
||||||
|
Authoritative platform-wide signal broadcast. Choose delivery
|
||||||
|
channels and construct the payload with precision.
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Channel Selector */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Label className="text-[10px] font-black uppercase text-slate-400 tracking-widest">
|
||||||
|
Select Uplink Channels
|
||||||
|
</Label>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{[
|
||||||
|
{ id: "PUSH", icon: BellRing, label: "Push Notification" },
|
||||||
|
{ id: "SMS", icon: MessageSquare, label: "SMS Gateway" },
|
||||||
|
{ id: "EMAIL", icon: Mail, label: "Email Relay" },
|
||||||
|
].map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveChannel(c.id as Channel)}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center justify-center gap-3 p-6 rounded-2xl border-2 transition-all group",
|
||||||
|
activeChannel === c.id
|
||||||
|
? "border-primary bg-primary/5 text-primary shadow-lg shadow-primary/10"
|
||||||
|
: "border-slate-100 bg-white text-slate-400 hover:border-slate-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<c.icon
|
||||||
|
className={cn(
|
||||||
|
"w-6 h-6 transition-transform group-active:scale-90",
|
||||||
|
activeChannel === c.id
|
||||||
|
? "text-primary"
|
||||||
|
: "text-slate-300",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest">
|
||||||
|
{c.id}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="bg-slate-100" />
|
||||||
|
|
||||||
|
{/* Dynamic Form Area */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{activeChannel === "PUSH" && (
|
||||||
|
<div className="space-y-4 animate-in slide-in-from-right-2 duration-300">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
||||||
|
Notification Title
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={pushForm.title}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPushForm({ ...pushForm, title: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Critical System Patch Available"
|
||||||
|
className="h-12 rounded-xl bg-slate-50 border-slate-200/60 font-bold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
||||||
|
Message Body
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
value={pushForm.body}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPushForm({ ...pushForm, body: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Update your client to version 4.2 now..."
|
||||||
|
className="min-h-[100px] rounded-xl bg-slate-50 border-slate-200/60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeChannel === "SMS" && (
|
||||||
|
<div className="space-y-4 animate-in slide-in-from-right-2 duration-300">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
||||||
|
Target Phone (Optional)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={smsForm.recipientPhone}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSmsForm({
|
||||||
|
...smsForm,
|
||||||
|
recipientPhone: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="+1 (555) 000-0000"
|
||||||
|
className="h-12 rounded-xl bg-slate-50 border-slate-200/60 font-bold"
|
||||||
|
/>
|
||||||
|
<p className="text-[9px] text-slate-400 font-medium italic">
|
||||||
|
Leave empty for multi-user broadcast.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
||||||
|
SMS Payload
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
value={smsForm.body}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSmsForm({ ...smsForm, body: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Your Yaltopia ticket code is XYZ-123..."
|
||||||
|
className="min-h-[100px] rounded-xl bg-slate-50 border-slate-200/60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeChannel === "EMAIL" && (
|
||||||
|
<div className="space-y-4 animate-in slide-in-from-right-2 duration-300">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
||||||
|
Email Subject
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={emailForm.subject}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEmailForm({
|
||||||
|
...emailForm,
|
||||||
|
subject: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="Important Account Update"
|
||||||
|
className="h-12 rounded-xl bg-slate-50 border-slate-200/60 font-bold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
||||||
|
HTML Content
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
value={emailForm.body}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEmailForm({ ...emailForm, body: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="<h1>Welcome to Yaltopia</h1>..."
|
||||||
|
className="min-h-[160px] rounded-xl bg-slate-50 border-slate-200/60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<select
|
|
||||||
className="h-10 px-3 bg-white border border-slate-200 text-xs font-bold uppercase tracking-widest rounded-none focus:outline-none focus:ring-1 focus:ring-slate-900"
|
|
||||||
value={typeFilter}
|
|
||||||
onChange={(e) => setTypeFilter(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">All Types</option>
|
|
||||||
<option value="system">System</option>
|
|
||||||
<option value="user">User</option>
|
|
||||||
<option value="alert">Alert</option>
|
|
||||||
<option value="invoice">Invoice</option>
|
|
||||||
<option value="payment">Payment</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
className="h-10 px-3 bg-white border border-slate-200 text-xs font-bold uppercase tracking-widest rounded-none focus:outline-none focus:ring-1 focus:ring-slate-900"
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">All Status</option>
|
|
||||||
<option value="read">Read</option>
|
|
||||||
<option value="unread">Unread</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
<DialogFooter className="p-8 pt-4 bg-slate-50 border-t border-slate-100 flex items-center justify-between">
|
||||||
{isLoading ? (
|
<Button
|
||||||
<div className="flex items-center justify-center py-24">
|
variant="ghost"
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-slate-300" />
|
className="h-12 px-6 rounded-xl font-black uppercase text-xs tracking-widest text-slate-400 hover:text-slate-900"
|
||||||
</div>
|
onClick={() => setIsSendModalOpen(false)}
|
||||||
) : filteredNotifications && filteredNotifications.length > 0 ? (
|
>
|
||||||
<Table>
|
Abort Mission
|
||||||
<TableHeader className="bg-slate-50/50">
|
</Button>
|
||||||
<TableRow className="hover:bg-transparent border-slate-100">
|
<Button
|
||||||
<TableHead className="w-[120px] text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6 py-4">
|
className="h-12 px-10 rounded-xl bg-slate-900 hover:bg-slate-800 text-white font-black uppercase text-xs tracking-[0.1em] shadow-lg shadow-slate-200 transition-all active:scale-95"
|
||||||
ID Reference
|
disabled={isPending}
|
||||||
</TableHead>
|
onClick={handleSend}
|
||||||
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
|
>
|
||||||
Message Intelligence
|
{isPending ? (
|
||||||
</TableHead>
|
<>
|
||||||
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
Classification
|
Broadcasting...
|
||||||
</TableHead>
|
</>
|
||||||
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
|
) : (
|
||||||
State
|
<>
|
||||||
</TableHead>
|
Commit {activeChannel} Signal
|
||||||
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
Timeline
|
</>
|
||||||
</TableHead>
|
)}
|
||||||
<TableHead className="text-right text-[10px] font-bold uppercase tracking-widest text-slate-500 px-10">
|
</Button>
|
||||||
Actions
|
</DialogFooter>
|
||||||
</TableHead>
|
</DialogContent>
|
||||||
</TableRow>
|
</Dialog>
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filteredNotifications.map((notification) => (
|
|
||||||
<TableRow
|
|
||||||
key={notification.id}
|
|
||||||
className={cn(
|
|
||||||
"group hover:bg-slate-50 transition-colors border-slate-100",
|
|
||||||
!notification.isRead && "bg-slate-50/20",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<TableCell className="px-6 py-4">
|
|
||||||
<code className="text-[10px] font-bold text-slate-400 font-mono tracking-tighter">
|
|
||||||
{notification.id.substring(0, 12)}
|
|
||||||
</code>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-6 py-4">
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-xs font-black tracking-tight uppercase",
|
|
||||||
notification.isRead
|
|
||||||
? "text-slate-500"
|
|
||||||
: "text-slate-900",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{notification.title}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-slate-500 line-clamp-1">
|
|
||||||
{notification.message}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-6">
|
|
||||||
<div className="flex items-center text-[10px] font-bold uppercase tracking-widest text-slate-600">
|
|
||||||
{getTypeIcon(notification.type)}
|
|
||||||
{notification.type}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-6">
|
|
||||||
{getStatusBadge(notification.isRead)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-6">
|
|
||||||
<div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-500">
|
|
||||||
<Calendar className="w-3.5 h-3.5 text-slate-300" />
|
|
||||||
{format(
|
|
||||||
new Date(notification.createdAt),
|
|
||||||
"MMM dd, HH:mm",
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right px-6">
|
|
||||||
{!notification.isRead && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-slate-400 hover:text-orange-500 transition-colors"
|
|
||||||
onClick={() => handleMarkAsRead(notification.id)}
|
|
||||||
>
|
|
||||||
<Eye className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-24 text-slate-400 font-bold uppercase tracking-widest text-[10px]">
|
|
||||||
{searchQuery || typeFilter || statusFilter
|
|
||||||
? "No matching telemetry records found"
|
|
||||||
: "No notification stream detected"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,11 +67,13 @@ export interface Proforma {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProformaRequestItem {
|
export interface ProformaRequestItem {
|
||||||
id: string;
|
id?: string;
|
||||||
itemName: string;
|
itemName: string;
|
||||||
|
itemDescription?: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
unitOfMeasure: string;
|
unitOfMeasure: string;
|
||||||
createdAt: string;
|
technicalSpecifications?: Record<string, any>;
|
||||||
|
createdAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProformaRequest {
|
export interface ProformaRequest {
|
||||||
|
|
@ -90,7 +92,11 @@ export interface ProformaRequest {
|
||||||
submissionDeadline: string;
|
submissionDeadline: string;
|
||||||
allowRevisions: boolean;
|
allowRevisions: boolean;
|
||||||
paymentTerms: string;
|
paymentTerms: string;
|
||||||
|
incoterms?: string;
|
||||||
taxIncluded: boolean;
|
taxIncluded: boolean;
|
||||||
|
discountStructure?: string;
|
||||||
|
validityPeriod?: number;
|
||||||
|
attachments?: { name: string; url: string }[];
|
||||||
items: ProformaRequestItem[];
|
items: ProformaRequestItem[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|
@ -144,6 +150,29 @@ class InvoiceService {
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new invoice
|
||||||
|
*/
|
||||||
|
async createInvoice(data: any): Promise<Invoice> {
|
||||||
|
const response = await apiClient.post<Invoice>("/invoices", data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing invoice
|
||||||
|
*/
|
||||||
|
async updateInvoice(id: string, data: any): Promise<Invoice> {
|
||||||
|
const response = await apiClient.put<Invoice>(`/invoices/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an invoice
|
||||||
|
*/
|
||||||
|
async deleteInvoice(id: string): Promise<void> {
|
||||||
|
await apiClient.delete(`/invoices/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all proforma invoices
|
* Get all proforma invoices
|
||||||
*/
|
*/
|
||||||
|
|
@ -159,6 +188,29 @@ class InvoiceService {
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new proforma invoice
|
||||||
|
*/
|
||||||
|
async createProforma(data: any): Promise<Proforma> {
|
||||||
|
const response = await apiClient.post<Proforma>("/proforma", data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing proforma invoice
|
||||||
|
*/
|
||||||
|
async updateProforma(id: string, data: any): Promise<Proforma> {
|
||||||
|
const response = await apiClient.put<Proforma>(`/proforma/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a proforma invoice
|
||||||
|
*/
|
||||||
|
async deleteProforma(id: string): Promise<void> {
|
||||||
|
await apiClient.delete(`/proforma/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all proforma requests (admin view)
|
* Get all proforma requests (admin view)
|
||||||
*/
|
*/
|
||||||
|
|
@ -173,6 +225,48 @@ class InvoiceService {
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get proforma request details (admin view)
|
||||||
|
*/
|
||||||
|
async getProformaRequestDetails(id: string): Promise<ProformaRequest> {
|
||||||
|
const response = await apiClient.get<ProformaRequest>(
|
||||||
|
`/admin/proforma-requests/${id}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Create a new proforma request
|
||||||
|
*/
|
||||||
|
async createProformaRequest(data: any): Promise<ProformaRequest> {
|
||||||
|
const response = await apiClient.post<ProformaRequest>(
|
||||||
|
"/proforma-requests",
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Update an existing proforma request
|
||||||
|
*/
|
||||||
|
async updateProformaRequest(id: string, data: any): Promise<ProformaRequest> {
|
||||||
|
const response = await apiClient.put<ProformaRequest>(
|
||||||
|
`/proforma-requests/${id}`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Close a proforma request
|
||||||
|
*/
|
||||||
|
async closeProformaRequest(id: string): Promise<void> {
|
||||||
|
await apiClient.post(`/proforma-requests/${id}/close`);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Cancel a proforma request
|
||||||
|
*/
|
||||||
|
async cancelProformaRequest(id: string): Promise<void> {
|
||||||
|
await apiClient.post(`/proforma-requests/${id}/cancel`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const invoiceService = new InvoiceService();
|
export const invoiceService = new InvoiceService();
|
||||||
|
|
|
||||||
|
|
@ -1,128 +1,175 @@
|
||||||
import apiClient from './api/client'
|
import apiClient from "./api/client";
|
||||||
|
|
||||||
export interface Notification {
|
export interface Notification {
|
||||||
id: string
|
id: string;
|
||||||
title: string
|
title: string;
|
||||||
message: string
|
body: string;
|
||||||
type: 'system' | 'user' | 'alert' | 'invoice' | 'payment'
|
icon?: string;
|
||||||
recipient: string
|
url?: string;
|
||||||
status: 'sent' | 'delivered' | 'read' | 'unread'
|
sentAt?: string;
|
||||||
isRead: boolean
|
scheduledFor?: string;
|
||||||
createdAt: string
|
isSent: boolean;
|
||||||
sentAt?: string
|
recipientId: string;
|
||||||
readAt?: string
|
data?: Record<string, any>;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationSettings {
|
export interface NotificationSettings {
|
||||||
emailNotifications: boolean
|
emailNotifications: boolean;
|
||||||
pushNotifications: boolean
|
pushNotifications: boolean;
|
||||||
invoiceReminders: boolean
|
invoiceReminders: boolean;
|
||||||
paymentAlerts: boolean
|
paymentAlerts: boolean;
|
||||||
systemUpdates: boolean
|
systemUpdates: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendPushNotificationRequest {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
icon?: string;
|
||||||
|
url?: string;
|
||||||
|
recipientId?: string;
|
||||||
|
scheduledFor?: string;
|
||||||
|
data?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendSmsNotificationRequest {
|
||||||
|
body: string;
|
||||||
|
recipientPhone?: string; // If null, system broadcast
|
||||||
|
scheduledFor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendEmailNotificationRequest {
|
||||||
|
subject: string;
|
||||||
|
body: string; // HTML or Plain text
|
||||||
|
recipientEmail?: string; // If null, system broadcast
|
||||||
|
scheduledFor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class NotificationService {
|
class NotificationService {
|
||||||
/**
|
/**
|
||||||
* Get all notifications for current user
|
* Get all notifications for current user (Paginated)
|
||||||
*/
|
*/
|
||||||
async getNotifications(params?: {
|
async getNotifications(params?: {
|
||||||
type?: string
|
page?: number;
|
||||||
status?: string
|
limit?: number;
|
||||||
search?: string
|
type?: string;
|
||||||
|
status?: string;
|
||||||
|
search?: string;
|
||||||
}): Promise<Notification[]> {
|
}): Promise<Notification[]> {
|
||||||
const response = await apiClient.get<Notification[]>('/notifications', {
|
const response = await apiClient.get<Notification[]>("/notifications", {
|
||||||
params,
|
params,
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get unread notification count
|
* Get unread notification count
|
||||||
*/
|
*/
|
||||||
async getUnreadCount(): Promise<number> {
|
async getUnreadCount(): Promise<number> {
|
||||||
const response = await apiClient.get<{ count: number }>('/notifications/unread-count')
|
const response = await apiClient.get<{ count: number }>(
|
||||||
return response.data.count
|
"/notifications/unread-count",
|
||||||
|
);
|
||||||
|
return response.data.count;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark notification as read
|
* Mark notification as read
|
||||||
*/
|
*/
|
||||||
async markAsRead(id: string): Promise<void> {
|
async markAsRead(id: string): Promise<void> {
|
||||||
await apiClient.post(`/notifications/${id}/read`)
|
await apiClient.post(`/notifications/${id}/read`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark all notifications as read
|
* Mark all notifications as read
|
||||||
*/
|
*/
|
||||||
async markAllAsRead(): Promise<void> {
|
async markAllAsRead(): Promise<void> {
|
||||||
await apiClient.post('/notifications/read-all')
|
await apiClient.post("/notifications/read-all");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Broadcast push, SMS, and/or email (Super Admin & Admin only)
|
* Send push notification (ADMIN only)
|
||||||
*/
|
*/
|
||||||
async sendBroadcast(data: {
|
async sendPushNotification(
|
||||||
title: string
|
data: SendPushNotificationRequest,
|
||||||
message: string
|
): Promise<Notification> {
|
||||||
channels: ('push' | 'sms' | 'email')[]
|
const response = await apiClient.post<Notification>(
|
||||||
audience?: 'all_end_users' | 'system_users_only' | 'everyone_with_access'
|
"/admin/notifications/send-push",
|
||||||
}): Promise<{ id: string }> {
|
|
||||||
const response = await apiClient.post<{ id: string }>(
|
|
||||||
'/admin/notifications/broadcast',
|
|
||||||
data,
|
data,
|
||||||
)
|
);
|
||||||
return response.data
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send notification (ADMIN only)
|
* Send SMS notification (ADMIN only)
|
||||||
*/
|
*/
|
||||||
async sendNotification(data: {
|
async sendSmsNotification(
|
||||||
title: string
|
data: SendSmsNotificationRequest,
|
||||||
message: string
|
): Promise<{ success: boolean; messageId: string }> {
|
||||||
type: string
|
const response = await apiClient.post<{
|
||||||
recipient?: string
|
success: boolean;
|
||||||
recipientType?: 'user' | 'all'
|
messageId: string;
|
||||||
}): Promise<Notification> {
|
}>("/admin/notifications/send-sms", data);
|
||||||
const response = await apiClient.post<Notification>('/notifications/send', data)
|
return response.data;
|
||||||
return response.data
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send Email notification (ADMIN only)
|
||||||
|
*/
|
||||||
|
async sendEmailNotification(
|
||||||
|
data: SendEmailNotificationRequest,
|
||||||
|
): Promise<{ success: boolean; messageId: string }> {
|
||||||
|
const response = await apiClient.post<{
|
||||||
|
success: boolean;
|
||||||
|
messageId: string;
|
||||||
|
}>("/admin/notifications/send-email", data);
|
||||||
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe to push notifications
|
* Subscribe to push notifications
|
||||||
*/
|
*/
|
||||||
async subscribeToPush(subscription: PushSubscription): Promise<void> {
|
async subscribeToPush(subscription: PushSubscription): Promise<void> {
|
||||||
await apiClient.post('/notifications/subscribe', subscription)
|
await apiClient.post("/notifications/subscribe", subscription);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unsubscribe from push notifications
|
* Unsubscribe from push notifications
|
||||||
*/
|
*/
|
||||||
async unsubscribeFromPush(endpoint: string): Promise<void> {
|
async unsubscribeFromPush(endpoint: string): Promise<void> {
|
||||||
await apiClient.delete(`/notifications/unsubscribe/${encodeURIComponent(endpoint)}`)
|
await apiClient.delete(
|
||||||
|
`/notifications/unsubscribe/${encodeURIComponent(endpoint)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get notification settings
|
* Get notification settings
|
||||||
*/
|
*/
|
||||||
async getSettings(): Promise<NotificationSettings> {
|
async getSettings(): Promise<NotificationSettings> {
|
||||||
const response = await apiClient.get<NotificationSettings>('/notifications/settings')
|
const response = await apiClient.get<NotificationSettings>(
|
||||||
return response.data
|
"/notifications/settings",
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update notification settings
|
* Update notification settings
|
||||||
*/
|
*/
|
||||||
async updateSettings(settings: Partial<NotificationSettings>): Promise<NotificationSettings> {
|
async updateSettings(
|
||||||
const response = await apiClient.put<NotificationSettings>('/notifications/settings', settings)
|
settings: Partial<NotificationSettings>,
|
||||||
return response.data
|
): Promise<NotificationSettings> {
|
||||||
|
const response = await apiClient.put<NotificationSettings>(
|
||||||
|
"/notifications/settings",
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send invoice reminder
|
* Send invoice reminder
|
||||||
*/
|
*/
|
||||||
async sendInvoiceReminder(invoiceId: string): Promise<void> {
|
async sendInvoiceReminder(invoiceId: string): Promise<void> {
|
||||||
await apiClient.post(`/notifications/invoice/${invoiceId}/reminder`)
|
await apiClient.post(`/notifications/invoice/${invoiceId}/reminder`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -130,20 +177,21 @@ class NotificationService {
|
||||||
*/
|
*/
|
||||||
async exportNotifications(notifications: Notification[]): Promise<Blob> {
|
async exportNotifications(notifications: Notification[]): Promise<Blob> {
|
||||||
const csvContent = [
|
const csvContent = [
|
||||||
['ID', 'Title', 'Message', 'Type', 'Status', 'Created Date', 'Read Date'],
|
["ID", "Title", "Body", "Status", "Created Date", "Sent Date"],
|
||||||
...notifications.map(n => [
|
...notifications.map((n) => [
|
||||||
n.id,
|
n.id,
|
||||||
n.title,
|
n.title,
|
||||||
n.message,
|
n.body,
|
||||||
n.type,
|
n.isSent ? "Sent" : "Scheduled",
|
||||||
n.status,
|
|
||||||
n.createdAt,
|
n.createdAt,
|
||||||
n.readAt || '-'
|
n.sentAt || "-",
|
||||||
])
|
]),
|
||||||
].map(row => row.join(',')).join('\n')
|
]
|
||||||
|
.map((row) => row.join(","))
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
return new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
return new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const notificationService = new NotificationService()
|
export const notificationService = new NotificationService();
|
||||||
|
|
|
||||||
|
|
@ -40,11 +40,17 @@ export interface PaymentRequest {
|
||||||
copiedAccountCount: number;
|
copiedAccountCount: number;
|
||||||
status: "DRAFT" | "SENT" | "OPENED" | "PAID" | "EXPIRED" | "CANCELLED";
|
status: "DRAFT" | "SENT" | "OPENED" | "PAID" | "EXPIRED" | "CANCELLED";
|
||||||
paymentId: string;
|
paymentId: string;
|
||||||
accounts: any[];
|
accounts: {
|
||||||
|
bankName: string;
|
||||||
|
accountName: string;
|
||||||
|
accountNumber: string;
|
||||||
|
currency: string;
|
||||||
|
}[];
|
||||||
pdfPath: string;
|
pdfPath: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
customerId?: string;
|
||||||
items: {
|
items: {
|
||||||
id: string;
|
id?: string;
|
||||||
description: string;
|
description: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
|
|
@ -99,6 +105,29 @@ class PaymentService {
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new payment
|
||||||
|
*/
|
||||||
|
async createPayment(data: any): Promise<Payment> {
|
||||||
|
const response = await apiClient.post<Payment>("/payments", data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing payment
|
||||||
|
*/
|
||||||
|
async updatePayment(id: string, data: any): Promise<Payment> {
|
||||||
|
const response = await apiClient.put<Payment>(`/payments/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a payment
|
||||||
|
*/
|
||||||
|
async deletePayment(id: string): Promise<void> {
|
||||||
|
await apiClient.delete(`/payments/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get payment requests
|
* Get payment requests
|
||||||
*/
|
*/
|
||||||
|
|
@ -113,6 +142,35 @@ class PaymentService {
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new payment request
|
||||||
|
*/
|
||||||
|
async createPaymentRequest(data: any): Promise<PaymentRequest> {
|
||||||
|
const response = await apiClient.post<PaymentRequest>(
|
||||||
|
"/payment-requests",
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing payment request
|
||||||
|
*/
|
||||||
|
async updatePaymentRequest(id: string, data: any): Promise<PaymentRequest> {
|
||||||
|
const response = await apiClient.put<PaymentRequest>(
|
||||||
|
`/payment-requests/${id}`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a payment request
|
||||||
|
*/
|
||||||
|
async deletePaymentRequest(id: string): Promise<void> {
|
||||||
|
await apiClient.delete(`/payment-requests/${id}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const paymentService = new PaymentService();
|
export const paymentService = new PaymentService();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user