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

View File

@ -0,0 +1,77 @@
{
"subscriptions": {
"transactions": [
{
"id": "sub_tx_001",
"userId": "u_123",
"userName": "John Doe",
"planName": "Professional",
"amount": 29.99,
"currency": "USD",
"status": "SUCCESS",
"paymentMethod": "Credit Card (**** 4242)",
"transactionDate": "2024-04-15T10:00:00Z",
"failureReason": null
},
{
"id": "sub_tx_002",
"userId": "u_456",
"userName": "Jane Smith",
"planName": "Enterprise",
"amount": 199.99,
"currency": "USD",
"status": "FAILED",
"paymentMethod": "PayPal",
"transactionDate": "2024-04-16T14:20:00Z",
"failureReason": "Insufficient funds"
}
]
},
"issues": [
{
"id": "iss_501",
"reporterId": "u_789",
"reporterName": "Bob Miller",
"type": "BUG",
"priority": "HIGH",
"title": "Cannot upload attachments in Proforma Requests",
"description": "Getting a 500 error when trying to upload PDF files larger than 2MB.",
"status": "OPEN",
"assignedTo": "admin_001",
"createdAt": "2024-04-16T08:30:00Z"
}
],
"faq": [
{
"id": "faq_001",
"question": "How do I reset my password?",
"answer": "Go to settings > security and click on 'Reset Password'.",
"category": "ACCOUNT",
"isPublished": true,
"order": 1
}
],
"support": {
"tickets": [
{
"id": "sup_101",
"requesterEmail": "help@client.com",
"subject": "Missing Invoice #INV-102",
"message": "I paid for my subscription but didn't receive the invoice.",
"status": "PENDING",
"createdAt": "2024-04-16T11:00:00Z"
}
]
},
"notifications": {
"sms": {
"to": "+1234567890",
"content": "Your payment was successful."
},
"email": {
"to": "user@example.com",
"subject": "Payment Confirmation",
"body": "Your payment of $29.99 has been processed."
}
}
}

10
package-lock.json generated
View File

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

View File

@ -38,6 +38,7 @@
"lucide-react": "^0.561.0", "lucide-react": "^0.561.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-is": "^19.2.5",
"react-router-dom": "^7.11.0", "react-router-dom": "^7.11.0",
"recharts": "^3.6.0", "recharts": "^3.6.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",

View File

@ -0,0 +1,23 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = "Textarea";
export { Textarea };

View File

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

View File

@ -1,4 +1,4 @@
import { useState, type ComponentType } from "react"; import React, { useState, type ComponentType, useEffect } from "react";
import { Outlet, Link, useLocation, useNavigate } from "react-router-dom"; import { 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: [
{
label: "Invoices",
path: "/admin/invoices",
icon: Receipt,
visible: (role) => getPermissions(role).canViewBusinessData,
}, },
{ icon: CreditCard, label: "Payments", path: "/admin/payments" },
{ {
icon: FileClock, label: "Proforma",
label: "Payment Requests", icon: FileSearch,
path: "/admin/payment-requests", children: [
{ label: "Records", path: "/admin/proforma" },
{ label: "Requests", path: "/admin/proforma-requests" },
],
visible: (role) => getPermissions(role).canViewBusinessData,
},
{
label: "Payments",
icon: CreditCard,
children: [
{ label: "Records", path: "/admin/payments" },
{ label: "Requests", path: "/admin/payment-requests" },
],
visible: (role) => getPermissions(role).canViewBusinessData,
},
],
visible: (role) => getPermissions(role).canViewBusinessData,
}, },
{ {
icon: ArrowRightLeft, icon: ArrowRightLeft,
label: "Subscription transactions", label: "Subscription transactions",
path: "/admin/transactions/subscriptions", path: "/admin/transactions/subscriptions",
}, },
{ icon: Users, label: "Users", path: "/admin/users" }, {
icon: Users,
label: "Users",
path: "/admin/users",
visible: (role) => getPermissions(role).canViewUsers,
},
{ {
icon: UserCog, icon: UserCog,
label: "System users", label: "System users",
path: "/admin/system-members", path: "/admin/system-members",
visible: (role) => role === "SUPER_ADMIN" || role === "ADMIN", visible: (role) => getPermissions(role).canManageSystem,
},
{
icon: FileText,
label: "Logs",
path: "/admin/logs",
visible: (role) => getPermissions(role).canViewSystemData,
}, },
{ icon: FileText, label: "Logs", path: "/admin/logs" },
{ icon: LifeBuoy, label: "Issues", path: "/admin/issues" }, { icon: LifeBuoy, label: "Issues", path: "/admin/issues" },
{ icon: HelpCircle, label: "FAQ & support", path: "/admin/support/faq" }, { icon: HelpCircle, label: "FAQ & support", path: "/admin/support/faq" },
{ icon: Settings, label: "Settings", path: "/admin/settings" }, {
{ icon: Wrench, label: "Maintenance", path: "/admin/maintenance" }, icon: Settings,
label: "Settings",
path: "/admin/settings",
visible: (role) => getPermissions(role).canManageSystem,
},
{
icon: Wrench,
label: "Maintenance",
path: "/admin/maintenance",
visible: (role) => getPermissions(role).canManageSystem,
},
{ icon: Megaphone, label: "Announcements", path: "/admin/announcements" }, { icon: Megaphone, label: "Announcements", path: "/admin/announcements" },
{ icon: Activity, label: "Audit", path: "/admin/audit" }, {
{ icon: Shield, label: "Security", path: "/admin/security" }, icon: Activity,
{ icon: BarChart3, label: "Analytics", path: "/admin/analytics" }, label: "Audit",
{ icon: Heart, label: "System Health", path: "/admin/health" }, path: "/admin/audit",
visible: (role) => getPermissions(role).canManageSystem,
},
{
icon: Shield,
label: "Security",
path: "/admin/security",
visible: (role) => getPermissions(role).canManageSystem,
},
{
icon: BarChart3,
label: "Analytics",
path: "/admin/analytics",
visible: (role) => getPermissions(role).canManageSystem,
},
{
icon: Heart,
label: "System Health",
path: "/admin/health",
visible: (role) => getPermissions(role).canManageSystem,
},
{ {
icon: Send, icon: Send,
label: "Send notification", label: "Send notification",
path: "/admin/notifications/broadcast", path: "/admin/notifications/broadcast",
visible: (role) => role === "SUPER_ADMIN" || role === "ADMIN", visible: (role) => getPermissions(role).canSendNotifications,
}, },
]; ];
const SidebarNavItem = ({
item,
depth = 0,
isActive,
}: {
item: NavItem;
depth?: number;
isActive: (path?: string) => boolean;
}) => {
const hasChildren = item.children && item.children.length > 0;
const isCurrentlyActive =
isActive(item.path) ||
(hasChildren && item.children?.some((child) => isActive(child.path)));
const [isOpen, setIsOpen] = useState(isCurrentlyActive);
// Keep open if it becomes active from external navigation (e.g. breadcrumbs or search)
useEffect(() => {
if (isCurrentlyActive) {
setIsOpen(true);
}
}, [isCurrentlyActive]);
const Icon = item.icon;
if (hasChildren) {
return (
<div className="space-y-1">
<button
onClick={() => setIsOpen(!isOpen)}
className={cn(
"w-full flex items-center justify-between px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-accent hover:text-foreground",
isCurrentlyActive
? "text-primary font-semibold"
: "text-foreground/70",
)}
style={{ paddingLeft: `${depth * 1 + 0.75}rem` }}
>
<div className="flex items-center gap-3">
{Icon && <Icon className="w-5 h-5" />}
{item.label}
</div>
{isOpen ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
{isOpen && (
<div className="space-y-1 ml-4 border-l border-slate-200">
{item.children?.map((child) => (
<SidebarNavItem
key={child.label}
item={child}
depth={depth + 1}
isActive={isActive}
/>
))}
</div>
)}
</div>
);
}
return (
<Link
to={item.path || "#"}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors",
isActive(item.path)
? "bg-primary text-primary-foreground shadow-sm"
: "text-foreground/70 hover:bg-accent hover:text-foreground",
)}
style={{ paddingLeft: `${depth * 1 + 0.75}rem` }}
>
{Icon && <Icon className="w-5 h-5" />}
{item.label}
</Link>
);
};
export function AppShell() { export function AppShell() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@ -117,12 +258,11 @@ export function AppShell() {
return null; return null;
}); });
const isActive = (path: string) => { const isActive = (path?: string) => {
if (!path) return false;
return location.pathname.startsWith(path); return location.pathname.startsWith(path);
}; };
// Removed unused getPageTitle as header title is no longer displayed
const handleLogout = async () => { const handleLogout = async () => {
await authService.logout(); await authService.logout();
navigate("/login", { replace: true }); navigate("/login", { replace: true });
@ -170,27 +310,14 @@ export function AppShell() {
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 px-4 py-2 space-y-1 overflow-y-auto"> <nav className="flex-1 px-4 py-2 space-y-1 overflow-y-auto">
{adminNavigationItems {adminNavigationItems
.filter((item) => .filter((item) => (item.visible ? item.visible(user?.role) : true))
item.visible ? item.visible(user?.role) : true, .map((item) => (
) <SidebarNavItem
.map((item) => { key={item.label}
const Icon = item.icon; item={item}
return ( isActive={isActive}
<Link />
key={item.path} ))}
to={item.path}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors",
isActive(item.path)
? "bg-primary text-primary-foreground"
: "text-foreground/70 hover:bg-accent hover:text-foreground",
)}
>
<Icon className="w-5 h-5" />
{item.label}
</Link>
);
})}
</nav> </nav>
{/* User Section */} {/* User Section */}

View File

@ -6,50 +6,110 @@ export const AdminRole = {
SUPER_ADMIN: "SUPER_ADMIN", SUPER_ADMIN: "SUPER_ADMIN",
ADMIN: "ADMIN", ADMIN: "ADMIN",
CUSTOMER_SUPPORT: "CUSTOMER_SUPPORT", CUSTOMER_SUPPORT: "CUSTOMER_SUPPORT",
} as const } as const;
export type AdminRoleValue = (typeof AdminRole)[keyof typeof AdminRole] export type AdminRoleValue = (typeof AdminRole)[keyof typeof AdminRole];
export interface RolePermissions {
canManageSystem: boolean; // Settings, Maintenance, Health, Security
canViewSystemData: boolean; // Audit logs, etc.
// App Users
canViewUsers: boolean;
canCreateUsers: boolean;
canEditUsers: boolean;
canDeleteUsers: boolean;
// Business Data (Invoices, Proforma, Payments)
canViewBusinessData: boolean;
canCreateBusinessData: boolean;
canEditBusinessData: boolean;
canDeleteBusinessData: boolean;
// Notifications
canViewNotifications: boolean;
canSendNotifications: boolean;
}
const PERMISSIONS: Record<AdminRoleValue, RolePermissions> = {
[AdminRole.SUPER_ADMIN]: {
canManageSystem: true,
canViewSystemData: true,
canViewUsers: true,
canCreateUsers: true,
canEditUsers: true,
canDeleteUsers: true,
canViewBusinessData: true,
canCreateBusinessData: true,
canEditBusinessData: true,
canDeleteBusinessData: true,
canViewNotifications: true,
canSendNotifications: true,
},
[AdminRole.ADMIN]: {
canManageSystem: false,
canViewSystemData: true,
canViewUsers: true,
canCreateUsers: false,
canEditUsers: true,
canDeleteUsers: false,
canViewBusinessData: true,
canCreateBusinessData: false,
canEditBusinessData: true,
canDeleteBusinessData: false,
canViewNotifications: true,
canSendNotifications: true,
},
[AdminRole.CUSTOMER_SUPPORT]: {
canManageSystem: false,
canViewSystemData: true,
canViewUsers: true,
canCreateUsers: true,
canEditUsers: true,
canDeleteUsers: true,
canViewBusinessData: true,
canCreateBusinessData: false,
canEditBusinessData: false,
canDeleteBusinessData: false,
canViewNotifications: true,
canSendNotifications: false,
},
};
export function getPermissions(role: string | undefined): RolePermissions {
const r = (role as AdminRoleValue) || AdminRole.CUSTOMER_SUPPORT;
return PERMISSIONS[r] || PERMISSIONS[AdminRole.CUSTOMER_SUPPORT];
}
const PANEL_ROLES = new Set<string>([ const PANEL_ROLES = new Set<string>([
AdminRole.SUPER_ADMIN, AdminRole.SUPER_ADMIN,
AdminRole.ADMIN, AdminRole.ADMIN,
AdminRole.CUSTOMER_SUPPORT, AdminRole.CUSTOMER_SUPPORT,
]) ]);
export function hasPanelAccess(role: string | undefined): boolean { export function hasPanelAccess(role: string | undefined): boolean {
if (!role) return false if (!role) return false;
return PANEL_ROLES.has(role) return PANEL_ROLES.has(role);
}
/** Full control (all menus + destructive actions) */
export function isSuperAdmin(role: string | undefined): boolean {
return role === AdminRole.SUPER_ADMIN
}
/** Can create/edit content (not read-only support) */
export function canEdit(role: string | undefined): boolean {
return role === AdminRole.SUPER_ADMIN || role === AdminRole.ADMIN
}
/** Internal system users / members management */
export function canAccessSystemMembers(role: string | undefined): boolean {
return role === AdminRole.SUPER_ADMIN || role === AdminRole.ADMIN
}
/** Push / SMS / email broadcast composer */
export function canSendBroadcast(role: string | undefined): boolean {
return role === AdminRole.SUPER_ADMIN || role === AdminRole.ADMIN
} }
export function roleLabel(role: string | undefined): string { export function roleLabel(role: string | undefined): string {
switch (role) { switch (role) {
case AdminRole.SUPER_ADMIN: case AdminRole.SUPER_ADMIN:
return "System Admin" return "System Admin";
case AdminRole.ADMIN: case AdminRole.ADMIN:
return "Admin" return "Admin";
case AdminRole.CUSTOMER_SUPPORT: case AdminRole.CUSTOMER_SUPPORT:
return "Customer Support" return "Customer Support";
default: default:
return role ?? "User" return role ?? "User";
} }
} }
/** Legacy helpers maintained for compatibility but using new logic */
export function isSuperAdmin(role: string | undefined): boolean {
return role === AdminRole.SUPER_ADMIN;
}
export function canEdit(role: string | undefined): boolean {
return getPermissions(role).canEditBusinessData;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,51 +1,98 @@
import { useState } from "react" import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { NavLink } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label" import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge" import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogFooter, DialogFooter,
DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" DialogDescription,
} from "@/components/ui/dialog";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select";
import { Search, Plus } from "lucide-react" import {
import { issueService } from "@/services" Search,
import type { IssueStatus } from "@/services/issue.service" Plus,
import { useAdminRole } from "@/hooks/use-admin-role" LifeBuoy,
import { toast } from "sonner" AlertCircle,
Clock,
CheckCircle2,
User as UserIcon,
ChevronLeft,
ChevronRight,
ArrowRight,
} from "lucide-react";
import { issueService } from "@/services";
import type { IssueStatus } from "@/services/issue.service";
import { useAdminRole } from "@/hooks/use-admin-role";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
const badgeForStatus = (s: IssueStatus) => { const getPriorityColor = (p: string) => {
const map: Record<IssueStatus, string> = { switch (p) {
OPEN: "bg-orange-100 text-orange-900 border-orange-200", case "HIGH":
IN_PROGRESS: "bg-blue-100 text-blue-900 border-blue-200", return "text-rose-600 bg-rose-50 border-rose-100";
RESOLVED: "bg-emerald-100 text-emerald-900 border-emerald-200", case "MEDIUM":
CLOSED: "bg-gray-100 text-gray-700 border-gray-200", return "text-amber-600 bg-amber-50 border-amber-100";
default:
return "text-slate-600 bg-slate-50 border-slate-100";
} }
return map[s] ?? "" };
}
const getStatusConfig = (s: IssueStatus) => {
switch (s) {
case "OPEN":
return {
label: "Open Queue",
icon: AlertCircle,
color: "text-orange-600 bg-orange-50",
badge: "bg-orange-500",
};
case "IN_PROGRESS":
return {
label: "In Progress",
icon: Clock,
color: "text-blue-600 bg-blue-50",
badge: "bg-blue-500",
};
case "RESOLVED":
return {
label: "Resolved",
icon: CheckCircle2,
color: "text-emerald-600 bg-emerald-50",
badge: "bg-emerald-500",
};
default:
return {
label: "Archived",
icon: CheckCircle2,
color: "text-slate-500 bg-slate-50",
badge: "bg-slate-400",
};
}
};
export default function IssuesPage() { export default function IssuesPage() {
const { canEdit } = useAdminRole() const { canEditBusinessData: canEdit } = useAdminRole();
const queryClient = useQueryClient() const queryClient = useQueryClient();
const [page, setPage] = useState(1) const [page, setPage] = useState(1);
const [search, setSearch] = useState("") const [search, setSearch] = useState("");
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false);
const [newIssue, setNewIssue] = useState({ const [newIssue, setNewIssue] = useState({
title: "", title: "",
description: "", description: "",
priority: "MEDIUM" as "LOW" | "MEDIUM" | "HIGH", priority: "MEDIUM" as "LOW" | "MEDIUM" | "HIGH",
}) });
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["admin", "issues", page, search], queryKey: ["admin", "issues", page, search],
@ -55,141 +102,203 @@ 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
? "border-primary text-primary"
: "border-transparent text-slate-400 hover:text-slate-600",
)
}
>
FAQ repository
</NavLink>
<NavLink
to="/admin/issues"
className={({ isActive }) =>
cn(
"pb-4 text-xs font-black uppercase tracking-[0.2em] transition-all border-b-2",
isActive
? "border-primary text-primary"
: "border-transparent text-slate-400 hover:text-slate-600",
)
}
>
Support Queue
</NavLink>
</div>
<Card className=" border border-gray-100 rounded-[ 6px] overflow-hidden">
<CardHeader className="p-8 border-b border-slate-100/50 space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6 uppercase">
<h2 className="text-xs font-black tracking-[0.2em] text-slate-400">
Support Request Queue
</h2>
<div className="relative group min-w-[320px]">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 group-focus-within:text-primary transition-colors" />
<Input <Input
className="pl-10 h-9 rounded-none border-gray-200 text-xs" 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 title or email…" placeholder="Search ticket titles or reporter..."
value={search} value={search}
onChange={(e) => { onChange={(e) => {
setSearch(e.target.value) setSearch(e.target.value);
setPage(1) 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>
<td colSpan={4} className="px-8 py-6">
<div className="h-3 bg-slate-50 rounded-full w-32 ml-auto"></div>
</td> </td>
</tr> </tr>
))
) : data?.data?.length ? ( ) : data?.data?.length ? (
data.data.map((issue) => ( data.data.map((issue) => {
<tr key={issue.id} className="hover:bg-gray-50 align-top"> const status = getStatusConfig(issue.status);
<td className="px-6 py-4 text-sm font-semibold text-gray-900 max-w-xs"> 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} {issue.title}
<p className="text-[11px] text-gray-500 font-normal mt-1 line-clamp-2"> </span>
<p className="text-[11px] text-slate-500 font-medium line-clamp-2 mt-1 leading-relaxed">
{issue.description} {issue.description}
</p> </p>
</div>
</td> </td>
<td className="px-6 py-4 text-xs text-gray-600"> <td className="px-8 py-6">
<div>{issue.reporterEmail}</div> <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 <Badge
variant="outline" variant="outline"
className="mt-1 text-[10px] rounded-none" className="mt-1 text-[9px] font-black uppercase tracking-tighter rounded-md py-0 px-1.5 opacity-60"
> >
{issue.reporterType} {issue.reporterType}
</Badge> </Badge>
</div>
</div>
</td> </td>
<td className="px-6 py-4 text-xs text-gray-500"></td> <td className="px-8 py-6">
<td className="px-6 py-4 text-xs font-bold text-gray-700"> <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} {issue.priority}
</Badge>
</td> </td>
<td className="px-6 py-4 text-xs text-gray-600"> <td className="px-8 py-6">
{issue.updatedAt <div className="flex flex-col gap-1">
? new Date(issue.updatedAt).toLocaleString() <span className="text-[10px] font-black text-slate-400 uppercase tracking-tighter">
: new Date(issue.createdAt).toLocaleString()} Updated
</span>
<span className="text-xs font-semibold text-slate-700">
{new Date(
issue.updatedAt || issue.createdAt,
).toLocaleDateString()}
</span>
</div>
</td> </td>
<td className="px-6 py-4 text-right"> <td className="px-8 py-6 text-right">
{canEdit ? ( {canEdit ? (
<Select <Select
value={issue.status} value={issue.status}
@ -200,75 +309,182 @@ export default function IssuesPage() {
}) })
} }
> >
<SelectTrigger className="h-8 w-[140px] rounded-none text-xs ml-auto"> <SelectTrigger className="h-10 w-[160px] rounded-xl text-[10px] font-black uppercase tracking-widest ml-auto bg-white border-slate-200/60 shadow-sm focus:ring-1 focus:ring-primary transition-all">
<div className="flex items-center gap-2">
<div
className={cn(
"w-1.5 h-1.5 rounded-full",
status.badge,
)}
/>
<SelectValue /> <SelectValue />
</div>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="rounded-xl border-slate-100 shadow-xl">
<SelectItem value="OPEN">Open</SelectItem> <SelectItem
<SelectItem value="IN_PROGRESS"> value="OPEN"
In progress className="text-xs font-bold uppercase transition-colors data-[state=checked]:text-primary"
>
Open Queue
</SelectItem>
<SelectItem
value="IN_PROGRESS"
className="text-xs font-bold uppercase transition-colors data-[state=checked]:text-primary"
>
In Progress
</SelectItem>
<SelectItem
value="RESOLVED"
className="text-xs font-bold uppercase transition-colors data-[state=checked]:text-primary text-emerald-600"
>
Resolved
</SelectItem>
<SelectItem
value="CLOSED"
className="text-xs font-bold uppercase transition-colors data-[state=checked]:text-primary italic opacity-50"
>
Archived
</SelectItem> </SelectItem>
<SelectItem value="RESOLVED">Resolved</SelectItem>
<SelectItem value="CLOSED">Closed</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
) : ( ) : (
<Badge <div
variant="outline" className={cn(
className={`rounded-none text-[10px] ${badgeForStatus(issue.status)}`} "flex items-center gap-2 ml-auto w-fit px-3 py-1.5 rounded-xl border",
status.color,
"border-opacity-50",
)}
> >
{issue.status} <status.icon className="w-3.5 h-3.5" />
</Badge> <span className="text-[10px] font-black uppercase tracking-widest">
{status.label}
</span>
</div>
)} )}
</td> </td>
</tr> </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">
Report Anomaly
</span>
</div>
<DialogTitle className="text-3xl font-black italic tracking-tighter uppercase leading-none">
Capture <span className="text-primary NOT-italic">Issue</span>
</DialogTitle>
<DialogDescription className="text-slate-400 text-xs font-medium mt-2 leading-relaxed">
Document the system anomaly or customer friction point with
technical precision. Reports are immediately queued for triage.
</DialogDescription>
</div>
<div className="p-8 space-y-6">
<div className="space-y-4">
<div className="grid gap-2">
<Label
htmlFor="iss-title"
className="text-[10px] font-black uppercase text-slate-400 tracking-widest"
>
Descriptive Headline
</Label>
<Input <Input
id="iss-title" id="iss-title"
value={newIssue.title} value={newIssue.title}
onChange={(e) => onChange={(e) =>
setNewIssue((n) => ({ ...n, title: e.target.value })) setNewIssue((n) => ({ ...n, title: e.target.value }))
} }
className="rounded-none" className="h-12 rounded-xl bg-slate-50 border-slate-200/60 focus:bg-white transition-all font-bold text-sm"
placeholder="e.g. Authentication loop on mobile safari..."
/> />
</div> </div>
<div className="grid gap-1"> <div className="grid gap-2">
<Label htmlFor="iss-desc">Description</Label> <Label
htmlFor="iss-desc"
className="text-[10px] font-black uppercase text-slate-400 tracking-widest"
>
Technical Narrative
</Label>
<textarea <textarea
id="iss-desc" id="iss-desc"
value={newIssue.description} value={newIssue.description}
onChange={(e) => onChange={(e) =>
setNewIssue((n) => ({ ...n, description: e.target.value })) setNewIssue((n) => ({ ...n, description: e.target.value }))
} }
className="flex min-h-[100px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" className="min-h-[120px] rounded-xl bg-slate-50 border-slate-200/60 p-4 text-sm font-medium focus:bg-white focus:outline-none transition-all resize-none"
placeholder="Steps to reproduce, error IDs, and environment context..."
/> />
</div> </div>
<div className="grid gap-1"> <div className="grid gap-2">
<Label>Priority</Label> <Label className="text-[10px] font-black uppercase text-slate-400 tracking-widest">
Triage Priority
</Label>
<Select <Select
value={newIssue.priority} value={newIssue.priority}
onValueChange={(v) => onValueChange={(v) =>
@ -278,27 +494,44 @@ export default function IssuesPage() {
})) }))
} }
> >
<SelectTrigger className="rounded-none"> <SelectTrigger className="h-12 rounded-xl bg-slate-50 border-slate-200/60 font-black text-xs uppercase tracking-widest">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="rounded-xl">
<SelectItem value="LOW">Low</SelectItem> <SelectItem
<SelectItem value="MEDIUM">Medium</SelectItem> value="LOW"
<SelectItem value="HIGH">High</SelectItem> 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> </SelectContent>
</Select> </Select>
</div> </div>
</div> </div>
<DialogFooter> </div>
<DialogFooter className="p-8 pt-4 bg-slate-50 border-t border-slate-100 flex items-center justify-between">
<Button <Button
variant="outline" variant="ghost"
className="rounded-none" className="h-12 px-6 rounded-xl font-black uppercase text-xs tracking-widest text-slate-400 hover:text-slate-900"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
> >
Cancel Discard
</Button> </Button>
<Button <Button
className="rounded-none" className="h-12 px-10 rounded-xl bg-primary hover:bg-primary/90 text-primary-foreground font-black uppercase text-xs tracking-[0.1em] shadow-lg shadow-primary/20 transition-all active:scale-95"
disabled={ disabled={
createMutation.isPending || createMutation.isPending ||
!newIssue.title.trim() || !newIssue.title.trim() ||
@ -306,11 +539,12 @@ export default function IssuesPage() {
} }
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
> >
Submit {createMutation.isPending ? "Queuing..." : "Commit Report"}
<ArrowRight className="w-4 h-4 ml-2" />
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
) );
} }

View File

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

View File

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

View File

@ -1,46 +1,60 @@
import { useState } from "react" import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { NavLink } from "react-router-dom";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label" import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge" import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogFooter, DialogFooter,
DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" DialogDescription,
} from "@/components/ui/dialog";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select";
import { Search, Plus, Pencil } from "lucide-react" import {
import { faqService } from "@/services" Search,
import type { FaqAudience, FaqEntry } from "@/services/faq.service" Plus,
import { useAdminRole } from "@/hooks/use-admin-role" Pencil,
import { toast } from "sonner" HelpCircle,
Users,
ShieldCheck,
Globe,
ArrowRight,
Library,
Settings2,
ChevronRight,
} from "lucide-react";
import { faqService } from "@/services";
import type { FaqAudience, FaqEntry } from "@/services/faq.service";
import { useAdminRole } from "@/hooks/use-admin-role";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
export default function FaqSupportPage() { export default function FaqSupportPage() {
const { canEdit } = useAdminRole() const { canEditBusinessData: canEdit } = useAdminRole();
const queryClient = useQueryClient() const queryClient = useQueryClient();
const [tab, setTab] = useState<"browse" | "manage">("browse") const [tab, setTab] = useState<"browse" | "manage">("browse");
const [audienceFilter, setAudienceFilter] = useState<FaqAudience | "ALL">( const [audienceFilter, setAudienceFilter] = useState<FaqAudience | "ALL">(
"ALL", "ALL",
) );
const [search, setSearch] = useState("") const [search, setSearch] = useState("");
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<FaqEntry | null>(null) const [editing, setEditing] = useState<FaqEntry | null>(null);
const [form, setForm] = useState({ const [form, setForm] = useState({
question: "", question: "",
answer: "", answer: "",
audience: "ALL" as FaqAudience, audience: "ALL" as FaqAudience,
}) });
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["admin", "faq", search, audienceFilter], queryKey: ["admin", "faq", search, audienceFilter],
@ -48,10 +62,9 @@ export default function FaqSupportPage() {
faqService.list({ faqService.list({
limit: 100, limit: 100,
search: search.trim() || undefined, search: search.trim() || undefined,
audience: audience: audienceFilter === "ALL" ? undefined : audienceFilter,
audienceFilter === "ALL" ? undefined : audienceFilter,
}), }),
}) });
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
@ -60,193 +73,280 @@ export default function FaqSupportPage() {
question: form.question, question: form.question,
answer: form.answer, answer: form.answer,
audience: form.audience, audience: form.audience,
}) });
} }
return faqService.create({ return faqService.create({
question: form.question, question: form.question,
answer: form.answer, answer: form.answer,
audience: form.audience, audience: form.audience,
isPublished: true, isPublished: true,
}) });
}, },
onSuccess: () => { onSuccess: () => {
toast.success(editing ? "FAQ updated" : "FAQ published") toast.success(
queryClient.invalidateQueries({ queryKey: ["admin", "faq"] }) editing ? "FAQ entry updated" : "FAQ entry published successfully",
setOpen(false) );
setEditing(null) queryClient.invalidateQueries({ queryKey: ["admin", "faq"] });
setForm({ setOpen(false);
question: "", setEditing(null);
answer: "", setForm({ question: "", answer: "", audience: "ALL" });
audience: "ALL",
})
}, },
onError: () => toast.error("Save failed"), onError: () =>
}) toast.error("Failure while committing FAQ data to repository"),
});
const browseItems = const browseItems = data?.data?.filter((e) => e.isPublished !== false) ?? [];
data?.data?.filter((e) => e.isPublished !== false) ?? []
return ( return (
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen"> <div className="space-y-8 mx-auto max-w-7xl mt-10 animate-in fade-in duration-500">
<div> {/* Header Section */}
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-2">
<div className="space-y-1">
<h1 className="text-3xl font-bold text-gray-900 tracking-tight"> <h1 className="text-3xl font-bold text-gray-900 tracking-tight">
FAQ &amp; support FAQ & <span className="text-primary NOT-italic">Support</span>
</h1> </h1>
<p className="text-gray-500 mt-1 max-w-2xl"> <p className="text-slate-500 text-sm font-medium max-w-xl leading-relaxed">
Browse answers for end users and internal system users. Editors can Curate and manage the central intelligence repository for both
publish entries and control which audience sees each question. standard users and internal system administrators.
</p> </p>
</div> </div>
</div>
<div className="flex gap-8 border-b border-gray-100">
<NavLink
to="/admin/support/faq"
className={({ isActive }) =>
cn(
"pb-4 text-xs font-black uppercase tracking-[0.2em] transition-all border-b-2",
isActive
? "border-primary text-primary"
: "border-transparent text-slate-400 hover:text-slate-600",
)
}
>
FAQ repository
</NavLink>
<NavLink
to="/admin/issues"
className={({ isActive }) =>
cn(
"pb-4 text-xs font-black uppercase tracking-[0.2em] transition-all border-b-2",
isActive
? "border-primary text-primary"
: "border-transparent text-slate-400 hover:text-slate-600",
)
}
>
Support Queue
</NavLink>
</div>
<Tabs <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
{faq.audience === "ALL" className={cn(
? "Everyone" "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" : faq.audience === "END_USER"
? "End users" ? "bg-emerald-500 text-white"
: "System users"} : "bg-primary text-white",
)}
>
{faq.audience === "ALL"
? "Global"
: faq.audience === "END_USER"
? "Customer"
: "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">
<p className="text-slate-500 text-sm font-medium leading-relaxed line-clamp-3">
{faq.answer} {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}
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} {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>
</div>
<div className="p-8 space-y-6 max-h-[60vh] overflow-y-auto">
<div className="space-y-4">
<div className="grid gap-2">
<Label className="text-[10px] font-black uppercase text-slate-400 tracking-widest">
Visibility Tier
</Label>
<Select <Select
value={form.audience} value={form.audience}
onValueChange={(v) => onValueChange={(v) =>
setForm((f) => ({ ...f, audience: v as FaqAudience })) setForm((f) => ({ ...f, audience: v as FaqAudience }))
} }
> >
<SelectTrigger className="rounded-none"> <SelectTrigger className="h-12 rounded-xl bg-slate-50 border-slate-200/60 font-black text-[10px] uppercase tracking-widest">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="rounded-xl">
<SelectItem value="ALL">Everyone (users &amp; system)</SelectItem> <SelectItem
<SelectItem value="END_USER">Platform customers only</SelectItem> value="ALL"
<SelectItem value="SYSTEM_USER">Panel / support staff only</SelectItem> className="text-[10px] font-black uppercase"
>
Global Visibility
</SelectItem>
<SelectItem
value="END_USER"
className="text-[10px] font-black uppercase"
>
Platform Customers Only
</SelectItem>
<SelectItem
value="SYSTEM_USER"
className="text-[10px] font-black uppercase text-primary"
>
Internal Panel Staff Only
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="grid gap-1"> <div className="grid gap-2">
<Label htmlFor="faq-q">Question</Label> <Label
htmlFor="faq-q"
className="text-[10px] font-black uppercase text-slate-400 tracking-widest"
>
Article Title / Question
</Label>
<Input <Input
id="faq-q" id="faq-q"
value={form.question} value={form.question}
onChange={(e) => onChange={(e) =>
setForm((f) => ({ ...f, question: e.target.value })) setForm((f) => ({ ...f, question: e.target.value }))
} }
className="rounded-none" className="h-12 rounded-xl bg-slate-50 border-slate-200/60 focus:bg-white transition-all font-bold text-sm"
placeholder="e.g. How to manage multiple ledgers?"
/> />
</div> </div>
<div className="grid gap-1"> <div className="grid gap-2">
<Label htmlFor="faq-a">Answer</Label> <Label
htmlFor="faq-a"
className="text-[10px] font-black uppercase text-slate-400 tracking-widest"
>
Authoritative Answer
</Label>
<textarea <textarea
id="faq-a" id="faq-a"
value={form.answer} value={form.answer}
onChange={(e) => onChange={(e) =>
setForm((f) => ({ ...f, answer: e.target.value })) setForm((f) => ({ ...f, answer: e.target.value }))
} }
className="flex min-h-[140px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" className="min-h-[160px] rounded-xl bg-slate-50 border-slate-200/60 p-4 text-sm font-medium focus:bg-white focus:outline-none transition-all resize-none"
placeholder="Provide precise, actionable steps..."
/> />
</div> </div>
</div> </div>
<DialogFooter> </div>
<DialogFooter className="p-8 pt-4 bg-slate-50 border-t border-slate-100 flex items-center justify-between">
<Button <Button
variant="outline" variant="ghost"
className="rounded-none" className="h-12 px-6 rounded-xl font-black uppercase text-xs tracking-widest text-slate-400 hover:text-slate-900"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
> >
Cancel Discard
</Button> </Button>
<Button <Button
className="rounded-none" className="h-12 px-10 rounded-xl bg-primary hover:bg-primary/90 text-primary-foreground font-black uppercase text-xs tracking-[0.1em] shadow-lg shadow-primary/20 transition-all active:scale-95"
disabled={ disabled={
saveMutation.isPending || saveMutation.isPending ||
!form.question.trim() || !form.question.trim() ||
@ -326,11 +473,12 @@ export default function FaqSupportPage() {
} }
onClick={() => saveMutation.mutate()} onClick={() => saveMutation.mutate()}
> >
Save {saveMutation.isPending ? "Syncing..." : "Publish Intelligence"}
<ArrowRight className="w-4 h-4 ml-2" />
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
) );
} }

View File

@ -1,19 +1,31 @@
import { useState } from "react" import { useState } from "react";
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge" import { Input } from "@/components/ui/input";
import { Search, CheckCircle2, XCircle } from "lucide-react" import { Badge } from "@/components/ui/badge";
import { subscriptionTransactionService } from "@/services" import {
import type { SubscriptionPaymentStatus } from "@/services/subscription-transaction.service" Search,
CheckCircle2,
XCircle,
ArrowRightLeft,
ChevronLeft,
ChevronRight,
Filter,
CreditCard,
User as UserIcon,
} from "lucide-react";
import { subscriptionTransactionService } from "@/services";
import type { SubscriptionPaymentStatus } from "@/services/subscription-transaction.service";
import { cn } from "@/lib/utils";
export default function SubscriptionTransactionsPage() { export default function SubscriptionTransactionsPage() {
const [tab, setTab] = useState<"succeeded" | "failed">("succeeded") const [tab, setTab] = useState<"succeeded" | "failed">("succeeded");
const [page, setPage] = useState(1) const [page, setPage] = useState(1);
const [search, setSearch] = useState("") const [search, setSearch] = useState("");
const status: SubscriptionPaymentStatus = const status: SubscriptionPaymentStatus =
tab === "succeeded" ? "SUCCEEDED" : "FAILED" tab === "succeeded" ? "SUCCEEDED" : "FAILED";
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["admin", "subscription-transactions", status, page, search], queryKey: ["admin", "subscription-transactions", status, page, search],
@ -24,149 +36,214 @@ 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">
<div className="flex items-center gap-2 text-primary mb-1">
<div className="p-2 bg-primary/10 rounded-lg">
<ArrowRightLeft className="w-5 h-5" />
</div>
<span className="text-xs font-black uppercase tracking-widest opacity-70">
Infrastructure
</span>
</div>
<h1 className="text-4xl font-black tracking-tighter text-slate-900 uppercase italic">
Subscription{" "}
<span className="text-primary NOT-italic">Transactions</span>
</h1> </h1>
<p className="text-gray-500 mt-1"> <p className="text-slate-500 text-sm font-medium max-w-xl leading-relaxed">
Successful charges and failed attempts for platform subscriptions. Monitor real-time platform revenue streams and troubleshoot declined
payment attempts across all subscription tiers.
</p> </p>
</div> </div>
</div>
<div className="grid grid-cols-1 gap-8">
<Card className="border-none shadow-[0_8px_30px_rgb(0,0,0,0.04)] bg-white/50 backdrop-blur-xl rounded-3xl overflow-hidden">
<CardHeader className="p-8 border-b border-slate-100/50 space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6">
<Tabs <Tabs
value={tab} value={tab}
onValueChange={(v) => { onValueChange={(v) => {
setTab(v as "succeeded" | "failed") setTab(v as "succeeded" | "failed");
setPage(1) setPage(1);
}} }}
className="space-y-6" className="w-full sm:w-auto"
> >
<TabsList className="rounded-none bg-gray-100 p-1"> <TabsList className="bg-slate-100/80 p-1 rounded-xl h-11 border border-slate-200/50">
<TabsTrigger value="succeeded" className="rounded-none gap-2"> <TabsTrigger
<CheckCircle2 className="h-4 w-4 text-emerald-600" /> value="succeeded"
Successful 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"
>
<CheckCircle2 className="h-3.5 w-3.5" />
Succeeded
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="failed" className="rounded-none gap-2"> <TabsTrigger
<XCircle className="h-4 w-4 text-red-600" /> value="failed"
Failed payments 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> </TabsTrigger>
</TabsList> </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>
</div>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
{error && ( {error && (
<p className="p-6 text-sm text-red-600"> <div className="m-8 p-6 bg-rose-50 border border-rose-100 rounded-2xl flex items-center gap-4 text-rose-700">
Unable to load transactions. Ensure the API exposes{" "} <XCircle className="w-6 h-6 flex-shrink-0" />
<code className="text-xs bg-gray-100 px-1"> <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>
</div>
)} )}
<div className="overflow-x-auto">
<table className="w-full text-left"> <div className="overflow-x-auto min-h-[400px]">
<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">
User <th className="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] w-[28%]">
Subscriber 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]">
Plan Service Plan
</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]">
Amount Transaction Value
</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]">
Provider / Ref Financial Gateway
</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]">
Date Captured At
</th> </th>
{tab === "failed" && ( {tab === "failed" && (
<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]">
Reason Resolution Logic
</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 State
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y"> <tbody className="divide-y divide-slate-100">
{isLoading ? ( {isLoading ? (
<tr> Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="animate-pulse">
<td <td
colSpan={tab === "failed" ? 7 : 6} colSpan={tab === "failed" ? 7 : 6}
className="px-6 py-16 text-center text-gray-400 animate-pulse" className="px-8 py-6"
> >
Loading <div className="h-4 bg-slate-100 rounded-full w-3/4 mb-2"></div>
<div className="h-3 bg-slate-50 rounded-full w-1/2"></div>
</td> </td>
</tr> </tr>
))
) : data?.data && data.data.length > 0 ? ( ) : data?.data && data.data.length > 0 ? (
data.data.map((row) => ( data.data.map((row) => (
<tr key={row.id} className="hover:bg-gray-50"> <tr
<td className="px-6 py-4 text-sm"> key={row.id}
<div className="font-semibold text-gray-900"> 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} {row.userEmail}
</div> </span>
<div className="text-[11px] text-gray-500"> <span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
{row.userId} {row.userId}
</span>
</div>
</div> </div>
</td> </td>
<td className="px-6 py-4 text-sm text-gray-700"> <td className="px-8 py-6">
<div className="flex items-center gap-2">
<Badge
variant="secondary"
className="bg-slate-100 text-slate-700 hover:bg-slate-200 border-none rounded-lg px-2.5 py-0.5 text-[10px] font-black uppercase"
>
{row.planName} {row.planName}
</td> </Badge>
<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> </div>
)}
</td> </td>
<td className="px-6 py-4 text-sm text-gray-600"> <td className="px-8 py-6">
{new Date(row.createdAt).toLocaleString()} <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> </td>
{tab === "failed" && ( {tab === "failed" && (
<td className="px-6 py-4 text-xs text-red-700 max-w-[200px]"> <td className="px-8 py-6 max-w-[240px]">
{row.failureReason ?? "—"} <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>
)} )}
<td className="px-6 py-4 text-right"> <td className="px-8 py-6 text-right">
<Badge <Badge
variant="outline" className={cn(
className="rounded-none text-[10px] uppercase" "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} {row.status}
</Badge> </Badge>
@ -177,43 +254,62 @@ export default function SubscriptionTransactionsPage() {
<tr> <tr>
<td <td
colSpan={tab === "failed" ? 7 : 6} colSpan={tab === "failed" ? 7 : 6}
className="px-6 py-16 text-center text-gray-400 italic text-sm" className="px-6 py-24 text-center"
> >
No rows for this filter. <div className="flex flex-col items-center justify-center space-y-3 opacity-30 grayscale">
<Filter className="w-12 h-12" />
<div className="flex flex-col">
<span className="text-sm font-black uppercase tracking-widest">
No matching logs
</span>
<span className="text-xs font-medium">
Adjust your criteria or verify live stream.
</span>
</div>
</div>
</td> </td>
</tr> </tr>
)} )}
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Pagination Controls */}
{data && data.totalPages > 1 && ( {data && data.totalPages > 1 && (
<div className="flex justify-between items-center px-6 py-3 border-t text-xs text-gray-600"> <div className="p-8 border-t border-slate-100 flex items-center justify-between bg-slate-50/30">
<span> <p className="text-xs font-bold text-slate-400 uppercase tracking-widest">
Page {data.page} of {data.totalPages} Showing{" "}
</span> <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"> <div className="flex gap-2">
<button <Button
type="button" variant="ghost"
className="underline disabled:opacity-40" size="sm"
className="h-10 rounded-xl px-4 font-black uppercase text-[10px] tracking-widest hover:bg-white"
disabled={page <= 1} disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))} onClick={() => setPage((p) => Math.max(1, p - 1))}
> >
Previous <ChevronLeft className="w-4 h-4 mr-1" /> Prev
</button> </Button>
<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">
type="button" {data.page} / {data.totalPages}
className="underline disabled:opacity-40" </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} disabled={page >= data.totalPages}
onClick={() => setPage((p) => p + 1)} onClick={() => setPage((p) => p + 1)}
> >
Next Next <ChevronRight className="w-4 h-4 ml-1" />
</button> </Button>
</div> </div>
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</Tabs>
</div> </div>
) </div>
);
} }

View File

@ -1,9 +1,9 @@
import { useParams, useNavigate } from "react-router-dom" import { useParams, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -11,81 +11,91 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select";
import { ArrowLeft, Edit, Key, Loader2 } from "lucide-react" import { ArrowLeft, Edit, Key, Loader2 } from "lucide-react";
import { userService } from "@/services" import { userService } from "@/services";
import { format } from "date-fns" import { useAdminRole } from "@/hooks/use-admin-role";
import { useState } from "react" import { format } from "date-fns";
import { toast } from "sonner" import { useState } from "react";
import { toast } from "sonner";
export default function UserDetailsPage() { export default function UserDetailsPage() {
const { id } = useParams() const { id } = useParams();
const navigate = useNavigate() const navigate = useNavigate();
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) const { canEditUsers } = useAdminRole();
const [isSubmitting, setIsSubmitting] = useState(false) const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [editForm, setEditForm] = useState({ const [editForm, setEditForm] = useState({
firstName: '', firstName: "",
lastName: '', lastName: "",
email: '', email: "",
role: '', role: "",
isActive: true, isActive: true,
}) });
const { data: user, isLoading, refetch } = useQuery({ const {
queryKey: ['admin', 'users', id], data: user,
isLoading,
refetch,
} = useQuery({
queryKey: ["admin", "users", id],
queryFn: () => userService.getUser(id!), queryFn: () => userService.getUser(id!),
enabled: !!id, enabled: !!id,
}) });
const handleEditClick = () => { const handleEditClick = () => {
if (user) { if (user) {
setEditForm({ setEditForm({
firstName: user.firstName || '', firstName: user.firstName || "",
lastName: user.lastName || '', lastName: user.lastName || "",
email: user.email, email: user.email,
role: user.role, role: user.role,
isActive: user.isActive, isActive: user.isActive,
}) });
setIsEditDialogOpen(true) setIsEditDialogOpen(true);
}
} }
};
const handleSaveEdit = async () => { const handleSaveEdit = async () => {
try { try {
setIsSubmitting(true) setIsSubmitting(true);
await userService.updateUser(id!, editForm) await userService.updateUser(id!, editForm);
toast.success("User updated successfully") toast.success("User updated successfully");
setIsEditDialogOpen(false) setIsEditDialogOpen(false);
refetch() refetch();
} catch (error) { } catch (error) {
toast.error("Failed to update user") toast.error("Failed to update user");
console.error('Update error:', error) console.error("Update error:", error);
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false);
}
} }
};
if (isLoading) { if (isLoading) {
return <div className="text-center py-8">Loading user details...</div> return <div className="text-center py-8">Loading user details...</div>;
} }
if (!user) { if (!user) {
return <div className="text-center py-8">User not found</div> return <div className="text-center py-8">User not found</div>;
} }
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => navigate('/admin/users')}> <Button
variant="ghost"
size="icon"
onClick={() => navigate("/admin/users")}
>
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
</Button> </Button>
<h2 className="text-3xl font-bold">User Details</h2> <h2 className="text-3xl font-bold">User Details</h2>
@ -95,7 +105,10 @@ export default function UserDetailsPage() {
<TabsList> <TabsList>
<TabsTrigger value="info">Information</TabsTrigger> <TabsTrigger value="info">Information</TabsTrigger>
<TabsTrigger value="statistics">Statistics</TabsTrigger> <TabsTrigger value="statistics">Statistics</TabsTrigger>
<TabsTrigger value="activity" onClick={() => navigate(`/admin/users/${id}/activity`)}> <TabsTrigger
value="activity"
onClick={() => navigate(`/admin/users/${id}/activity`)}
>
Activity Activity
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@ -106,7 +119,13 @@ 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 && (
<>
<Button
variant="outline"
size="sm"
onClick={handleEditClick}
>
<Edit className="w-4 h-4 mr-2" /> <Edit className="w-4 h-4 mr-2" />
Edit Edit
</Button> </Button>
@ -114,6 +133,13 @@ export default function UserDetailsPage() {
<Key className="w-4 h-4 mr-2" /> <Key className="w-4 h-4 mr-2" />
Reset Password Reset Password
</Button> </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>
) );
} }

View File

@ -4,18 +4,27 @@ import { useNavigate } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import {
import { Search, Eye, ChevronLeft, ChevronRight, Filter } from "lucide-react"; Search,
Eye,
ChevronLeft,
ChevronRight,
Filter,
Plus,
} from "lucide-react";
import { userService } from "@/services"; import { userService } from "@/services";
import { useAdminRole } from "@/hooks/use-admin-role";
import { format } from "date-fns"; import { format } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toast } from "sonner";
export default function UsersPage() { export default function UsersPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { canCreateUsers } = useAdminRole();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [limit] = useState(15); const [limit] = useState(15);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [roleFilter, setRoleFilter] = useState<string>("all"); const [roleFilter] = useState<string>("all");
const { data: usersData, isLoading } = useQuery({ const { data: usersData, isLoading } = useQuery({
queryKey: ["admin", "users", page, limit, search, roleFilter], queryKey: ["admin", "users", page, limit, search, roleFilter],
@ -52,7 +61,17 @@ export default function UsersPage() {
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* View only access: Add User and Import buttons removed */} {canCreateUsers && (
<Button
className="h-9 rounded-none bg-slate-900 border-none uppercase text-[10px] font-bold tracking-widest px-6"
onClick={() =>
toast.info("User creation module is being synchronized.")
}
>
<Plus className="w-4 h-4 mr-2" />
Add User
</Button>
)}
</div> </div>
</div> </div>

View File

@ -1,41 +1,74 @@
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import { import {
Table, Dialog,
TableBody, DialogContent,
TableCell, DialogDescription,
TableHead, DialogFooter,
TableHeader, DialogHeader,
TableRow, DialogTitle,
} from "@/components/ui/table"; } from "@/components/ui/dialog";
import { import {
Search, Search,
Eye,
CheckCheck, CheckCheck,
Send,
Plus,
BellRing,
Mail,
MessageSquare,
History,
Target,
ArrowRight,
ChevronRight,
Loader2, Loader2,
Calendar, Calendar,
Mail,
Tag,
} from "lucide-react"; } from "lucide-react";
import { notificationService } from "@/services/notification.service"; import { notificationService } from "@/services/notification.service";
import type {
SendPushNotificationRequest,
SendSmsNotificationRequest,
SendEmailNotificationRequest,
} from "@/services/notification.service";
import { useAdminRole } from "@/hooks/use-admin-role";
import { toast } from "sonner"; import { toast } from "sonner";
import { format } from "date-fns"; import { format } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export default function NotificationsPage() { type Channel = "PUSH" | "SMS" | "EMAIL";
const [searchQuery, setSearchQuery] = useState("");
const [typeFilter, setTypeFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const { export default function NotificationsPage() {
data: notifications, const { canSendNotifications } = useAdminRole();
isLoading, const queryClient = useQueryClient();
refetch, const [searchQuery, setSearchQuery] = useState("");
} = useQuery({ const [activeChannel, setActiveChannel] = useState<Channel>("PUSH");
const [isSendModalOpen, setIsSendModalOpen] = useState(false);
// Combined form state
const [pushForm, setPushForm] = useState<SendPushNotificationRequest>({
title: "",
body: "",
recipientId: "",
url: "",
icon: "/assets/icon.png",
});
const [smsForm, setSmsForm] = useState<SendSmsNotificationRequest>({
body: "",
recipientPhone: "",
});
const [emailForm, setEmailForm] = useState<SendEmailNotificationRequest>({
subject: "",
body: "",
recipientEmail: "",
});
const { data: notifications, isLoading } = useQuery({
queryKey: ["notifications"], queryKey: ["notifications"],
queryFn: () => notificationService.getNotifications(), queryFn: () => notificationService.getNotifications(),
}); });
@ -45,257 +78,490 @@ export default function NotificationsPage() {
queryFn: () => notificationService.getUnreadCount(), queryFn: () => notificationService.getUnreadCount(),
}); });
// Client-side filtering const pushMutation = useMutation({
mutationFn: (data: SendPushNotificationRequest) =>
notificationService.sendPushNotification(data),
onSuccess: () => {
toast.success("Network transmission: Push packet delivered to gateway");
setIsSendModalOpen(false);
queryClient.invalidateQueries({ queryKey: ["notifications"] });
},
});
const smsMutation = useMutation({
mutationFn: (data: SendSmsNotificationRequest) =>
notificationService.sendSmsNotification(data),
onSuccess: () => {
toast.success("Cellular uplink: SMS payload queued for broadcast");
setIsSendModalOpen(false);
queryClient.invalidateQueries({ queryKey: ["notifications"] });
},
});
const emailMutation = useMutation({
mutationFn: (data: SendEmailNotificationRequest) =>
notificationService.sendEmailNotification(data),
onSuccess: () => {
toast.success("SMTP Handshake: Email broadcast initiated");
setIsSendModalOpen(false);
queryClient.invalidateQueries({ queryKey: ["notifications"] });
},
});
const filteredNotifications = useMemo(() => { const filteredNotifications = useMemo(() => {
if (!notifications) return []; if (!notifications) return [];
return notifications.filter((n) => {
return notifications.filter((notification) => { if (!searchQuery) return true;
// Type filter const q = searchQuery.toLowerCase();
if (typeFilter && notification.type !== typeFilter) return false;
// 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 ( return (
notification.title.toLowerCase().includes(query) || n.title?.toLowerCase().includes(q) || n.body.toLowerCase().includes(q)
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>{" "}
unread notification{unreadCount !== 1 ? "s" : ""}
</p>
) : (
<p className="text-muted-foreground mt-1">
All messages been processed.
</p>
)}
</div> </div>
<div className="flex gap-2"> <span className="text-xs font-black uppercase tracking-widest opacity-70">
{unreadCount !== undefined && unreadCount > 0 && ( Messaging Hub
</span>
</div>
<h1 className="text-4xl font-black tracking-tighter text-slate-900 uppercase italic">
Command <span className="text-primary NOT-italic">Center</span>
</h1>
<p className="text-slate-500 text-sm font-medium max-w-xl leading-relaxed">
Dispatch multi-channel broadcasts and monitor real-time network
telemetry across the Yaltopia mesh.
</p>
</div>
<div className="flex items-center gap-3">
<Button <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" />
Mark All as Read Clear Signal
</Button>
<Button
className="h-12 px-8 rounded-2xl bg-slate-900 hover:bg-slate-800 text-white font-black uppercase text-xs tracking-[0.1em] shadow-xl shadow-slate-200 transition-all hover:-translate-y-0.5"
onClick={() => setIsSendModalOpen(true)}
>
<Send className="h-4 w-4 mr-2" />
New Broadcast
</Button> </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">
<span className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40">
System Status
</span>
<Badge className="bg-emerald-500 text-white border-none text-[10px] rounded-full px-2 py-0">
Active
</Badge>
</div>
<div>
<span className="text-5xl font-black italic tracking-tighter leading-none">
{unreadCount ?? 0}
</span>
<p className="text-slate-400 text-xs font-medium mt-2">
Active notifications in current window.
</p>
</div>
<div className="grid grid-cols-3 gap-4 border-t border-white/10 pt-6">
<div className="text-center">
<div className="text-lg font-black tracking-tight">
{notifications?.length ?? 0}
</div>
<div className="text-[8px] font-black uppercase tracking-widest opacity-40 mt-1 uppercase">
Push
</div>
</div>
<div className="text-center border-x border-white/10 px-2">
<div className="text-lg font-black tracking-tight"></div>
<div className="text-[8px] font-black uppercase tracking-widest opacity-40 mt-1 uppercase">
SMS
</div>
</div>
<div className="text-center">
<div className="text-lg font-black tracking-tight"></div>
<div className="text-[8px] font-black uppercase tracking-widest opacity-40 mt-1 uppercase">
Mail
</div>
</div>
</div>
</div>
</Card>
<Card className="border-none shadow-[0_8px_30px_rgb(0,0,0,0.04)] bg-white/50 backdrop-blur-xl rounded-3xl p-6">
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 mb-4 px-2">
Operator Directives
</h3>
<div className="space-y-4">
{[
{
icon: Target,
label: "Audience Segmentation",
desc: "Filter by role",
},
{
icon: Calendar,
label: "Scheduled Dispatch",
desc: "Queue for later",
},
{
icon: History,
label: "Audit Integrity",
desc: "Full log access",
},
].map((item, idx) => (
<div
key={idx}
className="flex items-center gap-4 p-3 rounded-2xl hover:bg-slate-50 transition-colors group cursor-default"
>
<div className="p-2.5 bg-slate-100 rounded-xl group-hover:bg-primary/10 transition-colors">
<item.icon className="w-4 h-4 text-slate-400 group-hover:text-primary transition-colors" />
</div>
<div>
<p className="text-xs font-black text-slate-900 tracking-tight">
{item.label}
</p>
<p className="text-[10px] text-slate-400 font-medium">
{item.desc}
</p>
</div>
</div>
))}
</div>
</Card>
</div>
{/* History Feed */}
<div className="lg:col-span-2 space-y-6">
<Card className="border-none shadow-[0_8px_30px_rgb(0,0,0,0.04)] bg-white/50 backdrop-blur-xl rounded-3xl overflow-hidden">
<CardHeader className="p-8 border-b border-slate-100/50 space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6 uppercase">
<h2 className="text-xs font-black tracking-[0.2em] text-slate-400">
Transmission Log
</h2>
<div className="relative group min-w-[280px]">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 group-focus-within:text-primary transition-colors" />
<Input <Input
placeholder="Filter notifications..." 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"
className="pl-9 h-10 border-slate-200/80 focus-visible:ring-slate-900 rounded-none shadow-none" placeholder="Search signal history..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
</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>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
<div className="overflow-x-auto min-h-[400px]">
<div className="p-4 space-y-4">
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-24"> Array.from({ length: 4 }).map((_, i) => (
<Loader2 className="w-8 h-8 animate-spin text-slate-300" /> <div
</div> key={i}
) : filteredNotifications && filteredNotifications.length > 0 ? ( className="h-24 bg-slate-100/50 animate-pulse rounded-2xl"
<Table> />
<TableHeader className="bg-slate-50/50"> ))
<TableRow className="hover:bg-transparent border-slate-100"> ) : filteredNotifications.length ? (
<TableHead className="w-[120px] text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6 py-4"> filteredNotifications.map((n) => (
ID Reference <div
</TableHead> key={n.id}
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6"> 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"
Message Intelligence
</TableHead>
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
Classification
</TableHead>
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
State
</TableHead>
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
Timeline
</TableHead>
<TableHead className="text-right text-[10px] font-bold uppercase tracking-widest text-slate-500 px-10">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredNotifications.map((notification) => (
<TableRow
key={notification.id}
className={cn(
"group hover:bg-slate-50 transition-colors border-slate-100",
!notification.isRead && "bg-slate-50/20",
)}
> >
<TableCell className="px-6 py-4"> <div className="absolute top-0 right-0 w-1 p-1 h-full bg-slate-100 group-hover:bg-primary transition-colors" />
<code className="text-[10px] font-bold text-slate-400 font-mono tracking-tighter"> <div className="flex items-start justify-between gap-6">
{notification.id.substring(0, 12)} <div className="flex gap-4">
</code> <div className="p-3 bg-slate-50 rounded-xl group-hover:bg-primary/5 transition-colors">
</TableCell> <BellRing className="w-5 h-5 text-slate-400 group-hover:text-primary transition-colors" />
<TableCell className="px-6 py-4"> </div>
<div className="flex flex-col gap-0.5"> <div className="space-y-1">
<span <div className="flex items-center gap-2">
className={cn( <span className="text-sm font-black text-slate-900 tracking-tight">
"text-xs font-black tracking-tight uppercase", {n.title}
notification.isRead </span>
? "text-slate-500" <Badge
: "text-slate-900", variant="outline"
)} className="text-[9px] font-black uppercase tracking-tighter opacity-50 px-1.5 py-0 border-slate-200"
> >
{notification.title} Push
</span> </Badge>
<span className="text-xs text-slate-500 line-clamp-1">
{notification.message}
</span>
</div> </div>
</TableCell> <p className="text-xs text-slate-500 font-medium leading-relaxed max-w-lg">
<TableCell className="px-6"> {n.body}
<div className="flex items-center text-[10px] font-bold uppercase tracking-widest text-slate-600"> </p>
{getTypeIcon(notification.type)}
{notification.type}
</div> </div>
</TableCell> </div>
<TableCell className="px-6"> <div className="flex flex-col items-end gap-2 shrink-0">
{getStatusBadge(notification.isRead)} <span className="text-[10px] font-bold text-slate-400">
</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( {format(
new Date(notification.createdAt), new Date(n.createdAt),
"MMM dd, HH:mm", "HH:mm · MMM d, yyyy",
)}
</span>
<Badge
className={cn(
"text-[9px] font-black uppercase tracking-widest rounded-lg px-2 border-none",
n.isSent
? "bg-emerald-500 text-white"
: "bg-slate-200 text-slate-500",
)} )}
</div>
</TableCell>
<TableCell className="text-right px-6">
{!notification.isRead && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-orange-500 transition-colors"
onClick={() => handleMarkAsRead(notification.id)}
> >
<Eye className="w-4 h-4" /> {n.isSent ? "Delivered" : "Queued"}
</Button> </Badge>
)} </div>
</TableCell> </div>
</TableRow> </div>
))} ))
</TableBody>
</Table>
) : ( ) : (
<div className="text-center py-24 text-slate-400 font-bold uppercase tracking-widest text-[10px]"> <div className="py-24 text-center">
{searchQuery || typeFilter || statusFilter <div className="flex flex-col items-center justify-center space-y-4 opacity-20 grayscale">
? "No matching telemetry records found" <History className="w-16 h-16" />
: "No notification stream detected"} <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>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div>
{/* Dispatch Dialog */}
<Dialog open={isSendModalOpen} onOpenChange={setIsSendModalOpen}>
<DialogContent className="rounded-3xl max-w-2xl p-0 border-none shadow-2xl overflow-hidden">
<div className="p-8 bg-slate-900 text-white overflow-hidden relative">
{/* Decorative element */}
<div className="absolute -top-12 -right-12 w-48 h-48 bg-primary/20 rounded-full blur-3xl" />
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-white/10 rounded-xl">
<Target className="w-5 h-5 text-primary" />
</div>
<span className="text-[10px] font-black uppercase tracking-[0.2em] opacity-60 italic">
Signal Transmission
</span>
</div>
<DialogTitle className="text-3xl font-black italic tracking-tighter uppercase leading-none">
Dispatch{" "}
<span className="text-primary NOT-italic">Broadcast</span>
</DialogTitle>
<DialogDescription className="text-slate-400 text-xs font-medium mt-2 leading-relaxed">
Authoritative platform-wide signal broadcast. Choose delivery
channels and construct the payload with precision.
</DialogDescription>
</div>
<div className="p-8">
<div className="space-y-8">
{/* Channel Selector */}
<div className="space-y-4">
<Label className="text-[10px] font-black uppercase text-slate-400 tracking-widest">
Select Uplink Channels
</Label>
<div className="grid grid-cols-3 gap-4">
{[
{ id: "PUSH", icon: BellRing, label: "Push Notification" },
{ id: "SMS", icon: MessageSquare, label: "SMS Gateway" },
{ id: "EMAIL", icon: Mail, label: "Email Relay" },
].map((c) => (
<button
key={c.id}
type="button"
onClick={() => setActiveChannel(c.id as Channel)}
className={cn(
"flex flex-col items-center justify-center gap-3 p-6 rounded-2xl border-2 transition-all group",
activeChannel === c.id
? "border-primary bg-primary/5 text-primary shadow-lg shadow-primary/10"
: "border-slate-100 bg-white text-slate-400 hover:border-slate-200",
)}
>
<c.icon
className={cn(
"w-6 h-6 transition-transform group-active:scale-90",
activeChannel === c.id
? "text-primary"
: "text-slate-300",
)}
/>
<span className="text-[10px] font-black uppercase tracking-widest">
{c.id}
</span>
</button>
))}
</div>
</div>
<Separator className="bg-slate-100" />
{/* Dynamic Form Area */}
<div className="space-y-6">
{activeChannel === "PUSH" && (
<div className="space-y-4 animate-in slide-in-from-right-2 duration-300">
<div className="grid gap-2">
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
Notification Title
</Label>
<Input
value={pushForm.title}
onChange={(e) =>
setPushForm({ ...pushForm, title: e.target.value })
}
placeholder="Critical System Patch Available"
className="h-12 rounded-xl bg-slate-50 border-slate-200/60 font-bold"
/>
</div>
<div className="grid gap-2">
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
Message Body
</Label>
<Textarea
value={pushForm.body}
onChange={(e) =>
setPushForm({ ...pushForm, body: e.target.value })
}
placeholder="Update your client to version 4.2 now..."
className="min-h-[100px] rounded-xl bg-slate-50 border-slate-200/60"
/>
</div>
</div>
)}
{activeChannel === "SMS" && (
<div className="space-y-4 animate-in slide-in-from-right-2 duration-300">
<div className="grid gap-2">
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
Target Phone (Optional)
</Label>
<Input
value={smsForm.recipientPhone}
onChange={(e) =>
setSmsForm({
...smsForm,
recipientPhone: e.target.value,
})
}
placeholder="+1 (555) 000-0000"
className="h-12 rounded-xl bg-slate-50 border-slate-200/60 font-bold"
/>
<p className="text-[9px] text-slate-400 font-medium italic">
Leave empty for multi-user broadcast.
</p>
</div>
<div className="grid gap-2">
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
SMS Payload
</Label>
<Textarea
value={smsForm.body}
onChange={(e) =>
setSmsForm({ ...smsForm, body: e.target.value })
}
placeholder="Your Yaltopia ticket code is XYZ-123..."
className="min-h-[100px] rounded-xl bg-slate-50 border-slate-200/60"
/>
</div>
</div>
)}
{activeChannel === "EMAIL" && (
<div className="space-y-4 animate-in slide-in-from-right-2 duration-300">
<div className="grid gap-2">
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
Email Subject
</Label>
<Input
value={emailForm.subject}
onChange={(e) =>
setEmailForm({
...emailForm,
subject: e.target.value,
})
}
placeholder="Important Account Update"
className="h-12 rounded-xl bg-slate-50 border-slate-200/60 font-bold"
/>
</div>
<div className="grid gap-2">
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">
HTML Content
</Label>
<Textarea
value={emailForm.body}
onChange={(e) =>
setEmailForm({ ...emailForm, body: e.target.value })
}
placeholder="<h1>Welcome to Yaltopia</h1>..."
className="min-h-[160px] rounded-xl bg-slate-50 border-slate-200/60"
/>
</div>
</div>
)}
</div>
</div>
</div>
<DialogFooter className="p-8 pt-4 bg-slate-50 border-t border-slate-100 flex items-center justify-between">
<Button
variant="ghost"
className="h-12 px-6 rounded-xl font-black uppercase text-xs tracking-widest text-slate-400 hover:text-slate-900"
onClick={() => setIsSendModalOpen(false)}
>
Abort Mission
</Button>
<Button
className="h-12 px-10 rounded-xl bg-slate-900 hover:bg-slate-800 text-white font-black uppercase text-xs tracking-[0.1em] shadow-lg shadow-slate-200 transition-all active:scale-95"
disabled={isPending}
onClick={handleSend}
>
{isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Broadcasting...
</>
) : (
<>
Commit {activeChannel} Signal
<ArrowRight className="w-4 h-4 ml-2" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
); );
} }

View File

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

View File

@ -1,128 +1,175 @@
import apiClient from './api/client' import apiClient from "./api/client";
export interface Notification { export interface Notification {
id: string id: string;
title: string title: string;
message: string body: string;
type: 'system' | 'user' | 'alert' | 'invoice' | 'payment' icon?: string;
recipient: string url?: string;
status: 'sent' | 'delivered' | 'read' | 'unread' sentAt?: string;
isRead: boolean scheduledFor?: string;
createdAt: string isSent: boolean;
sentAt?: string recipientId: string;
readAt?: string data?: Record<string, any>;
createdAt: string;
updatedAt: string;
} }
export interface NotificationSettings { export interface NotificationSettings {
emailNotifications: boolean emailNotifications: boolean;
pushNotifications: boolean pushNotifications: boolean;
invoiceReminders: boolean invoiceReminders: boolean;
paymentAlerts: boolean paymentAlerts: boolean;
systemUpdates: boolean systemUpdates: boolean;
}
export interface SendPushNotificationRequest {
title: string;
body: string;
icon?: string;
url?: string;
recipientId?: string;
scheduledFor?: string;
data?: Record<string, any>;
}
export interface SendSmsNotificationRequest {
body: string;
recipientPhone?: string; // If null, system broadcast
scheduledFor?: string;
}
export interface SendEmailNotificationRequest {
subject: string;
body: string; // HTML or Plain text
recipientEmail?: string; // If null, system broadcast
scheduledFor?: string;
} }
class NotificationService { class NotificationService {
/** /**
* Get all notifications for current user * Get all notifications for current user (Paginated)
*/ */
async getNotifications(params?: { async getNotifications(params?: {
type?: string page?: number;
status?: string limit?: number;
search?: string type?: string;
status?: string;
search?: string;
}): Promise<Notification[]> { }): Promise<Notification[]> {
const response = await apiClient.get<Notification[]>('/notifications', { const response = await apiClient.get<Notification[]>("/notifications", {
params, params,
}) });
return response.data return response.data;
} }
/** /**
* Get unread notification count * Get unread notification count
*/ */
async getUnreadCount(): Promise<number> { async getUnreadCount(): Promise<number> {
const response = await apiClient.get<{ count: number }>('/notifications/unread-count') const response = await apiClient.get<{ count: number }>(
return response.data.count "/notifications/unread-count",
);
return response.data.count;
} }
/** /**
* Mark notification as read * Mark notification as read
*/ */
async markAsRead(id: string): Promise<void> { async markAsRead(id: string): Promise<void> {
await apiClient.post(`/notifications/${id}/read`) await apiClient.post(`/notifications/${id}/read`);
} }
/** /**
* Mark all notifications as read * Mark all notifications as read
*/ */
async markAllAsRead(): Promise<void> { async markAllAsRead(): Promise<void> {
await apiClient.post('/notifications/read-all') await apiClient.post("/notifications/read-all");
} }
/** /**
* Broadcast push, SMS, and/or email (Super Admin & Admin only) * Send push notification (ADMIN only)
*/ */
async sendBroadcast(data: { async sendPushNotification(
title: string data: SendPushNotificationRequest,
message: string ): Promise<Notification> {
channels: ('push' | 'sms' | 'email')[] const response = await apiClient.post<Notification>(
audience?: 'all_end_users' | 'system_users_only' | 'everyone_with_access' "/admin/notifications/send-push",
}): Promise<{ id: string }> {
const response = await apiClient.post<{ id: string }>(
'/admin/notifications/broadcast',
data, data,
) );
return response.data return response.data;
} }
/** /**
* Send notification (ADMIN only) * Send SMS notification (ADMIN only)
*/ */
async sendNotification(data: { async sendSmsNotification(
title: string data: SendSmsNotificationRequest,
message: string ): Promise<{ success: boolean; messageId: string }> {
type: string const response = await apiClient.post<{
recipient?: string success: boolean;
recipientType?: 'user' | 'all' messageId: string;
}): Promise<Notification> { }>("/admin/notifications/send-sms", data);
const response = await apiClient.post<Notification>('/notifications/send', data) return response.data;
return response.data }
/**
* Send Email notification (ADMIN only)
*/
async sendEmailNotification(
data: SendEmailNotificationRequest,
): Promise<{ success: boolean; messageId: string }> {
const response = await apiClient.post<{
success: boolean;
messageId: string;
}>("/admin/notifications/send-email", data);
return response.data;
} }
/** /**
* Subscribe to push notifications * Subscribe to push notifications
*/ */
async subscribeToPush(subscription: PushSubscription): Promise<void> { async subscribeToPush(subscription: PushSubscription): Promise<void> {
await apiClient.post('/notifications/subscribe', subscription) await apiClient.post("/notifications/subscribe", subscription);
} }
/** /**
* Unsubscribe from push notifications * Unsubscribe from push notifications
*/ */
async unsubscribeFromPush(endpoint: string): Promise<void> { async unsubscribeFromPush(endpoint: string): Promise<void> {
await apiClient.delete(`/notifications/unsubscribe/${encodeURIComponent(endpoint)}`) await apiClient.delete(
`/notifications/unsubscribe/${encodeURIComponent(endpoint)}`,
);
} }
/** /**
* Get notification settings * Get notification settings
*/ */
async getSettings(): Promise<NotificationSettings> { async getSettings(): Promise<NotificationSettings> {
const response = await apiClient.get<NotificationSettings>('/notifications/settings') const response = await apiClient.get<NotificationSettings>(
return response.data "/notifications/settings",
);
return response.data;
} }
/** /**
* Update notification settings * Update notification settings
*/ */
async updateSettings(settings: Partial<NotificationSettings>): Promise<NotificationSettings> { async updateSettings(
const response = await apiClient.put<NotificationSettings>('/notifications/settings', settings) settings: Partial<NotificationSettings>,
return response.data ): Promise<NotificationSettings> {
const response = await apiClient.put<NotificationSettings>(
"/notifications/settings",
settings,
);
return response.data;
} }
/** /**
* Send invoice reminder * Send invoice reminder
*/ */
async sendInvoiceReminder(invoiceId: string): Promise<void> { async sendInvoiceReminder(invoiceId: string): Promise<void> {
await apiClient.post(`/notifications/invoice/${invoiceId}/reminder`) await apiClient.post(`/notifications/invoice/${invoiceId}/reminder`);
} }
/** /**
@ -130,20 +177,21 @@ class NotificationService {
*/ */
async exportNotifications(notifications: Notification[]): Promise<Blob> { async exportNotifications(notifications: Notification[]): Promise<Blob> {
const csvContent = [ const csvContent = [
['ID', 'Title', 'Message', 'Type', 'Status', 'Created Date', 'Read Date'], ["ID", "Title", "Body", "Status", "Created Date", "Sent Date"],
...notifications.map(n => [ ...notifications.map((n) => [
n.id, n.id,
n.title, n.title,
n.message, n.body,
n.type, n.isSent ? "Sent" : "Scheduled",
n.status,
n.createdAt, n.createdAt,
n.readAt || '-' n.sentAt || "-",
]) ]),
].map(row => row.join(',')).join('\n') ]
.map((row) => row.join(","))
.join("\n");
return new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }) return new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
} }
} }
export const notificationService = new NotificationService() export const notificationService = new NotificationService();

View File

@ -40,11 +40,17 @@ export interface PaymentRequest {
copiedAccountCount: number; copiedAccountCount: number;
status: "DRAFT" | "SENT" | "OPENED" | "PAID" | "EXPIRED" | "CANCELLED"; status: "DRAFT" | "SENT" | "OPENED" | "PAID" | "EXPIRED" | "CANCELLED";
paymentId: string; paymentId: string;
accounts: any[]; accounts: {
bankName: string;
accountName: string;
accountNumber: string;
currency: string;
}[];
pdfPath: string; pdfPath: string;
userId: string; userId: string;
customerId?: string;
items: { items: {
id: string; id?: string;
description: string; description: string;
quantity: number; quantity: number;
unitPrice: number; unitPrice: number;
@ -99,6 +105,29 @@ class PaymentService {
return response.data; return response.data;
} }
/**
* Create a new payment
*/
async createPayment(data: any): Promise<Payment> {
const response = await apiClient.post<Payment>("/payments", data);
return response.data;
}
/**
* Update an existing payment
*/
async updatePayment(id: string, data: any): Promise<Payment> {
const response = await apiClient.put<Payment>(`/payments/${id}`, data);
return response.data;
}
/**
* Delete a payment
*/
async deletePayment(id: string): Promise<void> {
await apiClient.delete(`/payments/${id}`);
}
/** /**
* Get payment requests * Get payment requests
*/ */
@ -113,6 +142,35 @@ class PaymentService {
); );
return response.data; return response.data;
} }
/**
* Create a new payment request
*/
async createPaymentRequest(data: any): Promise<PaymentRequest> {
const response = await apiClient.post<PaymentRequest>(
"/payment-requests",
data,
);
return response.data;
}
/**
* Update an existing payment request
*/
async updatePaymentRequest(id: string, data: any): Promise<PaymentRequest> {
const response = await apiClient.put<PaymentRequest>(
`/payment-requests/${id}`,
data,
);
return response.data;
}
/**
* Delete a payment request
*/
async deletePaymentRequest(id: string): Promise<void> {
await apiClient.delete(`/payment-requests/${id}`);
}
} }
export const paymentService = new PaymentService(); export const paymentService = new PaymentService();