![]() Server : Apache/2 System : Linux server-15-235-50-60 5.15.0-164-generic #174-Ubuntu SMP Fri Nov 14 20:25:16 UTC 2025 x86_64 User : gositeme ( 1004) PHP Version : 8.2.29 Disable Function : exec,system,passthru,shell_exec,proc_close,proc_open,dl,popen,show_source,posix_kill,posix_mkfifo,posix_getpwuid,posix_setpgid,posix_setsid,posix_setuid,posix_setgid,posix_seteuid,posix_setegid,posix_uname Directory : /home/gositeme/domains/lavocat.ca/private_html/src/pages/admin/registrations/ |
import { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import RegistrationForm from '@/components/RegistrationForm';
import { useSession } from 'next-auth/react';
import LayoutWithSidebar from '@/components/LayoutWithSidebar';
import { NextPage } from 'next';
import { format } from 'date-fns';
import { motion, AnimatePresence } from 'framer-motion';
import DocumentViewer from '@/components/DocumentViewer';
import DocumentManager from '@/components/DocumentManager';
import mammoth from 'mammoth'; // For .docx
import * as XLSX from 'xlsx'; // For .xlsx
interface Document {
id: string;
name: string;
url: string;
type: string;
createdAt: string;
}
interface Registration {
id: string;
firstName: string;
lastName: string;
email: string;
phone: string;
birthDate: string;
detaineeInfo: {
name: string;
facility: string;
inmateId: string;
incarcerationDate: string;
expectedReleaseDate?: string | null;
} | null;
relationship: string;
preferredLanguage: 'fr' | 'en';
preferredContactMethod: 'email' | 'phone' | 'mail';
message?: string | null;
status: 'PENDING' | 'APPROVED' | 'REJECTED' | 'MISSING_DOCUMENTS' | 'DOCUMENTS_UNDER_REVIEW' | 'ADDITIONAL_INFO_NEEDED' | 'VERIFICATION_IN_PROGRESS' | 'LAWYER_VERIFICATION' | 'FACILITY_VERIFICATION' | 'DOCUMENTS_EXPIRED' | 'DOCUMENTS_INCOMPLETE' | 'INFORMATION_MISMATCH' | 'PENDING_PAYMENT' | 'PAYMENT_RECEIVED' | 'PENDING_LAWYER_APPROVAL' | 'PENDING_FACILITY_APPROVAL' | 'ON_HOLD' | 'ESCALATED' | 'FINAL_REVIEW' | 'COMPLETED' | 'WebAd';
createdAt: string;
updatedAt: string;
userId?: string | null;
address?: {
street: string;
city: string;
state: string;
postalCode: string;
country: string;
} | null;
documents?: Document[];
reasonForJoining?: string | null;
urgentNeeds?: string | null;
}
type OpenPreview = {
doc: any;
window: Window | null;
isMaximized?: boolean;
position?: { x: number; y: number };
zIndex?: number;
id?: string;
};
type DraggableModalProps = {
doc: any; // You can replace 'any' with 'Document' if the structure matches
isMaximized?: boolean;
position?: { x: number; y: number };
zIndex?: number;
onClose: () => void;
onMaximize: () => void;
onDrag: (pos: { x: number; y: number }) => void;
onFocus: () => void;
};
const translations = {
en: {
back: 'Back to dashboard',
status: {
pending: 'Pending',
approved: 'Approved',
rejected: 'Rejected',
},
updateStatusError: 'Error updating status',
updateRegistrationError: 'Error updating registration',
loadRegistrationError: 'Error loading registration',
document: {
documents: 'Documents',
add: 'Add Document',
uploading: 'Uploading...',
uploadError: 'Error uploading document',
delete: 'Delete Document',
deleteError: 'Error deleting document',
unknownUploadError: 'Unknown error uploading document',
},
close: 'Close',
},
fr: {
back: 'Retour au tableau de bord',
status: {
pending: 'En attente',
approved: 'Approuvé',
rejected: 'Rejeté',
},
updateStatusError: 'Erreur lors de la mise à jour du statut',
updateRegistrationError: 'Erreur lors de la mise à jour de la demande',
loadRegistrationError: 'Erreur lors du chargement de la demande',
document: {
documents: 'Documents',
add: 'Ajouter un document',
uploading: 'Téléchargement...',
uploadError: 'Erreur lors du téléchargement du document',
delete: 'Supprimer le document',
deleteError: 'Erreur lors de la suppression du document',
unknownUploadError: 'Erreur inconnue lors du téléchargement du document',
},
close: 'Fermer',
}
};
const AdminRegistrationDetail: NextPage = () => {
const router = useRouter();
const { id } = router.query;
const { data: session, status } = useSession();
const [registration, setRegistration] = useState<Registration | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [isUpdating, setIsUpdating] = useState(false);
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
const [selectedDocument, setSelectedDocument] = useState<{
url: string;
type: string;
name: string;
} | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState('');
const [isDeleting, setIsDeleting] = useState<string | null>(null);
const [previewDoc, setPreviewDoc] = useState<{ url: string; name: string } | null>(null);
const [isMaximized, setIsMaximized] = useState(false);
const [openPreviews, setOpenPreviews] = useState<OpenPreview[]>([]); // [{doc, isMaximized, position, zIndex, id}]
const zIndexCounter = useRef(1000);
const [isEditing, setIsEditing] = useState(false);
// Move upload handler to top-level for DocumentManager
const onUploadFiles = async (files: File[]) => {
setIsUploading(true);
setUploadError('');
try {
for (let i = 0; i < files.length; i++) {
const file = files[i];
const formData = new FormData();
formData.append('file', file);
const uploadResponse = await fetch(`/api/admin/registrations/${id}/documents`, {
method: 'POST',
body: formData,
});
const contentType = uploadResponse.headers.get('content-type');
let document;
if (contentType && contentType.includes('application/json')) {
document = await uploadResponse.json();
} else {
const text = await uploadResponse.text();
throw new Error(text || 'Unknown error uploading document');
}
setRegistration(prev => prev ? {
...prev,
documents: [...(prev.documents || []), document],
} : null);
}
} catch (err) {
setUploadError(err instanceof Error ? err.message : 'Error uploading document');
console.error(err);
} finally {
setIsUploading(false);
}
};
// Move delete handler to top-level for DocumentManager and document list
const handleDeleteDocument = async (documentId: string) => {
if (!id || !documentId) return;
setIsDeleting(documentId);
try {
const response = await fetch(`/api/admin/registrations/${id}/documents/${documentId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Error deleting document');
}
setRegistration(prev => {
if (!prev) return null;
return {
...prev,
documents: prev.documents?.filter(doc => doc.id !== documentId) || [],
};
});
} catch (error) {
console.error('Error deleting document:', error);
setUploadError('Error deleting document');
} finally {
setIsDeleting(null);
}
};
// Check if we're in edit mode from URL query
useEffect(() => {
if (router.query.edit === 'true') {
setIsEditing(true);
}
}, [router.query.edit]);
useEffect(() => {
if (id) {
fetch(`/api/admin/registrations/${id}`)
.then(res => res.json())
.then(data => setRegistration(data))
.catch(err => setError('Failed to load registration'));
}
}, [id]);
const getStatusLabel = (status: string) => {
const { locale } = router;
// Normalize status to uppercase for consistent matching
const normalizedStatus = status.toUpperCase();
const statusMap: { [key: string]: { en: string; fr: string } } = {
PENDING: {
en: 'Pending',
fr: 'En attente'
},
MISSING_DOCUMENTS: {
en: 'Missing Documents',
fr: 'Documents manquants'
},
DOCUMENTS_UNDER_REVIEW: {
en: 'Documents Under Review',
fr: 'Documents en révision'
},
ADDITIONAL_INFO_NEEDED: {
en: 'Additional Info Needed',
fr: 'Informations supplémentaires requises'
},
VERIFICATION_IN_PROGRESS: {
en: 'Verification In Progress',
fr: 'Vérification en cours'
},
LAWYER_VERIFICATION: {
en: 'Lawyer Verification',
fr: 'Vérification par l\'avocat'
},
FACILITY_VERIFICATION: {
en: 'Facility Verification',
fr: 'Vérification par l\'établissement'
},
DOCUMENTS_EXPIRED: {
en: 'Documents Expired',
fr: 'Documents expirés'
},
DOCUMENTS_INCOMPLETE: {
en: 'Documents Incomplete',
fr: 'Documents incomplets'
},
INFORMATION_MISMATCH: {
en: 'Information Mismatch',
fr: 'Incohérence d\'informations'
},
PENDING_PAYMENT: {
en: 'Pending Payment',
fr: 'Paiement en attente'
},
PAYMENT_RECEIVED: {
en: 'Payment Received',
fr: 'Paiement reçu'
},
PENDING_LAWYER_APPROVAL: {
en: 'Pending Lawyer Approval',
fr: 'En attente de l\'approbation de l\'avocat'
},
PENDING_FACILITY_APPROVAL: {
en: 'Pending Facility Approval',
fr: 'En attente de l\'approbation de l\'établissement'
},
ON_HOLD: {
en: 'On Hold',
fr: 'En attente'
},
ESCALATED: {
en: 'Escalated',
fr: 'Escaladé'
},
FINAL_REVIEW: {
en: 'Final Review',
fr: 'Révision finale'
},
APPROVED: {
en: 'Approved',
fr: 'Approuvé'
},
REJECTED: {
en: 'Rejected',
fr: 'Rejeté'
},
COMPLETED: {
en: 'Completed',
fr: 'Terminé'
},
WebAd: {
en: 'Web Ad',
fr: 'Publicité web'
}
};
return statusMap[normalizedStatus]?.[locale as 'en' | 'fr'] || status;
};
const handleStatusChange = async (newStatus: string) => {
if (!registration) return;
setIsUpdatingStatus(true);
try {
const response = await fetch(`/api/admin/registrations/${registration.id}/status`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ status: newStatus }),
});
if (!response.ok) {
let errorMessage = 'Failed to update status';
try {
const errorData = await response.json();
errorMessage = errorData.error || errorData.message || errorMessage;
} catch (parseError) {
console.warn('Could not parse error response:', parseError);
errorMessage = `Server returned ${response.status}: ${response.statusText}`;
}
throw new Error(errorMessage);
}
const updatedRegistration = await response.json();
setRegistration(updatedRegistration);
// Clear any previous error
setError('');
} catch (error) {
console.error('Error updating status:', error);
const errorMessage = error instanceof Error ? error.message : 'Failed to update status';
setError(errorMessage);
} finally {
setIsUpdatingStatus(false);
}
};
const handleSave = async (formData: any) => {
try {
const response = await fetch(`/api/admin/registrations/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
if (!response.ok) {
const errorText = await response.text();
console.error('Error response:', response.status, errorText);
throw new Error('Failed to update registration');
}
router.push('/admin');
} catch (error) {
console.error('Error updating registration:', error);
}
};
const getStatusDescription = (status: string) => {
const { locale } = router;
const descriptionMap: { [key: string]: { en: string; fr: string } } = {
PENDING: {
en: 'The registration is pending initial review.',
fr: 'L\'inscription est en attente de révision initiale.'
},
MISSING_DOCUMENTS: {
en: 'Required documents are missing from the registration.',
fr: 'Des documents requis sont manquants dans l\'inscription.'
},
DOCUMENTS_UNDER_REVIEW: {
en: 'Submitted documents are currently being reviewed.',
fr: 'Les documents soumis sont actuellement en cours d\'examen.'
},
ADDITIONAL_INFO_NEEDED: {
en: 'More information is required to proceed with the registration.',
fr: 'Des informations supplémentaires sont nécessaires pour poursuivre l\'inscription.'
},
VERIFICATION_IN_PROGRESS: {
en: 'The registration is currently being verified.',
fr: 'L\'inscription est actuellement en cours de vérification.'
},
LAWYER_VERIFICATION: {
en: 'Documents are being verified by a lawyer.',
fr: 'Les documents sont en cours de vérification par un avocat.'
},
FACILITY_VERIFICATION: {
en: 'Documents are being verified by the facility.',
fr: 'Les documents sont en cours de vérification par l\'établissement.'
},
DOCUMENTS_EXPIRED: {
en: 'One or more submitted documents have expired.',
fr: 'Un ou plusieurs documents soumis ont expiré.'
},
DOCUMENTS_INCOMPLETE: {
en: 'Submitted documents are incomplete or invalid.',
fr: 'Les documents soumis sont incomplets ou invalides.'
},
INFORMATION_MISMATCH: {
en: 'There is a mismatch in the provided information.',
fr: 'Il y a une incohérence dans les informations fournies.'
},
PENDING_PAYMENT: {
en: 'Waiting for payment to be processed.',
fr: 'En attente du traitement du paiement.'
},
PAYMENT_RECEIVED: {
en: 'Payment has been successfully received.',
fr: 'Le paiement a été reçu avec succès.'
},
PENDING_LAWYER_APPROVAL: {
en: 'Registration is waiting for lawyer approval.',
fr: 'L\'inscription est en attente de l\'approbation de l\'avocat.'
},
PENDING_FACILITY_APPROVAL: {
en: 'Registration is waiting for facility approval.',
fr: 'L\'inscription est en attente de l\'approbation de l\'établissement.'
},
ON_HOLD: {
en: 'The registration is temporarily on hold.',
fr: 'L\'inscription est temporairement en attente.'
},
ESCALATED: {
en: 'The registration requires special attention.',
fr: 'L\'inscription nécessite une attention particulière.'
},
FINAL_REVIEW: {
en: 'The registration is in its final review stage.',
fr: 'L\'inscription est dans sa phase finale d\'examen.'
},
APPROVED: {
en: 'The registration has been approved and is active.',
fr: 'L\'inscription a été approuvée et est active.'
},
REJECTED: {
en: 'The registration has been rejected and cannot proceed.',
fr: 'L\'inscription a été rejetée et ne peut pas continuer.'
},
COMPLETED: {
en: 'The registration process has been fully completed.',
fr: 'Le processus d\'inscription est entièrement terminé.'
},
WebAd: {
en: 'Registration from web advertisement.',
fr: 'Inscription provenant d\'une publicité web.'
}
};
return descriptionMap[status]?.[locale as 'en' | 'fr'] ||
(locale === 'fr' ? 'Le statut de l\'inscription est en cours de traitement.' : 'The registration status is being processed.');
};
// Update openPreview to prevent duplicate modals and bring to front if already open
const openPreview = (doc: any) => {
setOpenPreviews(prev => {
const existing = prev.find(w => w.doc.id === doc.id);
if (existing) {
// Bring to front
return prev.map(w => w.doc.id === doc.id ? { ...w, zIndex: ++zIndexCounter.current } : w);
}
// Open new modal
return [
...prev,
{
doc,
window: null,
isMaximized: false,
position: { x: 100 + prev.length * 40, y: 100 + prev.length * 40 },
zIndex: ++zIndexCounter.current,
id: doc.id,
},
];
});
};
// Helper to close a preview window
const closePreview = (id: string) => setOpenPreviews(prev => prev.filter(w => w.id !== id));
// Helper to toggle maximize
const toggleMaximize = (id: string) => setOpenPreviews(prev => prev.map(w => w.id === id ? { ...w, isMaximized: !w.isMaximized } : w));
// Helper to bring to front
const bringToFront = (id: string) => setOpenPreviews(prev => prev.map(w => w.id === id ? { ...w, zIndex: ++zIndexCounter.current } : w));
// Helper to update position
const updatePosition = (id: string, pos: { x: number; y: number }) => setOpenPreviews(prev => prev.map(w => w.id === id ? { ...w, position: pos } : w));
// Ensure documents are mapped to FileItem structure for DocumentManager
const documentFiles = (registration?.documents || []).map((doc: any) => ({
id: doc.id,
name: doc.name,
url: doc.url,
type: doc.type || '',
}));
if (!registration) {
return <div>Loading...</div>;
}
return (
<LayoutWithSidebar>
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">
{isEditing ? 'Edit Registration' : 'Registration Details'}
</h1>
<div className="flex gap-2">
{isEditing ? (
<>
<button
onClick={() => setIsEditing(false)}
className="inline-flex items-center px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors shadow-md hover:shadow-lg"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Cancel Edit
</button>
<button
onClick={() => router.push('/admin/dashboard')}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-md hover:shadow-lg"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to Dashboard
</button>
</>
) : (
<>
<button
onClick={() => setIsEditing(true)}
className="inline-flex items-center px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors shadow-md hover:shadow-lg"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Edit Registration
</button>
<button
onClick={() => router.push('/admin/dashboard')}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-md hover:shadow-lg"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to Dashboard
</button>
</>
)}
</div>
</div>
{/* Status Update Section */}
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold mb-2">Current Status</h2>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
registration.status.toUpperCase() === 'APPROVED' ? 'bg-green-100 text-green-800' :
registration.status.toUpperCase() === 'REJECTED' ? 'bg-red-100 text-red-800' :
registration.status.toUpperCase() === 'COMPLETED' ? 'bg-green-100 text-green-800' :
registration.status.toUpperCase() === 'MISSING_DOCUMENTS' ? 'bg-orange-100 text-orange-800' :
registration.status.toUpperCase() === 'DOCUMENTS_UNDER_REVIEW' ? 'bg-blue-100 text-blue-800' :
registration.status.toUpperCase() === 'ADDITIONAL_INFO_NEEDED' ? 'bg-yellow-100 text-yellow-800' :
registration.status.toUpperCase() === 'VERIFICATION_IN_PROGRESS' ? 'bg-purple-100 text-purple-800' :
registration.status.toUpperCase() === 'LAWYER_VERIFICATION' ? 'bg-indigo-100 text-indigo-800' :
registration.status.toUpperCase() === 'FACILITY_VERIFICATION' ? 'bg-indigo-100 text-indigo-800' :
registration.status.toUpperCase() === 'DOCUMENTS_EXPIRED' ? 'bg-red-100 text-red-800' :
registration.status.toUpperCase() === 'DOCUMENTS_INCOMPLETE' ? 'bg-orange-100 text-orange-800' :
registration.status.toUpperCase() === 'INFORMATION_MISMATCH' ? 'bg-red-100 text-red-800' :
registration.status.toUpperCase() === 'PENDING_PAYMENT' ? 'bg-yellow-100 text-yellow-800' :
registration.status.toUpperCase() === 'PAYMENT_RECEIVED' ? 'bg-green-100 text-green-800' :
registration.status.toUpperCase() === 'PENDING_LAWYER_APPROVAL' ? 'bg-blue-100 text-blue-800' :
registration.status.toUpperCase() === 'PENDING_FACILITY_APPROVAL' ? 'bg-blue-100 text-blue-800' :
registration.status.toUpperCase() === 'ON_HOLD' ? 'bg-gray-100 text-gray-800' :
registration.status.toUpperCase() === 'ESCALATED' ? 'bg-red-100 text-red-800' :
registration.status.toUpperCase() === 'FINAL_REVIEW' ? 'bg-purple-100 text-purple-800' :
registration.status === 'WebAd' ? 'bg-pink-100 text-pink-800' :
'bg-yellow-100 text-yellow-800'
}`}>
{getStatusLabel(registration.status)}
</span>
<p className="text-sm text-gray-600 mt-1">
{getStatusDescription(registration.status)}
</p>
</div>
<div className="flex items-center gap-4">
<select
value={registration.status}
onChange={(e) => handleStatusChange(e.target.value)}
disabled={isUpdatingStatus}
className="px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="PENDING">{getStatusLabel('PENDING')}</option>
<option value="MISSING_DOCUMENTS">{getStatusLabel('MISSING_DOCUMENTS')}</option>
<option value="DOCUMENTS_UNDER_REVIEW">{getStatusLabel('DOCUMENTS_UNDER_REVIEW')}</option>
<option value="ADDITIONAL_INFO_NEEDED">{getStatusLabel('ADDITIONAL_INFO_NEEDED')}</option>
<option value="VERIFICATION_IN_PROGRESS">{getStatusLabel('VERIFICATION_IN_PROGRESS')}</option>
<option value="LAWYER_VERIFICATION">{getStatusLabel('LAWYER_VERIFICATION')}</option>
<option value="FACILITY_VERIFICATION">{getStatusLabel('FACILITY_VERIFICATION')}</option>
<option value="DOCUMENTS_EXPIRED">{getStatusLabel('DOCUMENTS_EXPIRED')}</option>
<option value="DOCUMENTS_INCOMPLETE">{getStatusLabel('DOCUMENTS_INCOMPLETE')}</option>
<option value="INFORMATION_MISMATCH">{getStatusLabel('INFORMATION_MISMATCH')}</option>
<option value="PENDING_PAYMENT">{getStatusLabel('PENDING_PAYMENT')}</option>
<option value="PAYMENT_RECEIVED">{getStatusLabel('PAYMENT_RECEIVED')}</option>
<option value="PENDING_LAWYER_APPROVAL">{getStatusLabel('PENDING_LAWYER_APPROVAL')}</option>
<option value="PENDING_FACILITY_APPROVAL">{getStatusLabel('PENDING_FACILITY_APPROVAL')}</option>
<option value="ON_HOLD">{getStatusLabel('ON_HOLD')}</option>
<option value="ESCALATED">{getStatusLabel('ESCALATED')}</option>
<option value="FINAL_REVIEW">{getStatusLabel('FINAL_REVIEW')}</option>
<option value="APPROVED">{getStatusLabel('APPROVED')}</option>
<option value="REJECTED">{getStatusLabel('REJECTED')}</option>
<option value="COMPLETED">{getStatusLabel('COMPLETED')}</option>
<option value="WebAd">{getStatusLabel('WebAd')}</option>
</select>
{isUpdatingStatus && (
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-blue-500"></div>
)}
</div>
</div>
</div>
{error && <div className="text-red-500 mb-4">{error}</div>}
{isEditing ? (
<RegistrationForm
initialData={registration}
initialValues={registration}
isEditing={true}
onSave={handleSave}
isAdmin={true}
mode="admin"
isFrench={registration?.preferredLanguage === 'fr'}
/>
) : (
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<h2 className="text-xl font-bold mb-4">Registration Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 className="font-semibold text-gray-700 mb-2">Personal Information</h3>
<div className="space-y-2">
<p><span className="font-medium">Name:</span> {registration.firstName} {registration.lastName}</p>
<p><span className="font-medium">Email:</span> {registration.email}</p>
<p><span className="font-medium">Phone:</span> {registration.phone}</p>
<p><span className="font-medium">Birth Date:</span> {registration.birthDate ? new Date(registration.birthDate).toLocaleDateString() : 'Not provided'}</p>
<p><span className="font-medium">Relationship:</span> {registration.relationship}</p>
<p><span className="font-medium">Preferred Language:</span> {registration.preferredLanguage === 'fr' ? 'French' : 'English'}</p>
<p><span className="font-medium">Preferred Contact:</span> {registration.preferredContactMethod}</p>
</div>
</div>
<div>
<h3 className="font-semibold text-gray-700 mb-2">Address</h3>
{registration.address ? (
<div className="space-y-2">
<p><span className="font-medium">Street:</span> {registration.address.street}</p>
<p><span className="font-medium">City:</span> {registration.address.city}</p>
<p><span className="font-medium">State/Province:</span> {registration.address.state}</p>
<p><span className="font-medium">Postal Code:</span> {registration.address.postalCode}</p>
<p><span className="font-medium">Country:</span> {registration.address.country}</p>
</div>
) : (
<p className="text-gray-500">No address provided</p>
)}
</div>
{registration.detaineeInfo && (
<div>
<h3 className="font-semibold text-gray-700 mb-2">Detainee Information</h3>
<div className="space-y-2">
<p><span className="font-medium">Name:</span> {registration.detaineeInfo.name}</p>
<p><span className="font-medium">Facility:</span> {registration.detaineeInfo.facility}</p>
<p><span className="font-medium">Inmate ID:</span> {registration.detaineeInfo.inmateId}</p>
<p><span className="font-medium">Incarceration Date:</span> {registration.detaineeInfo.incarcerationDate ? new Date(registration.detaineeInfo.incarcerationDate).toLocaleDateString() : 'Not provided'}</p>
{registration.detaineeInfo.expectedReleaseDate && (
<p><span className="font-medium">Expected Release:</span> {new Date(registration.detaineeInfo.expectedReleaseDate).toLocaleDateString()}</p>
)}
</div>
</div>
)}
<div>
<h3 className="font-semibold text-gray-700 mb-2">Additional Information</h3>
<div className="space-y-2">
{registration.reasonForJoining && (
<div className="mb-2">
<span className="font-medium">Reason for Joining:</span> {registration.reasonForJoining}
</div>
)}
{registration.urgentNeeds && (
<div className="mb-2">
<span className="font-medium">Urgent Needs:</span> {registration.urgentNeeds}
</div>
)}
{registration.message && (
<p><span className="font-medium">Message:</span> {registration.message}</p>
)}
<p><span className="font-medium">Created:</span> {new Date(registration.createdAt).toLocaleDateString()}</p>
<p><span className="font-medium">Last Updated:</span> {new Date(registration.updatedAt).toLocaleDateString()}</p>
</div>
</div>
</div>
</div>
)}
{/* Documents (user-style) */}
<div className="bg-white rounded-xl shadow-lg p-6 mt-8">
<h2 className="text-2xl font-bold mb-6">Documents</h2>
{/* Document Upload - Drag & Drop */}
<div
className={`relative flex flex-col items-center justify-center p-8 mb-8 rounded-2xl border-2 border-dashed transition-all duration-200 ${isUploading ? 'border-blue-500 bg-blue-50/60' : 'border-gray-300 bg-white/60'} backdrop-blur-md shadow-lg`}
onDragOver={e => { e.preventDefault(); e.stopPropagation(); }}
onDrop={e => {
e.preventDefault(); e.stopPropagation();
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const files = Array.from(e.dataTransfer.files);
onUploadFiles(files);
}
}}
style={{ minHeight: 140 }}
>
<input
type="file"
multiple
onChange={e => {
if (e.target.files) onUploadFiles(Array.from(e.target.files));
}}
disabled={isUploading}
className="absolute inset-0 opacity-0 cursor-pointer"
style={{ zIndex: 2 }}
/>
<div className="flex flex-col items-center pointer-events-none">
<svg className="w-12 h-12 text-blue-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4a1 1 0 011-1h8a1 1 0 011 1v12m-5 4v-4m0 0l-2 2m2-2l2 2" /></svg>
<span className="font-semibold text-gray-700">Drag & drop files here or <span className="text-blue-600 underline">browse</span></span>
<span className="text-xs text-gray-500 mt-1">PDF, images, video, audio, Word, Excel, and more. Max 10MB each.</span>
</div>
</div>
{uploadError && (
<p className="mt-2 text-sm text-red-600">{uploadError}</p>
)}
{/* Document List */}
<div className="space-y-4">
{registration?.documents?.map((doc) => (
<div key={doc.id} className="flex items-center justify-between p-4 bg-gray-50 rounded-lg shadow-sm">
<div className="flex items-center space-x-4">
<button
onClick={() => openPreview({ url: doc.url, name: doc.name, type: doc.type, id: doc.id })}
className="text-primary hover:text-primary-dark font-medium"
>
{doc.name}
</button>
<span className="text-xs text-gray-500">{doc.type}</span>
</div>
<div className="flex items-center gap-2">
<a
href={doc.url}
target="_blank"
rel="noopener noreferrer"
className="px-2 py-1 text-green-600 hover:text-green-800 rounded border border-green-100 hover:border-green-300 text-xs"
download
>
Download
</a>
<button
onClick={() => handleDeleteDocument(doc.id)}
disabled={isDeleting === doc.id}
className="ml-2 text-red-500 hover:text-red-700 p-1 rounded-full"
title="Delete"
>
{isDeleting === doc.id ? '...' : <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>}
</button>
</div>
</div>
))}
</div>
</div>
{/* Unified Document Viewer Modal */}
{openPreviews.length > 0 && (
<button
onClick={() => setOpenPreviews([])}
className="fixed top-24 right-8 z-50 bg-red-600 text-white px-4 py-2 rounded shadow hover:bg-red-700"
>
Close All Viewers
</button>
)}
<AnimatePresence>
{openPreviews
.filter(preview => typeof preview.id === 'string')
.map(({ doc, isMaximized, position, zIndex, id }) => (
<DraggableModal
key={id}
doc={doc}
isMaximized={isMaximized}
position={position}
zIndex={zIndex}
onClose={() => closePreview(id as string)}
onMaximize={() => toggleMaximize(id as string)}
onDrag={(pos: { x: number; y: number }) => updatePosition(id as string, pos)}
onFocus={() => bringToFront(id as string)}
/>
))}
</AnimatePresence>
{/* Actions */}
<div className="text-center mt-8">
<button
onClick={() => router.push('/admin/dashboard')}
className="bg-gray-600 text-white px-8 py-4 rounded-lg font-semibold shadow-lg hover:bg-gray-700 transition-all duration-200"
>
Back to Dashboard
</button>
</div>
</div>
</LayoutWithSidebar>
);
};
function DraggableModal({ doc, isMaximized, position, zIndex, onClose, onMaximize, onDrag, onFocus }: DraggableModalProps) {
const modalRef = useRef(null);
const [dragging, setDragging] = useState(false);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [textContent, setTextContent] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [officeText, setOfficeText] = useState<string | null>(null);
const [officeLoading, setOfficeLoading] = useState(false);
const [officeError, setOfficeError] = useState<string | null>(null);
const TEXT_TYPES = [
'text/plain',
'text/markdown',
'text/csv',
'application/json',
'text/x-log',
'text/x-yaml',
'text/yaml',
'text/xml',
'application/xml',
'text/html',
'text/css',
'text/javascript',
'application/javascript',
'application/typescript',
'text/typescript',
];
const TEXT_EXTENSIONS = [
'.txt', '.md', '.csv', '.log', '.json', '.ts', '.js', '.css', '.html', '.xml', '.yaml', '.yml'
];
const isTextFile = () => {
const ext = doc.name ? doc.name.slice(doc.name.lastIndexOf('.')).toLowerCase() : '';
return TEXT_TYPES.includes(doc.type) || TEXT_EXTENSIONS.includes(ext);
};
const isDocx = doc.name?.toLowerCase().endsWith('.docx');
const isXlsx = doc.name?.toLowerCase().endsWith('.xlsx');
const isPptx = doc.name?.toLowerCase().endsWith('.pptx');
useEffect(() => {
if (isTextFile()) {
setLoading(true);
setError(null);
fetch(doc.url)
.then(res => {
if (!res.ok) throw new Error('Failed to fetch file');
return res.text();
})
.then(setTextContent)
.catch(e => setError(e.message))
.finally(() => setLoading(false));
} else if (isDocx) {
setOfficeLoading(true);
setOfficeError(null);
fetch(doc.url)
.then(res => res.arrayBuffer())
.then(arrayBuffer => mammoth.extractRawText({ arrayBuffer }))
.then(result => setOfficeText(result.value))
.catch(e => setOfficeError('Failed to extract text from DOCX'))
.finally(() => setOfficeLoading(false));
} else if (isXlsx) {
setOfficeLoading(true);
setOfficeError(null);
fetch(doc.url)
.then(res => res.arrayBuffer())
.then(arrayBuffer => {
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
let text = '';
workbook.SheetNames.forEach(sheetName => {
const sheet = workbook.Sheets[sheetName];
text += `Sheet: ${sheetName}\n`;
text += XLSX.utils.sheet_to_csv(sheet);
text += '\n';
});
setOfficeText(text);
})
.catch(e => setOfficeError('Failed to extract text from XLSX'))
.finally(() => setOfficeLoading(false));
} else if (isPptx) {
setOfficeLoading(true);
setOfficeError(null);
// No robust in-browser PPTX parser, fallback to message
setTimeout(() => {
setOfficeError('Text preview for PowerPoint is not supported.');
setOfficeLoading(false);
}, 500);
}
// eslint-disable-next-line
}, [doc.url, doc.name]);
const handleHeaderMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
// Only start drag if not clicking a button
if (isMaximized || (e.target instanceof HTMLElement && e.target.closest('button'))) return;
setDragging(true);
const safePosition = position || { x: 0, y: 0 };
setOffset({
x: e.clientX - safePosition.x,
y: e.clientY - safePosition.y
});
onFocus();
};
const handleMouseMove = (e: MouseEvent) => {
if (!dragging || isMaximized) return;
// Clamp to viewport
const newX = Math.max(0, Math.min(window.innerWidth - 400, e.clientX - offset.x));
const newY = Math.max(0, Math.min(window.innerHeight - 200, e.clientY - offset.y));
onDrag({ x: newX, y: newY });
};
const handleMouseUp = () => setDragging(false);
useEffect(() => {
if (dragging) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
} else {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
}
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [dragging, handleMouseMove]);
return (
<div
ref={modalRef}
className={`fixed z-[${zIndex}] ${isMaximized ? 'inset-0' : ''}`}
style={isMaximized ? { left: 0, top: 0 } : { left: (position?.x ?? 0), top: (position?.y ?? 0) }}
onMouseDown={onFocus}
>
<div className={`bg-white rounded-2xl shadow-2xl ${isMaximized ? 'w-screen h-screen max-w-none max-h-none' : 'max-w-2xl w-full p-6'} relative flex flex-col border-2 border-primary`}
style={{ cursor: dragging ? 'grabbing' : 'default' }}
>
{/* Modal Header (Drag Handle) */}
<div
className="flex items-center justify-between px-4 py-2 bg-gray-100 rounded-t-2xl border-b border-gray-200 select-none cursor-move"
style={{ userSelect: 'none' }}
onMouseDown={handleHeaderMouseDown}
>
<span className="font-semibold text-lg text-gray-900 truncate">{doc.name}</span>
<div className="flex items-center gap-2 ml-4">
<button
onClick={onMaximize}
className="text-gray-400 hover:text-gray-700 mr-2"
title={isMaximized ? 'Restore' : 'Maximize'}
tabIndex={-1}
>
{isMaximized ? (
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2" strokeWidth="2" /><path d="M9 9h6v6H9z" /></svg>
) : (
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="2" strokeWidth="2" /></svg>
)}
</button>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-700"
tabIndex={-1}
>
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
</div>
{/* Preview content by type */}
{isDocx || isXlsx || isPptx ? (
officeLoading ? (
<div className="p-8 text-center text-gray-500">Loading preview...</div>
) : officeError ? (
<div className="p-8 text-center text-red-600">{officeError}</div>
) : (
<div className="p-4 max-h-[70vh] overflow-auto bg-gray-900 text-gray-100 rounded-lg shadow-inner border border-gray-300 text-left mt-6">
<pre className="whitespace-pre-wrap break-words text-sm font-mono">{officeText}</pre>
</div>
)
) : doc.type?.startsWith('image') ? (
<img src={doc.url} alt={doc.name} className="max-h-[60vh] mx-auto rounded-lg mt-6" style={isMaximized ? { maxHeight: '90vh' } : {}} />
) : doc.type?.startsWith('video') ? (
<video src={doc.url} controls className="max-h-[60vh] mx-auto rounded-lg mt-6" style={isMaximized ? { maxHeight: '90vh' } : {}} />
) : doc.type?.startsWith('audio') ? (
<audio src={doc.url} controls className="w-full mt-6" />
) : doc.type?.includes('pdf') ? (
<iframe src={doc.url} className="w-full h-[60vh] rounded-lg mt-6" title={doc.name} style={isMaximized ? { height: '90vh' } : {}} />
) : isTextFile() ? (
loading ? (
<div className="p-8 text-center text-gray-500">Loading preview...</div>
) : error ? (
<div className="p-8 text-center text-red-600">Failed to load file: {error}</div>
) : (
<div className="p-4 max-h-[70vh] overflow-auto bg-gray-900 text-gray-100 rounded-lg shadow-inner border border-gray-300 text-left mt-6">
<pre className="whitespace-pre-wrap break-words text-sm font-mono">{textContent}</pre>
</div>
)
) : (
<div className="text-gray-500 mt-6">Preview not available for this file type.</div>
)}
</div>
</div>
);
}
export default AdminRegistrationDetail;