feat: room amenities selection in room editor

This commit is contained in:
2026-06-29 20:49:51 -05:00
parent 262cd4745c
commit 91fce014fd
3 changed files with 78 additions and 7 deletions
+35 -2
View File
@@ -4,7 +4,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
type Image = { id: number; filename: string; data: string; category: string; room_id: number | null; location_target: string; uploaded_at: string }; type Image = { id: number; filename: string; data: string; category: string; room_id: number | null; location_target: string; uploaded_at: string };
type Slide = { id: number; title: string; subtitle: string; image_url: string | null; image_id: number | null; active: boolean; sort_order: number }; type Slide = { id: number; title: string; subtitle: string; image_url: string | null; image_id: number | null; active: boolean; sort_order: number };
type Room = { id: number; name: string; description: string; price: string; photos: string[]; featured_photo: string | null; active: boolean; sort_order: number }; type Room = { id: number; name: string; description: string; price: string; photos: string[]; featured_photo: string | null; active: boolean; sort_order: number; amenity_ids?: number[] };
type CalendarEvent = { id: number; date: string; title: string; type: string; color: string; description: string | null; active: boolean }; type CalendarEvent = { id: number; date: string; title: string; type: string; color: string; description: string | null; active: boolean };
type SocialEvent = { id: number; name: string; description: string; price: string | null; date: string | null; photos: string[]; featured_photo: string | null; active: boolean; sort_order: number }; type SocialEvent = { id: number; name: string; description: string; price: string | null; date: string | null; photos: string[]; featured_photo: string | null; active: boolean; sort_order: number };
type MenuItem = { id: number; category: string; name: string; description: string; price: string; is_daily_special: boolean; active: boolean; sort_order: number }; type MenuItem = { id: number; category: string; name: string; description: string; price: string; is_daily_special: boolean; active: boolean; sort_order: number };
@@ -848,7 +848,22 @@ function RoomsSection({ rooms, setRooms, images, onToast }: any) {
function RoomEditor({ room, setRoom, images, onSave, onCancel }: any) { function RoomEditor({ room, setRoom, images, onSave, onCancel }: any) {
const [showPicker, setShowPicker] = useState(false); const [showPicker, setShowPicker] = useState(false);
const [localPhotos, setLocalPhotos] = useState<string[]>(room.photos || []); const [localPhotos, setLocalPhotos] = useState<string[]>(room.photos || []);
const r = { ...room, photos: localPhotos }; const [allAmenities, setAllAmenities] = useState<Amenity[]>([]);
const [selectedAmenities, setSelectedAmenities] = useState<number[]>(room.amenity_ids || []);
const r = { ...room, photos: localPhotos, amenity_ids: selectedAmenities };
useEffect(() => {
fetch('/api/amenities')
.then(res => res.json())
.then(d => setAllAmenities(d.data || []))
.catch(() => {});
}, []);
const toggleAmenity = (id: number) => {
setSelectedAmenities(prev =>
prev.includes(id) ? prev.filter(a => a !== id) : [...prev, id]
);
};
const addPhotos = async (files: FileList) => { const addPhotos = async (files: FileList) => {
const newPhotos: string[] = []; const newPhotos: string[] = [];
@@ -891,6 +906,24 @@ function RoomEditor({ room, setRoom, images, onSave, onCancel }: any) {
</div> </div>
</div> </div>
<Tgl label="Active" checked={r.active} onChange={(v: boolean) => setRoom({ ...r, active: v })} /> <Tgl label="Active" checked={r.active} onChange={(v: boolean) => setRoom({ ...r, active: v })} />
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<label style={{ fontSize: '13px', fontWeight: 600, color: C.text }}>Amenities</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{allAmenities.map(a => (
<button key={a.id} onClick={() => toggleAmenity(a.id)}
style={{
display: 'flex', alignItems: 'center', gap: '4px',
padding: '6px 10px', borderRadius: 6, border: `1px solid ${selectedAmenities.includes(a.id) ? C.gold : C.border}`,
background: selectedAmenities.includes(a.id) ? C.goldLight : '#fff',
cursor: 'pointer', fontSize: '12px', transition: 'all 150ms',
color: selectedAmenities.includes(a.id) ? C.gold : C.text
}}>
<span style={{ width: 16, height: 16 }} dangerouslySetInnerHTML={{ __html: a.icon_svg.replace(/width="24"/g, 'width="16"').replace(/height="24"/g, 'height="16"') }} />
{a.name}
</button>
))}
</div>
</div>
</div> </div>
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '24px' }}> <div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end', marginTop: '24px' }}>
<Btn onClick={onCancel} variant="secondary">Cancel</Btn> <Btn onClick={onCancel} variant="secondary">Cancel</Btn>
+18 -2
View File
@@ -8,7 +8,15 @@ export async function GET(request: NextRequest, { params }: { params: { id: stri
if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
const { rows } = await db.query('SELECT * FROM rooms WHERE id = $1', [id]); const { rows } = await db.query('SELECT * FROM rooms WHERE id = $1', [id]);
if (!rows.length) return NextResponse.json({ error: 'Room not found' }, { status: 404 }); if (!rows.length) return NextResponse.json({ error: 'Room not found' }, { status: 404 });
return NextResponse.json({ data: rows[0] });
// Get amenities for this room
const { rows: amenityRows } = await db.query(
'SELECT amenity_id FROM room_amenity_links WHERE room_id = $1',
[id]
);
const amenity_ids = amenityRows.map(r => r.amenity_id);
return NextResponse.json({ data: { ...rows[0], amenity_ids } });
} catch (error: any) { } catch (error: any) {
console.error('GET /api/rooms/[id]:', error); console.error('GET /api/rooms/[id]:', error);
return NextResponse.json({ error: error.message }, { status: 500 }); return NextResponse.json({ error: error.message }, { status: 500 });
@@ -21,7 +29,7 @@ export async function PUT(request: NextRequest, { params }: { params: { id: stri
const id = parseInt(params.id); const id = parseInt(params.id);
if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
const body = await request.json(); const body = await request.json();
const { name, description, price, photos, sort_order, featured_photo, active } = body; const { name, description, price, photos, sort_order, featured_photo, active, amenity_ids } = body;
if (!name || !description || price === undefined) { if (!name || !description || price === undefined) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
@@ -31,6 +39,14 @@ export async function PUT(request: NextRequest, { params }: { params: { id: stri
`UPDATE rooms SET name = $1, description = $2, price = $3, photos = $4, sort_order = $5, featured_photo = $6, active = $7 WHERE id = $8 RETURNING *`, `UPDATE rooms SET name = $1, description = $2, price = $3, photos = $4, sort_order = $5, featured_photo = $6, active = $7 WHERE id = $8 RETURNING *`,
[name, description, price, photos || [], sort_order || 0, featured_photo || null, active !== false, id] [name, description, price, photos || [], sort_order || 0, featured_photo || null, active !== false, id]
); );
// Update amenities
await db.query('DELETE FROM room_amenity_links WHERE room_id = $1', [id]);
if (amenity_ids && amenity_ids.length > 0) {
const values = amenity_ids.map((aid: number) => `(${id}, ${aid})`).join(',');
await db.query(`INSERT INTO room_amenity_links (room_id, amenity_id) VALUES ${values}`);
}
return NextResponse.json({ data: rows[0] }); return NextResponse.json({ data: rows[0] });
} catch (error: any) { } catch (error: any) {
if ((error as Error).message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); if ((error as Error).message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+25 -3
View File
@@ -5,7 +5,21 @@ import { db } from '@/lib/db';
export async function GET() { export async function GET() {
try { try {
const { rows } = await db.query('SELECT * FROM rooms WHERE active = TRUE ORDER BY sort_order, id'); const { rows } = await db.query('SELECT * FROM rooms WHERE active = TRUE ORDER BY sort_order, id');
return NextResponse.json({ data: rows });
// Get amenity links for all rooms
const { rows: amenityLinks } = await db.query('SELECT room_id, amenity_id FROM room_amenity_links');
const amenityMap = new Map<number, number[]>();
for (const link of amenityLinks) {
if (!amenityMap.has(link.room_id)) amenityMap.set(link.room_id, []);
amenityMap.get(link.room_id)!.push(link.amenity_id);
}
const roomsWithAmenities = rows.map(r => ({
...r,
amenity_ids: amenityMap.get(r.id) || []
}));
return NextResponse.json({ data: roomsWithAmenities });
} catch (error: any) { } catch (error: any) {
console.error('GET /api/rooms error:', error); console.error('GET /api/rooms error:', error);
return NextResponse.json({ error: error.message || 'Failed to fetch rooms' }, { status: 500 }); return NextResponse.json({ error: error.message || 'Failed to fetch rooms' }, { status: 500 });
@@ -16,7 +30,7 @@ export async function POST(request: NextRequest) {
try { try {
await requireRole('admin'); await requireRole('admin');
const body = await request.json(); const body = await request.json();
const { name, description, price, photos, sort_order, featured_photo } = body; const { name, description, price, photos, sort_order, featured_photo, amenity_ids } = body;
if (!name || !description || price === undefined) { if (!name || !description || price === undefined) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
@@ -29,7 +43,15 @@ export async function POST(request: NextRequest) {
[name, description, price, photos || [], sort_order || 0, featured_photo || null] [name, description, price, photos || [], sort_order || 0, featured_photo || null]
); );
return NextResponse.json({ data: rows[0] }); const roomId = rows[0].id;
// Insert amenity links
if (amenity_ids && amenity_ids.length > 0) {
const values = amenity_ids.map((aid: number) => `(${roomId}, ${aid})`).join(',');
await db.query(`INSERT INTO room_amenity_links (room_id, amenity_id) VALUES ${values}`);
}
return NextResponse.json({ data: { ...rows[0], amenity_ids: amenity_ids || [] } });
} catch (error: any) { } catch (error: any) {
console.error('POST /api/rooms error:', error); console.error('POST /api/rooms error:', error);
return NextResponse.json({ error: error.message || 'Failed to create room' }, { status: 500 }); return NextResponse.json({ error: error.message || 'Failed to create room' }, { status: 500 });