From b8e1f435de2a356d248be30ac4b8acb23f591402 Mon Sep 17 00:00:00 2001 From: AlexCode-dot Date: Mon, 2 Jun 2025 21:40:46 +0200 Subject: [PATCH 01/10] feat(AdminBooking) - Setup booking api routes, GET, PATCH, DELETE - Setup bookingDbService logic for routes --- src/app/api/bookings/[id]/route.js | 33 ++++++++++++++++++++ src/app/api/bookings/route.js | 16 ++++++++++ src/lib/db/bookingDbService.js | 49 ++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 src/app/api/bookings/[id]/route.js diff --git a/src/app/api/bookings/[id]/route.js b/src/app/api/bookings/[id]/route.js new file mode 100644 index 0000000..f7abdb5 --- /dev/null +++ b/src/app/api/bookings/[id]/route.js @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server' +import { updateBookingSeats, deleteBookingById } from '@/lib/db/bookingDbService' +import { requireAdminAccess } from '@/lib/auth/requireAdminAccess' + +export async function PATCH(req, context) { + if (requireAdminAccess()) { + return NextResponse.json({ error: 'Endast tillgängligt för administratörer' }, { status: 403 }) + } + try { + const { id } = await context.params + const { seats } = await req.json() + const updated = await updateBookingSeats(id, seats) + return NextResponse.json(updated) + } catch (err) { + console.error('❌ PATCH /api/bookings/:id failed:', err) + return NextResponse.json({ error: err.message }, { status: 500 }) + } +} + +export async function DELETE(req, context) { + if (requireAdminAccess()) { + return NextResponse.json({ error: 'Endast tillgängligt för administratörer' }, { status: 403 }) + } + + try { + const { id } = await context.params + await deleteBookingById(id) + return NextResponse.json({ message: 'Booking deleted' }) + } catch (err) { + console.error('❌ DELETE /api/bookings/:id failed:', err) + return NextResponse.json({ error: err.message }, { status: 500 }) + } +} diff --git a/src/app/api/bookings/route.js b/src/app/api/bookings/route.js index e69de29..a346ec8 100644 --- a/src/app/api/bookings/route.js +++ b/src/app/api/bookings/route.js @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server' +import { getAllBookings } from '@/lib/db/bookingDbService' +import { requireAdminAccess } from '@/lib/auth/requireAdminAccess' + +export async function GET() { + if (requireAdminAccess()) { + return NextResponse.json({ error: 'Endast tillgängligt för administratörer' }, { status: 403 }) + } + + try { + const bookings = await getAllBookings() + return NextResponse.json(bookings) + } catch (err) { + return NextResponse.json({ error: err.message }, { status: 500 }) + } +} diff --git a/src/lib/db/bookingDbService.js b/src/lib/db/bookingDbService.js index abb15e1..74635b2 100644 --- a/src/lib/db/bookingDbService.js +++ b/src/lib/db/bookingDbService.js @@ -1,5 +1,54 @@ import Booking from '@/lib/db/models/Booking' +import connectDB from '@/lib/db/connectDB' +import Screening from '@/lib/db/models/Screening' export async function deleteBookingsByScreeningId(screeningId) { return await Booking.deleteMany({ screening: screeningId }) } + +export async function getAllBookings() { + await connectDB() + return await Booking.find().sort({ bookedAt: -1 }).lean() +} + +export async function deleteBookingById(id) { + await connectDB() + + const booking = await Booking.findById(id) + if (!booking) throw new Error('Booking not found') + + await Screening.updateOne( + { _id: booking.screening }, + { + $pull: { + bookedSeats: { + $or: booking.seats.map((s) => ({ row: s.row, seat: s.seat })), + }, + }, + } + ) + + await Booking.findByIdAndDelete(id) +} + +export async function updateBookingSeats(id, seats) { + await connectDB() + + const booking = await Booking.findById(id) + if (!booking) throw new Error('Booking not found') + + const screening = await Screening.findById(booking.screening) + if (!screening) throw new Error('Screening not found') + + screening.bookedSeats = screening.bookedSeats.filter( + (s) => !booking.seats.some((bs) => bs.row === s.row && bs.seat === s.seat) + ) + + screening.bookedSeats.push(...seats.map((s) => ({ ...s, _id: booking._id }))) + await screening.save() + + booking.seats = seats + await booking.save() + + return booking.toObject() +} From 56e82c69a486e8e81b59fd4f5a7c0ecc15712e1d Mon Sep 17 00:00:00 2001 From: AlexCode-dot Date: Mon, 2 Jun 2025 23:20:42 +0200 Subject: [PATCH 02/10] feat(API-Route) - Added api route for screening with detailed data populated with movie/room data (did not want to break roberts restful api structure) --- src/app/api/screenings/[id]/details/route.js | 21 ++++++++++++++++++++ src/lib/db/screeningDbService.js | 18 +++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 src/app/api/screenings/[id]/details/route.js diff --git a/src/app/api/screenings/[id]/details/route.js b/src/app/api/screenings/[id]/details/route.js new file mode 100644 index 0000000..eee1cf6 --- /dev/null +++ b/src/app/api/screenings/[id]/details/route.js @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server' +import connectDB from '@/lib/db/connectDB' +import { getScreeningWithDetails } from '@/lib/db/screeningDbService' + +export async function GET(req, context) { + const { id } = await context.params + + try { + await connectDB() + const screening = await getScreeningWithDetails(id) + + if (!screening) { + return NextResponse.json({ error: 'Visning hittades inte' }, { status: 404 }) + } + + return NextResponse.json({ screening }) + } catch (err) { + console.error('❌ API error in /screenings/[id]/details:', err) + return NextResponse.json({ error: 'Något gick fel' }, { status: 500 }) + } +} diff --git a/src/lib/db/screeningDbService.js b/src/lib/db/screeningDbService.js index 7d2ecc6..39ef8e2 100644 --- a/src/lib/db/screeningDbService.js +++ b/src/lib/db/screeningDbService.js @@ -74,3 +74,21 @@ export async function createScreening({ movieId, date, room }) { return await Screening.findById(screening._id).populate('room', 'name').populate('movie', 'title runtime') } + +export async function getScreeningWithDetails(id) { + const screening = await Screening.findById(id) + .populate('room', 'name rows wheelchairSeats') + .populate('movie', 'title runtime') + .lean() + + if (!screening) return null + + if (Array.isArray(screening.bookedSeats)) { + screening.bookedSeats.sort((a, b) => { + if (a.row !== b.row) return a.row - b.row + return a.seat - b.seat + }) + } + + return screening +} From 597cc24807fb2fdfbac0ea7b6106e7bee787beb1 Mon Sep 17 00:00:00 2001 From: AlexCode-dot Date: Mon, 2 Jun 2025 23:21:59 +0200 Subject: [PATCH 03/10] feat(AdminBooking) - Setup frontend API fetch functions --- src/lib/services/bookingApiService.js | 20 ++++++++++++++++++++ src/lib/services/screeningApiService.js | 6 ++++++ 2 files changed, 26 insertions(+) create mode 100644 src/lib/services/bookingApiService.js diff --git a/src/lib/services/bookingApiService.js b/src/lib/services/bookingApiService.js new file mode 100644 index 0000000..dd68c90 --- /dev/null +++ b/src/lib/services/bookingApiService.js @@ -0,0 +1,20 @@ +export async function fetchBookings() { + const res = await fetch('/api/bookings') + if (!res.ok) throw new Error('Kunde inte hämta bokningar.') + return await res.json() +} + +export async function deleteBookingById(id) { + const res = await fetch(`/api/bookings/${id}`, { method: 'DELETE' }) + return { success: res.ok } +} + +export async function updateBookingSeats(id, seats) { + const res = await fetch(`/api/bookings/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ seats }), + }) + if (!res.ok) throw new Error('Kunde inte uppdatera bokningen.') + return await res.json() +} diff --git a/src/lib/services/screeningApiService.js b/src/lib/services/screeningApiService.js index 974add8..30f3a1d 100644 --- a/src/lib/services/screeningApiService.js +++ b/src/lib/services/screeningApiService.js @@ -28,3 +28,9 @@ export async function deleteScreening(id) { const res = await fetch(`/api/screenings/${id}`, { method: 'DELETE' }) if (!res.ok) throw new Error('Kunde inte ta bort visningen.') } + +export async function fetchScreeningDetails(id) { + const res = await fetch(`/api/screenings/${id}/details`) + if (!res.ok) throw new Error('Kunde inte hämta screening info.') + return res.json() +} From fff2a1d342c792f0ab2ce97a48452cf96a8bcba5 Mon Sep 17 00:00:00 2001 From: AlexCode-dot Date: Mon, 2 Jun 2025 23:23:20 +0200 Subject: [PATCH 04/10] feat(AdminBooking) - Added "handle booking" section in admin page - Added logic for manage booking(filter, uodate, delete etc) --- src/app/admin/page.jsx | 8 ++ src/components/admin/AdminBookingFilters.jsx | 66 +++++++++ src/components/admin/AdminBookingList.jsx | 25 ++++ src/components/admin/AdminBookingModal.jsx | 73 ++++++++++ src/components/admin/AdminBookingPanel.jsx | 69 +++++++++ src/components/admin/AdminTabNav.jsx | 3 + src/components/booking/SeatMap.jsx | 6 +- src/hooks/useAdminBookings.js | 141 +++++++++++++++++++ 8 files changed, 388 insertions(+), 3 deletions(-) create mode 100644 src/components/admin/AdminBookingFilters.jsx create mode 100644 src/components/admin/AdminBookingList.jsx create mode 100644 src/components/admin/AdminBookingModal.jsx create mode 100644 src/components/admin/AdminBookingPanel.jsx create mode 100644 src/hooks/useAdminBookings.js diff --git a/src/app/admin/page.jsx b/src/app/admin/page.jsx index 4f3ea63..9f18372 100644 --- a/src/app/admin/page.jsx +++ b/src/app/admin/page.jsx @@ -11,6 +11,7 @@ import SuccessModal from '@/components/SuccessModal' import { useAdminData } from '@/hooks/useAdminData' import AdminTabNav from '@/components/admin/AdminTabNav' import AdminRoomList from '@/components/admin/AdminRoomList' +import AdminBookingPanel from '@/components/admin/AdminBookingPanel' export default function AdminPanel() { const [activeTab, setActiveTab] = useState('list') @@ -66,6 +67,13 @@ export default function AdminPanel() { )} + + {activeTab === 'bookings' && ( +
+ +
+ )} + {activeTab === 'import' && ( <> diff --git a/src/components/admin/AdminBookingFilters.jsx b/src/components/admin/AdminBookingFilters.jsx new file mode 100644 index 0000000..ec0da5a --- /dev/null +++ b/src/components/admin/AdminBookingFilters.jsx @@ -0,0 +1,66 @@ +'use client' + +export default function AdminBookingFilters({ filters, setFilters, movieOptions, roomOptions, resetFilters }) { + return ( +
+
+ setFilters((prev) => ({ ...prev, search: e.target.value }))} + /> +
+ +
+
+ + setFilters((prev) => ({ ...prev, bookingDate: e.target.value }))} + /> +
+ +
+ + setFilters((prev) => ({ ...prev, screeningDate: e.target.value }))} + /> +
+ +
+ + +
+ +
+ + +
+ + +
+
+ ) +} diff --git a/src/components/admin/AdminBookingList.jsx b/src/components/admin/AdminBookingList.jsx new file mode 100644 index 0000000..9c9cc9b --- /dev/null +++ b/src/components/admin/AdminBookingList.jsx @@ -0,0 +1,25 @@ +'use client' + +import { formatDateTime } from '@/lib/utils/formatDateTime' + +export default function AdminBookingList({ bookings, onShow, onDelete }) { + return ( +
    + {bookings.map((booking) => ( +
  • + {booking.name || 'Okänd användare'} – {booking.movieTitle} ( + {formatDateTime(booking.screeningTime)}) +
    Bokning skapades: {formatDateTime(booking.bookedAt)}
    +
    + + +
    +
  • + ))} +
+ ) +} diff --git a/src/components/admin/AdminBookingModal.jsx b/src/components/admin/AdminBookingModal.jsx new file mode 100644 index 0000000..c639fbf --- /dev/null +++ b/src/components/admin/AdminBookingModal.jsx @@ -0,0 +1,73 @@ +'use client' + +import SeatMap from '@/components/booking/SeatMap' +import ErrorMessage from '@/components/ErrorMessage' + +export default function AdminBookingModal({ + booking, + isEditing, + toggleEditMode, + setSelectedBooking, + onSave, + onClose, + errorMessage, + setErrorMessage, +}) { + return ( +
+
+ + +

{booking.movieTitle}

+

Salong {booking.room.name}

+ +
+ Bokade platser för: {booking.name} +
    + {Object.entries( + booking.seats.reduce((acc, seat) => { + const row = seat.row + const seatNum = seat.seat + if (!acc[row]) acc[row] = [] + acc[row].push(seatNum) + return acc + }, {}) + ).map(([row, seatNumbers]) => ( +
    + Rad {row}: Plats {seatNumbers.sort((a, b) => a - b).join(', ')} +
    + ))} +
+
+ + + + {isEditing && ( + + )} + + {errorMessage && setErrorMessage('')} />} + + + setSelectedBooking((prev) => ({ + ...prev, + seats: newSelectedSeats, + })) + : () => setErrorMessage('Du måste klicka på "Redigera platser" innan du kan göra ändringar.') + } + /> +
+
+ ) +} diff --git a/src/components/admin/AdminBookingPanel.jsx b/src/components/admin/AdminBookingPanel.jsx new file mode 100644 index 0000000..e0dd1db --- /dev/null +++ b/src/components/admin/AdminBookingPanel.jsx @@ -0,0 +1,69 @@ +'use client' + +import { useAdminBookings } from '@/hooks/useAdminBookings' +import AdminBookingFilters from './AdminBookingFilters' +import AdminBookingList from './AdminBookingList' +import AdminBookingModal from './AdminBookingModal' +import ConfirmModal from '@/components/ConfirmModal' +import SuccessModal from '@/components/SuccessModal' + +export default function AdminBookingPanel() { + const { + filtered, + selectedBooking, + isEditing, + modal, + successMessage, + errorMessage, + filters, + setFilters, + movieOptions, + roomOptions, + resetFilters, + confirmDeleteBooking, + setModal, + setSuccessMessage, + setErrorMessage, + showSeatMap, + setSelectedBooking, + toggleEditMode, + handleSaveBooking, + setIsEditing, + } = useAdminBookings() + + return ( +
+

Alla bokningar

+ + + + + + {selectedBooking && ( + { + setSelectedBooking(null) + setIsEditing(false) + }} + errorMessage={errorMessage} + setErrorMessage={setErrorMessage} + /> + )} + + {modal && setModal(null)} />} + + {successMessage && setSuccessMessage(null)} />} +
+ ) +} diff --git a/src/components/admin/AdminTabNav.jsx b/src/components/admin/AdminTabNav.jsx index 85382f3..3650d05 100644 --- a/src/components/admin/AdminTabNav.jsx +++ b/src/components/admin/AdminTabNav.jsx @@ -6,6 +6,9 @@ export default function AdminTabNav({ activeTab, setActiveTab }) { + diff --git a/src/components/booking/SeatMap.jsx b/src/components/booking/SeatMap.jsx index ef57306..eae8cbe 100644 --- a/src/components/booking/SeatMap.jsx +++ b/src/components/booking/SeatMap.jsx @@ -18,9 +18,9 @@ export default function SeatMap({ screening, selectedSeats, onSelect, nrOfTicket return } - if (selectedSeats.length < nrOfTickets) { - const updatedSelection = [...selectedSeats, { row: rowNumber, seat: seatNumber }] - onSelect?.(updatedSelection) + if (!nrOfTickets || selectedSeats.length < nrOfTickets) { + const updated = [...selectedSeats, { row: rowNumber, seat: seatNumber }] + onSelect?.(updated) } } diff --git a/src/hooks/useAdminBookings.js b/src/hooks/useAdminBookings.js new file mode 100644 index 0000000..9b15aed --- /dev/null +++ b/src/hooks/useAdminBookings.js @@ -0,0 +1,141 @@ +import { useEffect, useState } from 'react' +import { fetchBookings, deleteBookingById, updateBookingSeats } from '@/lib/services/bookingApiService' +import { fetchScreeningDetails } from '@/lib/services/screeningApiService' + +export function useAdminBookings() { + const [bookings, setBookings] = useState([]) + const [filtered, setFiltered] = useState([]) + const [selectedBooking, setSelectedBooking] = useState(null) + const [isEditing, setIsEditing] = useState(false) + const [modal, setModal] = useState(null) + const [successMessage, setSuccessMessage] = useState(null) + const [errorMessage, setErrorMessage] = useState('') + + const [filters, setFilters] = useState({ + search: '', + bookingDate: '', + screeningDate: '', + selectedMovie: '', + selectedRoom: '', + }) + + const [movieOptions, setMovieOptions] = useState([]) + const [roomOptions, setRoomOptions] = useState([]) + + useEffect(() => { + const load = async () => { + const data = await fetchBookings() + setBookings(data) + setFiltered(data) + setMovieOptions([...new Set(data.map((booking) => booking.movieTitle))].sort()) + setRoomOptions([...new Set(data.map((booking) => booking.roomName))].sort()) + } + load() + }, []) + + useEffect(() => { + const query = filters.search.toLowerCase() + const formatDate = (str) => new Date(str).toLocaleDateString('sv-SE') + + setFiltered( + bookings.filter((booking) => { + const nameMatch = booking.name?.toLowerCase().includes(query) + const titleMatch = booking.movieTitle?.toLowerCase().includes(query) + + const bookingMatch = !filters.bookingDate || formatDate(booking.bookedAt) === filters.bookingDate + const screeningMatch = !filters.screeningDate || formatDate(booking.screeningTime) === filters.screeningDate + const movieMatch = !filters.selectedMovie || booking.movieTitle === filters.selectedMovie + const roomMatch = !filters.selectedRoom || booking.roomName === filters.selectedRoom + + return (nameMatch || titleMatch) && bookingMatch && screeningMatch && movieMatch && roomMatch + }) + ) + }, [filters, bookings]) + + const resetFilters = () => { + setFilters({ + search: '', + bookingDate: '', + screeningDate: '', + selectedMovie: '', + selectedRoom: '', + }) + } + + const confirmDeleteBooking = (booking) => { + setModal({ + message: `Vill du ta bort bokningen för ${booking.name}?`, + onConfirm: async () => { + await deleteBookingById(booking._id) + setBookings((prev) => prev.filter((booking) => booking._id !== booking._id)) + setSuccessMessage('Bokningen har tagits bort!') + setModal(null) + }, + }) + } + + const showSeatMap = async (booking) => { + const data = await fetchScreeningDetails(booking.screening) + const screening = data.screening + + if (!screening?.room) return + setSelectedBooking({ + ...booking, + room: screening.room, + bookedSeats: screening.bookedSeats, + }) + } + + const toggleEditMode = () => { + setIsEditing((prev) => { + if (prev) { + setSelectedBooking((prevBooking) => ({ + ...prevBooking, + seats: prevBooking.bookedSeats.filter((seat) => String(seat._id) === String(prevBooking._id)), + })) + } + return !prev + }) + setErrorMessage('') + } + + const handleSaveBooking = async () => { + const updated = await updateBookingSeats(selectedBooking._id, selectedBooking.seats) + + const data = await fetchScreeningDetails(updated.screening) + const screening = data.screening + + setSelectedBooking({ + ...updated, + room: screening.room, + bookedSeats: screening.bookedSeats, + }) + + setBookings((prev) => prev.map((booking) => (booking._id === updated._id ? updated : booking))) + setIsEditing(false) + setSuccessMessage('Bokningen har uppdaterats!') + } + + return { + filtered, + selectedBooking, + isEditing, + modal, + successMessage, + errorMessage, + filters, + setFilters, + movieOptions, + roomOptions, + resetFilters, + confirmDeleteBooking, + setModal, + setSuccessMessage, + setErrorMessage, + showSeatMap, + setSelectedBooking, + toggleEditMode, + handleSaveBooking, + setIsEditing, + } +} From 02b21a890d5ac628647a61e3e415d6ad2a3ce938 Mon Sep 17 00:00:00 2001 From: AlexCode-dot Date: Mon, 2 Jun 2025 23:43:31 +0200 Subject: [PATCH 05/10] styling(AdminBooking) - Added styling for all Admin bookings components --- src/styles/admin/AdminBookings.scss | 17 ++++++ src/styles/admin/AdminBookingsFilters.scss | 66 ++++++++++++++++++++++ src/styles/admin/AdminBookingsList.scss | 47 +++++++++++++++ src/styles/admin/AdminBookingsModal.scss | 64 +++++++++++++++++++++ src/styles/admin/AdminIndex.scss | 1 + src/styles/admin/AdminNav.scss | 11 ++-- 6 files changed, 201 insertions(+), 5 deletions(-) create mode 100644 src/styles/admin/AdminBookings.scss create mode 100644 src/styles/admin/AdminBookingsFilters.scss create mode 100644 src/styles/admin/AdminBookingsList.scss create mode 100644 src/styles/admin/AdminBookingsModal.scss diff --git a/src/styles/admin/AdminBookings.scss b/src/styles/admin/AdminBookings.scss new file mode 100644 index 0000000..6e10a39 --- /dev/null +++ b/src/styles/admin/AdminBookings.scss @@ -0,0 +1,17 @@ +@use './AdminBookingsFilters'; +@use './AdminBookingsList'; +@use './AdminBookingsModal'; + +.admin-bookings { + &__search { + padding: 0.5rem; + width: 100%; + margin-bottom: 1rem; + } + + &__created { + font-size: 0.85rem; + color: #666; + margin-top: 0.3rem; + } +} diff --git a/src/styles/admin/AdminBookingsFilters.scss b/src/styles/admin/AdminBookingsFilters.scss new file mode 100644 index 0000000..a295e4a --- /dev/null +++ b/src/styles/admin/AdminBookingsFilters.scss @@ -0,0 +1,66 @@ +.admin-bookings__topbar { + display: flex; + flex-direction: column; + align-items: flex-start; + margin-bottom: 1.5rem; +} + +.admin-bookings__search-wrapper { + width: 100%; + max-width: 600px; + margin-bottom: 1rem; + + input { + width: 100%; + padding: 0.6rem; + font-size: 1rem; + border-radius: 6px; + border: 1px solid #ccc; + } +} + +.admin-bookings__filters-row { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: flex-end; + width: 100%; +} + +.admin-bookings__filter-group { + display: flex; + flex-direction: column; + font-size: 0.85rem; + + label { + margin-bottom: 0.2rem; + font-weight: 600; + color: #1f4163; + } + + input, + select { + padding: 0.5rem; + border-radius: 4px; + border: 1px solid #ccc; + font-size: 0.9rem; + min-width: 160px; + } +} + +.admin-bookings__clear { + margin-left: auto; + background-color: #03658c; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 6px; + font-size: 0.9rem; + cursor: pointer; + font-weight: bold; + + &:hover { + background-color: #52b3d9; + color: black; + } +} diff --git a/src/styles/admin/AdminBookingsList.scss b/src/styles/admin/AdminBookingsList.scss new file mode 100644 index 0000000..6cc2bce --- /dev/null +++ b/src/styles/admin/AdminBookingsList.scss @@ -0,0 +1,47 @@ +.admin-bookings__list { + list-style: none; + padding: 0; +} + +.admin-bookings__item { + background: #f7f7f7; + padding: 1rem; + margin-bottom: 0.5rem; + border-radius: 6px; + position: relative; +} + +.admin-bookings__delete { + position: absolute; + right: 1rem; + top: 1rem; + background: none; + border: 1px solid red; + color: red; + border-radius: 4px; + padding: 0.25rem 0.5rem; + font-size: 0.9rem; + cursor: pointer; + + &:hover { + background: rgba(255, 0, 0, 0.1); + } +} + +.admin-bookings__show-seats { + margin-top: 0.5rem; + background: #1f4163; + color: white; + padding: 0.4rem 0.8rem; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 0.9rem; + font-weight: 500; + transition: background 0.2s; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + + &:hover { + background: #16324e; + } +} diff --git a/src/styles/admin/AdminBookingsModal.scss b/src/styles/admin/AdminBookingsModal.scss new file mode 100644 index 0000000..16f8115 --- /dev/null +++ b/src/styles/admin/AdminBookingsModal.scss @@ -0,0 +1,64 @@ +.admin-bookings__edit { + display: block; + background-color: #f0ad4e; + color: white; + padding: 0.6rem 1rem; + border: none; + border-radius: 4px; + font-size: 0.9rem; + cursor: pointer; + transition: background 0.2s ease; + margin: 0 auto 0.5rem; + font-weight: bold; + + &:hover { + background-color: #ec971f; + } +} + +.admin-bookings__save { + display: block; + background-color: #2c6; + color: white; + padding: 0.6rem 1.3rem; + margin: 0 auto 1rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: bold; + font-size: 0.9rem; + + &:hover { + background-color: #279b55; + } +} + +.admin-room__main-modal-title { + color: #1f4163; + text-align: center; + margin: 0 auto 0.5rem; +} + +.admin-room__modal-selected-seats { + text-align: center; + font-weight: bold; + padding: 0.5rem; + border-radius: 6px; + background: #dceeff; + color: #1f4163; + border: 1px solid #a8c4e3; + width: 50%; + margin: 0 auto 1rem; +} + +.admin-room__modal-body { + max-height: calc(100vh - 6rem); + overflow-x: auto; +} + +.admin-room__movie-modal-title { + color: #1f4163; + margin: 0 auto 1rem; + text-align: center; + font-size: 1.5rem; +} diff --git a/src/styles/admin/AdminIndex.scss b/src/styles/admin/AdminIndex.scss index 51e292c..e6010e9 100644 --- a/src/styles/admin/AdminIndex.scss +++ b/src/styles/admin/AdminIndex.scss @@ -3,3 +3,4 @@ @use './AdminNav'; @use './AdminForm'; @use './AdminRoomForm'; +@use './AdminBookings'; diff --git a/src/styles/admin/AdminNav.scss b/src/styles/admin/AdminNav.scss index 04bea1d..62cd9c5 100644 --- a/src/styles/admin/AdminNav.scss +++ b/src/styles/admin/AdminNav.scss @@ -1,23 +1,24 @@ .admin-panel__nav { display: flex; - justify-content: center; + justify-content: space-between; gap: 1rem; margin-bottom: 2rem; flex-wrap: wrap; button { - flex: 1 1 160px; - text-align: center; + flex: 1; + min-width: 120px; + max-width: 180px; + padding: 0.7rem 1rem; background-color: #ffffff; color: #1f4163; border: 2px solid #1f4163; - padding: 0.7rem 1rem; border-radius: 6px; font-weight: bold; font-size: 0.9rem; cursor: pointer; + text-align: center; transition: all 0.2s ease-in-out; - min-width: 160px; &:hover { background-color: #1f4163; From 4ae12f8503eb6ff93dd70785c5ce18e764ffc1d1 Mon Sep 17 00:00:00 2001 From: AlexCode-dot Date: Tue, 3 Jun 2025 00:05:05 +0200 Subject: [PATCH 06/10] feat(AdminBooking) - Added some error handling/validations in frontend & backend --- src/app/api/bookings/[id]/route.js | 15 ++++++- src/components/admin/AdminBookingList.jsx | 3 ++ src/components/admin/AdminBookingPanel.jsx | 3 ++ src/hooks/useAdminBookings.js | 52 +++++++++++++++------- src/styles/admin/AdminBookings.scss | 9 ++++ 5 files changed, 65 insertions(+), 17 deletions(-) diff --git a/src/app/api/bookings/[id]/route.js b/src/app/api/bookings/[id]/route.js index f7abdb5..e8ffa7e 100644 --- a/src/app/api/bookings/[id]/route.js +++ b/src/app/api/bookings/[id]/route.js @@ -9,6 +9,19 @@ export async function PATCH(req, context) { try { const { id } = await context.params const { seats } = await req.json() + + if (!Array.isArray(seats) || seats.length === 0) { + return NextResponse.json({ error: 'Du måste skicka minst en plats.' }, { status: 400 }) + } + + const isValidSeat = (s) => typeof s.row === 'number' && typeof s.seat === 'number' + if (!seats.every(isValidSeat)) { + return NextResponse.json( + { error: 'Ogiltiga platser – varje plats måste ha rad och platsnummer.' }, + { status: 400 } + ) + } + const updated = await updateBookingSeats(id, seats) return NextResponse.json(updated) } catch (err) { @@ -25,7 +38,7 @@ export async function DELETE(req, context) { try { const { id } = await context.params await deleteBookingById(id) - return NextResponse.json({ message: 'Booking deleted' }) + return NextResponse.json({ message: 'Bokningen har tagits bort' }) } catch (err) { console.error('❌ DELETE /api/bookings/:id failed:', err) return NextResponse.json({ error: err.message }, { status: 500 }) diff --git a/src/components/admin/AdminBookingList.jsx b/src/components/admin/AdminBookingList.jsx index 9c9cc9b..549ab1f 100644 --- a/src/components/admin/AdminBookingList.jsx +++ b/src/components/admin/AdminBookingList.jsx @@ -3,6 +3,9 @@ import { formatDateTime } from '@/lib/utils/formatDateTime' export default function AdminBookingList({ bookings, onShow, onDelete }) { + if (bookings.length === 0) { + return

Inga bokningar hittades.

+ } return (
    {bookings.map((booking) => ( diff --git a/src/components/admin/AdminBookingPanel.jsx b/src/components/admin/AdminBookingPanel.jsx index e0dd1db..2640b17 100644 --- a/src/components/admin/AdminBookingPanel.jsx +++ b/src/components/admin/AdminBookingPanel.jsx @@ -6,6 +6,7 @@ import AdminBookingList from './AdminBookingList' import AdminBookingModal from './AdminBookingModal' import ConfirmModal from '@/components/ConfirmModal' import SuccessModal from '@/components/SuccessModal' +import ErrorMessage from '@/components/ErrorMessage' export default function AdminBookingPanel() { const { @@ -43,6 +44,8 @@ export default function AdminBookingPanel() { resetFilters={resetFilters} /> + {errorMessage && setErrorMessage('')} />} + {selectedBooking && ( diff --git a/src/hooks/useAdminBookings.js b/src/hooks/useAdminBookings.js index 9b15aed..38b75ae 100644 --- a/src/hooks/useAdminBookings.js +++ b/src/hooks/useAdminBookings.js @@ -24,12 +24,17 @@ export function useAdminBookings() { useEffect(() => { const load = async () => { - const data = await fetchBookings() - setBookings(data) - setFiltered(data) - setMovieOptions([...new Set(data.map((booking) => booking.movieTitle))].sort()) - setRoomOptions([...new Set(data.map((booking) => booking.roomName))].sort()) + try { + const data = await fetchBookings() + setBookings(data) + setFiltered(data) + setMovieOptions([...new Set(data.map((b) => b.movieTitle))].sort()) + setRoomOptions([...new Set(data.map((b) => b.roomName))].sort()) + } catch (err) { + setErrorMessage('Kunde inte hämta bokningar. Kontrollera servern eller din internetanslutning.') + } } + load() }, []) @@ -78,6 +83,11 @@ export function useAdminBookings() { const data = await fetchScreeningDetails(booking.screening) const screening = data.screening + if (!screening || !screening.room) { + setErrorMessage('Kunde inte hämta information om visningen.') + return + } + if (!screening?.room) return setSelectedBooking({ ...booking, @@ -100,20 +110,30 @@ export function useAdminBookings() { } const handleSaveBooking = async () => { - const updated = await updateBookingSeats(selectedBooking._id, selectedBooking.seats) + if (!selectedBooking || selectedBooking.seats.length === 0) { + setErrorMessage('Du måste välja minst en plats.') + return + } + try { + const updated = await updateBookingSeats(selectedBooking._id, selectedBooking.seats) - const data = await fetchScreeningDetails(updated.screening) - const screening = data.screening + const data = await fetchScreeningDetails(updated.screening) + const screening = data.screening - setSelectedBooking({ - ...updated, - room: screening.room, - bookedSeats: screening.bookedSeats, - }) + setSelectedBooking({ + ...updated, + room: screening.room, + bookedSeats: screening.bookedSeats, + }) - setBookings((prev) => prev.map((booking) => (booking._id === updated._id ? updated : booking))) - setIsEditing(false) - setSuccessMessage('Bokningen har uppdaterats!') + setBookings((prev) => prev.map((booking) => (booking._id === updated._id ? updated : booking))) + setIsEditing(false) + setErrorMessage('') + setSuccessMessage('Bokningen har uppdaterats!') + } catch (err) { + console.error(err) + setErrorMessage('Kunde inte spara bokningen. Försök igen.') + } } return { diff --git a/src/styles/admin/AdminBookings.scss b/src/styles/admin/AdminBookings.scss index 6e10a39..ecb5efd 100644 --- a/src/styles/admin/AdminBookings.scss +++ b/src/styles/admin/AdminBookings.scss @@ -14,4 +14,13 @@ color: #666; margin-top: 0.3rem; } + + &__no-results { + text-align: center; + display: block; + margin: 3rem auto 2rem; + color: #1f4163; + font-size: 1.5rem; + font-weight: bold; + } } From 0e03e89ede3454d8ed1ebec4cf09350557c96c55 Mon Sep 17 00:00:00 2001 From: AlexCode-dot Date: Tue, 3 Jun 2025 00:23:24 +0200 Subject: [PATCH 07/10] fix(SeatMap.jsx) - Fixed bug where user chould select seats when ticketcount was = 0 --- src/components/booking/SeatMap.jsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/booking/SeatMap.jsx b/src/components/booking/SeatMap.jsx index eae8cbe..8203289 100644 --- a/src/components/booking/SeatMap.jsx +++ b/src/components/booking/SeatMap.jsx @@ -10,6 +10,10 @@ export default function SeatMap({ screening, selectedSeats, onSelect, nrOfTicket const toggleSeatSelection = (rowNumber, seatNumber) => { if (isSeatBooked(rowNumber, seatNumber)) return + if (typeof nrOfTickets === 'number' && nrOfTickets === 0) { + return + } + const isAlreadySelected = selectedSeats.some((seat) => seat.row === rowNumber && seat.seat === seatNumber) if (isAlreadySelected) { @@ -18,10 +22,12 @@ export default function SeatMap({ screening, selectedSeats, onSelect, nrOfTicket return } - if (!nrOfTickets || selectedSeats.length < nrOfTickets) { - const updated = [...selectedSeats, { row: rowNumber, seat: seatNumber }] - onSelect?.(updated) + if (typeof nrOfTickets === 'number' && nrOfTickets > 0 && selectedSeats.length >= nrOfTickets) { + return } + + const updated = [...selectedSeats, { row: rowNumber, seat: seatNumber }] + onSelect?.(updated) } const groupedSeats = Object.entries( From 08c1bebd8dd82250998f057d52ceee8fe70afff8 Mon Sep 17 00:00:00 2001 From: AlexCode-dot Date: Tue, 3 Jun 2025 01:00:57 +0200 Subject: [PATCH 08/10] tests(booking) - Added tests for booking API routes for GET, PATCH and DELETE --- src/tests/api/bookings/bookings.test.js | 134 ++++++++++++++++++++ src/tests/api/movies/movies.test.js | 2 - src/tests/api/screenings/screenings.test.js | 2 - 3 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 src/tests/api/bookings/bookings.test.js diff --git a/src/tests/api/bookings/bookings.test.js b/src/tests/api/bookings/bookings.test.js new file mode 100644 index 0000000..87d6673 --- /dev/null +++ b/src/tests/api/bookings/bookings.test.js @@ -0,0 +1,134 @@ +import { expect, jest, test, describe, beforeAll, beforeEach } from '@jest/globals' + +jest.unstable_mockModule('@/lib/db/connectDB', () => ({ + __esModule: true, + default: jest.fn(), +})) + +jest.unstable_mockModule('@/lib/db/bookingDbService', () => ({ + __esModule: true, + getAllBookings: jest.fn(), + updateBookingSeats: jest.fn(), + deleteBookingById: jest.fn(), +})) + +jest.unstable_mockModule('@/lib/auth/requireAdminAccess', () => ({ + __esModule: true, + requireAdminAccess: jest.fn(() => false), +})) + +let GET, PATCH, DELETE +let bookingService + +beforeAll(async () => { + bookingService = await import('@/lib/db/bookingDbService') + const routeModule = await import('@/app/api/bookings/route') + const detailModule = await import('@/app/api/bookings/[id]/route') + GET = routeModule.GET + PATCH = detailModule.PATCH + DELETE = detailModule.DELETE +}) + +beforeEach(() => { + jest.clearAllMocks() + process.env.NODE_ENV = 'development' +}) + +describe('GET /api/bookings (mocked)', () => { + test('returns all bookings', async () => { + const mockBookings = [{ _id: '1', name: 'Alex' }] + bookingService.getAllBookings.mockResolvedValue(mockBookings) + + const res = await GET() + const data = await res.json() + + expect(res.status).toBe(200) + expect(data).toEqual(mockBookings) + }) + + test('returns 500 if DB throws error', async () => { + bookingService.getAllBookings.mockRejectedValue(new Error('Databasfel')) + + const res = await GET() + const data = await res.json() + + expect(res.status).toBe(500) + expect(data.error).toMatch(/databasfel/i) + }) +}) + +describe('PATCH /api/bookings/:id (mocked)', () => { + test('updates booking seats', async () => { + const updated = { _id: '1', seats: [{ row: 1, seat: 2 }] } + bookingService.updateBookingSeats.mockResolvedValue(updated) + + const req = { + json: async () => ({ seats: [{ row: 1, seat: 2 }] }), + } + + const res = await PATCH(req, { params: { id: '1' } }) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data).toEqual(updated) + }) + + test('returns 400 if seats array is missing or empty', async () => { + const req = { json: async () => ({ seats: [] }) } + const res = await PATCH(req, { params: { id: '1' } }) + const data = await res.json() + + expect(res.status).toBe(400) + expect(data.error).toMatch(/minst en plats/i) + }) + + test('returns 400 if seats are invalid', async () => { + const req = { + json: async () => ({ + seats: [{ row: 'a', seat: 2 }, { seat: 3 }], + }), + } + + const res = await PATCH(req, { params: { id: '1' } }) + const data = await res.json() + + expect(res.status).toBe(400) + expect(data.error).toMatch(/ogiltiga platser/i) + }) + + test('returns 500 if updateBookingSeats throws', async () => { + bookingService.updateBookingSeats.mockRejectedValue(new Error('Kunde inte uppdatera bokning')) + + const req = { + json: async () => ({ seats: [{ row: 1, seat: 2 }] }), + } + + const res = await PATCH(req, { params: { id: '1' } }) + const data = await res.json() + + expect(res.status).toBe(500) + expect(data.error).toMatch(/uppdatera bokning/i) + }) +}) + +describe('DELETE /api/bookings/:id (mocked)', () => { + test('deletes a booking successfully', async () => { + bookingService.deleteBookingById.mockResolvedValue() + + const res = await DELETE({}, { params: { id: '1' } }) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data.message).toMatch(/tagits bort/i) + }) + + test('returns 500 if deleteBookingById throws', async () => { + bookingService.deleteBookingById.mockRejectedValue(new Error('Något gick fel')) + + const res = await DELETE({}, { params: { id: '1' } }) + const data = await res.json() + + expect(res.status).toBe(500) + expect(data.error).toMatch(/något gick fel/i) + }) +}) diff --git a/src/tests/api/movies/movies.test.js b/src/tests/api/movies/movies.test.js index 9eac272..a2f381d 100644 --- a/src/tests/api/movies/movies.test.js +++ b/src/tests/api/movies/movies.test.js @@ -1,7 +1,5 @@ -// src/tests/admin/admin.test.js import { FormData } from 'formdata-node' import { expect, jest, test, describe, beforeEach, beforeAll } from '@jest/globals' -import { findMovieById } from '@/lib/db/movieDbService' // Mock database and services jest.unstable_mockModule('@/lib/db/connectDB', () => ({ diff --git a/src/tests/api/screenings/screenings.test.js b/src/tests/api/screenings/screenings.test.js index 80cf33a..870deb3 100644 --- a/src/tests/api/screenings/screenings.test.js +++ b/src/tests/api/screenings/screenings.test.js @@ -1,7 +1,5 @@ -// src/tests/screenings/screenings.test.js import { FormData } from 'formdata-node' import { expect, jest, test, describe, beforeEach, beforeAll } from '@jest/globals' -import { getScreeningById } from '@/lib/db/screeningDbService' // Mock database and services jest.unstable_mockModule('@/lib/db/connectDB', () => ({ From 07d7c7168a17b1f61d9c76f56c56e72e4956cb85 Mon Sep 17 00:00:00 2001 From: AlexCode-dot Date: Wed, 4 Jun 2025 12:39:43 +0200 Subject: [PATCH 09/10] fix(adminBooking) - Added loading state for better UX --- src/components/admin/AdminBookingList.jsx | 7 ++++++- src/components/admin/AdminBookingPanel.jsx | 3 ++- src/hooks/useAdminBookings.js | 4 ++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/admin/AdminBookingList.jsx b/src/components/admin/AdminBookingList.jsx index 549ab1f..5cf08ca 100644 --- a/src/components/admin/AdminBookingList.jsx +++ b/src/components/admin/AdminBookingList.jsx @@ -2,10 +2,15 @@ import { formatDateTime } from '@/lib/utils/formatDateTime' -export default function AdminBookingList({ bookings, onShow, onDelete }) { +export default function AdminBookingList({ bookings, onShow, onDelete, loading }) { + if (loading) { + return

    Laddar bokningar...

    + } + if (bookings.length === 0) { return

    Inga bokningar hittades.

    } + return (
      {bookings.map((booking) => ( diff --git a/src/components/admin/AdminBookingPanel.jsx b/src/components/admin/AdminBookingPanel.jsx index 2640b17..2703fd8 100644 --- a/src/components/admin/AdminBookingPanel.jsx +++ b/src/components/admin/AdminBookingPanel.jsx @@ -30,6 +30,7 @@ export default function AdminBookingPanel() { toggleEditMode, handleSaveBooking, setIsEditing, + loading, } = useAdminBookings() return ( @@ -46,7 +47,7 @@ export default function AdminBookingPanel() { {errorMessage && setErrorMessage('')} />} - + {selectedBooking && ( b.roomName))].sort()) } catch (err) { setErrorMessage('Kunde inte hämta bokningar. Kontrollera servern eller din internetanslutning.') + } finally { + setLoading(false) } } @@ -157,5 +160,6 @@ export function useAdminBookings() { toggleEditMode, handleSaveBooking, setIsEditing, + loading, } } From a988fdf42d11f28740373bcdb3e587265150cdbd Mon Sep 17 00:00:00 2001 From: AlexCode-dot Date: Wed, 4 Jun 2025 12:44:16 +0200 Subject: [PATCH 10/10] fix(scss) - case-sensitive fix --- src/styles/MovieDetailCard.module.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/styles/MovieDetailCard.module.scss b/src/styles/MovieDetailCard.module.scss index 5f84bbd..051f758 100644 --- a/src/styles/MovieDetailCard.module.scss +++ b/src/styles/MovieDetailCard.module.scss @@ -1,5 +1,5 @@ @use 'sass:color'; -@use './ShowTimesTabs.module.scss' as *; +@use './ShowtimesTabs.module.scss' as *; .movieDetailCard { ul,