updates
This commit is contained in:
Binary file not shown.
@@ -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))
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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}'
|
||||
|
||||
Binary file not shown.
@@ -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")
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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))
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
1044
Frontend/src/pages/admin/EditRoomPage.tsx
Normal file
1044
Frontend/src/pages/admin/EditRoomPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 */}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user