diff --git a/src/app/admin/page.jsx b/src/app/admin/page.jsx index 4a61174..42549f6 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') @@ -69,6 +70,13 @@ export default function AdminPanel() { )} + + {activeTab === 'bookings' && ( +
+ +
+ )} + {activeTab === 'import' && ( <> diff --git a/src/app/api/bookings/[id]/route.js b/src/app/api/bookings/[id]/route.js new file mode 100644 index 0000000..e8ffa7e --- /dev/null +++ b/src/app/api/bookings/[id]/route.js @@ -0,0 +1,46 @@ +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() + + 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) { + 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: '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/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/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/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..5cf08ca --- /dev/null +++ b/src/components/admin/AdminBookingList.jsx @@ -0,0 +1,33 @@ +'use client' + +import { formatDateTime } from '@/lib/utils/formatDateTime' + +export default function AdminBookingList({ bookings, onShow, onDelete, loading }) { + if (loading) { + return

Laddar bokningar...

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

Inga bokningar hittades.

+ } + + return ( + + ) +} 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..2703fd8 --- /dev/null +++ b/src/components/admin/AdminBookingPanel.jsx @@ -0,0 +1,73 @@ +'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' +import ErrorMessage from '@/components/ErrorMessage' + +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, + loading, + } = useAdminBookings() + + return ( +
+

Alla bokningar

+ + + + {errorMessage && setErrorMessage('')} />} + + + + {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..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 (selectedSeats.length < nrOfTickets) { - const updatedSelection = [...selectedSeats, { row: rowNumber, seat: seatNumber }] - onSelect?.(updatedSelection) + if (typeof nrOfTickets === 'number' && nrOfTickets > 0 && selectedSeats.length >= nrOfTickets) { + return } + + const updated = [...selectedSeats, { row: rowNumber, seat: seatNumber }] + onSelect?.(updated) } const groupedSeats = Object.entries( diff --git a/src/hooks/useAdminBookings.js b/src/hooks/useAdminBookings.js new file mode 100644 index 0000000..d9d7553 --- /dev/null +++ b/src/hooks/useAdminBookings.js @@ -0,0 +1,165 @@ +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 [loading, setLoading] = useState(true) + + const [filters, setFilters] = useState({ + search: '', + bookingDate: '', + screeningDate: '', + selectedMovie: '', + selectedRoom: '', + }) + + const [movieOptions, setMovieOptions] = useState([]) + const [roomOptions, setRoomOptions] = useState([]) + + useEffect(() => { + const load = async () => { + 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.') + } finally { + setLoading(false) + } + } + + 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 || !screening.room) { + setErrorMessage('Kunde inte hämta information om visningen.') + return + } + + 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 () => { + 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 + + setSelectedBooking({ + ...updated, + room: screening.room, + bookedSeats: screening.bookedSeats, + }) + + 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 { + filtered, + selectedBooking, + isEditing, + modal, + successMessage, + errorMessage, + filters, + setFilters, + movieOptions, + roomOptions, + resetFilters, + confirmDeleteBooking, + setModal, + setSuccessMessage, + setErrorMessage, + showSeatMap, + setSelectedBooking, + toggleEditMode, + handleSaveBooking, + setIsEditing, + loading, + } +} 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() +} diff --git a/src/lib/db/screeningDbService.js b/src/lib/db/screeningDbService.js index 5d3bff2..dec0e0a 100644 --- a/src/lib/db/screeningDbService.js +++ b/src/lib/db/screeningDbService.js @@ -80,3 +80,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 +} 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() +} diff --git a/src/styles/admin/AdminBookings.scss b/src/styles/admin/AdminBookings.scss new file mode 100644 index 0000000..ecb5efd --- /dev/null +++ b/src/styles/admin/AdminBookings.scss @@ -0,0 +1,26 @@ +@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; + } + + &__no-results { + text-align: center; + display: block; + margin: 3rem auto 2rem; + color: #1f4163; + font-size: 1.5rem; + font-weight: bold; + } +} 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; 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 2d3b2ea..e306b56 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 { findMoviesByTitle } 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 bbd644e..870deb3 100644 --- a/src/tests/api/screenings/screenings.test.js +++ b/src/tests/api/screenings/screenings.test.js @@ -1,4 +1,3 @@ -// src/tests/screenings/screenings.test.js import { FormData } from 'formdata-node' import { expect, jest, test, describe, beforeEach, beforeAll } from '@jest/globals'