801 lines
34 KiB
TypeScript
801 lines
34 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
AlertTriangle,
|
|
CheckCircle,
|
|
Clock,
|
|
XCircle,
|
|
Filter,
|
|
Search,
|
|
Eye,
|
|
Edit,
|
|
MessageSquare,
|
|
User,
|
|
Calendar,
|
|
MapPin,
|
|
RefreshCw,
|
|
X,
|
|
ArrowUp,
|
|
ArrowDown,
|
|
} from 'lucide-react';
|
|
import { toast } from 'react-toastify';
|
|
import Loading from '../../shared/components/Loading';
|
|
import EmptyState from '../../shared/components/EmptyState';
|
|
import { formatDate, formatRelativeTime } from '../../shared/utils/format';
|
|
import { logger } from '../../shared/utils/logger';
|
|
import complaintService, { Complaint, ComplaintUpdate } from '../../features/complaints/services/complaintService';
|
|
import userService from '../../features/auth/services/userService';
|
|
import Pagination from '../../shared/components/Pagination';
|
|
|
|
const IncidentComplaintManagementPage: React.FC = () => {
|
|
const [loading, setLoading] = useState(true);
|
|
const [complaints, setComplaints] = useState<Complaint[]>([]);
|
|
const [selectedComplaint, setSelectedComplaint] = useState<Complaint | null>(null);
|
|
const [complaintUpdates, setComplaintUpdates] = useState<ComplaintUpdate[]>([]);
|
|
const [showDetailModal, setShowDetailModal] = useState(false);
|
|
const [staffMembers, setStaffMembers] = useState<any[]>([]);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
const [filters, setFilters] = useState({
|
|
status: '',
|
|
priority: '',
|
|
category: '',
|
|
assigned_to: '',
|
|
search: '',
|
|
});
|
|
const [expandedFilters, setExpandedFilters] = useState(false);
|
|
const [updatingComplaintId, setUpdatingComplaintId] = useState<number | null>(null);
|
|
const [updateForm, setUpdateForm] = useState({
|
|
description: '',
|
|
update_type: 'note',
|
|
});
|
|
const [resolveForm, setResolveForm] = useState({
|
|
resolution_notes: '',
|
|
compensation_amount: '',
|
|
});
|
|
|
|
useEffect(() => {
|
|
fetchComplaints();
|
|
fetchStaffMembers();
|
|
}, [currentPage, filters.status, filters.priority, filters.category, filters.assigned_to]);
|
|
|
|
const fetchComplaints = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const params: any = {
|
|
page: currentPage,
|
|
limit: 20,
|
|
};
|
|
|
|
if (filters.status) params.status = filters.status;
|
|
if (filters.priority) params.priority = filters.priority;
|
|
if (filters.category) params.category = filters.category;
|
|
if (filters.assigned_to) params.assigned_to = parseInt(filters.assigned_to);
|
|
|
|
const response = await complaintService.getComplaints(params);
|
|
|
|
if (response.status === 'success' && response.data?.complaints) {
|
|
let filtered = response.data.complaints;
|
|
|
|
// Client-side search filter
|
|
if (filters.search) {
|
|
const searchLower = filters.search.toLowerCase();
|
|
filtered = filtered.filter(
|
|
(complaint: Complaint) =>
|
|
complaint.title?.toLowerCase().includes(searchLower) ||
|
|
complaint.guest_name?.toLowerCase().includes(searchLower) ||
|
|
complaint.description?.toLowerCase().includes(searchLower) ||
|
|
complaint.room_number?.toString().includes(searchLower)
|
|
);
|
|
}
|
|
|
|
setComplaints(filtered);
|
|
setTotalPages(response.data.pagination?.total_pages || 1);
|
|
}
|
|
} catch (error: any) {
|
|
logger.error('Error fetching complaints', error);
|
|
toast.error('Failed to load complaints');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchStaffMembers = async () => {
|
|
try {
|
|
const response = await userService.getUsers({ role: 'staff', limit: 100 });
|
|
if (response.data?.users) {
|
|
setStaffMembers(response.data.users);
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to fetch staff members', error);
|
|
}
|
|
};
|
|
|
|
const handleViewDetails = async (complaint: Complaint) => {
|
|
try {
|
|
const response = await complaintService.getComplaint(complaint.id);
|
|
if (response.status === 'success' && response.data) {
|
|
setSelectedComplaint(response.data.complaint);
|
|
setComplaintUpdates(response.data.updates || []);
|
|
setShowDetailModal(true);
|
|
}
|
|
} catch (error: any) {
|
|
toast.error('Failed to load complaint details');
|
|
}
|
|
};
|
|
|
|
const handleUpdateStatus = async (complaintId: number, newStatus: string) => {
|
|
try {
|
|
setUpdatingComplaintId(complaintId);
|
|
await complaintService.updateComplaint(complaintId, { status: newStatus });
|
|
toast.success(`Complaint marked as ${newStatus}`);
|
|
fetchComplaints();
|
|
if (selectedComplaint?.id === complaintId) {
|
|
const updated = await complaintService.getComplaint(complaintId);
|
|
if (updated.status === 'success') {
|
|
setSelectedComplaint(updated.data.complaint);
|
|
}
|
|
}
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.detail || 'Failed to update complaint');
|
|
} finally {
|
|
setUpdatingComplaintId(null);
|
|
}
|
|
};
|
|
|
|
const handleAssign = async (complaintId: number, staffId?: number) => {
|
|
try {
|
|
setUpdatingComplaintId(complaintId);
|
|
await complaintService.updateComplaint(complaintId, { assigned_to: staffId });
|
|
toast.success('Complaint assigned successfully');
|
|
fetchComplaints();
|
|
if (selectedComplaint?.id === complaintId) {
|
|
const updated = await complaintService.getComplaint(complaintId);
|
|
if (updated.status === 'success') {
|
|
setSelectedComplaint(updated.data.complaint);
|
|
}
|
|
}
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.detail || 'Failed to assign complaint');
|
|
} finally {
|
|
setUpdatingComplaintId(null);
|
|
}
|
|
};
|
|
|
|
const handleAddUpdate = async () => {
|
|
if (!selectedComplaint || !updateForm.description.trim()) {
|
|
toast.error('Please enter update description');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await complaintService.addComplaintUpdate(selectedComplaint.id, {
|
|
description: updateForm.description,
|
|
update_type: updateForm.update_type,
|
|
});
|
|
toast.success('Update added successfully');
|
|
setUpdateForm({ description: '', update_type: 'note' });
|
|
|
|
// Refresh complaint details
|
|
const updated = await complaintService.getComplaint(selectedComplaint.id);
|
|
if (updated.status === 'success') {
|
|
setSelectedComplaint(updated.data.complaint);
|
|
setComplaintUpdates(updated.data.updates || []);
|
|
}
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.detail || 'Failed to add update');
|
|
}
|
|
};
|
|
|
|
const handleResolve = async () => {
|
|
if (!selectedComplaint || !resolveForm.resolution_notes.trim()) {
|
|
toast.error('Please enter resolution notes');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setUpdatingComplaintId(selectedComplaint.id);
|
|
await complaintService.resolveComplaint(selectedComplaint.id, {
|
|
resolution_notes: resolveForm.resolution_notes,
|
|
compensation_amount: resolveForm.compensation_amount ? parseFloat(resolveForm.compensation_amount) : undefined,
|
|
});
|
|
toast.success('Complaint resolved successfully');
|
|
setResolveForm({ resolution_notes: '', compensation_amount: '' });
|
|
fetchComplaints();
|
|
setShowDetailModal(false);
|
|
setSelectedComplaint(null);
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.detail || 'Failed to resolve complaint');
|
|
} finally {
|
|
setUpdatingComplaintId(null);
|
|
}
|
|
};
|
|
|
|
const getStatusColor = (status: string) => {
|
|
switch (status) {
|
|
case 'open':
|
|
return 'bg-red-100 text-red-800 border-red-300';
|
|
case 'in_progress':
|
|
return 'bg-yellow-100 text-yellow-800 border-yellow-300';
|
|
case 'resolved':
|
|
return 'bg-green-100 text-green-800 border-green-300';
|
|
case 'closed':
|
|
return 'bg-gray-100 text-gray-800 border-gray-300';
|
|
default:
|
|
return 'bg-gray-100 text-gray-800 border-gray-300';
|
|
}
|
|
};
|
|
|
|
const getPriorityColor = (priority: string) => {
|
|
switch (priority) {
|
|
case 'urgent':
|
|
return 'bg-red-100 text-red-800 border-red-300';
|
|
case 'high':
|
|
return 'bg-orange-100 text-orange-800 border-orange-300';
|
|
case 'medium':
|
|
return 'bg-yellow-100 text-yellow-800 border-yellow-300';
|
|
case 'low':
|
|
return 'bg-blue-100 text-blue-800 border-blue-300';
|
|
default:
|
|
return 'bg-gray-100 text-gray-800 border-gray-300';
|
|
}
|
|
};
|
|
|
|
const getCategoryIcon = (category: string) => {
|
|
switch (category) {
|
|
case 'room_quality':
|
|
return '🏨';
|
|
case 'service':
|
|
return '👥';
|
|
case 'billing':
|
|
return '💰';
|
|
case 'noise':
|
|
return '🔊';
|
|
case 'cleanliness':
|
|
return '🧹';
|
|
case 'maintenance':
|
|
return '🔧';
|
|
default:
|
|
return '📋';
|
|
}
|
|
};
|
|
|
|
const openCount = complaints.filter(c => c.status === 'open').length;
|
|
const inProgressCount = complaints.filter(c => c.status === 'in_progress').length;
|
|
const urgentCount = complaints.filter(c => c.priority === 'urgent' && c.status !== 'resolved' && c.status !== 'closed').length;
|
|
|
|
if (loading && complaints.length === 0) {
|
|
return <Loading fullScreen text="Loading complaints..." />;
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 px-3 sm:px-4 md:px-6 lg:px-8 py-6 sm:py-8 md:py-10">
|
|
{/* Header */}
|
|
<div className="mb-6 sm:mb-8">
|
|
<div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-3">
|
|
<div className="h-1 w-12 sm:w-16 md:w-20 bg-gradient-to-r from-red-400 via-orange-500 to-yellow-600 rounded-full"></div>
|
|
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
|
|
Incident & Complaint Management
|
|
</h1>
|
|
</div>
|
|
<p className="text-slate-600 mt-2 sm:mt-3 text-sm sm:text-base md:text-lg font-light">
|
|
Track, assign, and resolve guest complaints and incidents
|
|
</p>
|
|
</div>
|
|
|
|
{/* Stats Cards */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-5 md:gap-6 mb-6 sm:mb-8">
|
|
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-4 sm:p-5 border-l-4 border-l-red-500">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1">Open</p>
|
|
<p className="text-2xl sm:text-3xl font-bold text-red-600">{openCount}</p>
|
|
</div>
|
|
<AlertTriangle className="w-8 h-8 sm:w-10 sm:h-10 text-red-500" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-4 sm:p-5 border-l-4 border-l-yellow-500">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1">In Progress</p>
|
|
<p className="text-2xl sm:text-3xl font-bold text-yellow-600">{inProgressCount}</p>
|
|
</div>
|
|
<Clock className="w-8 h-8 sm:w-10 sm:h-10 text-yellow-500" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-4 sm:p-5 border-l-4 border-l-orange-500">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1">Urgent</p>
|
|
<p className="text-2xl sm:text-3xl font-bold text-orange-600">{urgentCount}</p>
|
|
</div>
|
|
<ArrowUp className="w-8 h-8 sm:w-10 sm:h-10 text-orange-500" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-4 sm:p-5 border-l-4 border-l-blue-500">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1">Total</p>
|
|
<p className="text-2xl sm:text-3xl font-bold text-blue-600">{complaints.length}</p>
|
|
</div>
|
|
<AlertTriangle className="w-8 h-8 sm:w-10 sm:h-10 text-blue-500" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 mb-6 sm:mb-8">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-lg sm:text-xl font-bold text-slate-900 flex items-center gap-2">
|
|
<Filter className="w-5 h-5 text-blue-600" />
|
|
Filters
|
|
</h2>
|
|
<button
|
|
onClick={() => setExpandedFilters(!expandedFilters)}
|
|
className="flex items-center gap-2 px-3 py-2 text-sm text-blue-600 hover:text-blue-700 font-medium"
|
|
>
|
|
{expandedFilters ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
|
|
{expandedFilters ? 'Hide' : 'Show'} Filters
|
|
</button>
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div className="mb-4">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400 w-5 h-5" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search by title, guest name, room, or description..."
|
|
value={filters.search}
|
|
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
|
className="w-full pl-10 pr-4 py-2.5 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all duration-200 text-slate-700"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{expandedFilters && (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">Status</label>
|
|
<select
|
|
value={filters.status}
|
|
onChange={(e) => {
|
|
setFilters({ ...filters, status: e.target.value });
|
|
setCurrentPage(1);
|
|
}}
|
|
className="w-full px-3 py-2.5 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all duration-200 text-slate-700"
|
|
>
|
|
<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>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">Priority</label>
|
|
<select
|
|
value={filters.priority}
|
|
onChange={(e) => {
|
|
setFilters({ ...filters, priority: e.target.value });
|
|
setCurrentPage(1);
|
|
}}
|
|
className="w-full px-3 py-2.5 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all duration-200 text-slate-700"
|
|
>
|
|
<option value="">All Priorities</option>
|
|
<option value="urgent">Urgent</option>
|
|
<option value="high">High</option>
|
|
<option value="medium">Medium</option>
|
|
<option value="low">Low</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">Category</label>
|
|
<select
|
|
value={filters.category}
|
|
onChange={(e) => {
|
|
setFilters({ ...filters, category: e.target.value });
|
|
setCurrentPage(1);
|
|
}}
|
|
className="w-full px-3 py-2.5 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all duration-200 text-slate-700"
|
|
>
|
|
<option value="">All Categories</option>
|
|
<option value="room_quality">Room Quality</option>
|
|
<option value="service">Service</option>
|
|
<option value="billing">Billing</option>
|
|
<option value="noise">Noise</option>
|
|
<option value="cleanliness">Cleanliness</option>
|
|
<option value="maintenance">Maintenance</option>
|
|
<option value="other">Other</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">Assigned To</label>
|
|
<select
|
|
value={filters.assigned_to}
|
|
onChange={(e) => {
|
|
setFilters({ ...filters, assigned_to: e.target.value });
|
|
setCurrentPage(1);
|
|
}}
|
|
className="w-full px-3 py-2.5 border-2 border-slate-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all duration-200 text-slate-700"
|
|
>
|
|
<option value="">All Staff</option>
|
|
<option value="unassigned">Unassigned</option>
|
|
{staffMembers.map((staff) => (
|
|
<option key={staff.id} value={staff.id}>
|
|
{staff.full_name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-4 flex gap-2">
|
|
<button
|
|
onClick={() => {
|
|
setFilters({
|
|
status: '',
|
|
priority: '',
|
|
category: '',
|
|
assigned_to: '',
|
|
search: '',
|
|
});
|
|
setCurrentPage(1);
|
|
}}
|
|
className="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 font-medium"
|
|
>
|
|
Clear Filters
|
|
</button>
|
|
<button
|
|
onClick={fetchComplaints}
|
|
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium flex items-center gap-2"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Complaints List */}
|
|
<div className="bg-white/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-200/60 overflow-hidden">
|
|
{complaints.length === 0 ? (
|
|
<div className="p-8 sm:p-12">
|
|
<EmptyState
|
|
title="No complaints found"
|
|
description="There are no complaints matching your filters."
|
|
/>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full divide-y divide-slate-200">
|
|
<thead className="bg-slate-50">
|
|
<tr>
|
|
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
|
Complaint
|
|
</th>
|
|
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
|
Guest / Room
|
|
</th>
|
|
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
|
Status
|
|
</th>
|
|
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
|
Priority
|
|
</th>
|
|
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
|
Assigned To
|
|
</th>
|
|
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
|
Date
|
|
</th>
|
|
<th className="px-4 sm:px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
|
Actions
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-slate-200">
|
|
{complaints.map((complaint) => (
|
|
<tr
|
|
key={complaint.id}
|
|
className={`hover:bg-slate-50 transition-colors cursor-pointer ${
|
|
complaint.priority === 'urgent' && complaint.status !== 'resolved' && complaint.status !== 'closed'
|
|
? 'bg-red-50/50'
|
|
: ''
|
|
}`}
|
|
onClick={() => handleViewDetails(complaint)}
|
|
>
|
|
<td className="px-4 sm:px-6 py-4">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-2xl">{getCategoryIcon(complaint.category)}</span>
|
|
<div>
|
|
<div className="text-sm font-medium text-slate-900">{complaint.title}</div>
|
|
<div className="text-xs text-slate-500 capitalize">{complaint.category.replace('_', ' ')}</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 sm:px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm text-slate-900">{complaint.guest_name || 'N/A'}</div>
|
|
{complaint.room_number && (
|
|
<div className="text-xs text-slate-500 flex items-center gap-1">
|
|
<MapPin className="w-3 h-3" />
|
|
Room {complaint.room_number}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td className="px-4 sm:px-6 py-4 whitespace-nowrap">
|
|
<span
|
|
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full border ${getStatusColor(
|
|
complaint.status
|
|
)}`}
|
|
>
|
|
{complaint.status.replace('_', ' ')}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 sm:px-6 py-4 whitespace-nowrap">
|
|
<span
|
|
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full border ${getPriorityColor(
|
|
complaint.priority
|
|
)}`}
|
|
>
|
|
{complaint.priority}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 sm:px-6 py-4 whitespace-nowrap text-sm text-slate-500">
|
|
{complaint.assigned_staff_name || (
|
|
<span className="text-yellow-600 font-medium">Unassigned</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 sm:px-6 py-4 whitespace-nowrap text-sm text-slate-500">
|
|
{formatRelativeTime(complaint.created_at)}
|
|
</td>
|
|
<td className="px-4 sm:px-6 py-4 whitespace-nowrap text-sm font-medium">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleViewDetails(complaint);
|
|
}}
|
|
className="text-blue-600 hover:text-blue-900 p-1.5 hover:bg-blue-50 rounded-lg transition-colors"
|
|
title="View details"
|
|
>
|
|
<Eye className="w-4 h-4" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{totalPages > 1 && (
|
|
<div className="px-4 sm:px-6 py-4 border-t border-slate-200">
|
|
<Pagination
|
|
currentPage={currentPage}
|
|
totalPages={totalPages}
|
|
onPageChange={setCurrentPage}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Detail Modal */}
|
|
{showDetailModal && selectedComplaint && (
|
|
<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 shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
|
<div className="p-6 border-b border-slate-200">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-2xl font-bold text-slate-900">Complaint Details</h2>
|
|
<button
|
|
onClick={() => {
|
|
setShowDetailModal(false);
|
|
setSelectedComplaint(null);
|
|
setComplaintUpdates([]);
|
|
setUpdateForm({ description: '', update_type: 'note' });
|
|
setResolveForm({ resolution_notes: '', compensation_amount: '' });
|
|
}}
|
|
className="text-slate-400 hover:text-slate-600"
|
|
>
|
|
<X className="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-6 space-y-6">
|
|
{/* Complaint Info */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-500 mb-1">Status</label>
|
|
<span
|
|
className={`px-3 py-1 inline-flex text-sm font-semibold rounded-full border ${getStatusColor(
|
|
selectedComplaint.status
|
|
)}`}
|
|
>
|
|
{selectedComplaint.status.replace('_', ' ')}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-500 mb-1">Priority</label>
|
|
<span
|
|
className={`px-3 py-1 inline-flex text-sm font-semibold rounded-full border ${getPriorityColor(
|
|
selectedComplaint.priority
|
|
)}`}
|
|
>
|
|
{selectedComplaint.priority}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-500 mb-1">Title</label>
|
|
<p className="text-slate-900 font-medium">{selectedComplaint.title}</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-500 mb-1">Description</label>
|
|
<p className="text-slate-900 whitespace-pre-wrap">{selectedComplaint.description}</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-500 mb-1">Guest</label>
|
|
<p className="text-slate-900">{selectedComplaint.guest_name || 'N/A'}</p>
|
|
</div>
|
|
{selectedComplaint.room_number && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-500 mb-1">Room</label>
|
|
<p className="text-slate-900">Room {selectedComplaint.room_number}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-500 mb-1">Category</label>
|
|
<p className="text-slate-900 capitalize">{selectedComplaint.category.replace('_', ' ')}</p>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-500 mb-1">Created At</label>
|
|
<p className="text-slate-900">{formatDate(selectedComplaint.created_at)}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{selectedComplaint.assigned_staff_name && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-500 mb-1">Assigned To</label>
|
|
<p className="text-slate-900">{selectedComplaint.assigned_staff_name}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Updates Timeline */}
|
|
{complaintUpdates.length > 0 && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-500 mb-3">Updates Timeline</label>
|
|
<div className="space-y-3">
|
|
{complaintUpdates.map((update) => (
|
|
<div key={update.id} className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-sm font-semibold text-slate-900">{update.updated_by_name || 'System'}</span>
|
|
<span className="text-xs text-slate-500">{formatRelativeTime(update.created_at)}</span>
|
|
</div>
|
|
<p className="text-sm text-slate-700">{update.description}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
{selectedComplaint.status !== 'resolved' && selectedComplaint.status !== 'closed' && (
|
|
<div className="pt-4 border-t border-slate-200 space-y-4">
|
|
{!selectedComplaint.assigned_to && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">Assign To</label>
|
|
<select
|
|
onChange={(e) => {
|
|
if (e.target.value) {
|
|
handleAssign(selectedComplaint.id, parseInt(e.target.value));
|
|
}
|
|
}}
|
|
className="w-full px-3 py-2 border-2 border-slate-200 rounded-lg focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
|
|
>
|
|
<option value="">Select staff member...</option>
|
|
{staffMembers.map((staff) => (
|
|
<option key={staff.id} value={staff.id}>
|
|
{staff.full_name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">Add Update</label>
|
|
<textarea
|
|
value={updateForm.description}
|
|
onChange={(e) => setUpdateForm({ ...updateForm, description: e.target.value })}
|
|
rows={3}
|
|
placeholder="Add an update or note..."
|
|
className="w-full px-3 py-2 border-2 border-slate-200 rounded-lg focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
|
|
/>
|
|
<button
|
|
onClick={handleAddUpdate}
|
|
className="mt-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
|
|
>
|
|
Add Update
|
|
</button>
|
|
</div>
|
|
|
|
{selectedComplaint.status !== 'resolved' && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">Resolution Notes *</label>
|
|
<textarea
|
|
value={resolveForm.resolution_notes}
|
|
onChange={(e) => setResolveForm({ ...resolveForm, resolution_notes: e.target.value })}
|
|
rows={4}
|
|
placeholder="Enter resolution details..."
|
|
className="w-full px-3 py-2 border-2 border-slate-200 rounded-lg focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
|
|
/>
|
|
<div className="mt-2">
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">Compensation Amount (Optional)</label>
|
|
<input
|
|
type="number"
|
|
value={resolveForm.compensation_amount}
|
|
onChange={(e) => setResolveForm({ ...resolveForm, compensation_amount: e.target.value })}
|
|
placeholder="0.00"
|
|
className="w-full px-3 py-2 border-2 border-slate-200 rounded-lg focus:border-blue-400 focus:ring-4 focus:ring-blue-100"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={handleResolve}
|
|
disabled={updatingComplaintId === selectedComplaint.id}
|
|
className="mt-2 w-full px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium disabled:opacity-50"
|
|
>
|
|
{updatingComplaintId === selectedComplaint.id ? 'Resolving...' : 'Resolve Complaint'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => handleUpdateStatus(selectedComplaint.id, 'in_progress')}
|
|
disabled={updatingComplaintId === selectedComplaint.id || selectedComplaint.status === 'in_progress'}
|
|
className="flex-1 px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 font-medium disabled:opacity-50"
|
|
>
|
|
Mark In Progress
|
|
</button>
|
|
<button
|
|
onClick={() => handleUpdateStatus(selectedComplaint.id, 'closed')}
|
|
disabled={updatingComplaintId === selectedComplaint.id}
|
|
className="flex-1 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 font-medium disabled:opacity-50"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{selectedComplaint.status === 'resolved' && selectedComplaint.resolution_notes && (
|
|
<div className="pt-4 border-t border-slate-200">
|
|
<label className="block text-sm font-medium text-slate-500 mb-1">Resolution Notes</label>
|
|
<p className="text-slate-900 whitespace-pre-wrap">{selectedComplaint.resolution_notes}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default IncidentComplaintManagementPage;
|
|
|