updates
This commit is contained in:
@@ -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>
|
||||
|
||||
{}
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
31
Frontend/src/features/auth/services/sessionService.ts
Normal file
31
Frontend/src/features/auth/services/sessionService.ts
Normal 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();
|
||||
|
||||
65
Frontend/src/features/compliance/services/gdprService.ts
Normal file
65
Frontend/src/features/compliance/services/gdprService.ts
Normal 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();
|
||||
|
||||
3
Frontend/src/features/compliance/services/index.ts
Normal file
3
Frontend/src/features/compliance/services/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default } from './gdprService';
|
||||
export * from './gdprService';
|
||||
|
||||
55
Frontend/src/features/integrations/services/apiKeyService.ts
Normal file
55
Frontend/src/features/integrations/services/apiKeyService.ts
Normal 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();
|
||||
|
||||
5
Frontend/src/features/integrations/services/index.ts
Normal file
5
Frontend/src/features/integrations/services/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as webhookService } from './webhookService';
|
||||
export { default as apiKeyService } from './apiKeyService';
|
||||
export * from './webhookService';
|
||||
export * from './apiKeyService';
|
||||
|
||||
@@ -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();
|
||||
|
||||
54
Frontend/src/features/system/services/approvalService.ts
Normal file
54
Frontend/src/features/system/services/approvalService.ts
Normal 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();
|
||||
|
||||
43
Frontend/src/features/system/services/backupService.ts
Normal file
43
Frontend/src/features/system/services/backupService.ts
Normal 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();
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as approvalService } from './approvalService';
|
||||
export { default as backupService } from './backupService';
|
||||
export * from './approvalService';
|
||||
export * from './backupService';
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
308
Frontend/src/pages/admin/APIKeyManagementPage.tsx
Normal file
308
Frontend/src/pages/admin/APIKeyManagementPage.tsx
Normal 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;
|
||||
|
||||
264
Frontend/src/pages/admin/ApprovalManagementPage.tsx
Normal file
264
Frontend/src/pages/admin/ApprovalManagementPage.tsx
Normal 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;
|
||||
|
||||
201
Frontend/src/pages/admin/BackupManagementPage.tsx
Normal file
201
Frontend/src/pages/admin/BackupManagementPage.tsx
Normal 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;
|
||||
|
||||
147
Frontend/src/pages/admin/GDPRManagementPage.tsx
Normal file
147
Frontend/src/pages/admin/GDPRManagementPage.tsx
Normal 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;
|
||||
|
||||
279
Frontend/src/pages/admin/WebhookManagementPage.tsx
Normal file
279
Frontend/src/pages/admin/WebhookManagementPage.tsx
Normal 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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
205
Frontend/src/pages/customer/GDPRPage.tsx
Normal file
205
Frontend/src/pages/customer/GDPRPage.tsx
Normal 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
135
Frontend/src/pages/customer/SessionManagementPage.tsx
Normal file
135
Frontend/src/pages/customer/SessionManagementPage.tsx
Normal 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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user