Yaltopia-Ticket-Admin/src/pages/admin/payments/payment-requests.tsx
2026-05-09 11:24:55 +03:00

845 lines
35 KiB
TypeScript

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