update
This commit is contained in:
@@ -59,6 +59,7 @@ const InvoiceEditPage = lazy(() => import('./pages/admin/InvoiceEditPage'));
|
||||
const ProfilePage = lazy(() => import('./pages/customer/ProfilePage'));
|
||||
const LoyaltyPage = lazy(() => import('./pages/customer/LoyaltyPage'));
|
||||
const GroupBookingPage = lazy(() => import('./pages/customer/GroupBookingPage'));
|
||||
const ComplaintPage = lazy(() => import('./pages/customer/ComplaintPage'));
|
||||
const AboutPage = lazy(() => import('./features/content/pages/AboutPage'));
|
||||
const ContactPage = lazy(() => import('./features/content/pages/ContactPage'));
|
||||
const PrivacyPolicyPage = lazy(() => import('./features/content/pages/PrivacyPolicyPage'));
|
||||
@@ -93,6 +94,9 @@ const SecurityManagementPage = lazy(() => import('./pages/admin/SecurityManageme
|
||||
const EmailCampaignManagementPage = lazy(() => import('./pages/admin/EmailCampaignManagementPage'));
|
||||
const ReviewManagementPage = lazy(() => import('./pages/admin/ReviewManagementPage'));
|
||||
const BlogManagementPage = lazy(() => import('./pages/admin/BlogManagementPage'));
|
||||
const ComplaintManagementPage = lazy(() => import('./pages/admin/ComplaintManagementPage'));
|
||||
const FinancialAuditTrailPage = lazy(() => import('./pages/admin/FinancialAuditTrailPage'));
|
||||
const ComplianceReportingPage = lazy(() => import('./pages/admin/ComplianceReportingPage'));
|
||||
|
||||
const StaffDashboardPage = lazy(() => import('./pages/staff/DashboardPage'));
|
||||
const StaffBookingManagementPage = lazy(() => import('./pages/staff/BookingManagementPage'));
|
||||
@@ -401,6 +405,14 @@ function App() {
|
||||
</CustomerRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="complaints"
|
||||
element={
|
||||
<CustomerRoute>
|
||||
<ComplaintPage />
|
||||
</CustomerRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
{}
|
||||
@@ -521,6 +533,18 @@ function App() {
|
||||
path="blog"
|
||||
element={<BlogManagementPage />}
|
||||
/>
|
||||
<Route
|
||||
path="complaints"
|
||||
element={<ComplaintManagementPage />}
|
||||
/>
|
||||
<Route
|
||||
path="financial-audit"
|
||||
element={<FinancialAuditTrailPage />}
|
||||
/>
|
||||
<Route
|
||||
path="compliance"
|
||||
element={<ComplianceReportingPage />}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
{}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Service for managing guest complaints
|
||||
*/
|
||||
import apiClient from '../../../shared/services/apiClient';
|
||||
|
||||
export interface Complaint {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
status: 'open' | 'in_progress' | 'resolved' | 'closed' | 'escalated';
|
||||
guest_id: number;
|
||||
booking_id?: number;
|
||||
room_id?: number;
|
||||
assigned_to?: number;
|
||||
resolution?: string;
|
||||
resolved_at?: string;
|
||||
resolved_by?: number;
|
||||
guest_satisfaction_rating?: number;
|
||||
guest_feedback?: string;
|
||||
internal_notes?: string;
|
||||
attachments?: string[];
|
||||
requires_follow_up: boolean;
|
||||
follow_up_date?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
updates?: ComplaintUpdate[];
|
||||
}
|
||||
|
||||
export interface ComplaintUpdate {
|
||||
id: number;
|
||||
update_type: string;
|
||||
description: string;
|
||||
updated_by: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateComplaintRequest {
|
||||
booking_id?: number;
|
||||
room_id?: number;
|
||||
category: string;
|
||||
priority?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
attachments?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateComplaintRequest {
|
||||
status?: string;
|
||||
priority?: string;
|
||||
assigned_to?: number;
|
||||
resolution?: string;
|
||||
internal_notes?: string;
|
||||
requires_follow_up?: boolean;
|
||||
follow_up_date?: string;
|
||||
}
|
||||
|
||||
export interface ResolveComplaintRequest {
|
||||
resolution: string;
|
||||
guest_satisfaction_rating?: number;
|
||||
guest_feedback?: string;
|
||||
}
|
||||
|
||||
export interface AddComplaintUpdateRequest {
|
||||
update_type: string;
|
||||
description: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ComplaintFilters {
|
||||
status?: string;
|
||||
priority?: string;
|
||||
category?: string;
|
||||
assigned_to?: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
const complaintService = {
|
||||
/**
|
||||
* Create a new complaint
|
||||
*/
|
||||
async createComplaint(data: CreateComplaintRequest) {
|
||||
const response = await apiClient.post('/complaints', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get complaints with filters
|
||||
*/
|
||||
async getComplaints(filters: ComplaintFilters = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.status) params.append('status', filters.status);
|
||||
if (filters.priority) params.append('priority', filters.priority);
|
||||
if (filters.category) params.append('category', filters.category);
|
||||
if (filters.assigned_to) params.append('assigned_to', filters.assigned_to.toString());
|
||||
if (filters.page) params.append('page', filters.page.toString());
|
||||
if (filters.limit) params.append('limit', filters.limit.toString());
|
||||
|
||||
const response = await apiClient.get(`/complaints?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a specific complaint
|
||||
*/
|
||||
async getComplaint(complaintId: number) {
|
||||
const response = await apiClient.get(`/complaints/${complaintId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a complaint (admin/staff only)
|
||||
*/
|
||||
async updateComplaint(complaintId: number, data: UpdateComplaintRequest) {
|
||||
const response = await apiClient.put(`/complaints/${complaintId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve a complaint (admin/staff only)
|
||||
*/
|
||||
async resolveComplaint(complaintId: number, data: ResolveComplaintRequest) {
|
||||
const response = await apiClient.post(`/complaints/${complaintId}/resolve`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Add an update to a complaint
|
||||
*/
|
||||
async addComplaintUpdate(complaintId: number, data: AddComplaintUpdateRequest) {
|
||||
const response = await apiClient.post(`/complaints/${complaintId}/updates`, data);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default complaintService;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as complaintService } from './complaintService';
|
||||
export { default as guestProfileService } from './guestProfileService';
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Service for accessing financial audit trail
|
||||
*/
|
||||
import apiClient from '../../../shared/services/apiClient';
|
||||
|
||||
export interface FinancialAuditRecord {
|
||||
id: number;
|
||||
action_type: string;
|
||||
action_description: string;
|
||||
payment_id?: number;
|
||||
invoice_id?: number;
|
||||
booking_id?: number;
|
||||
amount?: number;
|
||||
previous_amount?: number;
|
||||
currency: string;
|
||||
performed_by: number;
|
||||
performed_by_email?: string;
|
||||
metadata?: Record<string, any>;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface FinancialAuditFilters {
|
||||
payment_id?: number;
|
||||
invoice_id?: number;
|
||||
booking_id?: number;
|
||||
action_type?: string;
|
||||
user_id?: number;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
const financialAuditService = {
|
||||
/**
|
||||
* Get financial audit trail with filters
|
||||
*/
|
||||
async getAuditTrail(filters: FinancialAuditFilters = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.payment_id) params.append('payment_id', filters.payment_id.toString());
|
||||
if (filters.invoice_id) params.append('invoice_id', filters.invoice_id.toString());
|
||||
if (filters.booking_id) params.append('booking_id', filters.booking_id.toString());
|
||||
if (filters.action_type) params.append('action_type', filters.action_type);
|
||||
if (filters.user_id) params.append('user_id', filters.user_id.toString());
|
||||
if (filters.start_date) params.append('start_date', filters.start_date);
|
||||
if (filters.end_date) params.append('end_date', filters.end_date);
|
||||
if (filters.page) params.append('page', filters.page.toString());
|
||||
if (filters.limit) params.append('limit', filters.limit.toString());
|
||||
|
||||
const response = await apiClient.get(`/financial/audit-trail?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a specific audit record
|
||||
*/
|
||||
async getAuditRecord(recordId: number) {
|
||||
const response = await apiClient.get(`/financial/audit-trail/${recordId}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default financialAuditService;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as paymentService } from './paymentService';
|
||||
export { default as invoiceService } from './invoiceService';
|
||||
export { default as financialAuditService } from './financialAuditService';
|
||||
|
||||
|
||||
65
Frontend/src/features/security/services/complianceService.ts
Normal file
65
Frontend/src/features/security/services/complianceService.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Service for compliance reporting
|
||||
*/
|
||||
import apiClient from '../../../shared/services/apiClient';
|
||||
|
||||
export interface ComplianceReport {
|
||||
period: {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
};
|
||||
financial_audit: {
|
||||
total_actions: number;
|
||||
description: string;
|
||||
};
|
||||
gdpr_compliance: {
|
||||
total_requests: number;
|
||||
completed_requests: number;
|
||||
completion_rate: number;
|
||||
description: string;
|
||||
};
|
||||
security_audit: {
|
||||
total_events: number;
|
||||
description: string;
|
||||
};
|
||||
compliance_status: string;
|
||||
generated_at: string;
|
||||
generated_by: string;
|
||||
}
|
||||
|
||||
export interface GDPRSummary {
|
||||
total_requests: number;
|
||||
pending_requests: number;
|
||||
completed_requests: number;
|
||||
completion_rate: number;
|
||||
requests_by_type: Record<string, number>;
|
||||
compliance_status: string;
|
||||
}
|
||||
|
||||
const complianceService = {
|
||||
/**
|
||||
* Get compliance report
|
||||
*/
|
||||
async getComplianceReport(startDate?: string, endDate?: string, format: 'json' | 'csv' = 'json') {
|
||||
const params = new URLSearchParams();
|
||||
if (startDate) params.append('start_date', startDate);
|
||||
if (endDate) params.append('end_date', endDate);
|
||||
params.append('format', format);
|
||||
|
||||
const response = await apiClient.get(`/compliance/report?${params.toString()}`, {
|
||||
responseType: format === 'csv' ? 'blob' : 'json',
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get GDPR compliance summary
|
||||
*/
|
||||
async getGDPRSummary() {
|
||||
const response = await apiClient.get('/compliance/gdpr-summary');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default complianceService;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as securityService } from './securityService';
|
||||
export { default as complianceService } from './complianceService';
|
||||
|
||||
|
||||
474
Frontend/src/pages/admin/ComplaintManagementPage.tsx
Normal file
474
Frontend/src/pages/admin/ComplaintManagementPage.tsx
Normal file
@@ -0,0 +1,474 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
AlertCircle,
|
||||
Search,
|
||||
Filter,
|
||||
Eye,
|
||||
Edit,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
XCircle,
|
||||
User,
|
||||
Calendar,
|
||||
Tag,
|
||||
MessageSquare,
|
||||
Download
|
||||
} 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';
|
||||
|
||||
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();
|
||||
}, [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: any) {
|
||||
toast.error(error.response?.data?.message || '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: any) {
|
||||
toast.error(error.response?.data?.message || '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: any) {
|
||||
toast.error(error.response?.data?.message || '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 any });
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || '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-[#d4af37] 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-[#d4af37] 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-[#d4af37] 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-[#d4af37] hover:text-[#c9a227] 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, onUpdateStatus }) => {
|
||||
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;
|
||||
}> = ({ complaint, 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-[#d4af37] 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-[#d4af37] 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-[#d4af37] 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;
|
||||
|
||||
280
Frontend/src/pages/admin/ComplianceReportingPage.tsx
Normal file
280
Frontend/src/pages/admin/ComplianceReportingPage.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
FileText,
|
||||
Download,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
TrendingUp,
|
||||
Shield,
|
||||
Activity,
|
||||
DollarSign
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import Loading from '../../shared/components/Loading';
|
||||
import complianceService, { ComplianceReport, GDPRSummary } from '../../features/security/services/complianceService';
|
||||
import { formatDate } from '../../shared/utils/format';
|
||||
|
||||
const ComplianceReportingPage: React.FC = () => {
|
||||
const [report, setReport] = useState<ComplianceReport | null>(null);
|
||||
const [gdprSummary, setGdprSummary] = useState<GDPRSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [dateRange, setDateRange] = useState({
|
||||
start_date: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
end_date: new Date().toISOString().split('T')[0],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchComplianceData();
|
||||
}, [dateRange]);
|
||||
|
||||
const fetchComplianceData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [reportResponse, gdprResponse] = await Promise.all([
|
||||
complianceService.getComplianceReport(dateRange.start_date, dateRange.end_date),
|
||||
complianceService.getGDPRSummary(),
|
||||
]);
|
||||
setReport(reportResponse.data);
|
||||
setGdprSummary(gdprResponse.data);
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load compliance data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
setGenerating(true);
|
||||
const response = await complianceService.getComplianceReport(
|
||||
dateRange.start_date,
|
||||
dateRange.end_date,
|
||||
'csv'
|
||||
);
|
||||
|
||||
// Create blob and download
|
||||
const blob = new Blob([response], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `compliance_report_${dateRange.start_date}_to_${dateRange.end_date}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
toast.success('Compliance report exported successfully');
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to export compliance report');
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getComplianceStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'compliant':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
case 'pending_review':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
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">Compliance Reporting</h1>
|
||||
<p className="text-gray-600">View compliance status and generate reports</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={generating}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-[#d4af37] text-white rounded-lg hover:bg-[#c9a227] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
<span>{generating ? 'Generating...' : 'Export CSV'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Date Range Filter */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.start_date}
|
||||
onChange={(e) => setDateRange({ ...dateRange, start_date: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.end_date}
|
||||
onChange={(e) => setDateRange({ ...dateRange, end_date: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={fetchComplianceData}
|
||||
className="w-full px-4 py-2 bg-[#d4af37] text-white rounded-lg hover:bg-[#c9a227] transition-colors"
|
||||
>
|
||||
Refresh Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compliance Status Overview */}
|
||||
{report && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="p-3 bg-blue-100 rounded-lg">
|
||||
<Activity className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium border ${getComplianceStatusColor(report.compliance_status)}`}>
|
||||
{report.compliance_status.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-1">Compliance Status</h3>
|
||||
<p className="text-2xl font-bold text-gray-900 capitalize">{report.compliance_status.replace('_', ' ')}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="p-3 bg-green-100 rounded-lg">
|
||||
<DollarSign className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-1">Financial Actions</h3>
|
||||
<p className="text-2xl font-bold text-gray-900">{report.financial_audit.total_actions}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">{report.financial_audit.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="p-3 bg-purple-100 rounded-lg">
|
||||
<Shield className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-1">GDPR Requests</h3>
|
||||
<p className="text-2xl font-bold text-gray-900">{report.gdpr_compliance.total_requests}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{report.gdpr_compliance.completed_requests} completed ({report.gdpr_compliance.completion_rate}%)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="p-3 bg-orange-100 rounded-lg">
|
||||
<Activity className="w-6 h-6 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-1">Security Events</h3>
|
||||
<p className="text-2xl font-bold text-gray-900">{report.security_audit.total_events}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">{report.security_audit.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detailed Reports */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Financial Audit Summary */}
|
||||
{report && (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Financial Audit Summary</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">Total Actions Logged</span>
|
||||
<span className="font-semibold text-gray-900">{report.financial_audit.total_actions}</span>
|
||||
</div>
|
||||
<div className="pt-3 border-t">
|
||||
<p className="text-sm text-gray-500">{report.financial_audit.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GDPR Compliance Summary */}
|
||||
{gdprSummary && (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">GDPR Compliance Summary</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">Total Requests</span>
|
||||
<span className="font-semibold text-gray-900">{gdprSummary.total_requests}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">Pending Requests</span>
|
||||
<span className="font-semibold text-yellow-600">{gdprSummary.pending_requests}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">Completed Requests</span>
|
||||
<span className="font-semibold text-green-600">{gdprSummary.completed_requests}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">Completion Rate</span>
|
||||
<span className="font-semibold text-gray-900">{gdprSummary.completion_rate}%</span>
|
||||
</div>
|
||||
<div className="pt-3 border-t">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium border ${getComplianceStatusColor(gdprSummary.compliance_status)}`}>
|
||||
{gdprSummary.compliance_status.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* GDPR Requests by Type */}
|
||||
{gdprSummary && gdprSummary.requests_by_type && (
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mt-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">GDPR Requests by Type</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{Object.entries(gdprSummary.requests_by_type).map(([type, count]) => (
|
||||
<div key={type} className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="text-sm text-gray-500 capitalize mb-1">{type.replace('_', ' ')}</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{count}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Report Period Info */}
|
||||
{report && (
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mt-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Report Period</h3>
|
||||
<p className="text-gray-600">
|
||||
{formatDate(report.period.start_date)} to {formatDate(report.period.end_date)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-500">Generated</p>
|
||||
<p className="text-sm text-gray-900">{formatDate(report.generated_at)}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">by {report.generated_by}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComplianceReportingPage;
|
||||
|
||||
@@ -37,7 +37,7 @@ const DashboardPage: React.FC = () => {
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
navigate('/');
|
||||
// Logout function will redirect to homepage
|
||||
} catch (error) {
|
||||
logger.error('Logout error', error);
|
||||
}
|
||||
|
||||
367
Frontend/src/pages/admin/FinancialAuditTrailPage.tsx
Normal file
367
Frontend/src/pages/admin/FinancialAuditTrailPage.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
FileText,
|
||||
Search,
|
||||
Filter,
|
||||
Eye,
|
||||
Download,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
User,
|
||||
CreditCard,
|
||||
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 financialAuditService, { FinancialAuditRecord, FinancialAuditFilters } from '../../features/payments/services/financialAuditService';
|
||||
import { formatDate } from '../../shared/utils/format';
|
||||
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
|
||||
import { exportData } from '../../shared/utils/exportUtils';
|
||||
|
||||
const FinancialAuditTrailPage: React.FC = () => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
const [records, setRecords] = useState<FinancialAuditRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedRecord, setSelectedRecord] = useState<FinancialAuditRecord | null>(null);
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
const [filters, setFilters] = useState<FinancialAuditFilters>({
|
||||
page: 1,
|
||||
limit: 50,
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const itemsPerPage = 50;
|
||||
|
||||
useEffect(() => {
|
||||
fetchAuditTrail();
|
||||
}, [filters, currentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setFilters(prev => ({ ...prev, page: currentPage }));
|
||||
}, [currentPage]);
|
||||
|
||||
const fetchAuditTrail = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await financialAuditService.getAuditTrail({
|
||||
...filters,
|
||||
page: currentPage,
|
||||
limit: itemsPerPage,
|
||||
});
|
||||
setRecords(response.data.audit_trail || []);
|
||||
if (response.data.pagination) {
|
||||
setTotalPages(response.data.pagination.total_pages || 1);
|
||||
setTotalItems(response.data.pagination.total || 0);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load audit trail');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetails = async (recordId: number) => {
|
||||
try {
|
||||
const response = await financialAuditService.getAuditRecord(recordId);
|
||||
setSelectedRecord(response.data);
|
||||
setShowDetailModal(true);
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load audit record');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const response = await financialAuditService.getAuditTrail({
|
||||
...filters,
|
||||
page: 1,
|
||||
limit: 10000, // Get all records for export
|
||||
});
|
||||
const records = response.data.audit_trail || [];
|
||||
|
||||
const csvData = records.map((record: FinancialAuditRecord) => ({
|
||||
'ID': record.id,
|
||||
'Action Type': record.action_type,
|
||||
'Description': record.action_description,
|
||||
'Payment ID': record.payment_id || '',
|
||||
'Invoice ID': record.invoice_id || '',
|
||||
'Booking ID': record.booking_id || '',
|
||||
'Amount': record.amount || '',
|
||||
'Previous Amount': record.previous_amount || '',
|
||||
'Currency': record.currency,
|
||||
'Performed By': record.performed_by_email || record.performed_by,
|
||||
'Created At': formatDate(record.created_at),
|
||||
}));
|
||||
|
||||
exportData(csvData, 'financial_audit_trail', 'csv');
|
||||
toast.success('Audit trail exported successfully');
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to export audit trail');
|
||||
}
|
||||
};
|
||||
|
||||
const getActionTypeColor = (actionType: string) => {
|
||||
if (actionType.includes('created')) return 'bg-blue-100 text-blue-800';
|
||||
if (actionType.includes('completed')) return 'bg-green-100 text-green-800';
|
||||
if (actionType.includes('refunded')) return 'bg-purple-100 text-purple-800';
|
||||
if (actionType.includes('failed')) return 'bg-red-100 text-red-800';
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
|
||||
if (loading && records.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">Financial Audit Trail</h1>
|
||||
<p className="text-gray-600">Complete audit log of all financial transactions</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-[#d4af37] text-white rounded-lg hover:bg-[#c9a227] transition-colors"
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
<span>Export CSV</span>
|
||||
</button>
|
||||
</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">Action Type</label>
|
||||
<select
|
||||
value={filters.action_type || ''}
|
||||
onChange={(e) => setFilters({ ...filters, action_type: e.target.value || undefined, page: 1 })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-transparent"
|
||||
>
|
||||
<option value="">All Actions</option>
|
||||
<option value="payment_created">Payment Created</option>
|
||||
<option value="payment_completed">Payment Completed</option>
|
||||
<option value="payment_refunded">Payment Refunded</option>
|
||||
<option value="payment_failed">Payment Failed</option>
|
||||
<option value="invoice_created">Invoice Created</option>
|
||||
<option value="invoice_updated">Invoice Updated</option>
|
||||
<option value="invoice_paid">Invoice Paid</option>
|
||||
<option value="refund_processed">Refund Processed</option>
|
||||
<option value="price_modified">Price Modified</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Payment ID</label>
|
||||
<input
|
||||
type="number"
|
||||
value={filters.payment_id || ''}
|
||||
onChange={(e) => setFilters({ ...filters, payment_id: e.target.value ? parseInt(e.target.value) : undefined, page: 1 })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-transparent"
|
||||
placeholder="Filter by payment ID"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Booking ID</label>
|
||||
<input
|
||||
type="number"
|
||||
value={filters.booking_id || ''}
|
||||
onChange={(e) => setFilters({ ...filters, booking_id: e.target.value ? parseInt(e.target.value) : undefined, page: 1 })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-transparent"
|
||||
placeholder="Filter by booking ID"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={() => setFilters({ page: 1, limit: 50 })}
|
||||
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>
|
||||
|
||||
{/* Audit Trail Table */}
|
||||
{records.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No Audit Records Found"
|
||||
description="There are no audit records 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">ID</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Action</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Description</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Amount</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Performed By</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Date</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{records.map((record) => (
|
||||
<tr key={record.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{record.id}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getActionTypeColor(record.action_type)}`}>
|
||||
{record.action_type.replace('_', ' ')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-900 max-w-md truncate">
|
||||
{record.action_description}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{record.amount ? formatCurrency(record.amount) : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{record.performed_by_email || `User ${record.performed_by}`}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{formatDate(record.created_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleViewDetails(record.id)}
|
||||
className="text-[#d4af37] hover:text-[#c9a227]"
|
||||
>
|
||||
<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 && selectedRecord && (
|
||||
<AuditRecordDetailModal
|
||||
record={selectedRecord}
|
||||
onClose={() => {
|
||||
setShowDetailModal(false);
|
||||
setSelectedRecord(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Audit Record Detail Modal
|
||||
const AuditRecordDetailModal: React.FC<{
|
||||
record: FinancialAuditRecord;
|
||||
onClose: () => void;
|
||||
}> = ({ record, onClose }) => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
|
||||
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-3xl 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">Audit Record 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-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-500">Action Type</label>
|
||||
<p className="text-gray-900 capitalize">{record.action_type.replace('_', ' ')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-500">Description</label>
|
||||
<p className="text-gray-900">{record.action_description}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{record.payment_id && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-500">Payment ID</label>
|
||||
<p className="text-gray-900">{record.payment_id}</p>
|
||||
</div>
|
||||
)}
|
||||
{record.invoice_id && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-500">Invoice ID</label>
|
||||
<p className="text-gray-900">{record.invoice_id}</p>
|
||||
</div>
|
||||
)}
|
||||
{record.booking_id && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-500">Booking ID</label>
|
||||
<p className="text-gray-900">{record.booking_id}</p>
|
||||
</div>
|
||||
)}
|
||||
{record.amount && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-500">Amount</label>
|
||||
<p className="text-gray-900">{formatCurrency(record.amount)}</p>
|
||||
</div>
|
||||
)}
|
||||
{record.previous_amount && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-500">Previous Amount</label>
|
||||
<p className="text-gray-900">{formatCurrency(record.previous_amount)}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-500">Currency</label>
|
||||
<p className="text-gray-900">{record.currency}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-500">Performed By</label>
|
||||
<p className="text-gray-900">{record.performed_by_email || `User ${record.performed_by}`}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-500">Date</label>
|
||||
<p className="text-gray-900">{formatDate(record.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{record.notes && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-500">Notes</label>
|
||||
<p className="text-gray-900">{record.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
{record.metadata && Object.keys(record.metadata).length > 0 && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-500">Additional Information</label>
|
||||
<pre className="bg-gray-50 p-3 rounded text-sm text-gray-900 overflow-x-auto">
|
||||
{JSON.stringify(record.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end pt-4 border-t">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default FinancialAuditTrailPage;
|
||||
|
||||
426
Frontend/src/pages/customer/ComplaintPage.tsx
Normal file
426
Frontend/src/pages/customer/ComplaintPage.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { AlertCircle, Plus, Eye, CheckCircle, Clock, 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 } from '../../features/guest_management/services/complaintService';
|
||||
import bookingService from '../../features/bookings/services/bookingService';
|
||||
import { formatDate } from '../../shared/utils/format';
|
||||
|
||||
const ComplaintPage: React.FC = () => {
|
||||
const [complaints, setComplaints] = useState<Complaint[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [selectedComplaint, setSelectedComplaint] = useState<Complaint | null>(null);
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
const [bookings, setBookings] = useState<any[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const itemsPerPage = 10;
|
||||
|
||||
useEffect(() => {
|
||||
fetchComplaints();
|
||||
fetchBookings();
|
||||
}, [currentPage]);
|
||||
|
||||
const fetchComplaints = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await complaintService.getComplaints({
|
||||
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: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load complaints');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchBookings = async () => {
|
||||
try {
|
||||
const response = await bookingService.getMyBookings();
|
||||
setBookings(response.data.bookings || []);
|
||||
} catch (error: any) {
|
||||
// Silently fail - bookings are optional
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateComplaint = async (data: any) => {
|
||||
try {
|
||||
await complaintService.createComplaint(data);
|
||||
toast.success('Complaint submitted successfully');
|
||||
setShowCreateModal(false);
|
||||
fetchComplaints();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to submit complaint');
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetails = async (complaintId: number) => {
|
||||
try {
|
||||
const response = await complaintService.getComplaint(complaintId);
|
||||
setSelectedComplaint(response.data.complaint);
|
||||
setShowDetailModal(true);
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Unable to load complaint details');
|
||||
}
|
||||
};
|
||||
|
||||
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">My Complaints</h1>
|
||||
<p className="text-gray-600">Submit and track your complaints</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-[#d4af37] text-white rounded-lg hover:bg-[#c9a227] transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
<span>Submit Complaint</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{complaints.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No Complaints"
|
||||
description="You haven't submitted any complaints yet"
|
||||
action={{
|
||||
label: 'Submit Complaint',
|
||||
onClick: () => setShowCreateModal(true),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<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">ID</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Title</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Category</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">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 ${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-[#d4af37] hover:text-[#c9a227]"
|
||||
>
|
||||
<Eye className="w-5 h-5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Create Complaint Modal */}
|
||||
{showCreateModal && (
|
||||
<CreateComplaintModal
|
||||
bookings={bookings}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSubmit={handleCreateComplaint}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Detail Modal */}
|
||||
{showDetailModal && selectedComplaint && (
|
||||
<ComplaintDetailModal
|
||||
complaint={selectedComplaint}
|
||||
onClose={() => {
|
||||
setShowDetailModal(false);
|
||||
setSelectedComplaint(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Create Complaint Modal Component
|
||||
const CreateComplaintModal: React.FC<{
|
||||
bookings: any[];
|
||||
onClose: () => void;
|
||||
onSubmit: (data: any) => void;
|
||||
}> = ({ bookings, onClose, onSubmit }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
booking_id: '',
|
||||
room_id: '',
|
||||
category: '',
|
||||
priority: 'medium',
|
||||
title: '',
|
||||
description: '',
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.title.trim() || !formData.description.trim() || !formData.category) {
|
||||
toast.error('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit({
|
||||
...formData,
|
||||
booking_id: formData.booking_id ? parseInt(formData.booking_id) : undefined,
|
||||
room_id: formData.room_id ? parseInt(formData.room_id) : undefined,
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 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">Submit Complaint</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<XCircle className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Related Booking (Optional)
|
||||
</label>
|
||||
<select
|
||||
value={formData.booking_id}
|
||||
onChange={(e) => setFormData({ ...formData, booking_id: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-transparent"
|
||||
>
|
||||
<option value="">Select a booking</option>
|
||||
{bookings.map((booking) => (
|
||||
<option key={booking.id} value={booking.id}>
|
||||
{booking.booking_number} - {formatDate(booking.check_in_date)} to {formatDate(booking.check_out_date)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Category *
|
||||
</label>
|
||||
<select
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-transparent"
|
||||
>
|
||||
<option value="">Select category</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>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Priority
|
||||
</label>
|
||||
<select
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-transparent"
|
||||
>
|
||||
<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">
|
||||
Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-transparent"
|
||||
placeholder="Brief description of the issue"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Description *
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
required
|
||||
rows={6}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#d4af37] focus:border-transparent"
|
||||
placeholder="Please provide detailed information about your complaint..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 bg-[#d4af37] text-white rounded-lg hover:bg-[#c9a227] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'Submit Complaint'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Complaint Detail Modal Component
|
||||
const ComplaintDetailModal: React.FC<{
|
||||
complaint: Complaint;
|
||||
onClose: () => void;
|
||||
}> = ({ complaint, onClose }) => {
|
||||
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.guest_satisfaction_rating && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-500">Your Rating</label>
|
||||
<p className="text-gray-900">{complaint.guest_satisfaction_rating} / 5</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 pt-4 border-t">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComplaintPage;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Calendar,
|
||||
Star,
|
||||
Users,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { useClickOutside } from '../hooks/useClickOutside';
|
||||
import { useCompanySettings } from '../contexts/CompanySettingsContext';
|
||||
@@ -196,6 +197,21 @@ const Header: React.FC<HeaderProps> = ({
|
||||
<Users className="w-4 h-4" />
|
||||
<span>Group Bookings</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/complaints"
|
||||
onClick={() =>
|
||||
setIsMobileMenuOpen(false)
|
||||
}
|
||||
className="flex items-center
|
||||
space-x-2 px-4 py-3 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
rounded-sm transition-all duration-300
|
||||
border-l-2 border-transparent
|
||||
hover:border-[#d4af37] font-light tracking-wide"
|
||||
>
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span>Complaints</span>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{userInfo?.role === 'admin' && (
|
||||
@@ -461,6 +477,18 @@ const Header: React.FC<HeaderProps> = ({
|
||||
<Users className="w-4 h-4" />
|
||||
<span className="font-light tracking-wide">Group Bookings</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/complaints"
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
className="flex items-center space-x-3
|
||||
px-4 py-2.5 text-white/90
|
||||
hover:bg-[#d4af37]/10 hover:text-[#d4af37]
|
||||
transition-all duration-300 border-l-2 border-transparent
|
||||
hover:border-[#d4af37]"
|
||||
>
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span className="font-light tracking-wide">Complaints</span>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{userInfo?.role === 'admin' && (
|
||||
|
||||
@@ -34,7 +34,7 @@ const SidebarAccountant: React.FC<SidebarAccountantProps> = ({
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
navigate('/');
|
||||
// Logout function will redirect to homepage
|
||||
if (isMobile) {
|
||||
setIsMobileOpen(false);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,10 @@ import {
|
||||
TrendingUp,
|
||||
Building2,
|
||||
Crown,
|
||||
Star
|
||||
Star,
|
||||
AlertCircle,
|
||||
FileCheck,
|
||||
ClipboardCheck
|
||||
} from 'lucide-react';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import { useResponsive } from '../../hooks';
|
||||
@@ -62,7 +65,7 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
navigate('/');
|
||||
// Logout function will redirect to homepage
|
||||
if (isMobile) {
|
||||
setIsMobileOpen(false);
|
||||
}
|
||||
@@ -149,6 +152,11 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
|
||||
icon: BarChart3,
|
||||
label: 'Analytics'
|
||||
},
|
||||
{
|
||||
path: '/admin/financial-audit',
|
||||
icon: FileCheck,
|
||||
label: 'Financial Audit'
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -175,6 +183,11 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
|
||||
icon: Award,
|
||||
label: 'Loyalty Program'
|
||||
},
|
||||
{
|
||||
path: '/admin/complaints',
|
||||
icon: AlertCircle,
|
||||
label: 'Complaints'
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -254,6 +267,11 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
|
||||
icon: Settings,
|
||||
label: 'Settings'
|
||||
},
|
||||
{
|
||||
path: '/admin/compliance',
|
||||
icon: ClipboardCheck,
|
||||
label: 'Compliance'
|
||||
},
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
@@ -41,7 +41,7 @@ const SidebarStaff: React.FC<SidebarStaffProps> = ({
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
navigate('/');
|
||||
// Logout function will redirect to homepage
|
||||
if (isMobile) {
|
||||
setIsMobileOpen(false);
|
||||
}
|
||||
|
||||
@@ -243,6 +243,9 @@ const useAuthStore = create<AuthState>((set, get) => ({
|
||||
});
|
||||
|
||||
toast.info('Logged out');
|
||||
|
||||
// Always redirect to homepage after logout
|
||||
window.location.href = '/';
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user