466 lines
19 KiB
TypeScript
466 lines
19 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
Eye,
|
|
XCircle
|
|
} from 'lucide-react';
|
|
import { toast } from 'react-toastify';
|
|
import Loading from '../../shared/components/Loading';
|
|
import EmptyState from '../../shared/components/EmptyState';
|
|
import Pagination from '../../shared/components/Pagination';
|
|
import complaintService, { Complaint, ComplaintFilters } from '../../features/guest_management/services/complaintService';
|
|
import { formatDate } from '../../shared/utils/format';
|
|
import { getUserFriendlyError } from '../../shared/utils/errorSanitizer';
|
|
|
|
const ComplaintManagementPage: React.FC = () => {
|
|
const [complaints, setComplaints] = useState<Complaint[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [selectedComplaint, setSelectedComplaint] = useState<Complaint | null>(null);
|
|
const [showDetailModal, setShowDetailModal] = useState(false);
|
|
const [showResolveModal, setShowResolveModal] = useState(false);
|
|
const [resolving, setResolving] = useState(false);
|
|
const [filters, setFilters] = useState<ComplaintFilters>({
|
|
page: 1,
|
|
limit: 20,
|
|
});
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
const [totalItems, setTotalItems] = useState(0);
|
|
const itemsPerPage = 20;
|
|
|
|
useEffect(() => {
|
|
fetchComplaints();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [filters, currentPage]);
|
|
|
|
useEffect(() => {
|
|
setFilters(prev => ({ ...prev, page: currentPage }));
|
|
}, [currentPage]);
|
|
|
|
const fetchComplaints = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const response = await complaintService.getComplaints({
|
|
...filters,
|
|
page: currentPage,
|
|
limit: itemsPerPage,
|
|
});
|
|
setComplaints(response.data.complaints || []);
|
|
if (response.data.pagination) {
|
|
setTotalPages(response.data.pagination.total_pages || 1);
|
|
setTotalItems(response.data.pagination.total || 0);
|
|
}
|
|
} catch (error: unknown) {
|
|
toast.error(getUserFriendlyError(error) || 'Unable to load complaints');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleViewDetails = async (complaintId: number) => {
|
|
try {
|
|
const response = await complaintService.getComplaint(complaintId);
|
|
setSelectedComplaint(response.data.complaint);
|
|
setShowDetailModal(true);
|
|
} catch (error: unknown) {
|
|
toast.error(getUserFriendlyError(error) || 'Unable to load complaint details');
|
|
}
|
|
};
|
|
|
|
const handleResolve = async (resolution: string, rating?: number, feedback?: string) => {
|
|
if (!selectedComplaint) return;
|
|
|
|
try {
|
|
setResolving(true);
|
|
await complaintService.resolveComplaint(selectedComplaint.id, {
|
|
resolution,
|
|
guest_satisfaction_rating: rating,
|
|
guest_feedback: feedback,
|
|
});
|
|
toast.success('Complaint resolved successfully');
|
|
setShowResolveModal(false);
|
|
setShowDetailModal(false);
|
|
fetchComplaints();
|
|
} catch (error: unknown) {
|
|
toast.error(getUserFriendlyError(error) || 'Unable to resolve complaint');
|
|
} finally {
|
|
setResolving(false);
|
|
}
|
|
};
|
|
|
|
const handleUpdateStatus = async (complaintId: number, status: string) => {
|
|
try {
|
|
await complaintService.updateComplaint(complaintId, { status });
|
|
toast.success('Complaint status updated');
|
|
fetchComplaints();
|
|
if (selectedComplaint?.id === complaintId) {
|
|
setSelectedComplaint({ ...selectedComplaint, status: status as 'open' | 'in_progress' | 'resolved' | 'closed' | 'escalated' });
|
|
}
|
|
} catch (error: unknown) {
|
|
toast.error(getUserFriendlyError(error) || 'Unable to update complaint');
|
|
}
|
|
};
|
|
|
|
const getPriorityColor = (priority: string) => {
|
|
switch (priority) {
|
|
case 'urgent':
|
|
return 'bg-red-100 text-red-800 border-red-200';
|
|
case 'high':
|
|
return 'bg-orange-100 text-orange-800 border-orange-200';
|
|
case 'medium':
|
|
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
|
case 'low':
|
|
return 'bg-blue-100 text-blue-800 border-blue-200';
|
|
default:
|
|
return 'bg-gray-100 text-gray-800 border-gray-200';
|
|
}
|
|
};
|
|
|
|
const getStatusColor = (status: string) => {
|
|
switch (status) {
|
|
case 'resolved':
|
|
return 'bg-green-100 text-green-800';
|
|
case 'closed':
|
|
return 'bg-gray-100 text-gray-800';
|
|
case 'in_progress':
|
|
return 'bg-blue-100 text-blue-800';
|
|
case 'escalated':
|
|
return 'bg-purple-100 text-purple-800';
|
|
case 'open':
|
|
return 'bg-yellow-100 text-yellow-800';
|
|
default:
|
|
return 'bg-gray-100 text-gray-800';
|
|
}
|
|
};
|
|
|
|
if (loading && complaints.length === 0) {
|
|
return <Loading />;
|
|
}
|
|
|
|
return (
|
|
<div className="container mx-auto px-4 py-8">
|
|
<div className="mb-8 flex justify-between items-center">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">Complaint Management</h1>
|
|
<p className="text-gray-600">Manage and resolve guest complaints</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Status</label>
|
|
<select
|
|
value={filters.status || ''}
|
|
onChange={(e) => setFilters({ ...filters, status: e.target.value || undefined, page: 1 })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[var(--luxury-gold)] focus:border-transparent"
|
|
>
|
|
<option value="">All Statuses</option>
|
|
<option value="open">Open</option>
|
|
<option value="in_progress">In Progress</option>
|
|
<option value="resolved">Resolved</option>
|
|
<option value="closed">Closed</option>
|
|
<option value="escalated">Escalated</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Priority</label>
|
|
<select
|
|
value={filters.priority || ''}
|
|
onChange={(e) => setFilters({ ...filters, priority: e.target.value || undefined, page: 1 })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[var(--luxury-gold)] focus:border-transparent"
|
|
>
|
|
<option value="">All Priorities</option>
|
|
<option value="low">Low</option>
|
|
<option value="medium">Medium</option>
|
|
<option value="high">High</option>
|
|
<option value="urgent">Urgent</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Category</label>
|
|
<select
|
|
value={filters.category || ''}
|
|
onChange={(e) => setFilters({ ...filters, category: e.target.value || undefined, page: 1 })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[var(--luxury-gold)] focus:border-transparent"
|
|
>
|
|
<option value="">All Categories</option>
|
|
<option value="room_quality">Room Quality</option>
|
|
<option value="service">Service</option>
|
|
<option value="cleanliness">Cleanliness</option>
|
|
<option value="noise">Noise</option>
|
|
<option value="billing">Billing</option>
|
|
<option value="staff_behavior">Staff Behavior</option>
|
|
<option value="amenities">Amenities</option>
|
|
<option value="other">Other</option>
|
|
</select>
|
|
</div>
|
|
<div className="flex items-end">
|
|
<button
|
|
onClick={() => setFilters({ page: 1, limit: 20 })}
|
|
className="w-full px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
|
>
|
|
Clear Filters
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Complaints List */}
|
|
{complaints.length === 0 ? (
|
|
<EmptyState
|
|
title="No Complaints Found"
|
|
description="There are no complaints matching your filters"
|
|
/>
|
|
) : (
|
|
<>
|
|
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Title</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Category</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Priority</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</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">
|
|
{complaints.map((complaint) => (
|
|
<tr key={complaint.id} className="hover:bg-gray-50">
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{complaint.id}</td>
|
|
<td className="px-6 py-4 text-sm text-gray-900">{complaint.title}</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
{complaint.category.replace('_', ' ')}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span className={`px-2 py-1 rounded-full text-xs font-medium border ${getPriorityColor(complaint.priority)}`}>
|
|
{complaint.priority}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(complaint.status)}`}>
|
|
{complaint.status.replace('_', ' ')}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
{formatDate(complaint.created_at)}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
|
<button
|
|
onClick={() => handleViewDetails(complaint.id)}
|
|
className="text-[var(--luxury-gold)] hover:text-[var(--luxury-gold-dark)] mr-3"
|
|
>
|
|
<Eye className="w-5 h-5" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<Pagination
|
|
currentPage={currentPage}
|
|
totalPages={totalPages}
|
|
totalItems={totalItems}
|
|
itemsPerPage={itemsPerPage}
|
|
onPageChange={setCurrentPage}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{/* Detail Modal */}
|
|
{showDetailModal && selectedComplaint && (
|
|
<ComplaintDetailModal
|
|
complaint={selectedComplaint}
|
|
onClose={() => {
|
|
setShowDetailModal(false);
|
|
setSelectedComplaint(null);
|
|
}}
|
|
onResolve={() => setShowResolveModal(true)}
|
|
onUpdateStatus={handleUpdateStatus}
|
|
/>
|
|
)}
|
|
|
|
{/* Resolve Modal */}
|
|
{showResolveModal && selectedComplaint && (
|
|
<ResolveComplaintModal
|
|
complaint={selectedComplaint}
|
|
onClose={() => setShowResolveModal(false)}
|
|
onResolve={handleResolve}
|
|
resolving={resolving}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Complaint Detail Modal Component
|
|
const ComplaintDetailModal: React.FC<{
|
|
complaint: Complaint;
|
|
onClose: () => void;
|
|
onResolve: () => void;
|
|
onUpdateStatus: (id: number, status: string) => void;
|
|
}> = ({ complaint, onClose, onResolve }) => {
|
|
return (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
|
<div className="p-6 border-b border-gray-200 flex justify-between items-center">
|
|
<h2 className="text-2xl font-bold text-gray-900">Complaint Details</h2>
|
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
|
<XCircle className="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
<div className="p-6 space-y-6">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">{complaint.title}</h3>
|
|
<p className="text-gray-600">{complaint.description}</p>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="text-sm font-medium text-gray-500">Category</label>
|
|
<p className="text-gray-900">{complaint.category.replace('_', ' ')}</p>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-medium text-gray-500">Priority</label>
|
|
<p className="text-gray-900 capitalize">{complaint.priority}</p>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-medium text-gray-500">Status</label>
|
|
<p className="text-gray-900 capitalize">{complaint.status.replace('_', ' ')}</p>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-medium text-gray-500">Created</label>
|
|
<p className="text-gray-900">{formatDate(complaint.created_at)}</p>
|
|
</div>
|
|
</div>
|
|
{complaint.resolution && (
|
|
<div>
|
|
<label className="text-sm font-medium text-gray-500">Resolution</label>
|
|
<p className="text-gray-900">{complaint.resolution}</p>
|
|
</div>
|
|
)}
|
|
{complaint.updates && complaint.updates.length > 0 && (
|
|
<div>
|
|
<label className="text-sm font-medium text-gray-500 mb-2 block">Updates</label>
|
|
<div className="space-y-2">
|
|
{complaint.updates.map((update) => (
|
|
<div key={update.id} className="bg-gray-50 p-3 rounded">
|
|
<p className="text-sm text-gray-900">{update.description}</p>
|
|
<p className="text-xs text-gray-500 mt-1">{formatDate(update.created_at)}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="flex justify-end space-x-3 pt-4 border-t">
|
|
{complaint.status !== 'resolved' && complaint.status !== 'closed' && (
|
|
<button
|
|
onClick={onResolve}
|
|
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
|
>
|
|
Resolve
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={onClose}
|
|
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Resolve Complaint Modal Component
|
|
const ResolveComplaintModal: React.FC<{
|
|
complaint: Complaint;
|
|
onClose: () => void;
|
|
onResolve: (resolution: string, rating?: number, feedback?: string) => void;
|
|
resolving: boolean;
|
|
}> = ({ onClose, onResolve, resolving }) => {
|
|
const [resolution, setResolution] = useState('');
|
|
const [rating, setRating] = useState<number | undefined>();
|
|
const [feedback, setFeedback] = useState('');
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white rounded-lg max-w-2xl w-full">
|
|
<div className="p-6 border-b border-gray-200">
|
|
<h2 className="text-2xl font-bold text-gray-900">Resolve Complaint</h2>
|
|
</div>
|
|
<div className="p-6 space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Resolution Details *
|
|
</label>
|
|
<textarea
|
|
value={resolution}
|
|
onChange={(e) => setResolution(e.target.value)}
|
|
rows={4}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[var(--luxury-gold)] focus:border-transparent"
|
|
placeholder="Describe how the complaint was resolved..."
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Guest Satisfaction Rating (1-5)
|
|
</label>
|
|
<select
|
|
value={rating || ''}
|
|
onChange={(e) => setRating(e.target.value ? parseInt(e.target.value) : undefined)}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[var(--luxury-gold)] focus:border-transparent"
|
|
>
|
|
<option value="">Not rated</option>
|
|
<option value="1">1 - Very Dissatisfied</option>
|
|
<option value="2">2 - Dissatisfied</option>
|
|
<option value="3">3 - Neutral</option>
|
|
<option value="4">4 - Satisfied</option>
|
|
<option value="5">5 - Very Satisfied</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Guest Feedback
|
|
</label>
|
|
<textarea
|
|
value={feedback}
|
|
onChange={(e) => setFeedback(e.target.value)}
|
|
rows={3}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[var(--luxury-gold)] focus:border-transparent"
|
|
placeholder="Guest feedback on resolution..."
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end space-x-3 pt-4">
|
|
<button
|
|
onClick={onClose}
|
|
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
|
|
disabled={resolving}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={() => onResolve(resolution, rating, feedback)}
|
|
disabled={!resolution.trim() || resolving}
|
|
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{resolving ? 'Resolving...' : 'Resolve Complaint'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ComplaintManagementPage;
|
|
|