sp/src/components/BillingInfo.jsx

607 lines
28 KiB
JavaScript

import React, { useEffect, useState, useMemo } from "react";
import { useIsLoggedIn } from '../lib/isLoggedIn';
import Loader from "./ui/loader";
import { PDFDownloadLink } from '@react-pdf/renderer';
import InvoicePDF from "../lib/InvoicePDF";
import { Eye, Download, ChevronUp, ChevronDown, Search, FileText } from "lucide-react";
import { Button } from "./ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog";
import * as XLSX from 'xlsx';
export default function UserBillingList() {
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
const { isLoggedIn, loading, error, sessionData } = useIsLoggedIn();
const [billingData, setBillingData] = useState([]);
const [dataLoading, setDataLoading] = useState(true);
const [apiError, setApiError] = useState(null);
const [selectedItem, setSelectedItem] = useState(null);
const [dialogOpen, setDialogOpen] = useState(false);
// Sorting state
const [sortConfig, setSortConfig] = useState({ key: 'created_at', direction: 'desc' });
// Filtering state
const [filters, setFilters] = useState({ status: '', cycle: '', service: '' });
// Search state
const [searchTerm, setSearchTerm] = useState('');
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage] = useState(20);
useEffect(() => {
if (isLoggedIn) {
fetchBillingData();
}
}, [isLoggedIn]);
const fetchBillingData = async () => {
try {
const res = await fetch(`${PUBLIC_USER_API_URL}?query=invoice-info`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
}
});
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
const data = await res.json();
// console.log('Resoponse Data', data)
// For regular users, filter to only show their own billing data
const filteredData = sessionData?.user_type === 'admin' || 'user' ? data.data || [] : (data.data || []).filter(item => item.user === sessionData?.email);
// console.log('Session Data', sessionData);
setBillingData(filteredData);
} catch (err) {
setApiError(err.message);
} finally {
setDataLoading(false);
}
};
// console.log('billing data', billingData)
const handleViewItem = (item) => {
setSelectedItem(item);
setDialogOpen(true);
};
const closeModal = () => {
setDialogOpen(false);
// setSelectedItem(null);
};
// Sorting functionality
const requestSort = (key) => {
let direction = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
setCurrentPage(1);
};
// Filter and sort data
const filteredAndSortedData = useMemo(() => {
let filteredData = [...billingData];
// Apply search
if (searchTerm) {
filteredData = filteredData.filter(item =>
Object.values(item).some(
val => val && val.toString().toLowerCase().includes(searchTerm.toLowerCase())
)
);
}
// Apply filters
if (filters.status) {
filteredData = filteredData.filter(item => item.status === filters.status);
}
if (filters.cycle) {
filteredData = filteredData.filter(item => item.cycle === filters.cycle);
}
if (filters.service) {
filteredData = filteredData.filter(item => item.service === filters.service);
}
// Apply sorting
if (sortConfig.key) {
filteredData.sort((a, b) => {
if (a[sortConfig.key] < b[sortConfig.key]) {
return sortConfig.direction === 'asc' ? -1 : 1;
}
if (a[sortConfig.key] > b[sortConfig.key]) {
return sortConfig.direction === 'asc' ? 1 : -1;
}
return 0;
});
}
return filteredData;
}, [billingData, searchTerm, filters, sortConfig]);
const currentItems = useMemo(() => {
const indexOfLastItem = currentPage * itemsPerPage;
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
return filteredAndSortedData.slice(indexOfFirstItem, indexOfLastItem);
}, [currentPage, itemsPerPage, filteredAndSortedData]);
const totalPages = Math.ceil(filteredAndSortedData.length / itemsPerPage);
const paginate = (pageNumber) => {
if (pageNumber > 0 && pageNumber <= totalPages) {
setCurrentPage(pageNumber);
}
};
const getPaginationRange = () => {
const range = [];
const maxVisiblePages = 5;
range.push(1);
if (currentPage > 3) {
range.push('...');
}
let start = Math.max(2, currentPage - 1);
let end = Math.min(totalPages - 1, currentPage + 1);
if (currentPage <= 3) {
end = Math.min(4, totalPages - 1);
} else if (currentPage >= totalPages - 2) {
start = Math.max(totalPages - 3, 2);
}
for (let i = start; i <= end; i++) {
if (i > 1 && i < totalPages) {
range.push(i);
}
}
if (currentPage < totalPages - 2) {
range.push('...');
}
if (totalPages > 1) {
range.push(totalPages);
}
return range;
};
// Get unique values for filter dropdowns
const uniqueServices = [...new Set(billingData.map(item => item.service))];
const uniqueStatuses = ['completed', 'pending', 'failed'];
const uniquecycles = ['monthly', 'yearly', 'one-time'];
// Reset filters
const resetFilters = () => {
setFilters({
status: '',
cycle: '',
service: ''
});
setSearchTerm('');
setCurrentPage(1);
};
// Handle filter change
const handleFilterChange = (e) => {
const { name, value } = e.target;
setFilters(prev => ({
...prev,
[name]: value
}));
setCurrentPage(1);
};
// Handle search
const handleSearch = (e) => {
setSearchTerm(e.target.value);
setCurrentPage(1);
};
const exportToExcel = () => {
const worksheet = XLSX.utils.json_to_sheet(filteredAndSortedData.map(item => ({
'Billing ID': item.billing_id,
'Service': item.service,
'Customer Name': item.name,
'Customer Email': item.user,
'Silicon ID': item.siliconId,
'Amount': item.amount,
'cycle': item.cycle,
'Status': item.status,
'Created At': formatDate(item.created_at),
'Remarks': item.remarks
})));
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "Billing Data");
XLSX.writeFile(workbook, `billing_data_${new Date().toISOString().slice(0, 10)}.xlsx`);
};
const getStatusColor = (status) => {
switch (status) {
case 'completed': return 'bg-green-100 text-green-800';
case 'pending': return 'bg-yellow-100 text-yellow-800';
case 'failed': return 'bg-red-100 text-red-800';
default: return 'bg-gray-100 text-gray-800';
}
};
const formatDate = (dateString) => {
if (!dateString) return 'N/A';
const options = { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' };
return new Date(dateString).toLocaleDateString(undefined, options);
};
const formatCurrency = (amount) => {
if (!amount) return '$0.00';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(parseFloat(amount));
};
if (loading || dataLoading) return <Loader />;
if (error || apiError) return <p>Error: {error?.message || apiError}</p>;
if (!isLoggedIn) {
return <p className="text-center mt-8">You need to be logged in to view this page. <a href="/" className="text-[#6d9e37]">Click Here</a> to go to the homepage.</p>;
}
// console.log('currentItems', currentItems)
return (
<section className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">My Billing History</h1>
<Button onClick={exportToExcel} variant="outline" className="flex items-center gap-2">
<FileText className="w-4 h-4" /> Export to Excel
</Button>
</div>
{/* Results Count */}
<div className="mb-2 text-sm text-gray-600">
Showing {currentItems.length} of {filteredAndSortedData.length} results
</div>
<div className="bg-white p-4 rounded-t-lg shadow">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* Search */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
placeholder="Search..."
value={searchTerm}
onChange={handleSearch}
className="bg-[#262626] pl-10 w-full px-3 py-2 border border-[#6d9e37] rounded-md outline-none"
/>
</div>
{/* Status Filter */}
<select
name="status"
value={filters.status}
onChange={handleFilterChange}
className="bg-[#262626] px-3 py-2 border border-[#6d9e37] rounded-md outline-none"
>
<option value="">All Statuses</option>
{uniqueStatuses.map(status => (
<option key={status} value={status}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</option>
))}
</select>
{/* cycle Filter */}
<select
name="cycle"
value={filters.cycle}
onChange={handleFilterChange}
className="bg-[#262626] px-3 py-2 border border-[#6d9e37] rounded-md outline-none"
>
<option value="">All cycles</option>
{uniquecycles.map(cycle => (
<option key={cycle} value={cycle}>
{cycle.charAt(0).toUpperCase() + cycle.slice(1)}
</option>
))}
</select>
{/* Service Filter */}
<select
name="service"
value={filters.service}
onChange={handleFilterChange}
className="bg-[#262626] px-3 py-2 border border-[#6d9e37] rounded-md outline-none"
>
<option value="">All Services</option>
{uniqueServices.map(service => (
<option key={service} value={service}>
{service}
</option>
))}
</select>
</div>
{/* Reset Filters Button */}
{(filters.status || filters.cycle || filters.service || searchTerm) && (
<div className="mt-3">
<Button
onClick={resetFilters}
size="sm"
>
Reset Filters
</Button>
</div>
)}
</div>
<div className="bg-white rounded-b-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
onClick={() => requestSort('billing_id')}
>
<div className="flex items-center">
Billing ID
{sortConfig.key === 'billing_id' && (
sortConfig.direction === 'asc' ?
<ChevronUp className="ml-1 h-4 w-4" /> :
<ChevronDown className="ml-1 h-4 w-4" />
)}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
onClick={() => requestSort('service')}
>
<div className="flex items-center">
Service
{sortConfig.key === 'service' && (
sortConfig.direction === 'asc' ?
<ChevronUp className="ml-1 h-4 w-4" /> :
<ChevronDown className="ml-1 h-4 w-4" />
)}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
onClick={() => requestSort('name')}
>
<div className="flex items-center">
Name
{sortConfig.key === 'name' && (
sortConfig.direction === 'asc' ?
<ChevronUp className="ml-1 h-4 w-4" /> :
<ChevronDown className="ml-1 h-4 w-4" />
)}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
onClick={() => requestSort('amount')}
>
<div className="flex items-center">
Amount
{sortConfig.key === 'amount' && (
sortConfig.direction === 'asc' ?
<ChevronUp className="ml-1 h-4 w-4" /> :
<ChevronDown className="ml-1 h-4 w-4" />
)}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
onClick={() => requestSort('status')}
>
<div className="flex items-center">
Status
{sortConfig.key === 'status' && (
sortConfig.direction === 'asc' ?
<ChevronUp className="ml-1 h-4 w-4" /> :
<ChevronDown className="ml-1 h-4 w-4" />
)}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
onClick={() => requestSort('created_at')}
>
<div className="flex items-center">
Created At
{sortConfig.key === 'created_at' && (
sortConfig.direction === 'asc' ?
<ChevronUp className="ml-1 h-4 w-4" /> :
<ChevronDown className="ml-1 h-4 w-4" />
)}
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{currentItems.length > 0 ? (
currentItems.map((item) => (
<tr key={item.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{item.billing_id}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{item.service}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{item.name??item.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-medium">
{formatCurrency(item.amount)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(item.status)}`}>
{item.status.charAt(0).toUpperCase() + item.status.slice(1)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDate(item.created_at)}
</td>
<td className="inline-flex px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
title="View Details"
onClick={() => handleViewItem(item)}
className="text-indigo-600 hover:text-indigo-900 mr-2"
>
<Eye className="w-5 h-5" />
</button>
<PDFDownloadLink
title="Download PDF"
document={<InvoicePDF data={item} />}
fileName={`invoice_${item.billing_id}.pdf`}
className="text-[#6d9e37] hover:text-green-600"
>
{({ loading }) => (loading ? '...' : <Download className="w-5 h-5" />)}
</PDFDownloadLink>
</td>
</tr>
))
) : (
<tr>
<td colSpan="7" className="px-6 py-4 text-center text-sm text-gray-500">
No records found
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Pagination */}
{filteredAndSortedData.length > itemsPerPage && (
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="text-sm text-gray-600">
Page {currentPage} of {totalPages}
</div>
<div className="flex flex-wrap gap-2">
<Button
onClick={() => paginate(1)}
disabled={currentPage === 1}
variant="outline"
size="sm"
>
First
</Button>
<Button
onClick={() => paginate(currentPage - 1)}
disabled={currentPage === 1}
variant="outline"
size="sm"
>
Previous
</Button>
{getPaginationRange().map((item, index) => {
if (item === '...') {
return (
<span key={`ellipsis-${index}`} className="px-2 py-1">
...
</span>
);
}
return (
<Button
key={`page-${item}`}
onClick={() => paginate(item)}
variant={currentPage === item ? "default" : "outline"}
size="sm"
>
{item}
</Button>
);
})}
<Button
onClick={() => paginate(currentPage + 1)}
disabled={currentPage === totalPages}
variant="outline"
size="sm"
>
Next
</Button>
<Button
onClick={() => paginate(totalPages)}
disabled={currentPage === totalPages}
variant="outline"
size="sm"
>
Last
</Button>
</div>
</div>
)}
{/* Dialog for View */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="">
Billing Details
</DialogTitle>
<DialogDescription>
Detailed information about your billing record
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div>
<h3 className="font-semibold text-neutral-950">Billing ID</h3>
<p className="text-neutral-400 font-medium">{selectedItem?.billing_id}</p>
</div>
<div>
<h3 className="font-semibold text-neutral-950">Service</h3>
<p className="text-neutral-400 font-medium">{selectedItem?.service}</p>
</div>
<div>
<h3 className="font-semibold text-neutral-950">User</h3>
<p className="text-neutral-400 font-medium">{selectedItem?.user}</p>
</div>
<div>
<h3 className="font-semibold text-neutral-950">cycle</h3>
<p className="text-neutral-400 font-medium">{selectedItem?.cycle}</p>
</div>
<div>
<h3 className="font-semibold text-neutral-950">Amount</h3>
<p className="text-neutral-400 font-medium">{formatCurrency(selectedItem?.amount)}</p>
</div>
<div>
<h3 className="font-semibold text-neutral-950">Status</h3>
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(selectedItem?.status)}`}>
{selectedItem?.status?.charAt(0).toUpperCase() + selectedItem?.status?.slice(1)}
</span>
</div>
<div>
<h3 className="font-semibold text-neutral-950">Created At</h3>
<p className="text-neutral-400 font-medium">{formatDate(selectedItem?.created_at)}</p>
</div>
<div>
<h3 className="font-semibold text-neutral-950">Updated At</h3>
<p className="text-neutral-400 font-medium">{formatDate(selectedItem?.updated_at)}</p>
</div>
</div>
<DialogFooter>
<PDFDownloadLink
document={<InvoicePDF data={selectedItem} />}
fileName={`invoice_${selectedItem?.billing_id}.pdf`}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
>
{({ loading }) => (loading ? 'Preparing PDF...' : 'Download PDF')}
</PDFDownloadLink>
<Button
onClick={closeModal}
variant="outline"
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}