356 lines
22 KiB
JavaScript
356 lines
22 KiB
JavaScript
import React, { useState } from 'react';
|
|
import { Head, router } from '@inertiajs/react';
|
|
import AdminLayout from '@/Layouts/AdminLayout';
|
|
import {
|
|
CheckCircle,
|
|
XCircle,
|
|
Clock,
|
|
X,
|
|
AlertTriangle,
|
|
ShieldCheck,
|
|
Edit3,
|
|
History,
|
|
Sparkles
|
|
} from 'lucide-react';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
|
|
export default function Verifications({ verifications, status, summary, history = [] }) {
|
|
const [selectedItem, setSelectedItem] = useState(null);
|
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
|
|
// Verification form overrides
|
|
const [ocrOverrides, setOcrOverrides] = useState({});
|
|
const [isEditingOcr, setIsEditingOcr] = useState(false);
|
|
const [rejectionReason, setRejectionReason] = useState('');
|
|
const [showRejectionForm, setShowRejectionForm] = useState(false);
|
|
|
|
const activeList = verifications?.data || [];
|
|
|
|
const openReview = (item) => {
|
|
setSelectedItem(item);
|
|
setOcrOverrides(item.ocr_data || {});
|
|
setIsEditingOcr(false);
|
|
setShowRejectionForm(false);
|
|
setRejectionReason('');
|
|
setIsDialogOpen(true);
|
|
};
|
|
|
|
const handleVerifySubmit = (action) => {
|
|
router.post(`/admin/workers/${selectedItem.id}/verify`, {
|
|
action,
|
|
rejection_reason: action === 'reject' ? rejectionReason : null,
|
|
ocr_overrides: ocrOverrides
|
|
}, {
|
|
onSuccess: () => {
|
|
setIsDialogOpen(false);
|
|
}
|
|
});
|
|
};
|
|
|
|
return (
|
|
<AdminLayout title="Verifications">
|
|
<Head title="Verifications" />
|
|
|
|
<div className="font-sans max-w-7xl mx-auto space-y-6 pb-12">
|
|
|
|
{/* Simplified Header */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 border-b border-slate-200 pb-5">
|
|
<div>
|
|
<h1 className="text-xl font-black text-slate-800 uppercase tracking-tight">Worker Verifications</h1>
|
|
<p className="text-xs text-slate-500 mt-0.5 font-medium">Verify workers, check passports, and override data inputs.</p>
|
|
</div>
|
|
|
|
{/* Filter Tabs */}
|
|
<div className="flex bg-slate-100 p-1 rounded-xl w-fit border border-slate-200">
|
|
{[
|
|
{ label: 'All', value: 'all' },
|
|
{ label: 'Approved', value: 'approved' },
|
|
{ label: 'Flagged', value: 'rejected' },
|
|
].map((tab) => (
|
|
<button
|
|
key={tab.value}
|
|
type="button"
|
|
onClick={() => router.get('/admin/workers/verifications', { status: tab.value })}
|
|
className={`px-4 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-wider transition-all ${
|
|
(status || 'all') === tab.value
|
|
? 'bg-white text-[#0F6E56] shadow-sm'
|
|
: 'text-slate-650 hover:text-slate-900'
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Queue Summary widgets */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
<div className="bg-white p-5 border border-slate-200 rounded-2xl flex items-center justify-between shadow-sm">
|
|
<div>
|
|
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">Auto-Approve Rate</span>
|
|
<span className="text-2xl font-black text-slate-800 mt-1">{summary?.auto_approve_rate || 0}%</span>
|
|
</div>
|
|
<div className="w-10 h-10 bg-emerald-50 rounded-xl flex items-center justify-center text-emerald-600">
|
|
<ShieldCheck className="w-5 h-5" />
|
|
</div>
|
|
</div>
|
|
<div className="bg-white p-5 border border-slate-200 rounded-2xl flex items-center justify-between shadow-sm">
|
|
<div>
|
|
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">Average Match Score</span>
|
|
<span className="text-2xl font-black text-blue-600 mt-1">{summary?.average_match_score || 0}%</span>
|
|
</div>
|
|
<div className="w-10 h-10 bg-blue-50 rounded-xl flex items-center justify-center text-blue-600">
|
|
<Sparkles className="w-5 h-5" />
|
|
</div>
|
|
</div>
|
|
<div className="bg-white p-5 border border-slate-200 rounded-2xl flex items-center justify-between shadow-sm">
|
|
<div>
|
|
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">Flagged Workers</span>
|
|
<span className="text-2xl font-black text-amber-600 mt-1">{summary?.flagged_count || 0} Pending</span>
|
|
</div>
|
|
<div className="w-10 h-10 bg-amber-50 rounded-xl flex items-center justify-center text-amber-600">
|
|
<AlertTriangle className="w-5 h-5" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Verification Queue Datagrid */}
|
|
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left border-collapse">
|
|
<thead>
|
|
<tr className="bg-slate-50/70 border-b border-slate-150 text-[10px] font-black text-slate-500 uppercase tracking-widest">
|
|
<th className="py-4 px-6">Worker Name</th>
|
|
<th className="py-4 px-6">Passport ID</th>
|
|
<th className="py-4 px-6">Confidence Score</th>
|
|
<th className="py-4 px-6">Warnings</th>
|
|
<th className="py-4 px-6">Processed Date</th>
|
|
<th className="py-4 px-6 text-right">Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100 text-xs font-bold text-slate-700">
|
|
{activeList.length > 0 ? (
|
|
activeList.map((item) => (
|
|
<tr key={item.id} className="hover:bg-slate-50/50 transition-colors">
|
|
<td className="py-4 px-6">
|
|
<div>
|
|
<div className="text-sm font-black text-slate-900">{item.worker_name}</div>
|
|
<div className="text-[10px] text-slate-400 font-semibold mt-0.5">{item.nationality} • {item.document_type}</div>
|
|
</div>
|
|
</td>
|
|
<td className="py-4 px-6 font-mono font-semibold text-slate-650">{item.passport_number}</td>
|
|
<td className="py-4 px-6">
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-24 bg-slate-100 rounded-full h-2 overflow-hidden">
|
|
<div
|
|
className={`h-full rounded-full ${
|
|
item.confidence > 90 ? 'bg-emerald-500' :
|
|
item.confidence > 70 ? 'bg-blue-500' : 'bg-amber-500'
|
|
}`}
|
|
style={{ width: `${item.confidence}%` }}
|
|
/>
|
|
</div>
|
|
<span className={`text-[10px] font-black ${
|
|
item.confidence > 90 ? 'text-emerald-600' :
|
|
item.confidence > 70 ? 'text-blue-600' : 'text-amber-600'
|
|
}`}>{item.confidence}%</span>
|
|
</div>
|
|
</td>
|
|
<td className="py-4 px-6">
|
|
{item.warnings.length > 0 ? (
|
|
<div className="flex flex-wrap gap-1">
|
|
{item.warnings.map((w, idx) => (
|
|
<span key={idx} className="inline-flex items-center bg-red-50 text-red-700 text-[9px] px-2.5 py-0.5 rounded-full uppercase font-black tracking-wide border border-red-100">
|
|
{w}
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<span className="text-[10px] font-black text-emerald-600 uppercase">None</span>
|
|
)}
|
|
</td>
|
|
<td className="py-4 px-6 text-slate-400 font-semibold">{item.processed_at}</td>
|
|
<td className="py-4 px-6 text-right">
|
|
<button
|
|
type="button"
|
|
onClick={() => openReview(item)}
|
|
className={`inline-flex items-center px-4 py-2 border rounded-xl text-[10px] font-black uppercase tracking-wider transition-colors focus:outline-none ${
|
|
item.status === 'flagged'
|
|
? 'bg-amber-500 text-white border-none shadow-md hover:bg-amber-600'
|
|
: 'bg-white hover:bg-slate-50 text-slate-700 border-slate-200'
|
|
}`}
|
|
>
|
|
{item.status === 'flagged' ? 'Verify' : 'Logs'}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
) : (
|
|
<tr>
|
|
<td colSpan="6" className="py-8 text-center text-slate-400 font-medium italic">
|
|
No verification records found in this queue.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Audit logs details grid */}
|
|
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm space-y-4">
|
|
<h3 className="text-xs font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5 border-b border-slate-100 pb-3">
|
|
<History className="w-4 h-4 text-slate-600" />
|
|
<span>Verification History</span>
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{history.length > 0 ? history.map((log, idx) => (
|
|
<div key={idx} className="bg-slate-50 p-3.5 rounded-xl border border-slate-100 flex items-center justify-between text-xs font-bold text-slate-700 shadow-inner">
|
|
<div className="flex items-center space-x-3">
|
|
<Clock className="w-4 h-4 text-slate-400" />
|
|
<span>{log.text}</span>
|
|
</div>
|
|
<span className="text-[10px] text-slate-400 font-semibold">{log.time} • IP: {log.ip}</span>
|
|
</div>
|
|
)) : (
|
|
<div className="text-center py-6 text-slate-400 text-xs font-medium italic">
|
|
No verification logs recorded.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Verification Audit Dialog - Simplified & Width Optimized */}
|
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
|
<DialogContent className="sm:max-w-xl w-full bg-white p-0 overflow-hidden font-sans rounded-[24px] border-none shadow-2xl max-h-[90vh] flex flex-col">
|
|
<DialogHeader className="p-6 border-b border-slate-100 bg-[#0f6e56]/5 flex-shrink-0">
|
|
<DialogTitle className="text-lg font-black text-slate-800 flex items-center justify-between tracking-tight">
|
|
<span className="flex items-center gap-2">
|
|
<ShieldCheck className="w-5 h-5 text-[#0F6E56]" />
|
|
<span>Verify Worker: <span className="text-[#0F6E56]">{selectedItem?.worker_name}</span></span>
|
|
</span>
|
|
<div className="flex items-center space-x-2">
|
|
<Badge className="border-none bg-[#0F6E56] text-white px-3.5 py-1.5 font-black text-[9px] uppercase tracking-wider rounded-lg shadow-sm">
|
|
<span>Score: {selectedItem?.confidence}%</span>
|
|
</Badge>
|
|
</div>
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="p-6 overflow-y-auto flex-1 bg-slate-50/30 space-y-6">
|
|
|
|
{/* Warnings Box - Rendered Inline */}
|
|
{selectedItem?.warnings?.length > 0 && (
|
|
<div className="bg-rose-50/50 border border-rose-100 p-5 rounded-2xl space-y-3 shadow-sm">
|
|
<span className="text-[10px] font-black text-rose-700 uppercase tracking-widest flex items-center gap-1.5">
|
|
<AlertTriangle className="w-4 h-4 text-rose-600 animate-bounce" />
|
|
<span>Verification Warnings</span>
|
|
</span>
|
|
<ul className="space-y-2 text-xs font-bold text-rose-950">
|
|
{selectedItem.warnings.map((w, idx) => (
|
|
<li key={idx} className="flex items-center gap-2 bg-white px-3 py-2 rounded-xl border border-rose-100 shadow-xs">
|
|
<div className="w-1.5 h-1.5 bg-rose-500 rounded-full flex-shrink-0" />
|
|
<span>{w}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{/* OCR Data Extraction Form - Styled Cleanly */}
|
|
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm space-y-6">
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between border-b border-slate-100 pb-2">
|
|
<h3 className="text-xs font-black text-slate-700 uppercase tracking-widest">Extracted Text Vitals</h3>
|
|
<button
|
|
onClick={() => setIsEditingOcr(!isEditingOcr)}
|
|
className="text-xs text-[#0F6E56] hover:text-[#085041] font-black flex items-center gap-1 uppercase tracking-wider text-[10px]"
|
|
>
|
|
<Edit3 className="w-3.5 h-3.5" />
|
|
<span>{isEditingOcr ? 'Cancel' : 'Edit values'}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
{Object.entries(ocrOverrides).map(([key, value]) => (
|
|
<div key={key} className="space-y-1.5">
|
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">{key}</label>
|
|
{isEditingOcr ? (
|
|
<input
|
|
type="text"
|
|
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs font-bold focus:bg-white outline-none focus:ring-2 focus:ring-[#0F6E56]/10 focus:border-[#0F6E56] transition-all"
|
|
value={value}
|
|
onChange={e => {
|
|
const newVal = e.target.value;
|
|
setOcrOverrides(prev => ({ ...prev, [key]: newVal }));
|
|
}}
|
|
/>
|
|
) : (
|
|
<div className="px-3.5 py-2.5 bg-slate-50 rounded-xl text-xs font-bold text-slate-800 border border-slate-100/50 font-mono tracking-wide">
|
|
{value}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Manual Rejection reason input */}
|
|
{showRejectionForm ? (
|
|
<div className="bg-rose-50/30 p-4 border border-rose-100 rounded-xl space-y-3">
|
|
<label className="text-[10px] font-black text-rose-700 uppercase tracking-widest block">Reason for Rejection</label>
|
|
<textarea
|
|
rows="2"
|
|
className="w-full bg-white border border-rose-200 rounded-xl p-3 text-xs font-bold focus:ring-4 focus:ring-rose-500/10 outline-none transition-all"
|
|
placeholder="Explain verification error (e.g. invalid document)..."
|
|
value={rejectionReason}
|
|
onChange={e => setRejectionReason(e.target.value)}
|
|
/>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => handleVerifySubmit('reject')}
|
|
className="px-4 py-2.5 bg-red-600 hover:bg-red-700 text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-md transition-colors"
|
|
>
|
|
Confirm
|
|
</button>
|
|
<button
|
|
onClick={() => setShowRejectionForm(false)}
|
|
className="px-4 py-2.5 bg-slate-200 text-slate-650 hover:bg-slate-350 rounded-xl text-[10px] font-black uppercase tracking-widest transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex gap-3 pt-4 border-t border-slate-100 flex-shrink-0">
|
|
<button
|
|
onClick={() => handleVerifySubmit('approve')}
|
|
className="flex-1 py-3 bg-[#0F6E56] text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-lg hover:bg-[#085041] transition-all flex items-center justify-center gap-1"
|
|
>
|
|
<CheckCircle className="w-4 h-4" />
|
|
<span>Approve</span>
|
|
</button>
|
|
<button
|
|
onClick={() => setShowRejectionForm(true)}
|
|
className="flex-1 py-3 bg-red-600 text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-lg hover:bg-red-700 transition-all flex items-center justify-center gap-1"
|
|
>
|
|
<XCircle className="w-4 h-4" />
|
|
<span>Reject</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</AdminLayout>
|
|
);
|
|
}
|