This commit is contained in:
Iliyan Angelov
2025-12-06 03:27:35 +02:00
parent 7667eb5eda
commit 5a8ca3c475
2211 changed files with 28086 additions and 37066 deletions

View File

@@ -17,10 +17,7 @@ const EditRoomPage: React.FC = () => {
const [editingRoom, setEditingRoom] = useState<Room | null>(null);
const [uploadingImages, setUploadingImages] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [customAmenityInput, setCustomAmenityInput] = useState('');
const [editingAmenity, setEditingAmenity] = useState<{ name: string; newName: string } | null>(null);
const [availableAmenities, setAvailableAmenities] = useState<string[]>([]);
const [roomTypes, setRoomTypes] = useState<Array<{ id: number; name: string }>>([]);
const [roomTypes, setRoomTypes] = useState<Array<{ id: number; name: string; amenities?: string[] | string }>>([]);
const [deletingImageUrl, setDeletingImageUrl] = useState<string | null>(null);
const [failedImageUrls, setFailedImageUrls] = useState<Set<string>>(new Set());
const [loadingImageUrls, setLoadingImageUrls] = useState<Set<string>>(new Set());
@@ -40,13 +37,27 @@ const EditRoomPage: React.FC = () => {
});
useEffect(() => {
if (id) {
fetchRoomData();
}
fetchAvailableAmenities();
fetchRoomTypes();
const loadData = async () => {
await fetchRoomTypes();
if (id) {
await fetchRoomData();
}
};
loadData();
}, [id]);
// Update amenities when room type changes
useEffect(() => {
if (formData.room_type_id && roomTypes.length > 0) {
const selectedRoomType = roomTypes.find(rt => rt.id === formData.room_type_id);
const inheritedAmenities = selectedRoomType ? getAmenitiesArray(selectedRoomType.amenities) : [];
setFormData(prev => ({
...prev,
amenities: inheritedAmenities
}));
}
}, [formData.room_type_id, roomTypes]);
// Reset image loading states when room data changes
useEffect(() => {
if (editingRoom) {
@@ -85,56 +96,53 @@ const EditRoomPage: React.FC = () => {
}
}, [editingRoom?.id]);
// Helper function to ensure amenities is always an array
const getAmenitiesArray = (amenities: any): string[] => {
if (!amenities) return [];
if (Array.isArray(amenities)) return amenities;
if (typeof amenities === 'string') {
try {
const parsed = JSON.parse(amenities);
if (Array.isArray(parsed)) return parsed;
} catch {
// Not JSON, treat as comma-separated
return amenities.split(',').map((a: string) => a.trim()).filter(Boolean);
}
}
return [];
};
const fetchRoomTypes = async () => {
try {
const response = await roomService.getRooms({ limit: 100, page: 1 });
const allUniqueRoomTypes = new Map<number, { id: number; name: string }>();
response.data.rooms.forEach((room: Room) => {
if (room.room_type && !allUniqueRoomTypes.has(room.room_type.id)) {
allUniqueRoomTypes.set(room.room_type.id, {
id: room.room_type.id,
name: room.room_type.name,
});
}
});
if (response.data.pagination && response.data.pagination.totalPages > 1) {
const totalPages = response.data.pagination.totalPages;
for (let page = 2; page <= totalPages; page++) {
try {
const pageResponse = await roomService.getRooms({ limit: 100, page });
pageResponse.data.rooms.forEach((room: Room) => {
if (room.room_type && !allUniqueRoomTypes.has(room.room_type.id)) {
allUniqueRoomTypes.set(room.room_type.id, {
id: room.room_type.id,
name: room.room_type.name,
});
}
});
} catch (err) {
logger.error(`Failed to fetch page ${page}`, err);
}
}
}
if (allUniqueRoomTypes.size > 0) {
setRoomTypes(Array.from(allUniqueRoomTypes.values()));
const response = await roomService.getRoomTypes();
if (response.data?.room_types) {
setRoomTypes(response.data.room_types);
}
} catch (err) {
logger.error('Failed to fetch room types', err);
// Fallback to old method if getRoomTypes fails
try {
const response = await roomService.getRooms({ limit: 100, page: 1 });
const allUniqueRoomTypes = new Map<number, { id: number; name: string }>();
response.data.rooms.forEach((room: Room) => {
if (room.room_type && !allUniqueRoomTypes.has(room.room_type.id)) {
allUniqueRoomTypes.set(room.room_type.id, {
id: room.room_type.id,
name: room.room_type.name,
});
}
});
if (allUniqueRoomTypes.size > 0) {
setRoomTypes(Array.from(allUniqueRoomTypes.values()));
}
} catch (fallbackErr) {
logger.error('Failed to fetch room types (fallback)', fallbackErr);
}
}
};
const fetchAvailableAmenities = async () => {
try {
const response = await roomService.getAmenities();
if (response.data?.amenities) {
setAvailableAmenities(response.data.amenities);
}
} catch (error) {
logger.error('Failed to fetch amenities', error);
}
};
// Amenities are now managed only at the room type level
// No need to fetch available amenities separately
const fetchRoomData = async () => {
if (!id) return null;
@@ -162,6 +170,10 @@ const EditRoomPage: React.FC = () => {
}
}
// Inherit amenities from room type when setting form data
const roomType = roomTypes.find(rt => rt.id === room.room_type_id);
const inheritedAmenities = roomType ? getAmenitiesArray(roomType.amenities) : amenitiesArray;
setFormData({
room_number: room.room_number,
floor: room.floor,
@@ -173,7 +185,7 @@ const EditRoomPage: React.FC = () => {
capacity: room.capacity?.toString() || '',
room_size: room.room_size || '',
view: room.view || '',
amenities: amenitiesArray,
amenities: inheritedAmenities, // Always use room type amenities
});
return room;
@@ -199,7 +211,8 @@ const EditRoomPage: React.FC = () => {
capacity: formData.capacity ? parseInt(formData.capacity) : undefined,
room_size: formData.room_size || undefined,
view: formData.view || undefined,
amenities: Array.isArray(formData.amenities) ? formData.amenities : [],
// Don't send amenities - backend will inherit from room type
amenities: [],
};
await contextUpdateRoom(editingRoom.id, updateData);
@@ -211,92 +224,8 @@ const EditRoomPage: React.FC = () => {
}
};
const toggleAmenity = (amenity: string) => {
setFormData(prev => ({
...prev,
amenities: prev.amenities.includes(amenity)
? prev.amenities.filter(a => a !== amenity)
: [...prev.amenities, amenity]
}));
};
const handleAddCustomAmenity = () => {
const trimmed = customAmenityInput.trim();
if (trimmed && !formData.amenities.includes(trimmed)) {
setFormData(prev => ({
...prev,
amenities: [...prev.amenities, trimmed]
}));
setCustomAmenityInput('');
}
};
const handleRemoveAmenity = (amenity: string) => {
setFormData(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();
});
setFormData(prev => ({
...prev,
amenities: prev.amenities.map(a => a === editingAmenity.name ? newName : a)
}));
setEditingAmenity(null);
await fetchAvailableAmenities();
await refreshRooms();
} 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));
setFormData(prev => ({
...prev,
amenities: prev.amenities.filter(a => a !== amenity)
}));
await fetchAvailableAmenities();
await refreshRooms();
} catch (error: any) {
toast.error(error.response?.data?.message || error.response?.data?.detail || 'Failed to delete amenity');
}
};
// Amenities are now managed only at the room type level
// Rooms automatically inherit amenities from their room type
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
@@ -763,7 +692,17 @@ const EditRoomPage: React.FC = () => {
</label>
<select
value={formData.room_type_id}
onChange={(e) => setFormData({ ...formData, room_type_id: parseInt(e.target.value) })}
onChange={(e) => {
const selectedRoomTypeId = parseInt(e.target.value);
// Find the selected room type and inherit its amenities
const selectedRoomType = roomTypes.find(rt => rt.id === selectedRoomTypeId);
const inheritedAmenities = selectedRoomType ? getAmenitiesArray(selectedRoomType.amenities) : [];
setFormData({
...formData,
room_type_id: selectedRoomTypeId,
amenities: inheritedAmenities // Inherit amenities from room type
});
}}
className="w-full px-5 py-3.5 bg-gradient-to-br from-white to-slate-50/50 border-2 border-slate-200 rounded-2xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100/50 transition-all duration-300 text-slate-800 font-medium shadow-sm hover:shadow-md focus:shadow-lg cursor-pointer appearance-none bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNNiA5TDEyIDE1TDE4IDkiIHN0cm9rZT0iIzkyOUE1IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvc3ZnPg==')] bg-[length:20px] bg-[right_1rem_center] bg-no-repeat pr-12"
required
>
@@ -913,184 +852,50 @@ const EditRoomPage: React.FC = () => {
<h2 className="text-xl sm:text-2xl font-bold bg-gradient-to-r from-slate-900 to-slate-700 bg-clip-text text-transparent">
Amenities & Features
</h2>
<p className="text-sm text-slate-500 mt-1">Customize room amenities and special features</p>
<p className="text-sm text-slate-500 mt-1">Amenities are inherited from the room type</p>
</div>
</div>
{/* Custom Amenity Input */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1">
<input
type="text"
value={customAmenityInput}
onChange={(e) => setCustomAmenityInput(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddCustomAmenity();
}
}}
placeholder="Add custom amenity..."
className="w-full px-5 py-3.5 bg-gradient-to-br from-white to-slate-50/50 border-2 border-slate-200 rounded-2xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100/50 transition-all duration-300 text-slate-800 font-medium shadow-sm hover:shadow-md focus:shadow-lg placeholder:text-slate-400"
/>
</div>
<button
type="button"
onClick={handleAddCustomAmenity}
disabled={!customAmenityInput.trim()}
className="px-6 py-3.5 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-2xl hover:from-amber-600 hover:to-amber-700 transition-all duration-300 font-semibold shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 transform hover:scale-105 disabled:hover:scale-100"
>
<Plus className="w-5 h-5" />
<span className="hidden sm:inline">Add</span>
</button>
</div>
{/* Selected Amenities */}
{formData.amenities.length > 0 && (
<div className="p-5 bg-gradient-to-br from-amber-50/50 to-amber-100/30 rounded-2xl border-2 border-amber-200/50 shadow-lg">
<p className="text-sm font-semibold text-slate-700 mb-4 flex items-center gap-2">
<Check className="w-4 h-4 text-amber-600" />
Selected Amenities ({formData.amenities.length})
</p>
<div className="flex flex-wrap gap-2.5">
{formData.amenities.map((amenity) => {
const isCustom = !availableAmenities.includes(amenity);
return (
<div
key={amenity}
className="group flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-amber-100 to-amber-50 rounded-xl border border-amber-300 shadow-md hover:shadow-lg transition-all duration-300"
>
<span className="text-sm font-semibold text-amber-800">{amenity}</span>
{isCustom && (
<span className="text-xs font-medium text-amber-700 bg-amber-200/50 px-2 py-0.5 rounded-lg">Custom</span>
)}
<button
type="button"
onClick={() => handleRemoveAmenity(amenity)}
className="ml-1 p-1 rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 transition-all duration-200"
title="Remove amenity"
>
<X className="w-4 h-4" />
</button>
</div>
);
})}
</div>
</div>
)}
{/* Available Amenities */}
<div className="border-2 border-slate-200 rounded-2xl p-4 sm:p-6 max-h-96 overflow-y-auto bg-gradient-to-br from-slate-50/50 to-white shadow-inner custom-scrollbar">
{availableAmenities.length === 0 ? (
<p className="text-sm text-slate-500 text-center py-8">Loading amenities...</p>
) : (
<div className="space-y-3">
<p className="text-sm font-semibold text-slate-700 mb-4 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-amber-500" />
Available Amenities
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-1 gap-3">
{availableAmenities.map((amenity) => {
const isSelected = formData.amenities.includes(amenity);
const isEditing = editingAmenity?.name === amenity;
return (
<div
<div className="border-2 border-slate-200 rounded-2xl p-5 sm:p-6 bg-gradient-to-br from-slate-50/50 to-white shadow-inner">
{(() => {
const selectedRoomType = roomTypes.find(rt => rt.id === formData.room_type_id);
const inheritedAmenities = selectedRoomType ? getAmenitiesArray(selectedRoomType.amenities) : [];
if (inheritedAmenities.length === 0) {
return (
<div className="text-center py-6">
<p className="text-sm text-slate-500 mb-2">No amenities defined for this room type.</p>
<p className="text-xs text-slate-400 italic">
Go to the <strong>Room Types</strong> tab in Advanced Room Management to add amenities to this room type.
</p>
</div>
);
}
return (
<>
<div className="mb-4">
<p className="text-xs text-slate-500 italic flex items-center gap-2">
<Check className="w-3 h-3 text-amber-500" />
Inherited from room type: <strong className="text-amber-600">{selectedRoomType?.name}</strong>
</p>
</div>
<div className="flex flex-wrap gap-2.5">
{inheritedAmenities.map((amenity) => (
<span
key={amenity}
className={`flex items-center gap-3 p-4 rounded-2xl transition-all duration-300 ${
isSelected
? 'bg-gradient-to-r from-amber-50 to-amber-100/50 border-2 border-amber-400 shadow-lg'
: 'bg-white border-2 border-slate-200 hover:border-amber-300 hover:shadow-md'
}`}
className="px-4 py-2 bg-gradient-to-r from-amber-100 to-amber-50 border border-amber-300 rounded-xl text-sm font-semibold text-amber-800 shadow-sm"
>
<label className="flex items-center gap-3 flex-1 cursor-pointer">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleAmenity(amenity)}
className="hidden"
/>
<div className={`w-6 h-6 rounded-xl border-2 flex items-center justify-center transition-all duration-300 flex-shrink-0 shadow-md ${
isSelected
? 'bg-gradient-to-br from-amber-400 to-amber-600 border-amber-500 shadow-amber-200'
: 'border-slate-300 bg-white'
}`}>
{isSelected && <Check className="w-4 h-4 text-white font-bold" />}
</div>
{isEditing ? (
<div className="flex items-center gap-2 flex-1">
<input
type="text"
value={editingAmenity.newName}
onChange={(e) => setEditingAmenity({ ...editingAmenity, newName: e.target.value })}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleSaveAmenityEdit();
} else if (e.key === 'Escape') {
setEditingAmenity(null);
}
}}
className="flex-1 px-4 py-2 bg-white border-2 border-amber-400 rounded-xl text-slate-800 text-sm font-medium focus:ring-2 focus:ring-amber-300"
autoFocus
/>
<button
type="button"
onClick={handleSaveAmenityEdit}
className="p-2 rounded-xl bg-green-50 text-green-600 hover:bg-green-100 transition-colors shadow-sm"
title="Save"
>
<Check className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => setEditingAmenity(null)}
className="p-2 rounded-xl bg-slate-100 text-slate-600 hover:bg-slate-200 transition-colors shadow-sm"
title="Cancel"
>
<X className="w-4 h-4" />
</button>
</div>
) : (
<span className={`text-sm flex-1 font-medium transition-colors ${
isSelected ? 'text-amber-800' : 'text-slate-700'
}`}>
{amenity}
</span>
)}
</label>
{!isEditing && (
<div className="flex items-center gap-2 flex-shrink-0">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleEditAmenity(amenity);
}}
className="p-2.5 rounded-xl bg-blue-50 border border-blue-200 text-blue-600 hover:bg-blue-100 hover:border-blue-300 transition-all duration-200 shadow-sm hover:shadow-md"
title="Edit amenity"
>
<EditIcon className="w-4 h-4" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleDeleteAmenity(amenity);
}}
className="p-2.5 rounded-xl bg-red-50 border border-red-200 text-red-600 hover:bg-red-100 hover:border-red-300 transition-all duration-200 shadow-sm hover:shadow-md"
title="Delete amenity"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
</div>
);
})}
</div>
</div>
)}
{amenity}
</span>
))}
</div>
<p className="text-xs text-slate-400 mt-4 italic">
To modify amenities, edit the room type in the <strong>Room Types</strong> tab.
</p>
</>
);
})()}
</div>
</div>