This commit is contained in:
Iliyan Angelov
2025-12-01 01:08:39 +02:00
parent 0fa2adeb19
commit 1a103a769f
234 changed files with 5513 additions and 283 deletions

View File

@@ -60,6 +60,7 @@ 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 GDPRPage = lazy(() => import('./pages/customer/GDPRPage'));
const AboutPage = lazy(() => import('./features/content/pages/AboutPage'));
const ContactPage = lazy(() => import('./features/content/pages/ContactPage'));
const PrivacyPolicyPage = lazy(() => import('./features/content/pages/PrivacyPolicyPage'));
@@ -97,6 +98,11 @@ 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 ApprovalManagementPage = lazy(() => import('./pages/admin/ApprovalManagementPage'));
const GDPRManagementPage = lazy(() => import('./pages/admin/GDPRManagementPage'));
const WebhookManagementPage = lazy(() => import('./pages/admin/WebhookManagementPage'));
const APIKeyManagementPage = lazy(() => import('./pages/admin/APIKeyManagementPage'));
const BackupManagementPage = lazy(() => import('./pages/admin/BackupManagementPage'));
const StaffDashboardPage = lazy(() => import('./pages/staff/DashboardPage'));
const StaffBookingManagementPage = lazy(() => import('./pages/staff/BookingManagementPage'));
@@ -413,6 +419,14 @@ function App() {
</CustomerRoute>
}
/>
<Route
path="gdpr"
element={
<ProtectedRoute>
<GDPRPage />
</ProtectedRoute>
}
/>
</Route>
{}
@@ -545,6 +559,26 @@ function App() {
path="compliance"
element={<ComplianceReportingPage />}
/>
<Route
path="approvals"
element={<ApprovalManagementPage />}
/>
<Route
path="gdpr"
element={<GDPRManagementPage />}
/>
<Route
path="webhooks"
element={<WebhookManagementPage />}
/>
<Route
path="api-keys"
element={<APIKeyManagementPage />}
/>
<Route
path="backups"
element={<BackupManagementPage />}
/>
</Route>
{}

View File

@@ -45,7 +45,8 @@ const CustomerRoute: React.FC<CustomerRouteProps> = ({
}
const isCustomer = userInfo?.role !== 'admin' && userInfo?.role !== 'staff' && userInfo?.role !== 'accountant';
// Explicitly check for customer role to prevent unexpected roles from accessing customer routes
const isCustomer = userInfo?.role === 'customer';
if (!isCustomer) {
if (userInfo?.role === 'admin') {
return <Navigate to="/admin/dashboard" replace />;

View File

@@ -0,0 +1,31 @@
import apiClient from '../../../shared/services/apiClient';
export interface UserSession {
id: number;
ip_address?: string;
user_agent?: string;
device_info?: string;
last_activity: string;
created_at: string;
expires_at: string;
}
class SessionService {
async getMySessions() {
const response = await apiClient.get('/sessions');
return response.data;
}
async revokeSession(sessionId: number) {
const response = await apiClient.delete(`/sessions/${sessionId}`);
return response.data;
}
async revokeAllSessions() {
const response = await apiClient.post('/sessions/revoke-all');
return response.data;
}
}
export default new SessionService();

View File

@@ -0,0 +1,65 @@
import apiClient from '../../../shared/services/apiClient';
export interface GDPRRequest {
id: number;
request_type: 'data_export' | 'data_deletion' | 'data_rectification' | 'consent_withdrawal';
status: 'pending' | 'processing' | 'completed' | 'rejected' | 'cancelled';
user_id: number;
user_email: string;
created_at: string;
processed_at?: string;
verification_token?: string;
expires_at?: string;
}
class GDPRService {
async requestDataExport() {
const response = await apiClient.post('/gdpr/export');
return response.data;
}
async getExportData(requestId: number, verificationToken: string) {
const response = await apiClient.get(`/gdpr/export/${requestId}`, {
params: { verification_token: verificationToken },
});
return response.data;
}
async downloadExport(requestId: number, verificationToken: string) {
const response = await apiClient.get(`/gdpr/export/${requestId}`, {
params: { verification_token: verificationToken },
responseType: 'blob',
});
return response.data;
}
async requestDataDeletion() {
const response = await apiClient.post('/gdpr/delete');
return response.data;
}
async confirmDataDeletion(requestId: number, verificationToken: string) {
const response = await apiClient.post(`/gdpr/delete/${requestId}/confirm`, null, {
params: { verification_token: verificationToken },
});
return response.data;
}
async getMyRequests() {
const response = await apiClient.get('/gdpr/requests');
return response.data;
}
async getAllRequests(filters?: { status?: string; page?: number; limit?: number }) {
const response = await apiClient.get('/gdpr/admin/requests', { params: filters });
return response.data;
}
async deleteRequest(requestId: number) {
const response = await apiClient.delete(`/gdpr/admin/requests/${requestId}`);
return response.data;
}
}
export default new GDPRService();

View File

@@ -0,0 +1,3 @@
export { default } from './gdprService';
export * from './gdprService';

View File

@@ -0,0 +1,55 @@
import apiClient from '../../../shared/services/apiClient';
export interface APIKey {
id: number;
name: string;
key_prefix: string;
scopes: string[];
rate_limit: number;
is_active: boolean;
last_used_at?: string;
expires_at?: string;
description?: string;
created_at: string;
}
export interface CreateAPIKeyData {
name: string;
scopes: string[];
description?: string;
rate_limit?: number;
expires_at?: string;
}
export interface UpdateAPIKeyData {
name?: string;
scopes?: string[];
description?: string;
rate_limit?: number;
expires_at?: string;
}
class APIKeyService {
async createAPIKey(data: CreateAPIKeyData) {
const response = await apiClient.post('/api-keys', data);
return response.data;
}
async getAPIKeys() {
const response = await apiClient.get('/api-keys');
return response.data;
}
async updateAPIKey(keyId: number, data: UpdateAPIKeyData) {
const response = await apiClient.put(`/api-keys/${keyId}`, data);
return response.data;
}
async revokeAPIKey(keyId: number) {
const response = await apiClient.delete(`/api-keys/${keyId}`);
return response.data;
}
}
export default new APIKeyService();

View File

@@ -0,0 +1,5 @@
export { default as webhookService } from './webhookService';
export { default as apiKeyService } from './apiKeyService';
export * from './webhookService';
export * from './apiKeyService';

View File

@@ -0,0 +1,77 @@
import apiClient from '../../../shared/services/apiClient';
export interface Webhook {
id: number;
name: string;
url: string;
events: string[];
status: 'active' | 'inactive' | 'paused';
secret?: string;
retry_count: number;
timeout_seconds: number;
description?: string;
created_at: string;
}
export interface WebhookDelivery {
id: number;
webhook_id: number;
event_type: string;
event_id: string;
status: 'pending' | 'success' | 'failed' | 'retrying';
response_status?: number;
attempt_count: number;
created_at: string;
delivered_at?: string;
}
export interface CreateWebhookData {
name: string;
url: string;
events: string[];
description?: string;
retry_count?: number;
timeout_seconds?: number;
}
export interface UpdateWebhookData {
name?: string;
url?: string;
events?: string[];
description?: string;
status?: string;
retry_count?: number;
timeout_seconds?: number;
}
class WebhookService {
async createWebhook(data: CreateWebhookData) {
const response = await apiClient.post('/webhooks', data);
return response.data;
}
async getWebhooks() {
const response = await apiClient.get('/webhooks');
return response.data;
}
async updateWebhook(webhookId: number, data: UpdateWebhookData) {
const response = await apiClient.put(`/webhooks/${webhookId}`, data);
return response.data;
}
async deleteWebhook(webhookId: number) {
const response = await apiClient.delete(`/webhooks/${webhookId}`);
return response.data;
}
async getWebhookDeliveries(webhookId: number, page: number = 1, limit: number = 50) {
const response = await apiClient.get(`/webhooks/${webhookId}/deliveries`, {
params: { page, limit },
});
return response.data;
}
}
export default new WebhookService();

View File

@@ -0,0 +1,54 @@
import apiClient from '../../../shared/services/apiClient';
export interface ApprovalRequest {
id: number;
approval_type: string;
status: 'pending' | 'approved' | 'rejected' | 'cancelled';
resource_type: string;
resource_id: number;
requested_by: number;
requested_at: string;
approved_by?: number;
approved_at?: string;
rejection_reason?: string;
priority: string;
notes?: string;
request_data?: any;
current_data?: any;
}
export interface ApprovalFilters {
approval_type?: string;
page?: number;
limit?: number;
}
class ApprovalService {
async getPendingApprovals(filters?: ApprovalFilters) {
const response = await apiClient.get('/approvals/pending', { params: filters });
return response.data;
}
async approveRequest(requestId: number, notes?: string) {
const response = await apiClient.post(`/approvals/${requestId}/approve`, { notes });
return response.data;
}
async rejectRequest(requestId: number, rejectionReason: string) {
const response = await apiClient.post(`/approvals/${requestId}/reject`, { rejection_reason: rejectionReason });
return response.data;
}
async getMyRequests(filters?: { status?: string; page?: number; limit?: number }) {
const response = await apiClient.get('/approvals/my-requests', { params: filters });
return response.data;
}
async cancelRequest(requestId: number) {
const response = await apiClient.delete(`/approvals/${requestId}`);
return response.data;
}
}
export default new ApprovalService();

View File

@@ -0,0 +1,43 @@
import apiClient from '../../../shared/services/apiClient';
export interface Backup {
filename: string;
path: string;
size_bytes: number;
size_mb: number;
created_at: string;
database: string;
status: string;
}
class BackupService {
async checkStatus() {
const response = await apiClient.get('/backups/status');
return response.data;
}
async createBackup() {
const response = await apiClient.post('/backups/create');
return response.data;
}
async listBackups() {
const response = await apiClient.get('/backups');
return response.data;
}
async downloadBackup(filename: string) {
const response = await apiClient.get(`/backups/${filename}`, {
responseType: 'blob',
});
return response.data;
}
async cleanupOldBackups() {
const response = await apiClient.post('/backups/cleanup');
return response.data;
}
}
export default new BackupService();

View File

@@ -0,0 +1,5 @@
export { default as approvalService } from './approvalService';
export { default as backupService } from './backupService';
export * from './approvalService';
export * from './backupService';

View File

@@ -158,7 +158,7 @@ const AccountantDashboardPage: React.FC = () => {
if (error || !stats) {
return (
<div className="space-y-6">
<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">
<EmptyState
title="Unable to Load Dashboard"
description={error?.message || 'Something went wrong. Please try again.'}
@@ -172,37 +172,37 @@ const AccountantDashboardPage: React.FC = () => {
}
return (
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
<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="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-6 animate-fade-in">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="h-1 w-16 bg-gradient-to-r from-emerald-400 to-emerald-600 rounded-full"></div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4 sm:gap-6 mb-6 sm:mb-8 md:mb-10 animate-fade-in">
<div className="w-full lg:w-auto">
<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-emerald-400 via-green-500 to-emerald-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">
Financial Dashboard
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Comprehensive financial overview and analytics</p>
<p className="text-slate-600 mt-2 sm:mt-3 text-sm sm:text-base md:text-lg font-light">Comprehensive financial overview and analytics</p>
</div>
{/* Date Range & Actions */}
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
<div className="flex gap-3 items-center">
<div className="flex flex-col sm:flex-row gap-3 w-full lg:w-auto">
<div className="flex flex-col xs:flex-row gap-2 sm:gap-3 w-full sm:w-auto">
<input
type="date"
value={dateRange.from}
onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
className="px-4 py-2.5 bg-white border-2 border-slate-200 rounded-xl focus:border-emerald-400 focus:ring-4 focus:ring-emerald-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md"
className="flex-1 sm:flex-none px-3 sm:px-4 py-2 sm:py-2.5 bg-white border-2 border-slate-200 rounded-xl focus:border-emerald-400 focus:ring-4 focus:ring-emerald-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md text-sm sm:text-base"
/>
<span className="text-slate-500 font-medium">to</span>
<span className="hidden xs:inline-flex items-center text-slate-500 font-medium">to</span>
<input
type="date"
value={dateRange.to}
onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
className="px-4 py-2.5 bg-white border-2 border-slate-200 rounded-xl focus:border-emerald-400 focus:ring-4 focus:ring-emerald-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md"
className="flex-1 sm:flex-none px-3 sm:px-4 py-2 sm:py-2.5 bg-white border-2 border-slate-200 rounded-xl focus:border-emerald-400 focus:ring-4 focus:ring-emerald-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md text-sm sm:text-base"
/>
</div>
<div className="flex gap-3 items-center">
<div className="flex gap-2 sm:gap-3 items-center w-full sm:w-auto">
<ExportButton
data={[
{
@@ -242,9 +242,9 @@ const AccountantDashboardPage: React.FC = () => {
<button
onClick={handleRefresh}
disabled={loading}
className="px-6 py-2.5 bg-gradient-to-r from-emerald-500 to-emerald-600 text-white rounded-xl font-semibold hover:from-emerald-600 hover:to-emerald-700 transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 text-sm"
className="flex-1 sm:flex-none px-4 sm:px-6 py-2 sm:py-2.5 bg-gradient-to-r from-emerald-500 to-emerald-600 text-white rounded-xl font-semibold hover:from-emerald-600 hover:to-emerald-700 transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 text-xs sm:text-sm"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
<RefreshCw className={`w-3 h-3 sm:w-4 sm:h-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
@@ -252,62 +252,62 @@ const AccountantDashboardPage: React.FC = () => {
</div>
{/* Financial Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5 md:gap-6 mb-6 sm:mb-8 md:mb-10">
{/* Total Revenue */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 border-l-4 border-l-emerald-500 animate-slide-up hover:shadow-2xl transition-all duration-300">
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 border-l-4 border-l-emerald-500 animate-slide-up hover:shadow-2xl hover:scale-[1.02] transition-all duration-300">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-2">Total Revenue</p>
<p className="text-3xl font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
<div className="flex-1 min-w-0 pr-2">
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1 sm:mb-2">Total Revenue</p>
<p className="text-lg sm:text-xl md:text-2xl lg:text-3xl font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent truncate">
{formatCurrency(financialSummary.totalRevenue || stats?.total_revenue || 0)}
</p>
</div>
<div className="bg-gradient-to-br from-emerald-100 to-emerald-200 p-4 rounded-2xl shadow-lg">
<DollarSign className="w-7 h-7 text-emerald-600" />
<div className="bg-gradient-to-br from-emerald-100 to-emerald-200 p-3 sm:p-4 rounded-xl sm:rounded-2xl shadow-lg flex-shrink-0">
<DollarSign className="w-5 h-5 sm:w-6 sm:h-6 md:w-7 md:h-7 text-emerald-600" />
</div>
</div>
<div className="flex items-center mt-5 pt-4 border-t border-slate-100">
<TrendingUp className="w-4 h-4 text-emerald-500 mr-2" />
<span className="text-emerald-600 font-semibold text-sm">Active</span>
<span className="text-slate-500 ml-2 text-sm">All time revenue</span>
<div className="flex items-center mt-4 sm:mt-5 pt-3 sm:pt-4 border-t border-slate-100">
<TrendingUp className="w-3 h-3 sm:w-4 sm:h-4 text-emerald-500 mr-1 sm:mr-2 flex-shrink-0" />
<span className="text-emerald-600 font-semibold text-xs sm:text-sm">Active</span>
<span className="text-slate-500 ml-1 sm:ml-2 text-xs sm:text-sm truncate">All time revenue</span>
</div>
</div>
{/* Total Payments */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 border-l-4 border-l-blue-500 animate-slide-up hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.1s' }}>
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 border-l-4 border-l-blue-500 animate-slide-up hover:shadow-2xl hover:scale-[1.02] transition-all duration-300" style={{ animationDelay: '0.1s' }}>
<div className="flex items-center justify-between">
<div>
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-2">Total Payments</p>
<p className="text-3xl font-bold bg-gradient-to-r from-blue-600 to-blue-700 bg-clip-text text-transparent">
<div className="flex-1 min-w-0 pr-2">
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1 sm:mb-2">Total Payments</p>
<p className="text-lg sm:text-xl md:text-2xl lg:text-3xl font-bold bg-gradient-to-r from-blue-600 to-blue-700 bg-clip-text text-transparent">
{financialSummary.totalPayments}
</p>
</div>
<div className="bg-gradient-to-br from-blue-100 to-blue-200 p-4 rounded-2xl shadow-lg">
<CreditCard className="w-7 h-7 text-blue-600" />
<div className="bg-gradient-to-br from-blue-100 to-blue-200 p-3 sm:p-4 rounded-xl sm:rounded-2xl shadow-lg flex-shrink-0">
<CreditCard className="w-5 h-5 sm:w-6 sm:h-6 md:w-7 md:h-7 text-blue-600" />
</div>
</div>
<div className="flex items-center mt-5 pt-4 border-t border-slate-100">
<span className="text-slate-500 text-sm">
<div className="flex items-center mt-4 sm:mt-5 pt-3 sm:pt-4 border-t border-slate-100">
<span className="text-slate-500 text-xs sm:text-sm truncate">
{financialSummary.pendingPayments} pending payments
</span>
</div>
</div>
{/* Total Invoices */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 border-l-4 border-l-purple-500 animate-slide-up hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.2s' }}>
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 border-l-4 border-l-purple-500 animate-slide-up hover:shadow-2xl hover:scale-[1.02] transition-all duration-300" style={{ animationDelay: '0.2s' }}>
<div className="flex items-center justify-between">
<div>
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-2">Total Invoices</p>
<p className="text-3xl font-bold bg-gradient-to-r from-purple-600 to-purple-700 bg-clip-text text-transparent">
<div className="flex-1 min-w-0 pr-2">
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1 sm:mb-2">Total Invoices</p>
<p className="text-lg sm:text-xl md:text-2xl lg:text-3xl font-bold bg-gradient-to-r from-purple-600 to-purple-700 bg-clip-text text-transparent">
{financialSummary.totalInvoices}
</p>
</div>
<div className="bg-gradient-to-br from-purple-100 to-purple-200 p-4 rounded-2xl shadow-lg">
<Receipt className="w-7 h-7 text-purple-600" />
<div className="bg-gradient-to-br from-purple-100 to-purple-200 p-3 sm:p-4 rounded-xl sm:rounded-2xl shadow-lg flex-shrink-0">
<Receipt className="w-5 h-5 sm:w-6 sm:h-6 md:w-7 md:h-7 text-purple-600" />
</div>
</div>
<div className="flex items-center mt-5 pt-4 border-t border-slate-100">
<span className="text-slate-500 text-sm">
<div className="flex items-center mt-4 sm:mt-5 pt-3 sm:pt-4 border-t border-slate-100">
<span className="text-slate-500 text-xs sm:text-sm truncate">
{financialSummary.paidInvoices} paid {financialSummary.overdueInvoices} overdue
</span>
</div>
@@ -315,17 +315,17 @@ const AccountantDashboardPage: React.FC = () => {
</div>
{/* Recent Payments and Invoices */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5 md:gap-6 mb-6 sm:mb-8 md:mb-10">
{/* Recent Payments */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in">
<div className="flex items-center justify-between mb-6 pb-4 border-b border-slate-200">
<h2 className="text-xl font-bold text-slate-900 flex items-center gap-2">
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in hover:shadow-2xl transition-all duration-300">
<div className="flex items-center justify-between mb-4 sm:mb-5 md:mb-6 pb-3 sm:pb-4 border-b border-slate-200">
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-slate-900 flex items-center gap-2">
<CreditCard className="w-5 h-5 text-emerald-600" />
Recent Payments
</h2>
<button
onClick={() => navigate('/accountant/payments')}
className="text-sm text-emerald-600 hover:text-emerald-700 font-semibold hover:underline transition-colors"
className="text-xs sm:text-sm text-emerald-600 hover:text-emerald-700 font-semibold hover:underline transition-colors"
>
View All
</button>
@@ -335,23 +335,23 @@ const AccountantDashboardPage: React.FC = () => {
<Loading text="Loading payments..." />
</div>
) : recentPayments && recentPayments.length > 0 ? (
<div className="space-y-3">
<div className="space-y-3 sm:space-y-4">
{recentPayments.slice(0, 5).map((payment) => (
<div
key={payment.id}
className="flex items-center justify-between p-4 bg-gradient-to-r from-slate-50 to-white rounded-xl hover:from-emerald-50 hover:to-yellow-50 border border-slate-200 hover:border-emerald-300 hover:shadow-lg cursor-pointer transition-all duration-200"
className="flex items-center justify-between p-3 sm:p-4 bg-gradient-to-r from-slate-50 to-white rounded-lg sm:rounded-xl hover:from-emerald-50 hover:to-yellow-50 border border-slate-200 hover:border-emerald-300 hover:shadow-lg cursor-pointer transition-all duration-200"
onClick={() => navigate(`/accountant/payments`)}
>
<div className="flex items-center space-x-4 flex-1">
<div className="p-3 bg-gradient-to-br from-blue-100 to-blue-200 rounded-xl shadow-md">
<CreditCard className="w-5 h-5 text-blue-600" />
<div className="flex items-center space-x-3 sm:space-x-4 flex-1 min-w-0">
<div className="p-2 sm:p-3 bg-gradient-to-br from-blue-100 to-blue-200 rounded-lg sm:rounded-xl shadow-md flex-shrink-0">
<CreditCard className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-slate-900 truncate text-lg">
<p className="font-bold text-slate-900 truncate text-sm sm:text-base md:text-lg">
{formatCurrency(payment.amount)}
</p>
<div className="flex items-center gap-2 mt-1">
<p className="text-sm text-slate-600 font-medium">
<div className="flex items-center gap-1 sm:gap-2 mt-1 flex-wrap">
<p className="text-xs sm:text-sm text-slate-600 font-medium">
{payment.payment_method || 'N/A'}
</p>
{payment.payment_date && (
@@ -362,7 +362,7 @@ const AccountantDashboardPage: React.FC = () => {
</div>
</div>
</div>
<span className={`px-3 py-1.5 text-xs font-semibold rounded-full border shadow-sm ${getPaymentStatusColor(payment.payment_status)}`}>
<span className={`px-2 sm:px-3 py-1 sm:py-1.5 text-xs font-semibold rounded-full border shadow-sm flex-shrink-0 ml-2 ${getPaymentStatusColor(payment.payment_status)}`}>
{payment.payment_status.charAt(0).toUpperCase() + payment.payment_status.slice(1)}
</span>
</div>
@@ -381,15 +381,15 @@ const AccountantDashboardPage: React.FC = () => {
</div>
{/* Recent Invoices */}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in" style={{ animationDelay: '0.2s' }}>
<div className="flex items-center justify-between mb-6 pb-4 border-b border-slate-200">
<h2 className="text-xl font-bold text-slate-900 flex items-center gap-2">
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.2s' }}>
<div className="flex items-center justify-between mb-4 sm:mb-5 md:mb-6 pb-3 sm:pb-4 border-b border-slate-200">
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-slate-900 flex items-center gap-2">
<Receipt className="w-5 h-5 text-purple-600" />
Recent Invoices
</h2>
<button
onClick={() => navigate('/accountant/invoices')}
className="text-sm text-emerald-600 hover:text-emerald-700 font-semibold hover:underline transition-colors"
className="text-xs sm:text-sm text-emerald-600 hover:text-emerald-700 font-semibold hover:underline transition-colors"
>
View All
</button>
@@ -399,23 +399,23 @@ const AccountantDashboardPage: React.FC = () => {
<Loading text="Loading invoices..." />
</div>
) : recentInvoices && recentInvoices.length > 0 ? (
<div className="space-y-3">
<div className="space-y-3 sm:space-y-4">
{recentInvoices.slice(0, 5).map((invoice) => (
<div
key={invoice.id}
className="flex items-center justify-between p-4 bg-gradient-to-r from-slate-50 to-white rounded-xl hover:from-purple-50 hover:to-indigo-50 border border-slate-200 hover:border-purple-300 hover:shadow-lg cursor-pointer transition-all duration-200"
className="flex items-center justify-between p-3 sm:p-4 bg-gradient-to-r from-slate-50 to-white rounded-lg sm:rounded-xl hover:from-purple-50 hover:to-indigo-50 border border-slate-200 hover:border-purple-300 hover:shadow-lg cursor-pointer transition-all duration-200"
onClick={() => navigate(`/accountant/invoices`)}
>
<div className="flex items-center space-x-4 flex-1">
<div className="p-3 bg-gradient-to-br from-purple-100 to-purple-200 rounded-xl shadow-md">
<Receipt className="w-5 h-5 text-purple-600" />
<div className="flex items-center space-x-3 sm:space-x-4 flex-1 min-w-0">
<div className="p-2 sm:p-3 bg-gradient-to-br from-purple-100 to-purple-200 rounded-lg sm:rounded-xl shadow-md flex-shrink-0">
<Receipt className="w-4 h-4 sm:w-5 sm:h-5 text-purple-600" />
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-slate-900 truncate text-lg">
<p className="font-bold text-slate-900 truncate text-sm sm:text-base md:text-lg">
{invoice.invoice_number}
</p>
<div className="flex items-center gap-2 mt-1">
<p className="text-sm text-slate-600 font-medium">
<div className="flex items-center gap-1 sm:gap-2 mt-1 flex-wrap">
<p className="text-xs sm:text-sm text-slate-600 font-medium">
{formatCurrency(invoice.total_amount || 0)}
</p>
{invoice.issue_date && (
@@ -426,7 +426,7 @@ const AccountantDashboardPage: React.FC = () => {
</div>
</div>
</div>
<span className={`px-3 py-1.5 text-xs font-semibold rounded-full border shadow-sm ${getInvoiceStatusColor(invoice.status)}`}>
<span className={`px-2 sm:px-3 py-1 sm:py-1.5 text-xs font-semibold rounded-full border shadow-sm flex-shrink-0 ml-2 ${getInvoiceStatusColor(invoice.status)}`}>
{invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}
</span>
</div>
@@ -447,22 +447,22 @@ const AccountantDashboardPage: React.FC = () => {
{/* Alerts Section */}
{(financialSummary.overdueInvoices > 0 || financialSummary.pendingPayments > 0) && (
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in">
<div className="flex items-center gap-3 mb-4">
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in hover:shadow-2xl transition-all duration-300">
<div className="flex items-center gap-2 sm:gap-3 mb-3 sm:mb-4">
<AlertCircle className="w-5 h-5 text-amber-600" />
<h2 className="text-xl font-bold text-slate-900">Financial Alerts</h2>
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-slate-900">Financial Alerts</h2>
</div>
<div className="space-y-3">
<div className="space-y-3 sm:space-y-4">
{financialSummary.overdueInvoices > 0 && (
<div className="p-4 bg-gradient-to-r from-rose-50 to-red-50 rounded-xl border border-rose-200">
<p className="text-rose-800 font-semibold">
<div className="p-3 sm:p-4 bg-gradient-to-r from-rose-50 to-red-50 rounded-lg sm:rounded-xl border border-rose-200">
<p className="text-rose-800 font-semibold text-sm sm:text-base">
{financialSummary.overdueInvoices} overdue invoice{financialSummary.overdueInvoices !== 1 ? 's' : ''} require attention
</p>
</div>
)}
{financialSummary.pendingPayments > 0 && (
<div className="p-4 bg-gradient-to-r from-amber-50 to-yellow-50 rounded-xl border border-amber-200">
<p className="text-amber-800 font-semibold">
<div className="p-3 sm:p-4 bg-gradient-to-r from-amber-50 to-yellow-50 rounded-lg sm:rounded-xl border border-amber-200">
<p className="text-amber-800 font-semibold text-sm sm:text-base">
{financialSummary.pendingPayments} pending payment{financialSummary.pendingPayments !== 1 ? 's' : ''} awaiting processing
</p>
</div>

View File

@@ -0,0 +1,308 @@
import React, { useEffect, useState } from 'react';
import { Plus, Trash2, Copy, Eye, EyeOff, Edit } from 'lucide-react';
import apiKeyService, { APIKey, CreateAPIKeyData, UpdateAPIKeyData } from '../../features/integrations/services/apiKeyService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import { formatDate } from '../../shared/utils/format';
const APIKeyManagementPage: React.FC = () => {
const [apiKeys, setApiKeys] = useState<APIKey[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingKey, setEditingKey] = useState<APIKey | null>(null);
const [newKey, setNewKey] = useState<string | null>(null);
const [formData, setFormData] = useState<CreateAPIKeyData>({
name: '',
scopes: [],
description: '',
rate_limit: 100,
});
const availableScopes = [
'read:bookings',
'write:bookings',
'read:payments',
'write:payments',
'read:invoices',
'write:invoices',
'read:users',
'write:users',
];
useEffect(() => {
fetchAPIKeys();
}, []);
const fetchAPIKeys = async () => {
try {
setLoading(true);
const response = await apiKeyService.getAPIKeys();
setApiKeys(response.data.api_keys || []);
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load API keys');
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingKey) {
await apiKeyService.updateAPIKey(editingKey.id, formData as UpdateAPIKeyData);
toast.success('API key updated successfully');
setShowModal(false);
setEditingKey(null);
resetForm();
fetchAPIKeys();
} else {
const response = await apiKeyService.createAPIKey(formData);
toast.success('API key created successfully');
setNewKey(response.data.key);
setShowModal(false);
resetForm();
fetchAPIKeys();
}
} catch (error: any) {
toast.error(error.response?.data?.message || `Unable to ${editingKey ? 'update' : 'create'} API key`);
}
};
const handleEdit = (key: APIKey) => {
setEditingKey(key);
setFormData({
name: key.name,
scopes: key.scopes,
description: key.description || '',
rate_limit: key.rate_limit,
expires_at: key.expires_at || undefined,
});
setShowModal(true);
};
const handleRevoke = async (id: number) => {
if (!confirm('Are you sure you want to revoke this API key?')) return;
try {
await apiKeyService.revokeAPIKey(id);
toast.success('API key revoked');
fetchAPIKeys();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to revoke API key');
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success('Copied to clipboard');
};
const resetForm = () => {
setFormData({
name: '',
scopes: [],
description: '',
rate_limit: 100,
});
setEditingKey(null);
};
const toggleScope = (scope: string) => {
setFormData({
...formData,
scopes: formData.scopes.includes(scope)
? formData.scopes.filter(s => s !== scope)
: [...formData.scopes, scope],
});
};
if (loading && apiKeys.length === 0) {
return <Loading fullScreen text="Loading API keys..." />;
}
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">
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4 sm:gap-6 mb-6 sm:mb-8 md:mb-10 animate-fade-in">
<div className="w-full lg:w-auto">
<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-indigo-400 via-purple-500 to-pink-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">
API Key Management
</h1>
</div>
<p className="text-slate-600 mt-2 sm:mt-3 text-sm sm:text-base md:text-lg font-light">Manage API keys for third-party access</p>
</div>
<button
onClick={() => setShowModal(true)}
className="w-full sm:w-auto px-4 sm:px-6 py-2 sm:py-2.5 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl font-semibold hover:from-blue-700 hover:to-blue-800 shadow-lg hover:shadow-xl transition-all duration-200 flex items-center justify-center gap-2 text-xs sm:text-sm"
>
<Plus className="w-4 h-4 sm:w-5 sm:h-5" />
Create API Key
</button>
</div>
{newKey && (
<div className="bg-gradient-to-r from-amber-50 to-yellow-50 border-2 border-amber-200 rounded-xl sm:rounded-2xl p-3 sm:p-4 mb-4 sm:mb-6 animate-fade-in shadow-lg">
<p className="font-semibold mb-2 text-sm sm:text-base text-amber-900">API Key Created (save this - it won't be shown again):</p>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 sm:gap-3">
<code className="flex-1 bg-white p-2 sm:p-3 rounded-lg sm:rounded-xl border-2 border-amber-200 text-xs sm:text-sm font-mono break-all">{newKey}</code>
<div className="flex gap-2">
<button
onClick={() => copyToClipboard(newKey)}
className="p-2 sm:p-2.5 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-lg sm:rounded-xl hover:from-blue-700 hover:to-blue-800 shadow-md hover:shadow-lg transition-all duration-200"
>
<Copy className="w-4 h-4" />
</button>
<button
onClick={() => setNewKey(null)}
className="p-2 sm:p-2.5 bg-slate-200 hover:bg-slate-300 rounded-lg sm:rounded-xl transition-colors"
>
</button>
</div>
</div>
</div>
)}
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in hover:shadow-2xl transition-all duration-300">
{apiKeys.length === 0 ? (
<div className="text-center py-8 sm:py-12">
<p className="text-slate-500 text-sm sm:text-base">No API keys configured</p>
</div>
) : (
<div className="space-y-3 sm:space-y-4">
{apiKeys.map((key) => (
<div
key={key.id}
className="bg-gradient-to-r from-slate-50 to-white border border-slate-200 rounded-lg sm:rounded-xl p-3 sm:p-4 hover:from-indigo-50 hover:to-purple-50 hover:border-indigo-300 hover:shadow-lg transition-all duration-200"
>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4">
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 sm:gap-3 mb-2">
<h3 className="font-semibold text-sm sm:text-base md:text-lg truncate">{key.name}</h3>
<span className="px-2 sm:px-3 py-1 bg-slate-100 text-slate-700 rounded-lg text-xs font-mono border border-slate-200">
{key.key_prefix}...
</span>
<span className={`px-2 sm:px-3 py-1 rounded-full text-xs font-semibold border shadow-sm ${
key.is_active ? 'bg-gradient-to-r from-emerald-50 to-green-50 text-emerald-800 border-emerald-200' :
'bg-gradient-to-r from-red-50 to-rose-50 text-red-800 border-red-200'
}`}>
{key.is_active ? 'Active' : 'Revoked'}
</span>
</div>
<p className="text-slate-600 text-xs sm:text-sm mb-1 sm:mb-2 line-clamp-1">
Scopes: {key.scopes.join(', ')}
</p>
<p className="text-slate-500 text-xs">
Rate Limit: {key.rate_limit}/min • Created: {formatDate(key.created_at)}
{key.last_used_at && ` • Last Used: ${formatDate(key.last_used_at)}`}
</p>
</div>
<div className="flex gap-2 w-full sm:w-auto">
{key.is_active && (
<>
<button
onClick={() => handleEdit(key)}
className="p-2 sm:p-2.5 text-blue-600 hover:bg-blue-50 rounded-lg sm:rounded-xl transition-colors flex-shrink-0"
title="Edit API key"
>
<Edit className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
<button
onClick={() => handleRevoke(key.id)}
className="px-3 sm:px-4 py-2 bg-gradient-to-r from-red-600 to-red-700 text-white rounded-lg sm:rounded-xl hover:from-red-700 hover:to-red-800 shadow-lg hover:shadow-xl transition-all duration-200 text-xs sm:text-sm font-semibold flex items-center justify-center gap-2"
>
<Trash2 className="w-4 h-4" />
Revoke
</button>
</>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
{showModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-3 sm:p-4">
<div className="bg-white/95 backdrop-blur-md rounded-xl sm:rounded-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto p-4 sm:p-5 md:p-6 shadow-2xl border border-slate-200/60">
<h2 className="text-xl sm:text-2xl font-bold mb-4 sm:mb-5 md:mb-6 text-slate-900">
{editingKey ? 'Edit API Key' : 'Create API Key'}
</h2>
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
<div>
<label className="block text-sm font-medium mb-1 sm:mb-2 text-slate-700">Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 sm:px-4 py-2 bg-white 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 font-medium shadow-sm hover:shadow-md text-sm sm:text-base"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 sm:mb-2 text-slate-700">Scopes</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 sm:gap-3">
{availableScopes.map((scope) => (
<label key={scope} className="flex items-center gap-2 p-2 hover:bg-slate-50 rounded-lg cursor-pointer">
<input
type="checkbox"
checked={formData.scopes.includes(scope)}
onChange={() => toggleScope(scope)}
className="w-4 h-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500"
/>
<span className="text-xs sm:text-sm text-slate-700">{scope}</span>
</label>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1 sm:mb-2 text-slate-700">Rate Limit (per minute)</label>
<input
type="number"
value={formData.rate_limit}
onChange={(e) => setFormData({ ...formData, rate_limit: parseInt(e.target.value) })}
className="w-full px-3 sm:px-4 py-2 bg-white 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 font-medium shadow-sm hover:shadow-md text-sm sm:text-base"
min="1"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 sm:mb-2 text-slate-700">Description</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-3 sm:px-4 py-2 bg-white 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 font-medium shadow-sm hover:shadow-md text-sm sm:text-base"
rows={3}
/>
</div>
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3 sm:gap-4 pt-2">
<button
type="button"
onClick={() => {
setShowModal(false);
resetForm();
}}
className="flex-1 sm:flex-none px-4 py-2 border-2 border-slate-300 rounded-xl hover:bg-slate-50 transition-all duration-200 text-slate-700 font-medium text-sm sm:text-base"
>
Cancel
</button>
<button
type="submit"
className="flex-1 sm:flex-none px-4 sm:px-6 py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl font-semibold hover:from-blue-700 hover:to-blue-800 shadow-lg hover:shadow-xl transition-all duration-200 text-sm sm:text-base"
>
{editingKey ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default APIKeyManagementPage;

View File

@@ -0,0 +1,264 @@
import React, { useEffect, useState } from 'react';
import { CheckCircle, XCircle, Clock, Eye, Filter, Trash2 } from 'lucide-react';
import approvalService, { ApprovalRequest } from '../../features/system/services/approvalService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import Pagination from '../../shared/components/Pagination';
import { formatDate } from '../../shared/utils/format';
const ApprovalManagementPage: React.FC = () => {
const [approvals, setApprovals] = useState<ApprovalRequest[]>([]);
const [loading, setLoading] = useState(true);
const [selectedApproval, setSelectedApproval] = useState<ApprovalRequest | null>(null);
const [showDetails, setShowDetails] = useState(false);
const [processingId, setProcessingId] = useState<number | null>(null);
const [filters, setFilters] = useState({
approval_type: '',
});
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const itemsPerPage = 20;
useEffect(() => {
fetchApprovals();
}, [filters, currentPage]);
const fetchApprovals = async () => {
try {
setLoading(true);
const response = await approvalService.getPendingApprovals({
...filters,
page: currentPage,
limit: itemsPerPage,
});
setApprovals(response.data.approvals || []);
if (response.data.pagination) {
setTotalPages(response.data.pagination.totalPages || 1);
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load approvals');
} finally {
setLoading(false);
}
};
const handleApprove = async (id: number) => {
try {
setProcessingId(id);
await approvalService.approveRequest(id);
toast.success('Approval request approved');
fetchApprovals();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to approve request');
} finally {
setProcessingId(null);
}
};
const handleReject = async (id: number) => {
const reason = prompt('Enter rejection reason:');
if (!reason) return;
try {
setProcessingId(id);
await approvalService.rejectRequest(id, reason);
toast.success('Approval request rejected');
fetchApprovals();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to reject request');
} finally {
setProcessingId(null);
}
};
const handleCancel = async (id: number) => {
if (!confirm('Are you sure you want to cancel this approval request?')) return;
try {
setProcessingId(id);
await approvalService.cancelRequest(id);
toast.success('Approval request cancelled');
fetchApprovals();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to cancel request');
} finally {
setProcessingId(null);
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'approved':
return <CheckCircle className="w-5 h-5 text-green-500" />;
case 'rejected':
return <XCircle className="w-5 h-5 text-red-500" />;
default:
return <Clock className="w-5 h-5 text-yellow-500" />;
}
};
if (loading && approvals.length === 0) {
return <Loading fullScreen text="Loading approvals..." />;
}
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">
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4 sm:gap-6 mb-6 sm:mb-8 md:mb-10 animate-fade-in">
<div className="w-full lg:w-auto">
<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-amber-400 via-orange-500 to-amber-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">
Approval Management
</h1>
</div>
<p className="text-slate-600 mt-2 sm:mt-3 text-sm sm:text-base md:text-lg font-light">Review and manage pending approval requests</p>
</div>
</div>
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in hover:shadow-2xl transition-all duration-300">
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4 mb-4 sm:mb-5 md:mb-6">
<div className="flex-1">
<select
value={filters.approval_type}
onChange={(e) => {
setFilters({ ...filters, approval_type: e.target.value });
setCurrentPage(1);
}}
className="w-full px-3 sm:px-4 py-2 sm:py-2.5 bg-white border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm hover:shadow-md text-sm sm:text-base"
>
<option value="">All Types</option>
<option value="invoice_update">Invoice Update</option>
<option value="payment_refund">Payment Refund</option>
<option value="invoice_mark_paid">Invoice Mark Paid</option>
<option value="financial_adjustment">Financial Adjustment</option>
</select>
</div>
</div>
{approvals.length === 0 ? (
<div className="text-center py-8 sm:py-12">
<p className="text-slate-500 text-sm sm:text-base">No pending approvals</p>
</div>
) : (
<div className="space-y-3 sm:space-y-4">
{approvals.map((approval) => (
<div
key={approval.id}
className="bg-gradient-to-r from-slate-50 to-white border border-slate-200 rounded-lg sm:rounded-xl p-3 sm:p-4 hover:from-amber-50 hover:to-yellow-50 hover:border-amber-300 hover:shadow-lg transition-all duration-200"
>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4">
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 sm:gap-3 mb-2">
{getStatusIcon(approval.status)}
<h3 className="font-semibold text-sm sm:text-base md:text-lg truncate">
{approval.approval_type.replace('_', ' ').toUpperCase()}
</h3>
<span className={`px-2 sm:px-3 py-1 rounded-full text-xs font-semibold border shadow-sm ${
approval.priority === 'urgent' ? 'bg-gradient-to-r from-red-50 to-rose-50 text-red-800 border-red-200' :
approval.priority === 'high' ? 'bg-gradient-to-r from-orange-50 to-amber-50 text-orange-800 border-orange-200' :
'bg-gradient-to-r from-blue-50 to-indigo-50 text-blue-800 border-blue-200'
}`}>
{approval.priority}
</span>
</div>
<p className="text-slate-600 text-xs sm:text-sm mb-1 sm:mb-2 truncate">
Resource: {approval.resource_type} #{approval.resource_id}
</p>
<p className="text-slate-500 text-xs">
Requested: {formatDate(approval.requested_at)}
</p>
{approval.notes && (
<p className="text-slate-600 text-xs sm:text-sm mt-2 line-clamp-2">{approval.notes}</p>
)}
</div>
<div className="flex gap-2 w-full sm:w-auto">
<button
onClick={() => {
setSelectedApproval(approval);
setShowDetails(true);
}}
className="p-2 sm:p-2.5 text-blue-600 hover:bg-blue-50 rounded-lg sm:rounded-xl transition-colors flex-shrink-0"
>
<Eye className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
{approval.status === 'pending' && (
<>
<button
onClick={() => handleApprove(approval.id)}
disabled={processingId === approval.id}
className="flex-1 sm:flex-none px-3 sm:px-4 py-2 bg-gradient-to-r from-emerald-600 to-emerald-700 text-white rounded-lg sm:rounded-xl hover:from-emerald-700 hover:to-emerald-800 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 text-xs sm:text-sm font-semibold"
>
Approve
</button>
<button
onClick={() => handleReject(approval.id)}
disabled={processingId === approval.id}
className="flex-1 sm:flex-none px-3 sm:px-4 py-2 bg-gradient-to-r from-red-600 to-red-700 text-white rounded-lg sm:rounded-xl hover:from-red-700 hover:to-red-800 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 text-xs sm:text-sm font-semibold"
>
Reject
</button>
</>
)}
</div>
</div>
</div>
))}
</div>
)}
{totalPages > 1 && (
<div className="mt-6">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
</div>
{showDetails && selectedApproval && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-3 sm:p-4">
<div className="bg-white/95 backdrop-blur-md rounded-xl sm:rounded-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto p-4 sm:p-5 md:p-6 shadow-2xl border border-slate-200/60">
<div className="flex justify-between items-center mb-4 sm:mb-5 md:mb-6">
<h2 className="text-xl sm:text-2xl font-bold text-slate-900">Approval Details</h2>
<button
onClick={() => setShowDetails(false)}
className="text-slate-500 hover:text-slate-700 p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
</button>
</div>
<div className="space-y-3 sm:space-y-4">
<div>
<label className="font-semibold text-sm sm:text-base text-slate-700 block mb-1">Type:</label>
<p className="text-sm sm:text-base text-slate-900">{selectedApproval.approval_type}</p>
</div>
<div>
<label className="font-semibold text-sm sm:text-base text-slate-700 block mb-1">Status:</label>
<p className="text-sm sm:text-base text-slate-900">{selectedApproval.status}</p>
</div>
<div>
<label className="font-semibold text-sm sm:text-base text-slate-700 block mb-1">Request Data:</label>
<pre className="bg-slate-100 p-3 sm:p-4 rounded-lg sm:rounded-xl overflow-auto text-xs sm:text-sm border border-slate-200">
{JSON.stringify(selectedApproval.request_data, null, 2)}
</pre>
</div>
{selectedApproval.current_data && (
<div>
<label className="font-semibold text-sm sm:text-base text-slate-700 block mb-1">Current Data:</label>
<pre className="bg-slate-100 p-3 sm:p-4 rounded-lg sm:rounded-xl overflow-auto text-xs sm:text-sm border border-slate-200">
{JSON.stringify(selectedApproval.current_data, null, 2)}
</pre>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default ApprovalManagementPage;

View File

@@ -0,0 +1,201 @@
import React, { useEffect, useState } from 'react';
import { Download, Trash2, Plus, HardDrive, AlertTriangle } from 'lucide-react';
import backupService, { Backup } from '../../features/system/services/backupService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import { formatDate } from '../../shared/utils/format';
const BackupManagementPage: React.FC = () => {
const [backups, setBackups] = useState<Backup[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [backupAvailable, setBackupAvailable] = useState<boolean | null>(null);
useEffect(() => {
fetchBackups();
checkBackupStatus();
}, []);
const checkBackupStatus = async () => {
try {
const response = await backupService.checkStatus();
setBackupAvailable(response.data.available);
if (!response.data.available) {
toast.warning(response.data.message || 'Backup service is not available', { autoClose: 8000 });
}
} catch (error: any) {
console.error('Error checking backup status:', error);
setBackupAvailable(false);
}
};
const fetchBackups = async () => {
try {
setLoading(true);
const response = await backupService.listBackups();
setBackups(response.data.backups || []);
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load backups');
} finally {
setLoading(false);
}
};
const handleCreateBackup = async () => {
if (!confirm('Create a new database backup?')) return;
try {
setCreating(true);
await backupService.createBackup();
toast.success('Backup created successfully');
fetchBackups();
} catch (error: any) {
const errorDetail = error.response?.data?.detail;
if (error.response?.status === 503 && errorDetail?.requires_installation) {
// Show a more detailed error for missing mysqldump
toast.error(
<div>
<p className="font-semibold mb-1">Backup service unavailable</p>
<p className="text-sm">{errorDetail.message || errorDetail}</p>
</div>,
{ autoClose: 10000 }
);
} else {
toast.error(error.response?.data?.detail?.message || error.response?.data?.detail || error.response?.data?.message || 'Unable to create backup');
}
} finally {
setCreating(false);
}
};
const handleDownload = async (filename: string) => {
try {
const blob = await backupService.downloadBackup(filename);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('Backup downloaded');
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to download backup');
}
};
const handleCleanup = async () => {
if (!confirm('Clean up old backups beyond retention period?')) return;
try {
const response = await backupService.cleanupOldBackups();
toast.success(`Removed ${response.data.removed_count} old backup(s)`);
fetchBackups();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to cleanup backups');
}
};
if (loading && backups.length === 0) {
return <Loading fullScreen text="Loading backups..." />;
}
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">
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4 sm:gap-6 mb-6 sm:mb-8 md:mb-10 animate-fade-in">
<div className="w-full lg:w-auto">
<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-slate-400 via-gray-500 to-slate-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">
Backup Management
</h1>
</div>
<p className="text-slate-600 mt-2 sm:mt-3 text-sm sm:text-base md:text-lg font-light">Manage database backups</p>
</div>
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3 w-full lg:w-auto">
<button
onClick={handleCleanup}
className="w-full sm:w-auto px-4 sm:px-6 py-2 sm:py-2.5 bg-gradient-to-r from-slate-600 to-slate-700 text-white rounded-xl font-semibold hover:from-slate-700 hover:to-slate-800 shadow-lg hover:shadow-xl transition-all duration-200 text-xs sm:text-sm"
>
Cleanup Old
</button>
<button
onClick={handleCreateBackup}
disabled={creating || backupAvailable === false}
className="w-full sm:w-auto px-4 sm:px-6 py-2 sm:py-2.5 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl font-semibold hover:from-blue-700 hover:to-blue-800 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center justify-center gap-2 text-xs sm:text-sm"
title={backupAvailable === false ? 'Backup service unavailable - install mysqldump' : ''}
>
<Plus className="w-4 h-4 sm:w-5 sm:h-5" />
{creating ? 'Creating...' : 'Create Backup'}
</button>
</div>
</div>
{/* Warning Banner */}
{backupAvailable === false && (
<div className="bg-gradient-to-r from-amber-50 to-yellow-50 border-2 border-amber-200 rounded-xl sm:rounded-2xl p-3 sm:p-4 md:p-5 mb-4 sm:mb-6 animate-fade-in shadow-lg">
<div className="flex items-start gap-3 sm:gap-4">
<AlertTriangle className="w-5 h-5 sm:w-6 sm:h-6 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-semibold text-amber-900 text-sm sm:text-base mb-1 sm:mb-2">Backup Service Unavailable</h3>
<p className="text-xs sm:text-sm text-amber-800 mb-2 sm:mb-3">
mysqldump is not installed. Please install MySQL client tools to enable backups:
</p>
<div className="bg-white/80 rounded-lg sm:rounded-xl p-2 sm:p-3 border border-amber-200">
<code className="text-xs sm:text-sm text-amber-900 font-mono block">
<div className="mb-1">Ubuntu/Debian: <span className="font-semibold">sudo apt-get install mysql-client</span></div>
<div className="mb-1">CentOS/RHEL: <span className="font-semibold">sudo yum install mysql</span></div>
<div>macOS: <span className="font-semibold">brew install mysql-client</span></div>
</code>
</div>
</div>
</div>
</div>
)}
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in hover:shadow-2xl transition-all duration-300">
{backups.length === 0 ? (
<div className="text-center py-8 sm:py-12">
<HardDrive className="w-12 h-12 sm:w-16 sm:h-16 text-slate-400 mx-auto mb-3 sm:mb-4" />
<p className="text-slate-500 text-sm sm:text-base">No backups available</p>
</div>
) : (
<div className="space-y-3 sm:space-y-4">
{backups.map((backup) => (
<div
key={backup.filename}
className="bg-gradient-to-r from-slate-50 to-white border border-slate-200 rounded-lg sm:rounded-xl p-3 sm:p-4 hover:from-blue-50 hover:to-indigo-50 hover:border-blue-300 hover:shadow-lg transition-all duration-200"
>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4">
<div className="flex items-start gap-3 sm:gap-4 flex-1 min-w-0">
<HardDrive className="w-6 h-6 sm:w-8 sm:h-8 text-blue-500 mt-1 flex-shrink-0" />
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-sm sm:text-base md:text-lg mb-1 truncate">{backup.filename}</h3>
<p className="text-slate-600 text-xs sm:text-sm mb-1">
Size: {backup.size_mb} MB Database: {backup.database}
</p>
<p className="text-slate-500 text-xs">
Created: {formatDate(backup.created_at)}
</p>
</div>
</div>
<button
onClick={() => handleDownload(backup.filename)}
className="w-full sm:w-auto px-3 sm:px-4 py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-lg sm:rounded-xl hover:from-blue-700 hover:to-blue-800 shadow-lg hover:shadow-xl transition-all duration-200 flex items-center justify-center gap-2 text-xs sm:text-sm font-semibold"
>
<Download className="w-4 h-4" />
Download
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default BackupManagementPage;

View File

@@ -0,0 +1,147 @@
import React, { useEffect, useState } from 'react';
import { Download, Trash2, FileText, Eye } from 'lucide-react';
import gdprService, { GDPRRequest } from '../../features/compliance/services/gdprService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import { formatDate } from '../../shared/utils/format';
const GDPRManagementPage: React.FC = () => {
const [requests, setRequests] = useState<GDPRRequest[]>([]);
const [loading, setLoading] = useState(true);
const [selectedRequest, setSelectedRequest] = useState<GDPRRequest | null>(null);
useEffect(() => {
fetchRequests();
}, []);
const fetchRequests = async () => {
try {
setLoading(true);
const response = await gdprService.getAllRequests();
setRequests(response.data.requests || []);
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load GDPR requests');
} finally {
setLoading(false);
}
};
const handleDownloadExport = async (request: GDPRRequest) => {
if (!request.verification_token) {
toast.error('Verification token not available');
return;
}
try {
const blob = await gdprService.downloadExport(request.id, request.verification_token);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `user_data_export_${request.id}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('Export downloaded');
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to download export');
}
};
const handleDelete = async (id: number) => {
if (!confirm('Are you sure you want to delete this GDPR request?')) return;
try {
await gdprService.deleteRequest(id);
toast.success('GDPR request deleted successfully');
fetchRequests();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to delete GDPR request');
}
};
if (loading) {
return <Loading fullScreen text="Loading GDPR requests..." />;
}
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">
<div className="mb-6 sm:mb-8 md:mb-10 animate-fade-in">
<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-blue-400 via-indigo-500 to-purple-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">
GDPR Compliance
</h1>
</div>
<p className="text-slate-600 mt-2 sm:mt-3 text-sm sm:text-base md:text-lg font-light">Manage data export and deletion requests</p>
</div>
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in hover:shadow-2xl transition-all duration-300">
{requests.length === 0 ? (
<div className="text-center py-8 sm:py-12">
<p className="text-slate-500 text-sm sm:text-base">No GDPR requests</p>
</div>
) : (
<div className="space-y-3 sm:space-y-4">
{requests.map((request) => (
<div
key={request.id}
className="bg-gradient-to-r from-slate-50 to-white border border-slate-200 rounded-lg sm:rounded-xl p-3 sm:p-4 hover:from-blue-50 hover:to-indigo-50 hover:border-blue-300 hover:shadow-lg transition-all duration-200"
>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4">
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 sm:gap-3 mb-2">
{request.request_type === 'data_export' ? (
<Download className="w-5 h-5 text-blue-500 flex-shrink-0" />
) : (
<Trash2 className="w-5 h-5 text-red-500 flex-shrink-0" />
)}
<h3 className="font-semibold text-sm sm:text-base md:text-lg truncate">
{request.request_type.replace('_', ' ').toUpperCase()}
</h3>
<span className={`px-2 sm:px-3 py-1 rounded-full text-xs font-semibold border shadow-sm ${
request.status === 'completed' ? 'bg-gradient-to-r from-emerald-50 to-green-50 text-emerald-800 border-emerald-200' :
request.status === 'pending' ? 'bg-gradient-to-r from-amber-50 to-yellow-50 text-amber-800 border-amber-200' :
'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200'
}`}>
{request.status}
</span>
</div>
<p className="text-slate-600 text-xs sm:text-sm mb-1 sm:mb-2 truncate">
User: {request.user_email}
</p>
<p className="text-slate-500 text-xs">
Created: {formatDate(request.created_at)}
{request.processed_at && ` • Processed: ${formatDate(request.processed_at)}`}
</p>
</div>
<div className="flex gap-2 w-full sm:w-auto">
{request.request_type === 'data_export' && request.status === 'completed' && (
<button
onClick={() => handleDownloadExport(request)}
className="px-3 sm:px-4 py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-lg sm:rounded-xl hover:from-blue-700 hover:to-blue-800 shadow-lg hover:shadow-xl transition-all duration-200 text-xs sm:text-sm font-semibold flex items-center justify-center gap-2"
>
<Download className="w-4 h-4" />
Download
</button>
)}
<button
onClick={() => handleDelete(request.id)}
className="p-2 sm:p-2.5 text-red-600 hover:bg-red-50 rounded-lg sm:rounded-xl transition-colors flex-shrink-0"
title="Delete request"
>
<Trash2 className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default GDPRManagementPage;

View File

@@ -0,0 +1,279 @@
import React, { useEffect, useState } from 'react';
import { Plus, Trash2, Eye, Activity, Edit } from 'lucide-react';
import webhookService, { Webhook, CreateWebhookData, UpdateWebhookData } from '../../features/integrations/services/webhookService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import { formatDate } from '../../shared/utils/format';
const WebhookManagementPage: React.FC = () => {
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingWebhook, setEditingWebhook] = useState<Webhook | null>(null);
const [selectedWebhook, setSelectedWebhook] = useState<Webhook | null>(null);
const [formData, setFormData] = useState<CreateWebhookData>({
name: '',
url: '',
events: [],
description: '',
retry_count: 3,
timeout_seconds: 30,
});
const availableEvents = [
'booking.created',
'booking.updated',
'booking.cancelled',
'payment.completed',
'payment.failed',
'invoice.created',
'invoice.paid',
'user.created',
'user.updated',
];
useEffect(() => {
fetchWebhooks();
}, []);
const fetchWebhooks = async () => {
try {
setLoading(true);
const response = await webhookService.getWebhooks();
setWebhooks(response.data.webhooks || []);
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load webhooks');
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingWebhook) {
await webhookService.updateWebhook(editingWebhook.id, formData as UpdateWebhookData);
toast.success('Webhook updated successfully');
} else {
const response = await webhookService.createWebhook(formData);
toast.success('Webhook created successfully');
// Show secret to user (only shown once)
if (response.data.webhook.secret) {
alert(`Webhook secret (save this - it won't be shown again): ${response.data.webhook.secret}`);
}
}
setShowModal(false);
setEditingWebhook(null);
resetForm();
fetchWebhooks();
} catch (error: any) {
toast.error(error.response?.data?.message || `Unable to ${editingWebhook ? 'update' : 'create'} webhook`);
}
};
const handleEdit = (webhook: Webhook) => {
setEditingWebhook(webhook);
setFormData({
name: webhook.name,
url: webhook.url,
events: webhook.events,
description: webhook.description || '',
retry_count: webhook.retry_count,
timeout_seconds: webhook.timeout_seconds,
});
setShowModal(true);
};
const handleDelete = async (id: number) => {
if (!confirm('Are you sure you want to delete this webhook?')) return;
try {
await webhookService.deleteWebhook(id);
toast.success('Webhook deleted successfully');
fetchWebhooks();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to delete webhook');
}
};
const resetForm = () => {
setFormData({
name: '',
url: '',
events: [],
description: '',
retry_count: 3,
timeout_seconds: 30,
});
setEditingWebhook(null);
};
const toggleEvent = (event: string) => {
setFormData({
...formData,
events: formData.events.includes(event)
? formData.events.filter(e => e !== event)
: [...formData.events, event],
});
};
if (loading && webhooks.length === 0) {
return <Loading fullScreen text="Loading webhooks..." />;
}
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">
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4 sm:gap-6 mb-6 sm:mb-8 md:mb-10 animate-fade-in">
<div className="w-full lg:w-auto">
<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-purple-400 via-indigo-500 to-blue-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">
Webhook Management
</h1>
</div>
<p className="text-slate-600 mt-2 sm:mt-3 text-sm sm:text-base md:text-lg font-light">Manage webhooks for external integrations</p>
</div>
<button
onClick={() => setShowModal(true)}
className="w-full sm:w-auto px-4 sm:px-6 py-2 sm:py-2.5 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl font-semibold hover:from-blue-700 hover:to-blue-800 shadow-lg hover:shadow-xl transition-all duration-200 flex items-center justify-center gap-2 text-xs sm:text-sm"
>
<Plus className="w-4 h-4 sm:w-5 sm:h-5" />
Create Webhook
</button>
</div>
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in hover:shadow-2xl transition-all duration-300">
{webhooks.length === 0 ? (
<div className="text-center py-8 sm:py-12">
<p className="text-slate-500 text-sm sm:text-base">No webhooks configured</p>
</div>
) : (
<div className="space-y-3 sm:space-y-4">
{webhooks.map((webhook) => (
<div
key={webhook.id}
className="bg-gradient-to-r from-slate-50 to-white border border-slate-200 rounded-lg sm:rounded-xl p-3 sm:p-4 hover:from-purple-50 hover:to-indigo-50 hover:border-purple-300 hover:shadow-lg transition-all duration-200"
>
<div className="flex items-start justify-between gap-3 sm:gap-4">
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 sm:gap-3 mb-2">
<Activity className="w-5 h-5 text-blue-500 flex-shrink-0" />
<h3 className="font-semibold text-sm sm:text-base md:text-lg truncate">{webhook.name}</h3>
<span className={`px-2 sm:px-3 py-1 rounded-full text-xs font-semibold border shadow-sm ${
webhook.status === 'active' ? 'bg-gradient-to-r from-emerald-50 to-green-50 text-emerald-800 border-emerald-200' :
'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200'
}`}>
{webhook.status}
</span>
</div>
<p className="text-slate-600 text-xs sm:text-sm mb-1 sm:mb-2 truncate">{webhook.url}</p>
<p className="text-slate-500 text-xs line-clamp-1">
Events: {webhook.events.join(', ')}
</p>
<p className="text-slate-500 text-xs">
Created: {formatDate(webhook.created_at)}
</p>
</div>
<div className="flex gap-2 w-full sm:w-auto">
<button
onClick={() => handleEdit(webhook)}
className="p-2 sm:p-2.5 text-blue-600 hover:bg-blue-50 rounded-lg sm:rounded-xl transition-colors flex-shrink-0"
title="Edit webhook"
>
<Edit className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
<button
onClick={() => handleDelete(webhook.id)}
className="p-2 sm:p-2.5 text-red-600 hover:bg-red-50 rounded-lg sm:rounded-xl transition-colors flex-shrink-0"
title="Delete webhook"
>
<Trash2 className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
{showModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-3 sm:p-4">
<div className="bg-white/95 backdrop-blur-md rounded-xl sm:rounded-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto p-4 sm:p-5 md:p-6 shadow-2xl border border-slate-200/60">
<h2 className="text-xl sm:text-2xl font-bold mb-4 sm:mb-5 md:mb-6 text-slate-900">
{editingWebhook ? 'Edit Webhook' : 'Create Webhook'}
</h2>
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
<div>
<label className="block text-sm font-medium mb-1 sm:mb-2 text-slate-700">Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 sm:px-4 py-2 bg-white 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 font-medium shadow-sm hover:shadow-md text-sm sm:text-base"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 sm:mb-2 text-slate-700">URL</label>
<input
type="url"
value={formData.url}
onChange={(e) => setFormData({ ...formData, url: e.target.value })}
className="w-full px-3 sm:px-4 py-2 bg-white 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 font-medium shadow-sm hover:shadow-md text-sm sm:text-base"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 sm:mb-2 text-slate-700">Events</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 sm:gap-3">
{availableEvents.map((event) => (
<label key={event} className="flex items-center gap-2 p-2 hover:bg-slate-50 rounded-lg cursor-pointer">
<input
type="checkbox"
checked={formData.events.includes(event)}
onChange={() => toggleEvent(event)}
className="w-4 h-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500"
/>
<span className="text-xs sm:text-sm text-slate-700">{event}</span>
</label>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1 sm:mb-2 text-slate-700">Description</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-3 sm:px-4 py-2 bg-white 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 font-medium shadow-sm hover:shadow-md text-sm sm:text-base"
rows={3}
/>
</div>
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3 sm:gap-4 pt-2">
<button
type="button"
onClick={() => {
setShowModal(false);
resetForm();
}}
className="flex-1 sm:flex-none px-4 py-2 border-2 border-slate-300 rounded-xl hover:bg-slate-50 transition-all duration-200 text-slate-700 font-medium text-sm sm:text-base"
>
Cancel
</button>
<button
type="submit"
className="flex-1 sm:flex-none px-4 sm:px-6 py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl font-semibold hover:from-blue-700 hover:to-blue-800 shadow-lg hover:shadow-xl transition-all duration-200 text-sm sm:text-base"
>
{editingWebhook ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default WebhookManagementPage;

View File

@@ -64,15 +64,15 @@ const DashboardPage: React.FC = () => {
const getPaymentStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'bg-green-100 text-green-800';
return 'bg-gradient-to-r from-emerald-50 to-green-50 text-emerald-800 border-emerald-200';
case 'pending':
return 'bg-yellow-100 text-yellow-800';
return 'bg-gradient-to-r from-amber-50 to-yellow-50 text-amber-800 border-amber-200';
case 'failed':
return 'bg-red-100 text-red-800';
return 'bg-gradient-to-r from-rose-50 to-red-50 text-rose-800 border-rose-200';
case 'refunded':
return 'bg-gray-100 text-gray-800';
return 'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200';
default:
return 'bg-gray-100 text-gray-800';
return 'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200';
}
};
@@ -98,7 +98,7 @@ const DashboardPage: React.FC = () => {
if (error || !stats) {
return (
<div className="container mx-auto px-4 py-8">
<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">
<EmptyState
title="Unable to Load Dashboard"
description={error?.message || 'Something went wrong. Please try again.'}
@@ -112,135 +112,143 @@ const DashboardPage: React.FC = () => {
}
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-10 animate-fade-in">
<h1 className="enterprise-section-title mb-2">
Dashboard
</h1>
<p className="enterprise-section-subtitle">
<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">
<div className="mb-6 sm:mb-8 md:mb-10 animate-fade-in">
<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-blue-400 via-indigo-500 to-purple-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">
Dashboard
</h1>
</div>
<p className="text-slate-600 mt-2 sm:mt-3 text-sm sm:text-base md:text-lg font-light">
Overview of your activity and bookings
</p>
</div>
{}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-10">
<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 md:mb-10">
{}
<div className="enterprise-stat-card border-l-4 border-blue-500 animate-slide-up" style={{ animationDelay: '0.1s' }}>
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-blue-100 rounded-lg">
<Calendar className="w-6 h-6 text-blue-600" />
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 border-l-4 border-l-blue-500 animate-slide-up hover:shadow-2xl hover:scale-[1.02] transition-all duration-300" style={{ animationDelay: '0.1s' }}>
<div className="flex items-center justify-between mb-3 sm:mb-4">
<div className="p-2 sm:p-3 md:p-4 bg-gradient-to-br from-blue-100 to-blue-200 rounded-lg sm:rounded-xl shadow-lg">
<Calendar className="w-5 h-5 sm:w-6 sm:h-6 md:w-7 md:h-7 text-blue-600" />
</div>
{stats.booking_change_percentage !== 0 && (
<span className={`text-sm font-medium flex items-center gap-1 ${
stats.booking_change_percentage > 0 ? 'text-green-600' : 'text-red-600'
<span className={`text-xs sm:text-sm font-semibold flex items-center gap-1 px-2 py-1 rounded-full ${
stats.booking_change_percentage > 0
? 'text-emerald-700 bg-emerald-50 border border-emerald-200'
: 'text-rose-700 bg-rose-50 border border-rose-200'
}`}>
{stats.booking_change_percentage > 0 ? (
<TrendingUp className="w-4 h-4" />
<TrendingUp className="w-3 h-3 sm:w-4 sm:h-4" />
) : (
<TrendingDown className="w-4 h-4" />
<TrendingDown className="w-3 h-3 sm:w-4 sm:h-4" />
)}
{Math.abs(stats.booking_change_percentage).toFixed(1)}%
</span>
)}
</div>
<h3 className="text-gray-500 text-sm font-medium mb-1">
<h3 className="text-slate-500 text-xs sm:text-sm font-semibold uppercase tracking-wider mb-1 sm:mb-2">
Total Bookings
</h3>
<p className="text-3xl font-bold text-gray-800">
<p className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-blue-600 to-blue-700 bg-clip-text text-transparent">
{stats.total_bookings}
</p>
</div>
{}
<div className="enterprise-stat-card border-l-4 border-green-500 animate-slide-up" style={{ animationDelay: '0.2s' }}>
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-green-100 rounded-lg">
<CurrencyIcon className="text-green-600" size={24} />
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 border-l-4 border-l-emerald-500 animate-slide-up hover:shadow-2xl hover:scale-[1.02] transition-all duration-300" style={{ animationDelay: '0.2s' }}>
<div className="flex items-center justify-between mb-3 sm:mb-4">
<div className="p-2 sm:p-3 md:p-4 bg-gradient-to-br from-emerald-100 to-emerald-200 rounded-lg sm:rounded-xl shadow-lg">
<CurrencyIcon className="text-emerald-600" size={24} />
</div>
{stats.spending_change_percentage !== 0 && (
<span className={`text-sm font-medium flex items-center gap-1 ${
stats.spending_change_percentage > 0 ? 'text-green-600' : 'text-red-600'
<span className={`text-xs sm:text-sm font-semibold flex items-center gap-1 px-2 py-1 rounded-full ${
stats.spending_change_percentage > 0
? 'text-emerald-700 bg-emerald-50 border border-emerald-200'
: 'text-rose-700 bg-rose-50 border border-rose-200'
}`}>
{stats.spending_change_percentage > 0 ? (
<TrendingUp className="w-4 h-4" />
<TrendingUp className="w-3 h-3 sm:w-4 sm:h-4" />
) : (
<TrendingDown className="w-4 h-4" />
<TrendingDown className="w-3 h-3 sm:w-4 sm:h-4" />
)}
{Math.abs(stats.spending_change_percentage).toFixed(1)}%
</span>
)}
</div>
<h3 className="text-gray-500 text-sm font-medium mb-1">
<h3 className="text-slate-500 text-xs sm:text-sm font-semibold uppercase tracking-wider mb-1 sm:mb-2">
Total Spending
</h3>
<p className="text-3xl font-bold text-gray-800">
<p className="text-xl sm:text-2xl md:text-3xl font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent truncate">
{formatCurrency(stats.total_spending)}
</p>
</div>
{}
<div className="enterprise-stat-card border-l-4 border-purple-500 animate-slide-up" style={{ animationDelay: '0.3s' }}>
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-purple-100 rounded-lg">
<Hotel className="w-6 h-6 text-purple-600" />
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 border-l-4 border-l-purple-500 animate-slide-up hover:shadow-2xl hover:scale-[1.02] transition-all duration-300" style={{ animationDelay: '0.3s' }}>
<div className="flex items-center justify-between mb-3 sm:mb-4">
<div className="p-2 sm:p-3 md:p-4 bg-gradient-to-br from-purple-100 to-purple-200 rounded-lg sm:rounded-xl shadow-lg">
<Hotel className="w-5 h-5 sm:w-6 sm:h-6 md:w-7 md:h-7 text-purple-600" />
</div>
{stats.currently_staying > 0 && (
<span className="text-sm text-green-600 font-medium">
<span className="text-xs sm:text-sm text-emerald-700 font-semibold px-2 py-1 bg-emerald-50 border border-emerald-200 rounded-full">
Active
</span>
)}
</div>
<h3 className="text-gray-500 text-sm font-medium mb-1">
<h3 className="text-slate-500 text-xs sm:text-sm font-semibold uppercase tracking-wider mb-1 sm:mb-2">
Currently Staying
</h3>
<p className="text-3xl font-bold text-gray-800">
<p className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-purple-600 to-purple-700 bg-clip-text text-transparent">
{stats.currently_staying}
</p>
</div>
{}
<div className="enterprise-stat-card border-l-4 border-orange-500 animate-slide-up" style={{ animationDelay: '0.4s' }}>
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-orange-100 rounded-lg">
<TrendingUp className="w-6 h-6 text-orange-600" />
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 border-l-4 border-l-amber-500 animate-slide-up hover:shadow-2xl hover:scale-[1.02] transition-all duration-300" style={{ animationDelay: '0.4s' }}>
<div className="flex items-center justify-between mb-3 sm:mb-4">
<div className="p-2 sm:p-3 md:p-4 bg-gradient-to-br from-amber-100 to-amber-200 rounded-lg sm:rounded-xl shadow-lg">
<TrendingUp className="w-5 h-5 sm:w-6 sm:h-6 md:w-7 md:h-7 text-amber-600" />
</div>
</div>
<h3 className="text-gray-500 text-sm font-medium mb-1">
<h3 className="text-slate-500 text-xs sm:text-sm font-semibold uppercase tracking-wider mb-1 sm:mb-2">
Upcoming Bookings
</h3>
<p className="text-3xl font-bold text-gray-800">
<p className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-amber-600 to-amber-700 bg-clip-text text-transparent">
{stats.upcoming_bookings.length}
</p>
</div>
</div>
{}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-5 md:gap-6">
{}
<div className="enterprise-card p-6 animate-fade-in">
<h2 className="text-xl font-bold text-gray-900 mb-6 pb-3 border-b border-gray-200">
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in hover:shadow-2xl transition-all duration-300">
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-slate-900 mb-4 sm:mb-5 md:mb-6 pb-3 sm:pb-4 border-b border-slate-200 flex items-center gap-2">
<Activity className="w-5 h-5 text-blue-600" />
Recent Activity
</h2>
{stats.recent_activity && stats.recent_activity.length > 0 ? (
<div className="space-y-4">
<div className="space-y-3 sm:space-y-4">
{stats.recent_activity.map((activity, index) => (
<div
key={activity.booking_id || index}
className="flex items-center space-x-4 pb-4 border-b border-gray-200 last:border-0 hover:bg-gray-50 -mx-2 px-2 py-1 rounded cursor-pointer transition-colors"
className="flex items-center space-x-3 sm:space-x-4 p-3 sm:p-4 bg-gradient-to-r from-slate-50 to-white rounded-lg sm:rounded-xl border border-slate-200 hover:from-blue-50 hover:to-indigo-50 hover:border-blue-300 hover:shadow-md cursor-pointer transition-all duration-200"
onClick={() => navigate(`/bookings/${activity.booking_id}`)}
>
<div className="p-2 bg-blue-100 rounded-lg">
<Activity className="w-5 h-5 text-blue-600" />
<div className="p-2 sm:p-3 bg-gradient-to-br from-blue-100 to-blue-200 rounded-lg sm:rounded-xl shadow-md flex-shrink-0">
<Activity className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
</div>
<div className="flex-1">
<p className="font-medium text-gray-800">
<div className="flex-1 min-w-0">
<p className="font-semibold text-slate-900 text-sm sm:text-base truncate">
{activity.action}
</p>
<p className="text-sm text-gray-500">
<p className="text-xs sm:text-sm text-slate-600 truncate">
{activity.room?.room_number || activity.booking_number}
</p>
</div>
<span className="text-sm text-gray-400">
<span className="text-xs sm:text-sm text-slate-400 flex-shrink-0">
{formatRelativeTime(new Date(activity.created_at))}
</span>
</div>
@@ -259,35 +267,36 @@ const DashboardPage: React.FC = () => {
</div>
{}
<div className="enterprise-card p-6 animate-fade-in" style={{ animationDelay: '0.2s' }}>
<h2 className="text-xl font-bold text-gray-900 mb-6 pb-3 border-b border-gray-200">
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.2s' }}>
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-slate-900 mb-4 sm:mb-5 md:mb-6 pb-3 sm:pb-4 border-b border-slate-200 flex items-center gap-2">
<Calendar className="w-5 h-5 text-purple-600" />
Upcoming Bookings
</h2>
{stats.upcoming_bookings && stats.upcoming_bookings.length > 0 ? (
<div className="space-y-4">
<div className="space-y-3 sm:space-y-4">
{stats.upcoming_bookings.map((booking) => (
<div
key={booking.id}
className="flex items-center justify-between pb-4 border-b border-gray-200 last:border-0 hover:bg-gray-50 -mx-2 px-2 py-1 rounded cursor-pointer transition-colors"
className="flex items-center justify-between p-3 sm:p-4 bg-gradient-to-r from-slate-50 to-white rounded-lg sm:rounded-xl border border-slate-200 hover:from-purple-50 hover:to-indigo-50 hover:border-purple-300 hover:shadow-md cursor-pointer transition-all duration-200"
onClick={() => navigate(`/bookings/${booking.id}`)}
>
<div>
<p className="font-medium text-gray-800">
<div className="flex-1 min-w-0">
<p className="font-semibold text-slate-900 text-sm sm:text-base truncate">
Room {booking.room?.room_number || 'N/A'}
</p>
<p className="text-sm text-gray-500">
<p className="text-xs sm:text-sm text-slate-600 mt-1">
{formatDate(booking.check_in_date, 'medium')}
</p>
<p className="text-xs text-gray-400 mt-1">
<p className="text-xs text-slate-500 mt-1 font-medium">
{formatCurrency(booking.total_price)}
</p>
</div>
<span className={`enterprise-badge ${
<span className={`px-2 sm:px-3 py-1 sm:py-1.5 text-xs font-semibold rounded-full border shadow-sm flex-shrink-0 ml-2 ${
booking.status === 'confirmed'
? 'bg-green-100 text-green-800 shadow-sm shadow-green-500/20'
? 'bg-gradient-to-r from-emerald-50 to-green-50 text-emerald-800 border-emerald-200'
: booking.status === 'pending'
? 'bg-yellow-100 text-yellow-800 shadow-sm shadow-yellow-500/20'
: 'bg-gray-100 text-gray-800 shadow-sm'
? 'bg-gradient-to-r from-amber-50 to-yellow-50 text-amber-800 border-amber-200'
: 'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200'
}`}>
{booking.status.charAt(0).toUpperCase() + booking.status.slice(1).replace('_', ' ')}
</span>
@@ -307,16 +316,17 @@ const DashboardPage: React.FC = () => {
</div>
{}
<div className="enterprise-card p-6 animate-fade-in" style={{ animationDelay: '0.3s' }}>
<div className="flex items-center justify-between mb-6 pb-3 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900">
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.3s' }}>
<div className="flex items-center justify-between mb-4 sm:mb-5 md:mb-6 pb-3 sm:pb-4 border-b border-slate-200">
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-slate-900 flex items-center gap-2">
<CreditCard className="w-5 h-5 text-emerald-600" />
Payment History
</h2>
<button
onClick={() => navigate('/bookings')}
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
className="text-xs sm:text-sm text-blue-600 hover:text-blue-700 font-semibold hover:underline transition-colors"
>
View All
View All
</button>
</div>
{loadingPayments ? (
@@ -324,34 +334,34 @@ const DashboardPage: React.FC = () => {
<Loading text="Loading payments..." />
</div>
) : recentPayments && recentPayments.length > 0 ? (
<div className="space-y-4">
<div className="space-y-3 sm:space-y-4">
{recentPayments.map((payment) => (
<div
key={payment.id}
className="flex items-center justify-between pb-4 border-b border-gray-200 last:border-0 hover:bg-gray-50 -mx-2 px-2 py-1 rounded cursor-pointer transition-colors"
className="flex items-center justify-between p-3 sm:p-4 bg-gradient-to-r from-slate-50 to-white rounded-lg sm:rounded-xl border border-slate-200 hover:from-emerald-50 hover:to-green-50 hover:border-emerald-300 hover:shadow-md cursor-pointer transition-all duration-200"
onClick={() => navigate(`/bookings/${payment.booking_id}`)}
>
<div className="flex items-center space-x-3 flex-1">
<div className="p-2 bg-blue-100 rounded-lg">
<CreditCard className="w-5 h-5 text-blue-600" />
<div className="flex items-center space-x-3 sm:space-x-4 flex-1 min-w-0">
<div className="p-2 sm:p-3 bg-gradient-to-br from-blue-100 to-blue-200 rounded-lg sm:rounded-xl shadow-md flex-shrink-0">
<CreditCard className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-800 truncate">
<p className="font-bold text-slate-900 truncate text-sm sm:text-base">
{formatCurrency(payment.amount)}
</p>
<div className="flex items-center gap-2 mt-1">
<p className="text-sm text-gray-500">
<div className="flex items-center gap-1 sm:gap-2 mt-1 flex-wrap">
<p className="text-xs sm:text-sm text-slate-600 font-medium">
{getPaymentMethodLabel(payment.payment_method)}
</p>
{payment.payment_date && (
<span className="text-xs text-gray-400">
<span className="text-xs text-slate-400">
{formatDate(payment.payment_date, 'short')}
</span>
)}
</div>
</div>
</div>
<span className={`enterprise-badge text-xs ${getPaymentStatusColor(payment.payment_status)}`}>
<span className={`px-2 sm:px-3 py-1 sm:py-1.5 text-xs font-semibold rounded-full border shadow-sm flex-shrink-0 ml-2 ${getPaymentStatusColor(payment.payment_status)}`}>
{payment.payment_status.charAt(0).toUpperCase() + payment.payment_status.slice(1)}
</span>
</div>

View File

@@ -0,0 +1,205 @@
import React, { useEffect, useState } from 'react';
import { Download, Trash2, FileText, AlertCircle } from 'lucide-react';
import gdprService, { GDPRRequest } from '../../features/compliance/services/gdprService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import { formatDate } from '../../shared/utils/format';
const GDPRPage: React.FC = () => {
const [requests, setRequests] = useState<GDPRRequest[]>([]);
const [loading, setLoading] = useState(true);
const [requesting, setRequesting] = useState(false);
useEffect(() => {
fetchRequests();
}, []);
const fetchRequests = async () => {
try {
setLoading(true);
const response = await gdprService.getMyRequests();
setRequests(response.data.requests || []);
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load GDPR requests');
} finally {
setLoading(false);
}
};
const handleRequestExport = async () => {
if (!confirm('Request a copy of your personal data? You will receive an email when ready.')) return;
try {
setRequesting(true);
await gdprService.requestDataExport();
toast.success('Data export request created. You will receive an email when ready.');
fetchRequests();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to create export request');
} finally {
setRequesting(false);
}
};
const handleRequestDeletion = async () => {
if (!confirm('WARNING: This will permanently delete your account and all associated data. This action cannot be undone. Are you absolutely sure?')) return;
if (!confirm('This is your last chance. Are you 100% certain you want to delete all your data?')) return;
try {
setRequesting(true);
const response = await gdprService.requestDataDeletion();
toast.success('Deletion request created. Please check your email to confirm.');
fetchRequests();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to create deletion request');
} finally {
setRequesting(false);
}
};
const handleDownload = async (request: GDPRRequest) => {
if (!request.verification_token) {
toast.error('Verification token not available');
return;
}
try {
const blob = await gdprService.downloadExport(request.id, request.verification_token);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `user_data_export_${request.id}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('Export downloaded');
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to download export');
}
};
if (loading) {
return <Loading fullScreen text="Loading..." />;
}
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">
<div className="max-w-4xl mx-auto">
<div className="mb-6 sm:mb-8 md:mb-10 animate-fade-in">
<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-blue-400 via-indigo-500 to-purple-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">
Data Privacy (GDPR)
</h1>
</div>
<p className="text-slate-600 mt-2 sm:mt-3 text-sm sm:text-base md:text-lg font-light">
Manage your personal data and privacy rights
</p>
</div>
<div className="space-y-4 sm:space-y-5 md:space-y-6">
{/* Data Export */}
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 lg:p-8 animate-fade-in hover:shadow-2xl transition-all duration-300">
<div className="flex flex-col sm:flex-row items-start gap-3 sm:gap-4 mb-3 sm:mb-4">
<Download className="w-6 h-6 sm:w-7 sm:h-7 text-blue-500 flex-shrink-0 mt-1" />
<div className="flex-1 min-w-0">
<h2 className="text-lg sm:text-xl md:text-2xl font-semibold mb-2 text-slate-900">Export Your Data</h2>
<p className="text-xs sm:text-sm md:text-base text-slate-600 mb-3 sm:mb-4">
Request a copy of all your personal data stored in our system. You will receive an email with a download link when your data is ready.
</p>
<button
onClick={handleRequestExport}
disabled={requesting}
className="w-full sm:w-auto px-4 sm:px-6 py-2 sm:py-2.5 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl font-semibold hover:from-blue-700 hover:to-blue-800 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 text-xs sm:text-sm"
>
{requesting ? 'Requesting...' : 'Request Data Export'}
</button>
</div>
</div>
</div>
{/* Data Deletion */}
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border-2 border-red-200/60 p-4 sm:p-5 md:p-6 lg:p-8 animate-fade-in hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.1s' }}>
<div className="flex flex-col sm:flex-row items-start gap-3 sm:gap-4 mb-3 sm:mb-4">
<Trash2 className="w-6 h-6 sm:w-7 sm:h-7 text-red-500 flex-shrink-0 mt-1" />
<div className="flex-1 min-w-0">
<h2 className="text-lg sm:text-xl md:text-2xl font-semibold mb-2 text-red-900">Delete Your Data</h2>
<div className="bg-gradient-to-r from-red-50 to-rose-50 border-2 border-red-200 rounded-lg sm:rounded-xl p-3 sm:p-4 mb-3 sm:mb-4">
<div className="flex items-start gap-2 sm:gap-3">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<div>
<p className="text-xs sm:text-sm text-red-800 font-semibold mb-1">Warning: This action cannot be undone</p>
<p className="text-xs sm:text-sm text-red-700">
Requesting data deletion will permanently remove your account and all associated data including bookings, payments, and personal information. This action is irreversible.
</p>
</div>
</div>
</div>
<button
onClick={handleRequestDeletion}
disabled={requesting}
className="w-full sm:w-auto px-4 sm:px-6 py-2 sm:py-2.5 bg-gradient-to-r from-red-600 to-red-700 text-white rounded-xl font-semibold hover:from-red-700 hover:to-red-800 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 text-xs sm:text-sm"
>
{requesting ? 'Requesting...' : 'Request Data Deletion'}
</button>
</div>
</div>
</div>
{/* Request History */}
{requests.length > 0 && (
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 lg:p-8 animate-fade-in hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.2s' }}>
<h2 className="text-lg sm:text-xl md:text-2xl font-semibold mb-3 sm:mb-4 md:mb-5 text-slate-900">Request History</h2>
<div className="space-y-3 sm:space-y-4">
{requests.map((request) => (
<div
key={request.id}
className="bg-gradient-to-r from-slate-50 to-white border border-slate-200 rounded-lg sm:rounded-xl p-3 sm:p-4 hover:from-blue-50 hover:to-indigo-50 hover:border-blue-300 hover:shadow-md transition-all duration-200"
>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4">
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 sm:gap-3 mb-2">
{request.request_type === 'data_export' ? (
<Download className="w-5 h-5 text-blue-500 flex-shrink-0" />
) : (
<Trash2 className="w-5 h-5 text-red-500 flex-shrink-0" />
)}
<h3 className="font-semibold text-sm sm:text-base">
{request.request_type === 'data_export' ? 'Data Export' : 'Data Deletion'}
</h3>
<span className={`px-2 sm:px-3 py-1 rounded-full text-xs font-semibold border shadow-sm ${
request.status === 'completed' ? 'bg-gradient-to-r from-emerald-50 to-green-50 text-emerald-800 border-emerald-200' :
request.status === 'pending' ? 'bg-gradient-to-r from-amber-50 to-yellow-50 text-amber-800 border-amber-200' :
'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200'
}`}>
{request.status}
</span>
</div>
<p className="text-xs sm:text-sm text-slate-600">
Created: {formatDate(request.created_at)}
{request.processed_at && ` • Processed: ${formatDate(request.processed_at)}`}
</p>
</div>
{request.request_type === 'data_export' && request.status === 'completed' && (
<button
onClick={() => handleDownload(request)}
className="w-full sm:w-auto px-3 sm:px-4 py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-lg sm:rounded-xl hover:from-blue-700 hover:to-blue-800 shadow-lg hover:shadow-xl transition-all duration-200 text-xs sm:text-sm font-semibold"
>
Download
</button>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default GDPRPage;

View File

@@ -18,16 +18,21 @@ import {
Eye,
EyeOff,
RefreshCw,
KeyRound
KeyRound,
LogOut,
Monitor
} from 'lucide-react';
import { toast } from 'react-toastify';
import authService from '../../features/auth/services/authService';
import sessionService from '../../features/auth/services/sessionService';
import useAuthStore from '../../store/useAuthStore';
import Loading from '../../shared/components/Loading';
import EmptyState from '../../shared/components/EmptyState';
import { useAsync } from '../../shared/hooks/useAsync';
import { useGlobalLoading } from '../../shared/contexts/LoadingContext';
import { normalizeImageUrl } from '../../shared/utils/imageUtils';
import { formatDate } from '../../shared/utils/format';
import { UserSession } from '../../features/auth/services/sessionService';
const profileValidationSchema = yup.object().shape({
name: yup
@@ -55,7 +60,11 @@ const passwordValidationSchema = yup.object().shape({
newPassword: yup
.string()
.required('New password is required')
.min(6, 'Password must be at least 6 characters'),
.min(8, 'Password must be at least 8 characters')
.matches(/[A-Z]/, 'Password must contain at least one uppercase letter')
.matches(/[a-z]/, 'Password must contain at least one lowercase letter')
.matches(/[0-9]/, 'Password must contain at least one number')
.matches(/[!@#$%^&*(),.?":{}|<>]/, 'Password must contain at least one special character (!@#$%^&*(),.?":{}|<>)'),
confirmPassword: yup
.string()
.required('Please confirm your password')
@@ -68,7 +77,7 @@ type PasswordFormData = yup.InferType<typeof passwordValidationSchema>;
const ProfilePage: React.FC = () => {
const { userInfo, setUser } = useAuthStore();
const { setLoading } = useGlobalLoading();
const [activeTab, setActiveTab] = useState<'profile' | 'password' | 'mfa'>('profile');
const [activeTab, setActiveTab] = useState<'profile' | 'password' | 'mfa' | 'sessions'>('profile');
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState<{
current: boolean;
@@ -402,6 +411,17 @@ const ProfilePage: React.FC = () => {
<Shield className="w-4 h-4 sm:w-5 sm:h-5 inline mr-2" />
Two-Factor Authentication
</button>
<button
onClick={() => setActiveTab('sessions')}
className={`py-3 sm:py-4 px-2 sm:px-1 border-b-2 font-medium text-xs sm:text-sm transition-colors whitespace-nowrap ${
activeTab === 'sessions'
? 'border-[#d4af37] text-[#d4af37]'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<Monitor className="w-4 h-4 sm:w-5 sm:h-5 inline mr-2" />
Active Sessions
</button>
</div>
</div>
@@ -967,9 +987,133 @@ const ProfilePage: React.FC = () => {
)}
</div>
)}
{/* Sessions Tab */}
{activeTab === 'sessions' && (
<SessionsTab />
)}
</div>
</div>
);
};
const SessionsTab: React.FC = () => {
const [sessions, setSessions] = useState<UserSession[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchSessions();
}, []);
const fetchSessions = async () => {
try {
setLoading(true);
const response = await sessionService.getMySessions();
setSessions(response.data.sessions || []);
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load sessions');
} finally {
setLoading(false);
}
};
const handleRevoke = async (sessionId: number) => {
if (!confirm('Are you sure you want to revoke this session?')) return;
try {
await sessionService.revokeSession(sessionId);
toast.success('Session revoked');
fetchSessions();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to revoke session');
}
};
const handleRevokeAll = async () => {
if (!confirm('Are you sure you want to revoke all other sessions?')) return;
try {
await sessionService.revokeAllSessions();
toast.success('All other sessions revoked');
fetchSessions();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to revoke sessions');
}
};
if (loading) {
return (
<div className="luxury-glass rounded-sm p-4 sm:p-6 lg:p-8 border border-[#d4af37]/20 shadow-2xl">
<Loading text="Loading sessions..." />
</div>
);
}
return (
<div className="luxury-glass rounded-sm p-4 sm:p-6 lg:p-8 border border-[#d4af37]/20 shadow-2xl animate-slide-up space-y-5 sm:space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl sm:text-2xl font-serif font-semibold text-gray-900 mb-2 flex items-center gap-2">
<Monitor className="w-5 h-5 sm:w-6 sm:h-6 text-[#d4af37]" />
Active Sessions
</h2>
<p className="text-xs sm:text-sm text-gray-600 font-light">
Manage your active sessions across different devices
</p>
</div>
{sessions.length > 1 && (
<button
onClick={handleRevokeAll}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm"
>
Revoke All Others
</button>
)}
</div>
{sessions.length === 0 ? (
<div className="text-center py-12">
<p className="text-slate-500">No active sessions</p>
</div>
) : (
<div className="space-y-4">
{sessions.map((session) => (
<div
key={session.id}
className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-4 flex-1">
<Monitor className="w-5 h-5 text-gray-500 mt-1" />
<div className="flex-1">
<p className="font-semibold mb-1">
{session.user_agent || 'Unknown Device'}
</p>
<p className="text-gray-600 text-sm mb-1">
IP: {session.ip_address || 'Unknown'}
</p>
<p className="text-gray-500 text-xs">
Last Activity: {formatDate(session.last_activity)}
</p>
<p className="text-gray-500 text-xs">
Expires: {formatDate(session.expires_at)}
</p>
</div>
</div>
<button
onClick={() => handleRevoke(session.id)}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 flex items-center gap-2 text-sm"
>
<LogOut className="w-4 h-4" />
Revoke
</button>
</div>
</div>
))}
</div>
)}
</div>
);
};
export default ProfilePage;

View File

@@ -0,0 +1,135 @@
import React, { useEffect, useState } from 'react';
import { LogOut, Monitor, Smartphone, Tablet } from 'lucide-react';
import sessionService, { UserSession } from '../../features/auth/services/sessionService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import { formatDate } from '../../shared/utils/format';
const SessionManagementPage: React.FC = () => {
const [sessions, setSessions] = useState<UserSession[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchSessions();
}, []);
const fetchSessions = async () => {
try {
setLoading(true);
const response = await sessionService.getMySessions();
setSessions(response.data.sessions || []);
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load sessions');
} finally {
setLoading(false);
}
};
const handleRevoke = async (sessionId: number) => {
if (!confirm('Are you sure you want to revoke this session?')) return;
try {
await sessionService.revokeSession(sessionId);
toast.success('Session revoked');
fetchSessions();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to revoke session');
}
};
const handleRevokeAll = async () => {
if (!confirm('Are you sure you want to revoke all other sessions?')) return;
try {
await sessionService.revokeAllSessions();
toast.success('All other sessions revoked');
fetchSessions();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to revoke sessions');
}
};
const getDeviceIcon = (userAgent?: string) => {
if (!userAgent) return <Monitor className="w-5 h-5" />;
if (userAgent.includes('Mobile')) return <Smartphone className="w-5 h-5" />;
if (userAgent.includes('Tablet')) return <Tablet className="w-5 h-5" />;
return <Monitor className="w-5 h-5" />;
};
if (loading) {
return <Loading fullScreen text="Loading sessions..." />;
}
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">
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4 sm:gap-6 mb-6 sm:mb-8 md:mb-10 animate-fade-in">
<div className="w-full lg:w-auto">
<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-blue-400 via-indigo-500 to-purple-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">
Active Sessions
</h1>
</div>
<p className="text-slate-600 mt-2 sm:mt-3 text-sm sm:text-base md:text-lg font-light">Manage your active sessions</p>
</div>
{sessions.length > 1 && (
<button
onClick={handleRevokeAll}
className="w-full sm:w-auto px-4 sm:px-6 py-2 sm:py-2.5 bg-gradient-to-r from-red-600 to-red-700 text-white rounded-xl font-semibold hover:from-red-700 hover:to-red-800 shadow-lg hover:shadow-xl transition-all duration-200 text-xs sm:text-sm"
>
Revoke All Other Sessions
</button>
)}
</div>
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in hover:shadow-2xl transition-all duration-300">
{sessions.length === 0 ? (
<div className="text-center py-8 sm:py-12">
<p className="text-slate-500 text-sm sm:text-base">No active sessions</p>
</div>
) : (
<div className="space-y-3 sm:space-y-4">
{sessions.map((session) => (
<div
key={session.id}
className="bg-gradient-to-r from-slate-50 to-white border border-slate-200 rounded-lg sm:rounded-xl p-3 sm:p-4 hover:from-red-50 hover:to-rose-50 hover:border-red-300 hover:shadow-lg transition-all duration-200"
>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4">
<div className="flex items-start gap-3 sm:gap-4 flex-1 min-w-0">
<div className="mt-1 flex-shrink-0 text-slate-600">
{getDeviceIcon(session.user_agent)}
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm sm:text-base mb-1 truncate">
{session.user_agent || 'Unknown Device'}
</p>
<p className="text-slate-600 text-xs sm:text-sm mb-1">
IP: {session.ip_address || 'Unknown'}
</p>
<p className="text-slate-500 text-xs">
Last Activity: {formatDate(session.last_activity)}
</p>
<p className="text-slate-500 text-xs">
Expires: {formatDate(session.expires_at)}
</p>
</div>
</div>
<button
onClick={() => handleRevoke(session.id)}
className="w-full sm:w-auto px-3 sm:px-4 py-2 bg-gradient-to-r from-red-600 to-red-700 text-white rounded-lg sm:rounded-xl hover:from-red-700 hover:to-red-800 shadow-lg hover:shadow-xl transition-all duration-200 flex items-center justify-center gap-2 text-xs sm:text-sm font-semibold"
>
<LogOut className="w-4 h-4" />
Revoke
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default SessionManagementPage;

View File

@@ -173,7 +173,7 @@ const StaffDashboardPage: React.FC = () => {
if (error || !stats) {
return (
<div className="space-y-6">
<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">
<EmptyState
title="Unable to Load Dashboard"
description={error?.message || 'Something went wrong. Please try again.'}
@@ -187,123 +187,124 @@ const StaffDashboardPage: React.FC = () => {
}
return (
<div className="space-y-8 bg-gradient-to-br from-slate-50 via-white to-slate-50 min-h-screen -m-6 p-8">
<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">
{}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-6 animate-fade-in">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="h-1 w-16 bg-gradient-to-r from-blue-400 to-blue-600 rounded-full"></div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-clip-text text-transparent tracking-tight">
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4 sm:gap-6 mb-6 sm:mb-8 md:mb-10 animate-fade-in">
<div className="w-full lg:w-auto">
<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-blue-400 via-indigo-500 to-purple-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">
Staff Dashboard
</h1>
</div>
<p className="text-slate-600 mt-3 text-lg font-light">Hotel operations overview and management</p>
<p className="text-slate-600 mt-2 sm:mt-3 text-sm sm:text-base md:text-lg font-light">Hotel operations overview and management</p>
</div>
{}
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-3">
<div className="flex flex-col sm:flex-row gap-3 w-full lg:w-auto">
<div className="flex flex-col xs:flex-row gap-2 sm:gap-3 w-full sm:w-auto">
<input
type="date"
value={dateRange.from}
onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })}
className="px-4 py-2.5 bg-white border-2 border-gray-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all duration-200 text-gray-700 font-medium shadow-sm hover:shadow-md"
className="flex-1 sm:flex-none px-3 sm:px-4 py-2 sm:py-2.5 bg-white 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 font-medium shadow-sm hover:shadow-md text-sm sm:text-base"
/>
<span className="text-slate-500 font-medium">to</span>
<span className="hidden xs:inline-flex items-center text-slate-500 font-medium">to</span>
<input
type="date"
value={dateRange.to}
onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })}
className="px-4 py-2.5 bg-white border-2 border-gray-200 rounded-xl focus:border-blue-400 focus:ring-4 focus:ring-blue-100 transition-all duration-200 text-gray-700 font-medium shadow-sm hover:shadow-md"
className="flex-1 sm:flex-none px-3 sm:px-4 py-2 sm:py-2.5 bg-white 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 font-medium shadow-sm hover:shadow-md text-sm sm:text-base"
/>
</div>
<button
onClick={handleRefresh}
className="px-5 py-2.5 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl font-semibold shadow-lg hover:shadow-xl hover:from-blue-700 hover:to-blue-800 transition-all duration-200 flex items-center gap-2"
disabled={loading}
className="flex-1 sm:flex-none px-4 sm:px-6 py-2 sm:py-2.5 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl font-semibold shadow-lg hover:shadow-xl hover:from-blue-700 hover:to-blue-800 transition-all duration-200 flex items-center justify-center gap-2 text-xs sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<RefreshCw className="w-5 h-5" />
<RefreshCw className={`w-3 h-3 sm:w-4 sm:h-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
</div>
{}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
<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 md:mb-10 animate-fade-in" style={{ animationDelay: '0.1s' }}>
{}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 border-l-4 border-l-emerald-500 animate-slide-up hover:shadow-2xl transition-all duration-300">
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 border-l-4 border-l-emerald-500 animate-slide-up hover:shadow-2xl hover:scale-[1.02] transition-all duration-300">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-2">Total Revenue</p>
<p className="text-3xl font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent">
<div className="flex-1 min-w-0 pr-2">
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1 sm:mb-2">Total Revenue</p>
<p className="text-lg sm:text-xl md:text-2xl lg:text-3xl font-bold bg-gradient-to-r from-emerald-600 to-emerald-700 bg-clip-text text-transparent truncate">
{formatCurrency(stats?.total_revenue || 0)}
</p>
</div>
<div className="bg-gradient-to-br from-emerald-100 to-emerald-200 p-4 rounded-2xl shadow-lg">
<TrendingUp className="w-7 h-7 text-emerald-600" />
<div className="bg-gradient-to-br from-emerald-100 to-emerald-200 p-3 sm:p-4 rounded-xl sm:rounded-2xl shadow-lg flex-shrink-0">
<TrendingUp className="w-5 h-5 sm:w-6 sm:h-6 md:w-7 md:h-7 text-emerald-600" />
</div>
</div>
<div className="flex items-center mt-5 pt-4 border-t border-slate-100">
<span className="text-slate-500 text-sm">
<div className="flex items-center mt-4 sm:mt-5 pt-3 sm:pt-4 border-t border-slate-100">
<span className="text-slate-500 text-xs sm:text-sm truncate">
{stats?.total_payments || 0} completed payments
</span>
</div>
</div>
{}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 border-l-4 border-l-blue-500 animate-slide-up hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.2s' }}>
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 border-l-4 border-l-blue-500 animate-slide-up hover:shadow-2xl hover:scale-[1.02] transition-all duration-300" style={{ animationDelay: '0.2s' }}>
<div className="flex items-center justify-between">
<div>
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-2">Total Bookings</p>
<p className="text-3xl font-bold bg-gradient-to-r from-blue-600 to-blue-700 bg-clip-text text-transparent">
<div className="flex-1 min-w-0 pr-2">
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1 sm:mb-2">Total Bookings</p>
<p className="text-lg sm:text-xl md:text-2xl lg:text-3xl font-bold bg-gradient-to-r from-blue-600 to-blue-700 bg-clip-text text-transparent">
{stats?.total_bookings || 0}
</p>
</div>
<div className="bg-gradient-to-br from-blue-100 to-blue-200 p-4 rounded-2xl shadow-lg">
<Calendar className="w-7 h-7 text-blue-600" />
<div className="bg-gradient-to-br from-blue-100 to-blue-200 p-3 sm:p-4 rounded-xl sm:rounded-2xl shadow-lg flex-shrink-0">
<Calendar className="w-5 h-5 sm:w-6 sm:h-6 md:w-7 md:h-7 text-blue-600" />
</div>
</div>
<div className="flex items-center mt-5 pt-4 border-t border-slate-100">
<span className="text-slate-500 text-sm">
<div className="flex items-center mt-4 sm:mt-5 pt-3 sm:pt-4 border-t border-slate-100">
<span className="text-slate-500 text-xs sm:text-sm truncate">
{stats?.active_bookings || 0} active bookings
</span>
</div>
</div>
{}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 border-l-4 border-l-purple-500 animate-slide-up hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.3s' }}>
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 border-l-4 border-l-purple-500 animate-slide-up hover:shadow-2xl hover:scale-[1.02] transition-all duration-300" style={{ animationDelay: '0.3s' }}>
<div className="flex items-center justify-between">
<div>
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-2">Available Rooms</p>
<p className="text-3xl font-bold bg-gradient-to-r from-purple-600 to-purple-700 bg-clip-text text-transparent">
<div className="flex-1 min-w-0 pr-2">
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1 sm:mb-2">Available Rooms</p>
<p className="text-lg sm:text-xl md:text-2xl lg:text-3xl font-bold bg-gradient-to-r from-purple-600 to-purple-700 bg-clip-text text-transparent">
{stats?.available_rooms || 0}
</p>
</div>
<div className="bg-gradient-to-br from-purple-100 to-purple-200 p-4 rounded-2xl shadow-lg">
<Hotel className="w-7 h-7 text-purple-600" />
<div className="bg-gradient-to-br from-purple-100 to-purple-200 p-3 sm:p-4 rounded-xl sm:rounded-2xl shadow-lg flex-shrink-0">
<Hotel className="w-5 h-5 sm:w-6 sm:h-6 md:w-7 md:h-7 text-purple-600" />
</div>
</div>
<div className="flex items-center mt-5 pt-4 border-t border-slate-100">
<span className="text-slate-500 text-sm">
<div className="flex items-center mt-4 sm:mt-5 pt-3 sm:pt-4 border-t border-slate-100">
<span className="text-slate-500 text-xs sm:text-sm truncate">
{stats?.total_rooms || 0} total rooms
</span>
</div>
</div>
{}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 border-l-4 border-l-amber-500 animate-slide-up hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.4s' }}>
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 border-l-4 border-l-amber-500 animate-slide-up hover:shadow-2xl hover:scale-[1.02] transition-all duration-300" style={{ animationDelay: '0.4s' }}>
<div className="flex items-center justify-between">
<div>
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-2">Pending Payments</p>
<p className="text-3xl font-bold bg-gradient-to-r from-amber-600 to-amber-700 bg-clip-text text-transparent">
<div className="flex-1 min-w-0 pr-2">
<p className="text-slate-500 text-xs font-semibold uppercase tracking-wider mb-1 sm:mb-2">Pending Payments</p>
<p className="text-lg sm:text-xl md:text-2xl lg:text-3xl font-bold bg-gradient-to-r from-amber-600 to-amber-700 bg-clip-text text-transparent">
{stats?.pending_payments || 0}
</p>
</div>
<div className="bg-gradient-to-br from-amber-100 to-amber-200 p-4 rounded-2xl shadow-lg">
<CreditCard className="w-7 h-7 text-amber-600" />
<div className="bg-gradient-to-br from-amber-100 to-amber-200 p-3 sm:p-4 rounded-xl sm:rounded-2xl shadow-lg flex-shrink-0">
<CreditCard className="w-5 h-5 sm:w-6 sm:h-6 md:w-7 md:h-7 text-amber-600" />
</div>
</div>
<div className="flex items-center mt-5 pt-4 border-t border-slate-100">
<span className="text-slate-500 text-sm">
<div className="flex items-center mt-4 sm:mt-5 pt-3 sm:pt-4 border-t border-slate-100">
<span className="text-slate-500 text-xs sm:text-sm truncate">
Require attention
</span>
</div>
@@ -311,17 +312,17 @@ const StaffDashboardPage: React.FC = () => {
</div>
{}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5 md:gap-6">
{}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in">
<div className="flex items-center justify-between mb-6 pb-4 border-b border-slate-200">
<h2 className="text-xl font-bold text-slate-900 flex items-center gap-2">
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in hover:shadow-2xl transition-all duration-300">
<div className="flex items-center justify-between mb-4 sm:mb-5 md:mb-6 pb-3 sm:pb-4 border-b border-slate-200">
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-slate-900 flex items-center gap-2">
<Calendar className="w-5 h-5 text-blue-600" />
Recent Bookings
</h2>
<button
onClick={() => navigate('/staff/bookings')}
className="text-sm text-blue-600 hover:text-blue-700 font-semibold"
className="text-xs sm:text-sm text-blue-600 hover:text-blue-700 font-semibold hover:underline transition-colors"
>
View All
</button>
@@ -331,28 +332,28 @@ const StaffDashboardPage: React.FC = () => {
) : recentBookings.length === 0 ? (
<EmptyState title="No bookings found" description="No recent bookings to display" />
) : (
<div className="space-y-4">
<div className="space-y-3 sm:space-y-4">
{recentBookings.map((booking) => (
<div
key={booking.id}
className="p-4 bg-gradient-to-r from-slate-50 to-white rounded-xl border border-slate-200 hover:shadow-md transition-all duration-200 cursor-pointer"
className="p-3 sm:p-4 bg-gradient-to-r from-slate-50 to-white rounded-lg sm:rounded-xl border border-slate-200 hover:from-blue-50 hover:to-indigo-50 hover:border-blue-300 hover:shadow-lg transition-all duration-200 cursor-pointer"
onClick={() => navigate(`/staff/bookings/${booking.id}`)}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className="font-bold text-slate-900">{booking.booking_number}</span>
<div className="flex items-center justify-between gap-3 sm:gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 sm:gap-3 mb-2 flex-wrap">
<span className="font-bold text-slate-900 text-sm sm:text-base truncate">{booking.booking_number}</span>
{getBookingStatusBadge(booking.status || 'pending')}
</div>
<p className="text-sm text-slate-600">
<p className="text-xs sm:text-sm text-slate-600 truncate">
{booking.user?.name || 'Guest'} Room {booking.room?.room_number || 'N/A'}
</p>
<p className="text-xs text-slate-500 mt-1">
{booking.check_in_date && formatDate(booking.check_in_date)} - {booking.check_out_date && formatDate(booking.check_out_date)}
</p>
</div>
<div className="text-right">
<p className="font-bold text-emerald-600">{formatCurrency(booking.total_price || 0)}</p>
<div className="text-right flex-shrink-0">
<p className="font-bold text-emerald-600 text-sm sm:text-base">{formatCurrency(booking.total_price || 0)}</p>
</div>
</div>
</div>
@@ -362,15 +363,15 @@ const StaffDashboardPage: React.FC = () => {
</div>
{}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 p-6 animate-fade-in">
<div className="flex items-center justify-between mb-6 pb-4 border-b border-slate-200">
<h2 className="text-xl font-bold text-slate-900 flex items-center gap-2">
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in hover:shadow-2xl transition-all duration-300">
<div className="flex items-center justify-between mb-4 sm:mb-5 md:mb-6 pb-3 sm:pb-4 border-b border-slate-200">
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-slate-900 flex items-center gap-2">
<CreditCard className="w-5 h-5 text-emerald-600" />
Recent Payments
</h2>
<button
onClick={() => navigate('/staff/payments')}
className="text-sm text-blue-600 hover:text-blue-700 font-semibold"
className="text-xs sm:text-sm text-blue-600 hover:text-blue-700 font-semibold hover:underline transition-colors"
>
View All
</button>
@@ -380,29 +381,29 @@ const StaffDashboardPage: React.FC = () => {
) : recentPayments.length === 0 ? (
<EmptyState title="No payments found" description="No recent payments to display" />
) : (
<div className="space-y-4">
<div className="space-y-3 sm:space-y-4">
{recentPayments.map((payment) => (
<div
key={payment.id}
className="p-4 bg-gradient-to-r from-slate-50 to-white rounded-xl border border-slate-200 hover:shadow-md transition-all duration-200"
className="p-3 sm:p-4 bg-gradient-to-r from-slate-50 to-white rounded-lg sm:rounded-xl border border-slate-200 hover:from-emerald-50 hover:to-green-50 hover:border-emerald-300 hover:shadow-lg transition-all duration-200"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className="font-bold text-slate-900 font-mono text-sm">
<div className="flex items-center justify-between gap-3 sm:gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 sm:gap-3 mb-2 flex-wrap">
<span className="font-bold text-slate-900 font-mono text-xs sm:text-sm truncate">
{payment.transaction_id || `PAY-${payment.id}`}
</span>
{getPaymentStatusBadge(payment.payment_status)}
</div>
<p className="text-sm text-slate-600">
<p className="text-xs sm:text-sm text-slate-600 truncate">
{payment.booking?.booking_number || 'N/A'} {payment.payment_method || 'N/A'}
</p>
<p className="text-xs text-slate-500 mt-1">
{payment.payment_date && formatDate(payment.payment_date)}
</p>
</div>
<div className="text-right">
<p className="font-bold text-emerald-600">{formatCurrency(payment.amount || 0)}</p>
<div className="text-right flex-shrink-0">
<p className="font-bold text-emerald-600 text-sm sm:text-base">{formatCurrency(payment.amount || 0)}</p>
</div>
</div>
</div>

View File

@@ -30,7 +30,13 @@ import {
Star,
AlertCircle,
FileCheck,
ClipboardCheck
ClipboardCheck,
CheckCircle2,
Download,
Webhook,
Key,
HardDrive,
Activity
} from 'lucide-react';
import useAuthStore from '../../store/useAuthStore';
import { useResponsive } from '../../hooks';
@@ -272,6 +278,31 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
icon: ClipboardCheck,
label: 'Compliance'
},
{
path: '/admin/approvals',
icon: CheckCircle2,
label: 'Approvals'
},
{
path: '/admin/gdpr',
icon: Download,
label: 'GDPR'
},
{
path: '/admin/webhooks',
icon: Webhook,
label: 'Webhooks'
},
{
path: '/admin/api-keys',
icon: Key,
label: 'API Keys'
},
{
path: '/admin/backups',
icon: HardDrive,
label: 'Backups'
},
]
},
];