This commit is contained in:
Iliyan Angelov
2025-12-02 10:42:35 +02:00
parent 4b053ce703
commit 2d770dd27b
24 changed files with 1766 additions and 1402 deletions

View File

@@ -562,3 +562,122 @@ async def upload_blog_image(
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error uploading image: {str(e)}')
@router.get('/admin/tags', response_model=dict)
async def get_all_tags(
current_user: User = Depends(authorize_roles('admin')),
db: Session = Depends(get_db)
):
"""Get all unique tags from all blog posts (admin only)"""
try:
all_posts = db.query(BlogPost).all()
all_unique_tags = set()
tag_usage = {} # Track how many posts use each tag
for post in all_posts:
if post.tags:
try:
tags_list = json.loads(post.tags)
for tag in tags_list:
all_unique_tags.add(tag)
tag_usage[tag] = tag_usage.get(tag, 0) + 1
except:
pass
tags_with_usage = [
{'name': tag, 'usage_count': tag_usage.get(tag, 0)}
for tag in sorted(all_unique_tags)
]
return success_response({
'tags': tags_with_usage,
'total': len(tags_with_usage)
})
except Exception as e:
logger.error(f"Error in get_all_tags: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.put('/admin/tags/rename', response_model=dict)
async def rename_tag(
old_tag: str = Query(..., description='Old tag name'),
new_tag: str = Query(..., description='New tag name'),
current_user: User = Depends(authorize_roles('admin')),
db: Session = Depends(get_db)
):
"""Rename a tag across all blog posts (admin only)"""
try:
if not old_tag or not new_tag:
raise HTTPException(status_code=400, detail='Both old_tag and new_tag are required')
if old_tag == new_tag:
raise HTTPException(status_code=400, detail='Old and new tag names must be different')
# Get all posts that contain the old tag
all_posts = db.query(BlogPost).all()
updated_count = 0
for post in all_posts:
if post.tags:
try:
tags_list = json.loads(post.tags)
if old_tag in tags_list:
# Replace old tag with new tag
tags_list = [new_tag if tag == old_tag else tag for tag in tags_list]
post.tags = json.dumps(tags_list)
updated_count += 1
except:
pass
db.commit()
return success_response({
'old_tag': old_tag,
'new_tag': new_tag,
'updated_posts': updated_count
}, message=f'Tag renamed successfully. Updated {updated_count} post(s).')
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"Error in rename_tag: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.delete('/admin/tags', response_model=dict)
async def delete_tag(
tag: str = Query(..., description='Tag name to delete'),
current_user: User = Depends(authorize_roles('admin')),
db: Session = Depends(get_db)
):
"""Delete a tag from all blog posts (admin only)"""
try:
if not tag:
raise HTTPException(status_code=400, detail='Tag name is required')
# Get all posts that contain the tag
all_posts = db.query(BlogPost).all()
updated_count = 0
for post in all_posts:
if post.tags:
try:
tags_list = json.loads(post.tags)
if tag in tags_list:
# Remove the tag
tags_list = [t for t in tags_list if t != tag]
post.tags = json.dumps(tags_list) if tags_list else None
updated_count += 1
except:
pass
db.commit()
return success_response({
'deleted_tag': tag,
'updated_posts': updated_count
}, message=f'Tag deleted successfully. Updated {updated_count} post(s).')
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"Error in delete_tag: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -10,7 +10,7 @@ from ...security.middleware.auth import get_current_user, authorize_roles
from ...auth.models.user import User
from ..models.room import Room, RoomStatus
from ..models.room_type import RoomType
from ..schemas.room import CreateRoomRequest, UpdateRoomRequest, BulkDeleteRoomsRequest
from ..schemas.room import CreateRoomRequest, UpdateRoomRequest, BulkDeleteRoomsRequest, UpdateAmenityRequest
from ...shared.utils.response_helpers import success_response
from ...reviews.models.review import Review, ReviewStatus
from ...bookings.models.booking import Booking, BookingStatus
@@ -72,6 +72,112 @@ async def get_amenities(db: Session=Depends(get_db)):
logger.error(f'Error fetching amenities: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.put('/amenities/{old_name}', dependencies=[Depends(authorize_roles('admin'))])
async def update_amenity(old_name: str, request: UpdateAmenityRequest, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
"""Update/rename an amenity across all rooms and room types."""
try:
import json
updated_count = 0
# Update in room types
room_types = db.query(RoomType).all()
for rt in room_types:
if rt.amenities:
amenities_list = []
if isinstance(rt.amenities, list):
amenities_list = rt.amenities
elif isinstance(rt.amenities, str):
try:
amenities_list = json.loads(rt.amenities)
except:
amenities_list = [s.strip() for s in rt.amenities.split(',') if s.strip()]
if old_name in amenities_list:
amenities_list = [request.new_name if a == old_name else a for a in amenities_list]
rt.amenities = amenities_list
updated_count += 1
# Update in rooms
rooms = db.query(Room).all()
for room in rooms:
if room.amenities:
amenities_list = []
if isinstance(room.amenities, list):
amenities_list = room.amenities
elif isinstance(room.amenities, str):
try:
amenities_list = json.loads(room.amenities)
except:
amenities_list = [s.strip() for s in room.amenities.split(',') if s.strip()]
if old_name in amenities_list:
amenities_list = [request.new_name if a == old_name else a for a in amenities_list]
room.amenities = amenities_list
updated_count += 1
db.commit()
return success_response(
data={'updated_count': updated_count, 'old_name': old_name, 'new_name': request.new_name},
message=f'Amenity "{old_name}" updated to "{request.new_name}" in {updated_count} location(s)'
)
except Exception as e:
db.rollback()
logger.error(f'Error updating amenity: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.delete('/amenities/{amenity_name}', dependencies=[Depends(authorize_roles('admin'))])
async def delete_amenity(amenity_name: str, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
"""Remove an amenity from all rooms and room types."""
try:
import json
updated_count = 0
# Remove from room types
room_types = db.query(RoomType).all()
for rt in room_types:
if rt.amenities:
amenities_list = []
if isinstance(rt.amenities, list):
amenities_list = rt.amenities
elif isinstance(rt.amenities, str):
try:
amenities_list = json.loads(rt.amenities)
except:
amenities_list = [s.strip() for s in rt.amenities.split(',') if s.strip()]
if amenity_name in amenities_list:
amenities_list = [a for a in amenities_list if a != amenity_name]
rt.amenities = amenities_list if amenities_list else []
updated_count += 1
# Remove from rooms
rooms = db.query(Room).all()
for room in rooms:
if room.amenities:
amenities_list = []
if isinstance(room.amenities, list):
amenities_list = room.amenities
elif isinstance(room.amenities, str):
try:
amenities_list = json.loads(room.amenities)
except:
amenities_list = [s.strip() for s in room.amenities.split(',') if s.strip()]
if amenity_name in amenities_list:
amenities_list = [a for a in amenities_list if a != amenity_name]
room.amenities = amenities_list if amenities_list else []
updated_count += 1
db.commit()
return success_response(
data={'updated_count': updated_count, 'amenity_name': amenity_name},
message=f'Amenity "{amenity_name}" removed from {updated_count} location(s)'
)
except Exception as e:
db.rollback()
logger.error(f'Error deleting amenity: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get('/room-types')
async def get_room_types(db: Session=Depends(get_db)):
"""Get all room types for dropdowns and forms."""
@@ -457,7 +563,21 @@ async def upload_room_images(id: int, images: List[UploadFile]=File(...), curren
continue
await f.write(content)
image_urls.append(f'/uploads/rooms/{filename}')
# Handle existing_images - it might be a list, a JSON string, or None
existing_images = room.images or []
if isinstance(existing_images, str):
# If it's a string, try to parse it as JSON
import json
try:
existing_images = json.loads(existing_images)
except (json.JSONDecodeError, TypeError):
# If parsing fails, treat as empty list
existing_images = []
# Ensure it's a list
if not isinstance(existing_images, list):
existing_images = []
updated_images = existing_images + image_urls
room.images = updated_images
db.commit()
@@ -483,7 +603,21 @@ async def delete_room_images(id: int, image_url: str=Query(..., description='Ima
if not normalized_url.startswith('/'):
normalized_url = f'/{normalized_url}'
filename = Path(normalized_url).name
# Handle existing_images - it might be a list, a JSON string, or None
existing_images = room.images or []
if isinstance(existing_images, str):
# If it's a string, try to parse it as JSON
import json
try:
existing_images = json.loads(existing_images)
except (json.JSONDecodeError, TypeError):
# If parsing fails, treat as empty list
existing_images = []
# Ensure it's a list
if not isinstance(existing_images, list):
existing_images = []
updated_images = []
for img in existing_images:
stored_path = img if img.startswith('/') else f'/{img}'

View File

@@ -105,3 +105,8 @@ class BulkDeleteRoomsRequest(BaseModel):
raise ValueError('All room IDs must be positive integers')
return v
class UpdateAmenityRequest(BaseModel):
"""Schema for updating/renaming an amenity."""
new_name: str = Field(..., min_length=1, max_length=100, description="New name for the amenity")

View File

@@ -54,11 +54,14 @@ async def get_rooms_with_ratings(db: Session, rooms: List[Room], base_url: str)
result.append(room_dict)
return result
def get_predefined_amenities() -> List[str]:
return ['Free WiFi', 'WiFi', 'High-Speed Internet', 'WiFi in Room', 'Flat-Screen TV', 'TV', 'Cable TV', 'Satellite TV', 'Smart TV', 'Netflix', 'Streaming Services', 'DVD Player', 'Stereo System', 'Radio', 'iPod Dock', 'Air Conditioning', 'AC', 'Heating', 'Climate Control', 'Ceiling Fan', 'Air Purifier', 'Private Bathroom', 'Ensuite Bathroom', 'Bathtub', 'Jacuzzi Bathtub', 'Hot Tub', 'Shower', 'Rain Shower', 'Walk-in Shower', 'Bidet', 'Hair Dryer', 'Hairdryer', 'Bathrobes', 'Slippers', 'Toiletries', 'Premium Toiletries', 'Towels', 'Mini Bar', 'Minibar', 'Refrigerator', 'Fridge', 'Microwave', 'Coffee Maker', 'Electric Kettle', 'Tea Making Facilities', 'Coffee Machine', 'Nespresso Machine', 'Kitchenette', 'Dining Table', 'Room Service', 'Breakfast Included', 'Breakfast', 'Complimentary Water', 'Bottled Water', 'Desk', 'Writing Desk', 'Office Desk', 'Work Desk', 'Sofa', 'Sitting Area', 'Lounge Area', 'Dining Area', 'Separate Living Area', 'Wardrobe', 'Closet', 'Dresser', 'Mirror', 'Full-Length Mirror', 'Seating Area', 'King Size Bed', 'Queen Size Bed', 'Double Bed', 'Twin Beds', 'Single Bed', 'Extra Bedding', 'Pillow Menu', 'Premium Bedding', 'Blackout Curtains', 'Soundproofing', 'Safe', 'In-Room Safe', 'Safety Deposit Box', 'Smoke Detector', 'Fire Extinguisher', 'Security System', 'Key Card Access', 'Door Lock', 'Pepper Spray', 'USB Charging Ports', 'USB Ports', 'USB Outlets', 'Power Outlets', 'Charging Station', 'Laptop Safe', 'HDMI Port', 'Phone', 'Desk Phone', 'Wake-Up Service', 'Alarm Clock', 'Digital Clock', 'Balcony', 'Private Balcony', 'Terrace', 'Patio', 'City View', 'Ocean View', 'Sea View', 'Mountain View', 'Garden View', 'Pool View', 'Park View', 'Window', 'Large Windows', 'Floor-to-Ceiling Windows', '24-Hour Front Desk', '24 Hour Front Desk', '24/7 Front Desk', 'Concierge Service', 'Butler Service', 'Housekeeping', 'Daily Housekeeping', 'Turndown Service', 'Laundry Service', 'Dry Cleaning', 'Ironing Service', 'Luggage Storage', 'Bell Service', 'Valet Parking', 'Parking', 'Free Parking', 'Airport Shuttle', 'Shuttle Service', 'Car Rental', 'Taxi Service', 'Gym Access', 'Fitness Center', 'Fitness Room', 'Spa Access', 'Spa', 'Sauna', 'Steam Room', 'Hot Tub', 'Massage Service', 'Beauty Services', 'Swimming Pool', 'Pool', 'Indoor Pool', 'Outdoor Pool', 'Infinity Pool', 'Pool Access', 'Golf Course', 'Tennis Court', 'Beach Access', 'Water Sports', 'Business Center', 'Meeting Room', 'Conference Room', 'Fax Service', 'Photocopying', 'Printing Service', 'Secretarial Services', 'Wheelchair Accessible', 'Accessible Room', 'Elevator Access', 'Ramp Access', 'Accessible Bathroom', 'Lowered Sink', 'Grab Bars', 'Hearing Accessible', 'Visual Alarm', 'Family Room', 'Kids Welcome', 'Baby Crib', 'Extra Bed', 'Crib', 'Childcare Services', 'Pets Allowed', 'Pet Friendly', 'Smoking Room', 'Non-Smoking Room', 'No Smoking', 'Interconnecting Rooms', 'Adjoining Rooms', 'Suite', 'Separate Bedroom', 'Kitchen', 'Full Kitchen', 'Dishwasher', 'Oven', 'Stove', 'Washing Machine', 'Dryer', 'Iron', 'Ironing Board', 'Clothes Rack', 'Umbrella', 'Shoe Shine Service', 'Fireplace', 'Jacuzzi', 'Steam Shower', 'Spa Bath', 'Bidet Toilet', 'Smart Home System', 'Lighting Control', 'Curtain Control', 'Automated Systems', 'Personalized Service', 'VIP Treatment', 'Butler', 'Private Entrance', 'Private Elevator', 'Panic Button', 'Blu-ray Player', 'Gaming Console', 'PlayStation', 'Xbox', 'Sound System', 'Surround Sound', 'Music System', 'Library', 'Reading Room', 'Study Room', 'Private Pool', 'Private Garden', 'Yard', 'Courtyard', 'Outdoor Furniture', 'BBQ Facilities', 'Picnic Area']
async def get_amenities_list(db: Session) -> List[str]:
all_amenities = set(get_predefined_amenities())
"""
Get all unique amenities from the database only.
Aggregates amenities from room_types and rooms tables.
"""
all_amenities = set()
# Get amenities from room types
room_types = db.query(RoomType.amenities).all()
for rt in room_types:
if rt.amenities:
@@ -74,6 +77,8 @@ async def get_amenities_list(db: Session) -> List[str]:
all_amenities.update([s.strip() for s in rt.amenities.split(',') if s.strip()])
except:
all_amenities.update([s.strip() for s in rt.amenities.split(',') if s.strip()])
# Get amenities from rooms
rooms = db.query(Room.amenities).all()
for r in rooms:
if r.amenities:
@@ -89,4 +94,5 @@ async def get_amenities_list(db: Session) -> List[str]:
all_amenities.update([s.strip() for s in r.amenities.split(',') if s.strip()])
except:
all_amenities.update([s.strip() for s in r.amenities.split(',') if s.strip()])
return sorted(list(all_amenities))

View File

@@ -93,6 +93,7 @@ const NotificationManagementPage = lazy(() => import('./pages/admin/Notification
const ReceptionDashboardPage = lazy(() => import('./pages/admin/ReceptionDashboardPage'));
const LoyaltyManagementPage = lazy(() => import('./pages/admin/LoyaltyManagementPage'));
const AdvancedRoomManagementPage = lazy(() => import('./pages/admin/AdvancedRoomManagementPage'));
const EditRoomPage = lazy(() => import('./pages/admin/EditRoomPage'));
const RatePlanManagementPage = lazy(() => import('./pages/admin/RatePlanManagementPage'));
const PackageManagementPage = lazy(() => import('./pages/admin/PackageManagementPage'));
const SecurityManagementPage = lazy(() => import('./pages/admin/SecurityManagementPage'));
@@ -543,6 +544,10 @@ function App() {
path="advanced-rooms"
element={<AdvancedRoomManagementPage />}
/>
<Route
path="rooms/:id/edit"
element={<EditRoomPage />}
/>
<Route
path="page-content"
element={<PageContentDashboardPage />}

View File

@@ -143,6 +143,26 @@ class BlogService {
});
return response.data;
}
// Tag management endpoints
async getAllTags(): Promise<{ status: string; data: { tags: Array<{ name: string; usage_count: number }>; total: number } }> {
const response = await apiClient.get('/blog/admin/tags');
return response.data;
}
async renameTag(oldTag: string, newTag: string): Promise<{ status: string; data: { old_tag: string; new_tag: string; updated_posts: number }; message?: string }> {
const response = await apiClient.put('/blog/admin/tags/rename', null, {
params: { old_tag: oldTag, new_tag: newTag },
});
return response.data;
}
async deleteTag(tag: string): Promise<{ status: string; data: { deleted_tag: string; updated_posts: number }; message?: string }> {
const response = await apiClient.delete('/blog/admin/tags', {
params: { tag },
});
return response.data;
}
}
export const blogService = new BlogService();

View File

@@ -169,6 +169,33 @@ export const getAmenities = async (): Promise<{
};
};
export const updateAmenity = async (
oldName: string,
newName: string
): Promise<{ success: boolean; message: string; data: { updated_count: number; old_name: string; new_name: string } }> => {
const response = await apiClient.put(`/rooms/amenities/${encodeURIComponent(oldName)}`, {
new_name: newName,
});
const responseData = response.data;
return {
success: responseData.status === 'success' || responseData.success === true,
message: responseData.message || '',
data: responseData.data || { updated_count: 0, old_name: oldName, new_name: newName },
};
};
export const deleteAmenity = async (
amenityName: string
): Promise<{ success: boolean; message: string; data: { updated_count: number; amenity_name: string } }> => {
const response = await apiClient.delete(`/rooms/amenities/${encodeURIComponent(amenityName)}`);
const data = response.data;
return {
success: data.status === 'success' || data.success === true,
message: data.message || '',
data: data.data || { updated_count: 0, amenity_name: amenityName },
};
};
export interface RoomType {
id: number;
name: string;
@@ -264,6 +291,8 @@ export default {
getRoomByNumber,
searchAvailableRooms,
getAmenities,
updateAmenity,
deleteAmenity,
getRoomTypes,
createRoom,
updateRoom,

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Hotel,
Wrench,
@@ -15,6 +16,7 @@ import {
MapPin,
Plus,
Edit,
Trash2,
X,
Image as ImageIcon,
Check,
@@ -35,6 +37,7 @@ import { useRoomContext } from '../../features/rooms/contexts/RoomContext';
type Tab = 'status-board' | 'maintenance' | 'housekeeping' | 'inspections';
const AdvancedRoomManagementPage: React.FC = () => {
const navigate = useNavigate();
const {
statusBoardRooms,
statusBoardLoading,
@@ -73,6 +76,8 @@ const AdvancedRoomManagementPage: React.FC = () => {
const [roomTypes, setRoomTypes] = useState<Array<{ id: number; name: string }>>([]);
const [uploadingImages, setUploadingImages] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [customAmenityInput, setCustomAmenityInput] = useState('');
const [editingAmenity, setEditingAmenity] = useState<{ name: string; newName: string } | null>(null);
// Define fetchFloors before using it in useEffect
const fetchFloors = useCallback(async () => {
@@ -451,87 +456,31 @@ const AdvancedRoomManagementPage: React.FC = () => {
}
};
const handleEditRoom = async (room: Room) => {
setEditingRoom(room);
let amenitiesArray: string[] = [];
if (room.amenities) {
if (Array.isArray(room.amenities)) {
amenitiesArray = room.amenities;
} else if (typeof room.amenities === 'string') {
try {
const parsed = JSON.parse(room.amenities);
amenitiesArray = Array.isArray(parsed) ? parsed : [];
} catch {
const amenitiesStr: string = room.amenities;
amenitiesArray = amenitiesStr.split(',').map((a: string) => a.trim()).filter(Boolean);
}
}
}
setRoomFormData({
room_number: room.room_number,
floor: room.floor,
room_type_id: room.room_type_id,
status: room.status,
featured: room.featured,
price: room.price?.toString() || '',
description: room.description || '',
capacity: room.capacity?.toString() || '',
room_size: room.room_size || '',
view: room.view || '',
amenities: amenitiesArray,
});
setShowRoomModal(true);
try {
const fullRoom = await roomService.getRoomByNumber(room.room_number);
const roomData = fullRoom.data.room;
let updatedAmenitiesArray: string[] = [];
if (roomData.amenities) {
if (Array.isArray(roomData.amenities)) {
updatedAmenitiesArray = roomData.amenities;
} else if (typeof roomData.amenities === 'string') {
try {
const parsed = JSON.parse(roomData.amenities);
updatedAmenitiesArray = Array.isArray(parsed) ? parsed : [];
} catch {
const amenitiesStr: string = roomData.amenities;
updatedAmenitiesArray = amenitiesStr.split(',').map((a: string) => a.trim()).filter(Boolean);
}
}
}
setRoomFormData({
room_number: roomData.room_number,
floor: roomData.floor,
room_type_id: roomData.room_type_id,
status: roomData.status,
featured: roomData.featured,
price: roomData.price?.toString() || '',
description: roomData.description || '',
capacity: roomData.capacity?.toString() || '',
room_size: roomData.room_size || '',
view: roomData.view || '',
amenities: updatedAmenitiesArray,
});
setEditingRoom(roomData);
} catch (error) {
logger.error('Failed to fetch full room details', error);
toast.error('Failed to load complete room details');
}
const handleEditRoom = (room: Room) => {
navigate(`/admin/rooms/${room.id}/edit`);
};
const handleDeleteRoom = async (id: number) => {
if (!window.confirm('Are you sure you want to delete this room?')) return;
const room = contextRooms.find(r => r.id === id) || statusBoardRooms.find(r => r.id === id);
const roomNumber = room?.room_number || 'this room';
if (!window.confirm(`Are you sure you want to delete room ${roomNumber}? This action cannot be undone.`)) {
return;
}
try {
await contextDeleteRoom(id);
toast.success(`Room ${roomNumber} deleted successfully`);
await refreshStatusBoard();
await refreshRooms();
// Remove from expanded rooms if it was expanded
setExpandedRooms(prev => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
} catch (error: any) {
// Error already handled in context
toast.error(error.response?.data?.message || error.response?.data?.detail || 'Failed to delete room');
}
};
@@ -552,6 +501,8 @@ const AdvancedRoomManagementPage: React.FC = () => {
});
setSelectedFiles([]);
setUploadingImages(false);
setCustomAmenityInput('');
setEditingAmenity(null);
};
const toggleAmenity = (amenity: string) => {
@@ -563,6 +514,86 @@ const AdvancedRoomManagementPage: React.FC = () => {
}));
};
const handleAddCustomAmenity = () => {
const trimmed = customAmenityInput.trim();
if (trimmed && !roomFormData.amenities.includes(trimmed)) {
setRoomFormData(prev => ({
...prev,
amenities: [...prev.amenities, trimmed]
}));
setCustomAmenityInput('');
}
};
const handleRemoveAmenity = (amenity: string) => {
setRoomFormData(prev => ({
...prev,
amenities: prev.amenities.filter(a => a !== amenity)
}));
};
const handleEditAmenity = (amenity: string) => {
setEditingAmenity({ name: amenity, newName: amenity });
};
const handleSaveAmenityEdit = async () => {
if (!editingAmenity || editingAmenity.name === editingAmenity.newName.trim()) {
setEditingAmenity(null);
return;
}
const newName = editingAmenity.newName.trim();
if (!newName) {
toast.error('Amenity name cannot be empty');
return;
}
try {
await roomService.updateAmenity(editingAmenity.name, newName);
toast.success(`Amenity "${editingAmenity.name}" updated to "${newName}"`);
setAvailableAmenities(prev => {
const updated = prev.map(a => a === editingAmenity.name ? newName : a);
return updated.sort();
});
setRoomFormData(prev => ({
...prev,
amenities: prev.amenities.map(a => a === editingAmenity.name ? newName : a)
}));
setEditingAmenity(null);
await fetchAvailableAmenities();
await refreshRooms();
await refreshStatusBoard();
} catch (error: any) {
toast.error(error.response?.data?.message || error.response?.data?.detail || 'Failed to update amenity');
}
};
const handleDeleteAmenity = async (amenity: string) => {
if (!window.confirm(`Are you sure you want to delete "${amenity}"? This will remove it from all rooms and room types.`)) {
return;
}
try {
await roomService.deleteAmenity(amenity);
toast.success(`Amenity "${amenity}" deleted successfully`);
setAvailableAmenities(prev => prev.filter(a => a !== amenity));
setRoomFormData(prev => ({
...prev,
amenities: prev.amenities.filter(a => a !== amenity)
}));
await fetchAvailableAmenities();
await refreshRooms();
await refreshStatusBoard();
} catch (error: any) {
toast.error(error.response?.data?.message || error.response?.data?.detail || 'Failed to delete amenity');
}
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const files = Array.from(e.target.files);
@@ -806,17 +837,29 @@ const AdvancedRoomManagementPage: React.FC = () => {
<span>{getStatusLabel(effectiveStatus)}</span>
</div>
{/* Edit Button */}
{/* Action Buttons */}
<div className="absolute top-3 left-3 flex gap-2 z-20">
<button
onClick={(e) => {
e.stopPropagation();
handleEditRoomFromStatusBoard(room.id);
}}
className="absolute top-3 left-3 p-2 bg-white/90 backdrop-blur-sm rounded-lg text-blue-600 hover:text-blue-700 hover:bg-white transition-all duration-200 shadow-md hover:shadow-lg border border-blue-200 hover:border-blue-300 z-20"
className="p-2 bg-white/90 backdrop-blur-sm rounded-lg text-blue-600 hover:text-blue-700 hover:bg-white transition-all duration-200 shadow-md hover:shadow-lg border border-blue-200 hover:border-blue-300"
title="Edit Room"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteRoom(room.id);
}}
className="p-2 bg-white/90 backdrop-blur-sm rounded-lg text-rose-600 hover:text-rose-700 hover:bg-white transition-all duration-200 shadow-md hover:shadow-lg border border-rose-200 hover:border-rose-300 hover:scale-105"
title={`Delete Room ${room.room_number}`}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{/* Room Content */}
<div className="p-5 pt-16 cursor-pointer" onClick={() => toggleRoomExpansion(room.id)}>

View File

@@ -353,8 +353,9 @@ const AuditLogsPage: React.FC = () => {
{}
{showDetails && selectedLog && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg max-w-3xl w-full max-h-[90vh] overflow-y-auto">
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 overflow-y-auto p-4">
<div className="min-h-full flex items-start justify-center py-8">
<div className="bg-white rounded-lg max-w-3xl w-full my-8 max-h-[calc(100vh-4rem)] overflow-y-auto">
<div className="p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-bold text-gray-900">Audit Log Details</h2>
@@ -461,6 +462,7 @@ const AuditLogsPage: React.FC = () => {
</div>
</div>
</div>
</div>
)}
</div>
);

View File

@@ -417,8 +417,9 @@ const BannerManagementPage: React.FC = () => {
{}
{showModal && (
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-3 sm:p-4 animate-fade-in">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-2xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden border border-slate-200/50 animate-scale-in">
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-3 sm:p-4 animate-fade-in">
<div className="min-h-full flex items-start justify-center py-8">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-2xl w-full my-8 max-h-[calc(100vh-4rem)] overflow-hidden border border-slate-200/50 animate-scale-in">
<div className="sticky top-0 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 py-4 sm:py-5 border-b border-slate-700 z-10">
<div className="flex justify-between items-center">
<div>
@@ -440,7 +441,7 @@ const BannerManagementPage: React.FC = () => {
</button>
</div>
</div>
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<div className="overflow-y-auto max-h-[calc(100vh-12rem)]">
<form onSubmit={handleSubmit} className="p-4 sm:p-6 space-y-4 sm:space-y-5">
<div>
<label className="block text-xs sm:text-sm font-semibold text-slate-600 uppercase tracking-wider mb-2">
@@ -680,6 +681,8 @@ const BannerManagementPage: React.FC = () => {
</div>
</div>
</div>
</div>
</div>
)}
<ConfirmationDialog

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState, useRef } from 'react';
import { Plus, Search, Edit, Trash2, X, Eye, EyeOff, Loader2, Calendar, User, Tag, GripVertical, Image as ImageIcon, Type, Quote, List, Video, ArrowRight, MoveUp, MoveDown, Sparkles, Upload } from 'lucide-react';
import { Plus, Search, Edit, Trash2, X, Eye, EyeOff, Loader2, Tag, GripVertical, Image as ImageIcon, Type, Quote, List, Video, ArrowRight, MoveUp, MoveDown, Sparkles, Upload } from 'lucide-react';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import Pagination from '../../shared/components/Pagination';
@@ -43,6 +43,16 @@ const BlogManagementPage: React.FC = () => {
const [showSectionBuilder, setShowSectionBuilder] = useState(false);
const [uploadingImages, setUploadingImages] = useState<{ [key: string]: boolean }>({});
// Tag management state
const [showTagController, setShowTagController] = useState(false);
const [tags, setTags] = useState<Array<{ name: string; usage_count: number }>>([]);
const [tagsLoading, setTagsLoading] = useState(false);
const [editingTag, setEditingTag] = useState<{ old: string; new: string } | null>(null);
const [deleteTagConfirm, setDeleteTagConfirm] = useState<{ show: boolean; tag: string | null }>({
show: false,
tag: null,
});
useEffect(() => {
setCurrentPage(1);
}, [filters]);
@@ -51,6 +61,12 @@ const BlogManagementPage: React.FC = () => {
fetchPosts();
}, [filters, currentPage]);
useEffect(() => {
if (showTagController) {
fetchTags();
}
}, [showTagController]);
const fetchPosts = async () => {
try {
setLoading(true);
@@ -275,6 +291,50 @@ const BlogManagementPage: React.FC = () => {
}
};
// Tag management functions
const fetchTags = async () => {
try {
setTagsLoading(true);
const response = await blogService.getAllTags();
if (response.status === 'success' && response.data) {
setTags(response.data.tags);
}
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to load tags');
} finally {
setTagsLoading(false);
}
};
const handleRenameTag = async () => {
if (!editingTag || !editingTag.new.trim() || editingTag.old === editingTag.new.trim()) {
setEditingTag(null);
return;
}
try {
const response = await blogService.renameTag(editingTag.old, editingTag.new.trim());
toast.success(response.message || 'Tag renamed successfully');
setEditingTag(null);
fetchTags();
fetchPosts(); // Refresh posts to show updated tags
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to rename tag');
}
};
const handleDeleteTag = async () => {
if (!deleteTagConfirm.tag) return;
try {
const response = await blogService.deleteTag(deleteTagConfirm.tag);
toast.success(response.message || 'Tag deleted successfully');
setDeleteTagConfirm({ show: false, tag: null });
fetchTags();
fetchPosts(); // Refresh posts to show updated tags
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to delete tag');
}
};
const formatDate = (dateString?: string) => {
if (!dateString) return 'Not published';
return new Date(dateString).toLocaleDateString('en-US', {
@@ -398,6 +458,14 @@ const BlogManagementPage: React.FC = () => {
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">Blog Management</h1>
<p className="text-gray-600 mt-1">Manage your blog posts and content</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setShowTagController(true)}
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-lg hover:from-blue-700 hover:to-blue-800 transition-all shadow-lg"
>
<Tag className="w-5 h-5" />
<span>Manage Tags</span>
</button>
<button
onClick={() => handleOpenModal()}
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-[#d4af37] to-[#c9a227] text-white rounded-lg hover:from-[#f5d76e] hover:to-[#d4af37] transition-all shadow-lg"
@@ -407,6 +475,7 @@ const BlogManagementPage: React.FC = () => {
</button>
</div>
</div>
</div>
{/* Filters */}
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
@@ -530,8 +599,9 @@ const BlogManagementPage: React.FC = () => {
{/* Create/Edit Modal */}
{showModal && (
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-3 sm:p-4 animate-fade-in">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-4xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden border border-slate-200/50 animate-scale-in">
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-3 sm:p-4 animate-fade-in">
<div className="min-h-full flex items-start justify-center py-8">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-4xl w-full my-8 max-h-[calc(100vh-4rem)] overflow-hidden border border-slate-200/50 animate-scale-in">
<div className="sticky top-0 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 py-4 sm:py-5 border-b border-slate-700 z-10">
<div className="flex justify-between items-center">
<div>
@@ -551,7 +621,7 @@ const BlogManagementPage: React.FC = () => {
</div>
</div>
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<div className="overflow-y-auto max-h-[calc(100vh-12rem)]">
<form onSubmit={handleSubmit} className="p-4 sm:p-6 space-y-5 sm:space-y-6">
<div>
<label className="block text-xs sm:text-sm font-semibold text-slate-600 uppercase tracking-wider mb-2">Title *</label>
@@ -1177,6 +1247,7 @@ const BlogManagementPage: React.FC = () => {
</div>
</div>
</div>
</div>
)}
{/* Delete Confirmation */}
@@ -1187,6 +1258,131 @@ const BlogManagementPage: React.FC = () => {
title="Delete Blog Post"
message="Are you sure you want to delete this blog post? This action cannot be undone."
/>
{/* Tag Controller Modal */}
{showTagController && (
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-3 sm:p-4 animate-fade-in">
<div className="min-h-full flex items-start justify-center py-8">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-4xl w-full my-8 max-h-[calc(100vh-4rem)] overflow-hidden border border-slate-200/50 animate-scale-in">
<div className="sticky top-0 bg-gradient-to-r from-blue-600 via-blue-700 to-blue-600 px-4 sm:px-6 py-4 sm:py-5 border-b border-blue-800 z-10">
<div className="flex justify-between items-center">
<div>
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-white">
Tag Controller
</h2>
<p className="text-blue-100/80 text-xs sm:text-sm font-light mt-1">
Manage all blog tags - rename or delete tags across all posts
</p>
</div>
<button
onClick={() => {
setShowTagController(false);
setEditingTag(null);
}}
className="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl text-white hover:bg-blue-800/50 transition-all duration-200 border border-blue-500 hover:border-blue-300"
>
<X className="w-5 h-5 sm:w-6 sm:h-6" />
</button>
</div>
</div>
<div className="overflow-y-auto max-h-[calc(100vh-12rem)]">
<div className="p-4 sm:p-6">
{tagsLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
</div>
) : tags.length === 0 ? (
<div className="text-center py-12">
<Tag className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<p className="text-gray-500 text-lg">No tags found</p>
<p className="text-gray-400 text-sm mt-2">Tags will appear here when you add them to blog posts</p>
</div>
) : (
<div className="space-y-3">
{tags.map((tag) => (
<div
key={tag.name}
className="bg-gradient-to-br from-gray-50 to-white border-2 border-gray-200 rounded-xl p-4 hover:border-blue-300 transition-all"
>
{editingTag && editingTag.old === tag.name ? (
<div className="flex items-center gap-3">
<input
type="text"
value={editingTag.new}
onChange={(e) => setEditingTag({ ...editingTag, new: e.target.value })}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleRenameTag();
} else if (e.key === 'Escape') {
setEditingTag(null);
}
}}
autoFocus
className="flex-1 px-4 py-2 border-2 border-blue-400 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter new tag name"
/>
<button
onClick={handleRenameTag}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Save
</button>
<button
onClick={() => setEditingTag(null)}
className="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400 transition-colors"
>
Cancel
</button>
</div>
) : (
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 flex-1">
<div className="flex items-center gap-2">
<Tag className="w-5 h-5 text-blue-600" />
<span className="text-lg font-semibold text-gray-900">{tag.name}</span>
</div>
<span className="px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-sm font-medium">
Used in {tag.usage_count} {tag.usage_count === 1 ? 'post' : 'posts'}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setEditingTag({ old: tag.name, new: tag.name })}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2"
>
<Edit className="w-4 h-4" />
<span>Rename</span>
</button>
<button
onClick={() => setDeleteTagConfirm({ show: true, tag: tag.name })}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
<span>Delete</span>
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
</div>
)}
{/* Delete Tag Confirmation */}
<ConfirmationDialog
isOpen={deleteTagConfirm.show}
onClose={() => setDeleteTagConfirm({ show: false, tag: null })}
onConfirm={handleDeleteTag}
title="Delete Tag"
message={`Are you sure you want to delete the tag "${deleteTagConfirm.tag}"? This will remove it from all blog posts. This action cannot be undone.`}
/>
</div>
</div>
);

View File

@@ -115,7 +115,7 @@ const BookingManagementPage: React.FC = () => {
const response = await invoiceService.getInvoicesByBooking(booking.id);
// Check response structure - handle both possible formats
const invoices = response.data?.invoices || response.data?.data?.invoices || [];
const invoices = response.data?.invoices || (response.data as any)?.data?.invoices || [];
const hasInvoice = Array.isArray(invoices) && invoices.length > 0;
return {
@@ -197,7 +197,7 @@ const BookingManagementPage: React.FC = () => {
let invoice = null;
if (response.status === 'success' && response.data) {
// Try different possible response structures
invoice = response.data.invoice || response.data.data?.invoice || response.data;
invoice = response.data.invoice || (response.data as any).data?.invoice || response.data;
logger.debug('Extracted invoice', { invoice });
}
@@ -637,8 +637,9 @@ const BookingManagementPage: React.FC = () => {
{}
{showDetailModal && selectedBooking && (
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-3 sm:p-4 animate-fade-in">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl w-full max-w-3xl max-h-[95vh] sm:max-h-[90vh] overflow-hidden animate-scale-in border border-slate-200">
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-3 sm:p-4 animate-fade-in">
<div className="min-h-full flex items-start justify-center py-8">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl w-full max-w-3xl my-8 max-h-[calc(100vh-4rem)] overflow-hidden animate-scale-in border border-slate-200">
{}
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-8 py-6 border-b border-slate-700">
<div className="flex justify-between items-center">
@@ -655,7 +656,7 @@ const BookingManagementPage: React.FC = () => {
</div>
</div>
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<div className="overflow-y-auto max-h-[calc(100vh-12rem)]">
<div className="p-4 sm:p-6 md:p-8 space-y-5 sm:space-y-6">
{}
<div className="grid grid-cols-2 gap-6">
@@ -987,6 +988,7 @@ const BookingManagementPage: React.FC = () => {
</div>
</div>
</div>
</div>
)}
{/* Create Booking Modal */}

View File

@@ -1044,8 +1044,9 @@ const BusinessDashboardPage: React.FC = () => {
{}
{showPromotionModal && (
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden border border-gray-200">
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-4">
<div className="min-h-full flex items-start justify-center py-8">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-3xl my-8 max-h-[calc(100vh-4rem)] overflow-hidden border border-gray-200">
{}
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-8 py-6 border-b border-slate-700">
<div className="flex justify-between items-center">
@@ -1067,7 +1068,7 @@ const BusinessDashboardPage: React.FC = () => {
</div>
{}
<div className="p-8 overflow-y-auto max-h-[calc(90vh-120px)] custom-scrollbar">
<div className="p-8 overflow-y-auto max-h-[calc(100vh-12rem)] custom-scrollbar">
<form onSubmit={handlePromotionSubmit} className="space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
@@ -1242,6 +1243,7 @@ const BusinessDashboardPage: React.FC = () => {
</div>
</div>
</div>
</div>
)}
</div>
)}

File diff suppressed because it is too large Load Diff

View File

@@ -753,8 +753,9 @@ const LoyaltyManagementPage: React.FC = () => {
{/* Tier Modal */}
{showTierModal && (
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-3 sm:p-4 animate-fade-in">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-2xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden border border-slate-200/50 animate-scale-in">
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-3 sm:p-4 animate-fade-in">
<div className="min-h-full flex items-start justify-center py-8">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-2xl w-full my-8 max-h-[calc(100vh-4rem)] overflow-hidden border border-slate-200/50 animate-scale-in">
<div className="sticky top-0 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 py-4 sm:py-5 border-b border-slate-700 z-10">
<div className="flex justify-between items-center">
<div>
@@ -776,7 +777,7 @@ const LoyaltyManagementPage: React.FC = () => {
</button>
</div>
</div>
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<div className="overflow-y-auto max-h-[calc(100vh-12rem)]">
<form onSubmit={handleTierSubmit} className="p-4 sm:p-6 space-y-4 sm:space-y-5">
<div className="grid grid-cols-2 gap-4">
<div>
@@ -920,12 +921,14 @@ const LoyaltyManagementPage: React.FC = () => {
</div>
</div>
</div>
</div>
)}
{/* Reward Modal */}
{showRewardModal && (
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-3 sm:p-4 animate-fade-in">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-3xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden border border-slate-200/50 animate-scale-in">
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-3 sm:p-4 animate-fade-in">
<div className="min-h-full flex items-start justify-center py-8">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-3xl w-full my-8 max-h-[calc(100vh-4rem)] overflow-hidden border border-slate-200/50 animate-scale-in">
<div className="sticky top-0 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 py-4 sm:py-5 border-b border-slate-700 z-10">
<div className="flex justify-between items-center">
<div>
@@ -947,7 +950,7 @@ const LoyaltyManagementPage: React.FC = () => {
</button>
</div>
</div>
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<div className="overflow-y-auto max-h-[calc(100vh-12rem)]">
<form onSubmit={handleRewardSubmit} className="p-4 sm:p-6 space-y-4 sm:space-y-5">
<div className="grid grid-cols-2 gap-4">
<div>
@@ -1150,6 +1153,7 @@ const LoyaltyManagementPage: React.FC = () => {
</div>
</div>
</div>
</div>
)}
{/* Delete Confirmation Dialog */}

View File

@@ -315,8 +315,9 @@ const PromotionManagementPage: React.FC = () => {
{}
{showModal && (
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-4 animate-fade-in">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden border border-slate-200 animate-scale-in">
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-4 animate-fade-in">
<div className="min-h-full flex items-start justify-center py-8">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-3xl my-8 max-h-[calc(100vh-4rem)] overflow-hidden border border-slate-200 animate-scale-in">
{}
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-8 py-6 border-b border-slate-700">
<div className="flex justify-between items-center">
@@ -338,7 +339,7 @@ const PromotionManagementPage: React.FC = () => {
</div>
{}
<div className="p-8 overflow-y-auto max-h-[calc(90vh-120px)] custom-scrollbar">
<div className="p-8 overflow-y-auto max-h-[calc(100vh-12rem)] custom-scrollbar">
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
@@ -513,6 +514,8 @@ const PromotionManagementPage: React.FC = () => {
</div>
</div>
</div>
</div>
</div>
)}
</div>
);

File diff suppressed because it is too large Load Diff

View File

@@ -475,8 +475,9 @@ const IPWhitelistTab: React.FC = () => {
</div>
{showAddModal && (
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-3 sm:p-4 animate-fade-in">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-md w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden border border-slate-200/50 animate-scale-in">
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-3 sm:p-4 animate-fade-in">
<div className="min-h-full flex items-start justify-center py-8">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-md w-full my-8 max-h-[calc(100vh-4rem)] overflow-hidden border border-slate-200/50 animate-scale-in">
<div className="sticky top-0 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 py-4 sm:py-5 border-b border-slate-700 z-10">
<div className="flex justify-between items-center">
<div>
@@ -491,7 +492,7 @@ const IPWhitelistTab: React.FC = () => {
</button>
</div>
</div>
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<div className="overflow-y-auto max-h-[calc(100vh-12rem)]">
<div className="p-4 sm:p-6 space-y-4 sm:space-y-5">
<input
type="text"
@@ -649,8 +650,9 @@ const IPBlacklistTab: React.FC = () => {
</div>
{showAddModal && (
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-3 sm:p-4 animate-fade-in">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-md w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden border border-slate-200/50 animate-scale-in">
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-3 sm:p-4 animate-fade-in">
<div className="min-h-full flex items-start justify-center py-8">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-md w-full my-8 max-h-[calc(100vh-4rem)] overflow-hidden border border-slate-200/50 animate-scale-in">
<div className="sticky top-0 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 py-4 sm:py-5 border-b border-slate-700 z-10">
<div className="flex justify-between items-center">
<div>
@@ -665,7 +667,7 @@ const IPBlacklistTab: React.FC = () => {
</button>
</div>
</div>
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<div className="overflow-y-auto max-h-[calc(100vh-12rem)]">
<div className="p-4 sm:p-6 space-y-4 sm:space-y-5">
<input
type="text"
@@ -888,8 +890,9 @@ const OAuthProvidersTab: React.FC = () => {
</div>
{showAddModal && (
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-3 sm:p-4 animate-fade-in">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-2xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden border border-slate-200/50 animate-scale-in">
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-3 sm:p-4 animate-fade-in">
<div className="min-h-full flex items-start justify-center py-8">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-2xl w-full my-8 max-h-[calc(100vh-4rem)] overflow-hidden border border-slate-200/50 animate-scale-in">
<div className="sticky top-0 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 py-4 sm:py-5 border-b border-slate-700 z-10">
<div className="flex justify-between items-center">
<div>
@@ -908,7 +911,7 @@ const OAuthProvidersTab: React.FC = () => {
</button>
</div>
</div>
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<div className="overflow-y-auto max-h-[calc(100vh-12rem)]">
<div className="p-4 sm:p-6 space-y-3 sm:space-y-4">
<input
type="text"
@@ -1352,8 +1355,9 @@ const GDPRRequestsTab: React.FC = () => {
{/* Request Details Modal */}
{selectedRequest && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl p-4 sm:p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="fixed inset-0 bg-black/50 z-50 overflow-y-auto p-4">
<div className="min-h-full flex items-start justify-center py-8">
<div className="bg-white rounded-xl p-4 sm:p-6 max-w-2xl w-full my-8 max-h-[calc(100vh-4rem)] overflow-y-auto">
<div className="flex justify-between items-start mb-4">
<h4 className="text-base sm:text-lg font-semibold">GDPR Request Details</h4>
<button
@@ -1401,6 +1405,7 @@ const GDPRRequestsTab: React.FC = () => {
</div>
</div>
</div>
</div>
)}
</div>
);

View File

@@ -240,8 +240,9 @@ const ServiceManagementPage: React.FC = () => {
{}
{showModal && (
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-4 animate-fade-in">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-hidden border border-slate-200 animate-scale-in">
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-4 animate-fade-in">
<div className="min-h-full flex items-start justify-center py-8">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-md my-8 max-h-[calc(100vh-4rem)] overflow-hidden border border-slate-200 animate-scale-in">
{}
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-6 py-5 border-b border-slate-700">
<div className="flex justify-between items-center">
@@ -345,6 +346,7 @@ const ServiceManagementPage: React.FC = () => {
</div>
</div>
</div>
</div>
)}
</div>
);

View File

@@ -388,8 +388,9 @@ const UserManagementPage: React.FC = () => {
{}
{showModal && (
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-3 sm:p-4 animate-fade-in">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl w-full max-w-md max-h-[95vh] sm:max-h-[90vh] overflow-hidden border border-slate-200 animate-scale-in">
<div className="fixed inset-0 bg-black/70 backdrop-blur-md z-50 overflow-y-auto p-3 sm:p-4 animate-fade-in">
<div className="min-h-full flex items-start justify-center py-8">
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl w-full max-w-md my-8 max-h-[calc(100vh-4rem)] overflow-hidden border border-slate-200 animate-scale-in">
{}
<div className="bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-6 py-5 border-b border-slate-700">
<div className="flex justify-between items-center">
@@ -410,7 +411,7 @@ const UserManagementPage: React.FC = () => {
</div>
</div>
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
<div className="overflow-y-auto max-h-[calc(100vh-12rem)]">
<form onSubmit={handleSubmit} className="p-4 sm:p-6 space-y-4 sm:space-y-5">
<div>
<label className="block text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
@@ -513,6 +514,7 @@ const UserManagementPage: React.FC = () => {
</div>
</div>
</div>
</div>
)}
</div>
);