507 lines
26 KiB
JavaScript
507 lines
26 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import { Head, router, Link } from '@inertiajs/react';
|
|
import AdminLayout from '@/Layouts/AdminLayout';
|
|
import {
|
|
Users,
|
|
Building2,
|
|
ShieldAlert,
|
|
DollarSign,
|
|
LifeBuoy,
|
|
Search,
|
|
RefreshCw,
|
|
Download,
|
|
Calendar,
|
|
Filter,
|
|
FileSpreadsheet,
|
|
Globe,
|
|
X,
|
|
FileText
|
|
} from 'lucide-react';
|
|
|
|
export default function ReportsHub({
|
|
reportType = 'workers',
|
|
startDate = '',
|
|
endDate = '',
|
|
status = 'all',
|
|
search = '',
|
|
headers = [],
|
|
reportData = {},
|
|
nationalities = [],
|
|
perPage = 10,
|
|
nationality = 'all'
|
|
}) {
|
|
// Local filter state
|
|
const [localStartDate, setLocalStartDate] = useState(startDate);
|
|
const [localEndDate, setLocalEndDate] = useState(endDate);
|
|
const [localStatus, setLocalStatus] = useState(status);
|
|
const [localSearch, setLocalSearch] = useState(search);
|
|
const [localNationality, setLocalNationality] = useState(nationality || 'all');
|
|
const [localPerPage, setLocalPerPage] = useState(perPage || 10);
|
|
|
|
// Sync with props when props change (e.g. after tab switch)
|
|
useEffect(() => {
|
|
setLocalStartDate(startDate);
|
|
setLocalEndDate(endDate);
|
|
setLocalStatus(status);
|
|
setLocalSearch(search);
|
|
setLocalNationality(nationality || 'all');
|
|
setLocalPerPage(perPage || 10);
|
|
}, [reportType, startDate, endDate, status, search, nationality, perPage]);
|
|
|
|
const isPaginated = reportData && typeof reportData === 'object' && !Array.isArray(reportData);
|
|
const dataList = isPaginated ? (reportData.data || []) : (reportData || []);
|
|
const totalRecords = isPaginated ? (reportData.total || 0) : dataList.length;
|
|
|
|
const handleTabChange = (type) => {
|
|
router.get('/admin/analytics', {
|
|
report_type: type,
|
|
start_date: localStartDate,
|
|
end_date: localEndDate,
|
|
status: 'all',
|
|
search: '',
|
|
per_page: localPerPage
|
|
}, {
|
|
preserveState: false
|
|
});
|
|
};
|
|
|
|
const applyFilters = () => {
|
|
router.get('/admin/analytics', {
|
|
report_type: reportType,
|
|
start_date: localStartDate,
|
|
end_date: localEndDate,
|
|
status: localStatus,
|
|
search: localSearch,
|
|
nationality: reportType === 'workers' ? localNationality : undefined,
|
|
per_page: localPerPage
|
|
}, {
|
|
preserveState: true,
|
|
replace: true
|
|
});
|
|
};
|
|
|
|
const resetFilters = () => {
|
|
setLocalStartDate('');
|
|
setLocalEndDate('');
|
|
setLocalStatus('all');
|
|
setLocalSearch('');
|
|
setLocalNationality('all');
|
|
setLocalPerPage(10);
|
|
|
|
router.get('/admin/analytics', {
|
|
report_type: reportType,
|
|
start_date: '',
|
|
end_date: '',
|
|
status: 'all',
|
|
search: '',
|
|
per_page: 10
|
|
});
|
|
};
|
|
|
|
const handlePerPageChange = (val) => {
|
|
setLocalPerPage(val);
|
|
router.get('/admin/analytics', {
|
|
report_type: reportType,
|
|
start_date: localStartDate,
|
|
end_date: localEndDate,
|
|
status: localStatus,
|
|
search: localSearch,
|
|
nationality: reportType === 'workers' ? localNationality : undefined,
|
|
per_page: val,
|
|
page: 1
|
|
}, {
|
|
preserveState: true,
|
|
replace: true
|
|
});
|
|
};
|
|
|
|
const handleExportCSV = () => {
|
|
const params = new URLSearchParams({
|
|
report_type: reportType,
|
|
start_date: localStartDate,
|
|
end_date: localEndDate,
|
|
status: localStatus,
|
|
search: localSearch,
|
|
export: 'csv'
|
|
});
|
|
if (reportType === 'workers' && localNationality !== 'all') {
|
|
params.append('nationality', localNationality);
|
|
}
|
|
window.location.href = `/admin/analytics?${params.toString()}`;
|
|
};
|
|
|
|
// Context-aware status list for dropdown
|
|
const getStatusOptions = () => {
|
|
switch (reportType) {
|
|
case 'workers':
|
|
return [
|
|
{ value: 'all', label: 'All Statuses' },
|
|
{ value: 'Available', label: 'Available' },
|
|
{ value: 'hired', label: 'Hired' },
|
|
{ value: 'inactive', label: 'Inactive' },
|
|
{ value: 'suspended', label: 'Suspended' },
|
|
{ value: 'banned', label: 'Banned' }
|
|
];
|
|
case 'employers':
|
|
return [
|
|
{ value: 'all', label: 'All Subscriptions' },
|
|
{ value: 'active', label: 'Active Pass' },
|
|
{ value: 'inactive', label: 'Inactive Pass' }
|
|
];
|
|
case 'payments':
|
|
return [
|
|
{ value: 'all', label: 'All Payments' },
|
|
{ value: 'success', label: 'Success' },
|
|
{ value: 'failed', label: 'Failed' },
|
|
{ value: 'refunded', label: 'Refunded' }
|
|
];
|
|
case 'tickets':
|
|
return [
|
|
{ value: 'all', label: 'All Statuses' },
|
|
{ value: 'Open', label: 'Open' },
|
|
{ value: 'In-Progress', label: 'In-Progress' },
|
|
{ value: 'Resolved', label: 'Resolved' },
|
|
{ value: 'Closed', label: 'Closed' }
|
|
];
|
|
case 'safety':
|
|
return [
|
|
{ value: 'all', label: 'All Statuses' },
|
|
{ value: 'Pending', label: 'Pending' },
|
|
{ value: 'Resolved', label: 'Resolved' }
|
|
];
|
|
default:
|
|
return [{ value: 'all', label: 'All Statuses' }];
|
|
}
|
|
};
|
|
|
|
const tabs = [
|
|
{ id: 'workers', label: 'Workers List', icon: Users, desc: 'Registration & status report' },
|
|
{ id: 'employers', label: 'Employers List', icon: Building2, desc: 'Subscription & spending report' },
|
|
{ id: 'payments', label: 'Payments Ledger', icon: DollarSign, desc: 'Revenue & transactions report' },
|
|
{ id: 'tickets', label: 'Support Inquiries', icon: LifeBuoy, desc: 'Inquiries & voice notes report' },
|
|
{ id: 'safety', label: 'Safety Moderation', icon: ShieldAlert, desc: 'Moderation & flagged user reports' },
|
|
];
|
|
|
|
const renderCell = (val, cellIdx, type) => {
|
|
const lowerVal = String(val).toLowerCase();
|
|
|
|
// Status Badges
|
|
if (['active', 'available', 'success', 'yes', 'approved', 'resolved'].includes(lowerVal)) {
|
|
return (
|
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider bg-emerald-50 text-emerald-700 border border-emerald-200">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
|
|
{val}
|
|
</span>
|
|
);
|
|
}
|
|
if (['pending', 'in-progress', 'under review', 'no'].includes(lowerVal)) {
|
|
return (
|
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider bg-amber-50 text-amber-700 border border-amber-200">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-amber-500"></span>
|
|
{val}
|
|
</span>
|
|
);
|
|
}
|
|
if (['failed', 'inactive', 'suspended', 'banned', 'closed', 'rejected'].includes(lowerVal)) {
|
|
return (
|
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider bg-rose-50 text-rose-700 border border-rose-200">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-rose-500"></span>
|
|
{val}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ID Formats
|
|
if (typeof val === 'string' && (val.startsWith('WRK-') || val.startsWith('EMP-') || val.startsWith('PAY-') || val.startsWith('TCK-') || val.startsWith('REP-'))) {
|
|
return (
|
|
<span className="font-mono font-bold text-[#0F6E56] tracking-wider bg-teal-50 px-2.5 py-1 rounded-lg border border-teal-100">
|
|
{val}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// Email style
|
|
if (typeof val === 'string' && val.includes('@')) {
|
|
return <span className="font-medium text-gray-500 text-xs">{val}</span>;
|
|
}
|
|
|
|
return <span className="font-bold text-gray-800">{val}</span>;
|
|
};
|
|
|
|
return (
|
|
<AdminLayout title="Operational Reports Portal">
|
|
<Head title="Operational Reports Portal" />
|
|
|
|
<div className="max-w-7xl mx-auto space-y-6 pb-16 px-4 sm:px-6 lg:px-8">
|
|
|
|
{/* Header Title & Description */}
|
|
<div className="bg-gradient-to-r from-teal-900 via-[#0F6E56] to-emerald-800 rounded-3xl p-6 md:p-8 text-white shadow-xl relative overflow-hidden">
|
|
<div className="absolute right-0 bottom-0 opacity-10 pointer-events-none transform translate-y-6 translate-x-6">
|
|
<FileText className="w-80 h-80" />
|
|
</div>
|
|
<div className="relative z-10 space-y-2">
|
|
<span className="text-[10px] bg-teal-500/30 border border-teal-400/20 px-3 py-1.5 rounded-full font-bold uppercase tracking-widest text-teal-200">
|
|
Management Portal
|
|
</span>
|
|
<h1 className="text-2xl md:text-3xl font-black tracking-tight mt-1">Platform Reports Portal</h1>
|
|
<p className="text-sm text-teal-100 max-w-2xl font-medium">
|
|
Gain direct access to database operational reports, track registrations, monitor logs, analyze payment histories, and export filtered tabular datasets.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Report Module Navigation Tabs */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3">
|
|
{tabs.map((tab) => {
|
|
const Icon = tab.icon;
|
|
const isActive = reportType === tab.id;
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => handleTabChange(tab.id)}
|
|
className={`flex flex-col items-start text-left p-4 rounded-2xl border transition-all duration-300 ${
|
|
isActive
|
|
? 'bg-white border-[#0F6E56] shadow-lg scale-[1.01] ring-2 ring-[#0F6E56]/10'
|
|
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300 hover:bg-gray-50/80 shadow-sm'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2.5">
|
|
<div className={`p-2 rounded-xl ${isActive ? 'bg-teal-50 text-[#0F6E56]' : 'bg-gray-100 text-gray-500'}`}>
|
|
<Icon className="w-5 h-5" />
|
|
</div>
|
|
<span className={`text-xs font-black uppercase tracking-wider ${isActive ? 'text-gray-900' : 'text-gray-700'}`}>
|
|
{tab.label}
|
|
</span>
|
|
</div>
|
|
<span className="text-[10px] text-gray-400 font-semibold mt-2 line-clamp-1">
|
|
{tab.desc}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Filter & Table Block */}
|
|
<div className="bg-white border border-gray-200 rounded-3xl shadow-sm overflow-hidden">
|
|
|
|
{/* Filter Bar */}
|
|
<div className="p-6 border-b border-gray-100 bg-gray-50/50 space-y-4">
|
|
<div className="flex items-center gap-2 text-xs font-bold text-gray-800 uppercase tracking-wider">
|
|
<Filter className="w-4 h-4 text-teal-600" />
|
|
<span>Filters & Query Parameters</span>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
|
{/* Start Date */}
|
|
<div className="space-y-1">
|
|
<label className="text-[10px] text-gray-400 font-extrabold uppercase tracking-widest block">Start Date</label>
|
|
<div className="relative">
|
|
<Calendar className="w-3.5 h-3.5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
|
<input
|
|
type="date"
|
|
className="w-full bg-white border border-gray-250 rounded-xl pl-9 pr-3 py-2 text-xs font-bold focus:ring-2 focus:ring-[#0F6E56]/10 outline-none transition-all"
|
|
value={localStartDate}
|
|
onChange={e => setLocalStartDate(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* End Date */}
|
|
<div className="space-y-1">
|
|
<label className="text-[10px] text-gray-400 font-extrabold uppercase tracking-widest block">End Date</label>
|
|
<div className="relative">
|
|
<Calendar className="w-3.5 h-3.5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
|
<input
|
|
type="date"
|
|
className="w-full bg-white border border-gray-250 rounded-xl pl-9 pr-3 py-2 text-xs font-bold focus:ring-2 focus:ring-[#0F6E56]/10 outline-none transition-all"
|
|
value={localEndDate}
|
|
onChange={e => setLocalEndDate(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status filter */}
|
|
<div className="space-y-1">
|
|
<label className="text-[10px] text-gray-400 font-extrabold uppercase tracking-widest block">Status</label>
|
|
<select
|
|
className="w-full bg-white border border-gray-250 rounded-xl px-3 py-2 text-xs font-bold focus:ring-2 focus:ring-[#0F6E56]/10 outline-none cursor-pointer transition-all"
|
|
value={localStatus}
|
|
onChange={e => setLocalStatus(e.target.value)}
|
|
>
|
|
{getStatusOptions().map(opt => (
|
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Workers Tab specific filter: Nationality */}
|
|
{reportType === 'workers' ? (
|
|
<div className="space-y-1">
|
|
<label className="text-[10px] text-gray-400 font-extrabold uppercase tracking-widest block">Nationality</label>
|
|
<div className="relative">
|
|
<Globe className="w-3.5 h-3.5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
|
<select
|
|
className="w-full bg-white border border-gray-250 rounded-xl pl-9 pr-3 py-2 text-xs font-bold focus:ring-2 focus:ring-[#0F6E56]/10 outline-none cursor-pointer transition-all"
|
|
value={localNationality}
|
|
onChange={e => setLocalNationality(e.target.value)}
|
|
>
|
|
<option value="all">All Nationalities</option>
|
|
{nationalities.map(n => (
|
|
<option key={n} value={n}>{n}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="hidden lg:block"></div>
|
|
)}
|
|
|
|
{/* Search Box */}
|
|
<div className="space-y-1">
|
|
<label className="text-[10px] text-gray-400 font-extrabold uppercase tracking-widest block">Search Query</label>
|
|
<div className="relative">
|
|
<Search className="w-3.5 h-3.5 text-gray-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search by keywords..."
|
|
className="w-full bg-white border border-gray-250 rounded-xl pl-9 pr-3 py-2 text-xs font-bold focus:ring-2 focus:ring-[#0F6E56]/10 outline-none transition-all placeholder-gray-400"
|
|
value={localSearch}
|
|
onChange={e => setLocalSearch(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action buttons */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pt-2">
|
|
<span className="text-[11px] text-gray-400 font-bold uppercase tracking-wider bg-gray-100 px-3 py-1.5 rounded-lg border border-gray-200/50 self-start">
|
|
Matches found: {totalRecords} records
|
|
</span>
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<button
|
|
onClick={resetFilters}
|
|
className="inline-flex items-center gap-1.5 px-4 py-2 border border-gray-200 bg-white hover:bg-gray-50 text-gray-600 rounded-xl text-xs font-bold uppercase tracking-wider transition-colors"
|
|
>
|
|
<X className="w-3.5 h-3.5" />
|
|
<span>Reset</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={applyFilters}
|
|
className="inline-flex items-center gap-1.5 px-5 py-2 bg-[#0F6E56] hover:bg-[#0c5845] text-white rounded-xl text-xs font-bold uppercase tracking-wider transition-colors shadow-sm shadow-teal-900/10"
|
|
>
|
|
<RefreshCw className="w-3.5 h-3.5" />
|
|
<span>Apply Filters</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={handleExportCSV}
|
|
className="inline-flex items-center gap-1.5 px-5 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl text-xs font-bold uppercase tracking-wider transition-colors shadow-sm shadow-emerald-900/10"
|
|
>
|
|
<Download className="w-3.5 h-3.5" />
|
|
<span>Export CSV</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Report Table View */}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full border-collapse text-left">
|
|
<thead>
|
|
<tr className="border-b border-gray-200 bg-gray-50 text-[10px] text-gray-400 font-black uppercase tracking-widest">
|
|
{headers.map((hdr, idx) => (
|
|
<th key={idx} className="px-6 py-4">{hdr}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100">
|
|
{dataList.map((row, idx) => (
|
|
<tr key={idx} className="hover:bg-teal-50/10 transition-colors">
|
|
{Object.values(row).map((val, cellIdx) => (
|
|
<td key={cellIdx} className="px-6 py-4 text-xs">
|
|
{renderCell(val, cellIdx, reportType)}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
|
|
{dataList.length === 0 && (
|
|
<tr>
|
|
<td colSpan={headers.length || 1} className="px-6 py-16 text-center">
|
|
<div className="flex flex-col items-center justify-center space-y-2 text-gray-400">
|
|
<FileSpreadsheet className="w-12 h-12 stroke-[1.5]" />
|
|
<span className="font-black uppercase tracking-wider text-xs">No Records Found</span>
|
|
<span className="text-[10px] text-gray-400 font-semibold max-w-xs leading-relaxed">
|
|
There are no database records matching your current filter configuration.
|
|
</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
<div className="p-5 border-t border-gray-150 flex flex-col sm:flex-row items-center justify-between gap-4 bg-gray-50/50">
|
|
<div className="flex items-center gap-4">
|
|
<span className="text-xs text-gray-500 font-bold">
|
|
{isPaginated && reportData.total > 0 ? (
|
|
`Showing ${reportData.from || 0} to ${reportData.to || 0} of ${reportData.total} records`
|
|
) : (
|
|
`Showing ${dataList.length} records`
|
|
)}
|
|
</span>
|
|
|
|
{/* Per Page Select */}
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] text-gray-400 font-extrabold uppercase tracking-wider">Per Page:</span>
|
|
<select
|
|
value={localPerPage}
|
|
onChange={(e) => handlePerPageChange(Number(e.target.value))}
|
|
className="bg-white border border-gray-200 rounded-xl px-2 py-1 text-xs font-bold focus:ring-2 focus:ring-[#0F6E56]/10 outline-none cursor-pointer"
|
|
>
|
|
<option value="10">10</option>
|
|
<option value="25">25</option>
|
|
<option value="50">50</option>
|
|
<option value="100">100</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{isPaginated && reportData.last_page > 1 && (
|
|
<div className="flex items-center space-x-2">
|
|
<Link
|
|
href={reportData.prev_page_url || '#'}
|
|
preserveScroll
|
|
className={`px-4 py-2 bg-white border border-gray-200 rounded-xl text-xs font-bold text-gray-700 shadow-xs hover:bg-gray-50 transition-colors ${
|
|
!reportData.prev_page_url && 'opacity-50 pointer-events-none'
|
|
}`}
|
|
>
|
|
Previous
|
|
</Link>
|
|
|
|
<span className="text-xs text-gray-500 font-bold px-2">
|
|
Page {reportData.current_page} of {reportData.last_page}
|
|
</span>
|
|
|
|
<Link
|
|
href={reportData.next_page_url || '#'}
|
|
preserveScroll
|
|
className={`px-4 py-2 bg-white border border-gray-200 rounded-xl text-xs font-bold text-gray-700 shadow-xs hover:bg-gray-50 transition-colors ${
|
|
!reportData.next_page_url && 'opacity-50 pointer-events-none'
|
|
}`}
|
|
>
|
|
Next
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</AdminLayout>
|
|
);
|
|
}
|