From a5457726f118150f37ec2e1a66d439af5cf09543 Mon Sep 17 00:00:00 2001 From: Danka Marcell Date: Fri, 14 Nov 2025 11:03:19 +0100 Subject: [PATCH 01/25] feat: implement TimePicker component for improved time selection in reservation forms --- .../src/components/calendar/add-comment.tsx | 42 +++---- .../components/calendar/add-reservation.tsx | 52 +++----- .../src/components/calendar/calendar.tsx | 2 +- .../components/calendar/comment-details.tsx | 90 +++++++------- .../calendar/reservation-details.tsx | 72 +++++------ .../src/components/calendar/time-picker.tsx | 112 ++++++++++++++++++ .../src/components/calendar/validDate.tsx | 2 +- .../src/hooks/useReservationDetails.ts | 35 +++++- apps/frontend/src/lib/reservationSubmitter.ts | 4 - 9 files changed, 256 insertions(+), 155 deletions(-) create mode 100644 apps/frontend/src/components/calendar/time-picker.tsx diff --git a/apps/frontend/src/components/calendar/add-comment.tsx b/apps/frontend/src/components/calendar/add-comment.tsx index f6447b8..a6b209f 100644 --- a/apps/frontend/src/components/calendar/add-comment.tsx +++ b/apps/frontend/src/components/calendar/add-comment.tsx @@ -3,15 +3,27 @@ import { useState } from 'react'; import { useUser } from '@/hooks/useUser'; // Import your user hook import axiosApi from '@/lib/apiSetup'; +import { TimePicker } from './time-picker'; + interface AddCommentProps { onGetData: () => void; onAddEvent: () => void; } export default function AddComment(props: AddCommentProps) { + // Initialize with rounded times (15-minute intervals) + const getRoundedDate = () => { + const date = new Date(); + const minutes = Math.floor(date.getMinutes() / 15) * 15; + date.setMinutes(minutes); + date.setSeconds(0); + date.setMilliseconds(0); + return date; + }; + const [comment, setComment] = useState(''); - const [startTime, setStartTime] = useState(new Date()); - const [endTime, setEndTime] = useState(new Date()); + const [startTime, setStartTime] = useState(getRoundedDate()); + const [endTime, setEndTime] = useState(getRoundedDate()); const [isReservable, setIsReservable] = useState(false); const { user } = useUser(); // Get current user @@ -62,30 +74,10 @@ export default function AddComment(props: AddCommentProps) { /> -
-
- - setStartTime(new Date(e.target.value))} - /> -
+
+ -
- - setEndTime(new Date(e.target.value))} - /> -
+
diff --git a/apps/frontend/src/components/calendar/add-reservation.tsx b/apps/frontend/src/components/calendar/add-reservation.tsx index e001762..81a4f3f 100644 --- a/apps/frontend/src/components/calendar/add-reservation.tsx +++ b/apps/frontend/src/components/calendar/add-reservation.tsx @@ -8,6 +8,8 @@ import { Band } from '@/types/band'; import { Reservation } from '@/types/reservation'; import { User } from '@/types/user'; +import { TimePicker } from './time-picker'; + interface AddPanelProps { onGetData: () => void; currentDate: Date; @@ -26,8 +28,18 @@ export default function AddReservation(props: AddPanelProps) { const [users, setUsers] = useState([]); const [user, setUser] = useState(); - const [startTime, setStartTime] = useState(new Date()); - const [endTime, setEndTime] = useState(new Date()); + // Initialize with rounded times (15-minute intervals) + const getRoundedDate = () => { + const date = new Date(); + const minutes = Math.floor(date.getMinutes() / 15) * 15; + date.setMinutes(minutes); + date.setSeconds(0); + date.setMilliseconds(0); + return date; + }; + + const [startTime, setStartTime] = useState(getRoundedDate()); + const [endTime, setEndTime] = useState(getRoundedDate()); const [userSuggestions, setUserSuggestions] = useState([]); const [bandSuggestions, setBandSuggestions] = useState([]); @@ -133,16 +145,6 @@ export default function AddReservation(props: AddPanelProps) { } }; - function shiftStart(date: Date) { - date.setHours(date.getHours() + 1); - setStartTime(date); - } - - function shiftEnd(date: Date) { - date.setHours(date.getHours() + 1); - setEndTime(date); - } - // For add-reservation.tsx - replace the return part with: return (
@@ -224,30 +226,10 @@ export default function AddReservation(props: AddPanelProps) {
-
-
- - shiftStart(new Date(e.target.value))} - /> -
+
+ -
- - shiftEnd(new Date(e.target.value))} - /> -
+
{!valid && ( diff --git a/apps/frontend/src/components/calendar/calendar.tsx b/apps/frontend/src/components/calendar/calendar.tsx index 3cf84f2..f366775 100644 --- a/apps/frontend/src/components/calendar/calendar.tsx +++ b/apps/frontend/src/components/calendar/calendar.tsx @@ -71,7 +71,7 @@ export default function Calendar() { return (
-
+
-
-
- - {isEditing ? ( - setEditStartTimeValue(new Date(e.target.value))} - /> - ) : ( + {isEditing ? ( +
+ + +
+ ) : ( +
+
+

{new Date(props.clickedComment.startTime).getHours()}: {new Date(props.clickedComment.startTime).getMinutes().toString().padStart(2, '0')}

- )} -
-
- - {isEditing ? ( - setEditEndTimeValue(new Date(e.target.value))} - /> - ) : ( +
+
+

{new Date(props.clickedComment.endTime).getHours()}: {new Date(props.clickedComment.endTime).getMinutes().toString().padStart(2, '0')}

- )} +
-
+ )}
{isEditing ? ( <> @@ -168,8 +169,13 @@ export default function CommentDetails(props: EventDetailsProps) { {/* Footer with Actions */}
diff --git a/apps/frontend/src/components/calendar/reservation-details.tsx b/apps/frontend/src/components/calendar/reservation-details.tsx index 730bf62..5d6bcdf 100644 --- a/apps/frontend/src/components/calendar/reservation-details.tsx +++ b/apps/frontend/src/components/calendar/reservation-details.tsx @@ -1,9 +1,11 @@ // components/calendar/reservation-details.tsx -import React, { useMemo } from 'react'; +import { useMemo } from 'react'; import { useReservationDetails } from '@/hooks/useReservationDetails'; import { Reservation } from '@/types/reservation'; +import { TimePicker } from './time-picker'; + interface EventDetailsProps { isEventDetails: boolean; setIsEventDetails: (value: boolean) => void; @@ -18,6 +20,8 @@ export default function ReservationDetails(props: EventDetailsProps) { const { isEditing, + editStartTimeValue, + editEndTimeValue, setEditStartTimeValue, setEditEndTimeValue, user, @@ -94,53 +98,29 @@ export default function ReservationDetails(props: EventDetailsProps) { )} {/* Time Range */} -
-
- - {isEditing ? ( - setEditStartTimeValue(new Date(e.target.value))} - /> - ) : ( + {isEditing ? ( +
+ + +
+ ) : ( +
+
+

{new Date(props.clickedEvent.startTime).getHours()}: {new Date(props.clickedEvent.startTime).getMinutes().toString().padStart(2, '0')}

- )} -
- -
- - {isEditing ? ( - setEditEndTimeValue(new Date(e.target.value))} - /> - ) : ( +
+
+

{new Date(props.clickedEvent.endTime).getHours()}: {new Date(props.clickedEvent.endTime).getMinutes().toString().padStart(2, '0')}

- )} +
-
+ )} {/* Status */}
@@ -184,15 +164,25 @@ export default function ReservationDetails(props: EventDetailsProps) {
{!isEditing && ( )} diff --git a/apps/frontend/src/components/calendar/time-picker.tsx b/apps/frontend/src/components/calendar/time-picker.tsx new file mode 100644 index 0000000..cf4af54 --- /dev/null +++ b/apps/frontend/src/components/calendar/time-picker.tsx @@ -0,0 +1,112 @@ +import React, { useEffect, useState } from 'react'; + +interface TimePickerProps { + label: string; + value: Date; + onChange: (date: Date) => void; + className?: string; +} + +export function TimePicker({ label, value, onChange, className = '' }: TimePickerProps) { + const roundedMinute = Math.floor(value.getMinutes() / 15) * 15; + + const [selectedDate, setSelectedDate] = useState(value); + const [selectedHour, setSelectedHour] = useState(value.getHours()); + const [selectedMinute, setSelectedMinute] = useState(roundedMinute); + + useEffect(() => { + const newRoundedMinute = Math.floor(value.getMinutes() / 15) * 15; + setSelectedDate(value); + setSelectedHour(value.getHours()); + setSelectedMinute(newRoundedMinute); + + // If the value's minutes weren't rounded, trigger onChange with rounded value + if (value.getMinutes() !== newRoundedMinute) { + const roundedDate = new Date(value); + roundedDate.setMinutes(newRoundedMinute); + roundedDate.setSeconds(0); + roundedDate.setMilliseconds(0); + onChange(roundedDate); + } + }, [value, onChange]); + + const hours = Array.from({ length: 24 }, (_, i) => i); + const minutes = [0, 15, 30, 45]; + + const handleDateChange = (e: React.ChangeEvent) => { + const newDate = new Date(e.target.value); + newDate.setHours(selectedHour); + newDate.setMinutes(selectedMinute); + setSelectedDate(newDate); + onChange(newDate); + }; + + const handleHourChange = (e: React.ChangeEvent) => { + const hour = parseInt(e.target.value); + const newDate = new Date(selectedDate); + newDate.setHours(hour); + newDate.setMinutes(selectedMinute); + setSelectedHour(hour); + onChange(newDate); + }; + + const handleMinuteChange = (e: React.ChangeEvent) => { + const minute = parseInt(e.target.value); + const newDate = new Date(selectedDate); + newDate.setHours(selectedHour); + newDate.setMinutes(minute); + setSelectedMinute(minute); + onChange(newDate); + }; + + const formatDate = (date: Date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + }; + + return ( +
+ + +
+ {/* Date picker */} + + + {/* Hour picker */} + + + : + + {/* Minute picker */} + +
+
+ ); +} diff --git a/apps/frontend/src/components/calendar/validDate.tsx b/apps/frontend/src/components/calendar/validDate.tsx index 625e6d8..7d2d4a7 100644 --- a/apps/frontend/src/components/calendar/validDate.tsx +++ b/apps/frontend/src/components/calendar/validDate.tsx @@ -7,7 +7,7 @@ export default function validDate( reservation: Reservation | undefined, reservations: Reservation[] ): boolean { - if (start > end || end.getHours() - start.getHours() > 3) { + if (start > end || end.getHours() - start.getHours() > 3 || end.valueOf() - start.valueOf() < 30 * 60 * 1000) { return false; } if (reservation) { diff --git a/apps/frontend/src/hooks/useReservationDetails.ts b/apps/frontend/src/hooks/useReservationDetails.ts index af1796c..a730e21 100644 --- a/apps/frontend/src/hooks/useReservationDetails.ts +++ b/apps/frontend/src/hooks/useReservationDetails.ts @@ -76,14 +76,35 @@ export function useReservationDetails(props: ReservationDetailsProps) { .catch(() => {}); }; - const onDelete = () => { - if (!props.clickedEvent) return; - if (new Date(props.clickedEvent.startTime).getTime() < Date.now()) return; + const onDelete = async () => { + if (!props.clickedEvent) { + console.error('No clicked event to delete'); + return; + } + + const reservationStart = new Date(props.clickedEvent.startTime); + if (reservationStart.getTime() < Date.now()) { + //alert('Nem törölhetsz múltbeli foglalást!'); + return; + } + //if (!window.confirm('Biztosan törlöd a foglalást?')) return; - axiosApi.delete(`/reservations/${props.clickedEvent.id}`).then(() => { + + try { + //console.log('Deleting reservation with ID:', props.clickedEvent.id); + await axiosApi.delete(`/reservations/${props.clickedEvent.id}`); + //console.log('Reservation deleted successfully'); + + // Close modal first to prevent any state issues + props.setIsEventDetails(false); + + // Then refresh data props.onGetData(); - props.setIsEventDetails(!props.isEventDetails); - }); + } catch (error: any) { + console.error('Error deleting reservation:', error); + //const errorMessage = error.response?.data?.message || error.message || 'Ismeretlen hiba történt'; + //alert(`Hiba történt a foglalás törlése közben: ${errorMessage}`); + } }; const onEdit = () => { @@ -246,6 +267,8 @@ export function useReservationDetails(props: ReservationDetailsProps) { isEditing, editNameValue, setEditNameValue, + editStartTimeValue, + editEndTimeValue, setEditStartTimeValue, setEditEndTimeValue, user, diff --git a/apps/frontend/src/lib/reservationSubmitter.ts b/apps/frontend/src/lib/reservationSubmitter.ts index 184535e..922d40a 100644 --- a/apps/frontend/src/lib/reservationSubmitter.ts +++ b/apps/frontend/src/lib/reservationSubmitter.ts @@ -41,10 +41,6 @@ export async function submitReservation(params: { const start = new Date(startTime); const end = new Date(endTime); - // Adjust time zone - start.setHours(start.getHours() - 1); - end.setHours(end.getHours() - 1); - const reservationsOfWeek = getReservationsOfWeek(reservations, band); const reservationsOfDay = getReservationsOfDay(reservations, band, start); From fd43cba30e545715c4eaa759537ad575546a5148 Mon Sep 17 00:00:00 2001 From: Danka Marcell Date: Fri, 9 Jan 2026 17:34:53 +0100 Subject: [PATCH 02/25] Made fixes on comment and gatekeeper appliance access --- .../components/calendar/comment-details.tsx | 44 +++++++++++-------- .../calendar/day/day-reservation.tsx | 2 +- .../calendar/reservation-details.tsx | 5 ++- .../src/hooks/useReservationDetails.ts | 26 ++++++++++- 4 files changed, 54 insertions(+), 23 deletions(-) diff --git a/apps/frontend/src/components/calendar/comment-details.tsx b/apps/frontend/src/components/calendar/comment-details.tsx index 3aa89ef..6fa2f09 100644 --- a/apps/frontend/src/components/calendar/comment-details.tsx +++ b/apps/frontend/src/components/calendar/comment-details.tsx @@ -1,5 +1,6 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; +import { useUser } from '@/hooks/useUser'; import axiosApi from '@/lib/apiSetup'; import { Comment } from '@/types/comment'; @@ -14,12 +15,15 @@ interface EventDetailsProps { } export default function CommentDetails(props: EventDetailsProps) { + const { user: me } = useUser(); const [isEditing, setIsEditing] = useState(false); const [editValue, setEditValue] = useState(''); const [editStartTimeValue, setEditStartTimeValue] = useState(null); const [editEndTimeValue, setEditEndTimeValue] = useState(null); const [isReservable, setIsReservable] = useState(false); + const isAdmin = me?.role === 'ADMIN'; + const onDelete = async () => { if (!props.clickedComment) { console.error('No clicked comment to delete'); @@ -168,23 +172,27 @@ export default function CommentDetails(props: EventDetailsProps) { {/* Footer with Actions */}
- - + {isAdmin && ( + <> + + + + )}
diff --git a/apps/frontend/src/components/calendar/day/day-reservation.tsx b/apps/frontend/src/components/calendar/day/day-reservation.tsx index 8c15cac..4e6275c 100644 --- a/apps/frontend/src/components/calendar/day/day-reservation.tsx +++ b/apps/frontend/src/components/calendar/day/day-reservation.tsx @@ -43,7 +43,7 @@ export default function DayReservation(props: DayEventProps) { return (
- {CurrentUserIsGK() && ( + {CurrentUserIsGK() && gateKeeper === null && currentUserCanBeGateKeeper() && ( )} diff --git a/apps/frontend/src/hooks/useReservationDetails.ts b/apps/frontend/src/hooks/useReservationDetails.ts index a730e21..d283761 100644 --- a/apps/frontend/src/hooks/useReservationDetails.ts +++ b/apps/frontend/src/hooks/useReservationDetails.ts @@ -39,8 +39,17 @@ export function useReservationDetails(props: ReservationDetailsProps) { const [valid, setValid] = useState(true); const hasEditRights = useMemo(() => { - return me?.role === 'ADMIN' || props.clickedEvent?.userId === me?.id; - }, [me, props.clickedEvent]); + if (me?.role === 'ADMIN') return true; + if (props.clickedEvent?.userId === me?.id) return true; + + // Check if user is a member of the band associated with this reservation + if (band && band.members && me?.id) { + const isUserInBand = band.members.some((member) => member.userId === me.id); + if (isUserInBand) return true; + } + + return false; + }, [me, props.clickedEvent, band]); const getGateKeeper = (membershipId: number | null) => { setGateKeeperMembershipId(membershipId); @@ -143,6 +152,18 @@ export function useReservationDetails(props: ReservationDetailsProps) { return gateKeepers.find((m) => m.userId === me?.id) || null; } + function currentUserCanBeGateKeeper(): boolean { + if (!me || !props.clickedEvent) return false; + // User cannot be gatekeeper for their own reservation + if (props.clickedEvent.userId === me.id) return false; + // User cannot be gatekeeper for a band they're a member of + if (band && band.members) { + const isUserInBand = band.members.some((member) => member.userId === me.id); + if (isUserInBand) return false; + } + return true; + } + const handleSubmitMail = (message: string, email: string, sender: string) => { fetch('/api/kir-mail/', { method: 'POST', @@ -277,6 +298,7 @@ export function useReservationDetails(props: ReservationDetailsProps) { valid, hasEditRights, CurrentUserIsGK, + currentUserCanBeGateKeeper, onSetGK, onDelete, onEdit, From 05f43757a89015251ab7154c08a67637b1f73826 Mon Sep 17 00:00:00 2001 From: Danka Marcell Date: Wed, 18 Mar 2026 20:42:53 +0100 Subject: [PATCH 03/25] feat: add sanction management features including SanctionRecord model, settings for penalties, and updates to reservation handling --- .../migration.sql | 20 ++ .../migration.sql | 2 + .../migration.sql | 30 ++ apps/backend/prisma/schema.prisma | 58 +++- apps/backend/src/app.module.ts | 6 + apps/backend/src/bands/bands.service.ts | 5 +- apps/backend/src/comments/comments.service.ts | 43 ++- .../src/periods/dto/create-period.dto.ts | 13 + .../src/periods/dto/update-period.dto.ts | 15 + .../backend/src/periods/periods.controller.ts | 47 ++++ apps/backend/src/periods/periods.module.ts | 11 + apps/backend/src/periods/periods.service.ts | 41 +++ .../entities/reservation.entity.ts | 9 +- .../src/reservations/reservations.service.ts | 172 +++++++++++- .../dto/create-sanction-record.dto.ts | 23 ++ .../entities/sanction-record.entity.ts | 29 ++ .../sanction-records.controller.ts | 44 +++ .../sanction-records.module.ts | 11 + .../sanction-records.service.ts | 158 +++++++++++ .../src/settings/dto/update-settings.dto.ts | 28 ++ .../src/settings/settings.controller.ts | 32 +++ apps/backend/src/settings/settings.module.ts | 11 + apps/backend/src/settings/settings.service.ts | 44 +++ apps/frontend/declarations.d.ts | 3 + apps/frontend/src/app/admin_panel/page.tsx | 36 ++- .../frontend/src/app/my-reservations/page.tsx | 235 ++++++++++++++++ apps/frontend/src/app/profile/page.tsx | 174 ++++++++++++ apps/frontend/src/app/reservation/page.tsx | 14 +- apps/frontend/src/app/stats/page.tsx | 5 +- .../src/components/calendar/add-panel.tsx | 4 +- .../components/calendar/add-reservation.tsx | 261 ++++++++---------- .../components/calendar/day/day-comment.tsx | 18 +- .../calendar/day/day-reservation.tsx | 99 +++++-- .../calendar/isReservationOvertime.tsx | 37 ++- .../calendar/reservation-details.tsx | 37 ++- .../calendar/reservation-legend.tsx | 43 +++ .../src/components/calendar/validDate.tsx | 13 +- .../src/components/layout/sidebar.tsx | 14 +- .../src/hooks/collisionWithAdminRes.tsx | 3 + apps/frontend/src/hooks/useQuotaCheck.ts | 131 +++++++++ .../src/hooks/useReservationDetails.ts | 84 ++++-- apps/frontend/src/hooks/useUser.tsx | 9 +- apps/frontend/src/hooks/useWeekStatus.ts | 69 +++++ apps/frontend/src/lib/reservationSubmitter.ts | 60 ++-- apps/frontend/src/middleware.ts | 61 ++++ apps/frontend/src/types/reservation.ts | 2 + apps/frontend/src/utils/withAuth.tsx | 117 ++++++++ avatar.svg | Bin 0 -> 22022 bytes 48 files changed, 2104 insertions(+), 277 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260109180915_reservation_enhancements/migration.sql create mode 100644 apps/backend/prisma/migrations/20260116023247_kolis_fogadas/migration.sql create mode 100644 apps/backend/prisma/migrations/20260116170512_add_sanction_records/migration.sql create mode 100644 apps/backend/src/periods/dto/create-period.dto.ts create mode 100644 apps/backend/src/periods/dto/update-period.dto.ts create mode 100644 apps/backend/src/periods/periods.controller.ts create mode 100644 apps/backend/src/periods/periods.module.ts create mode 100644 apps/backend/src/periods/periods.service.ts create mode 100644 apps/backend/src/sanction-records/dto/create-sanction-record.dto.ts create mode 100644 apps/backend/src/sanction-records/entities/sanction-record.entity.ts create mode 100644 apps/backend/src/sanction-records/sanction-records.controller.ts create mode 100644 apps/backend/src/sanction-records/sanction-records.module.ts create mode 100644 apps/backend/src/sanction-records/sanction-records.service.ts create mode 100644 apps/backend/src/settings/dto/update-settings.dto.ts create mode 100644 apps/backend/src/settings/settings.controller.ts create mode 100644 apps/backend/src/settings/settings.module.ts create mode 100644 apps/backend/src/settings/settings.service.ts create mode 100644 apps/frontend/declarations.d.ts create mode 100644 apps/frontend/src/app/my-reservations/page.tsx create mode 100644 apps/frontend/src/app/profile/page.tsx create mode 100644 apps/frontend/src/components/calendar/reservation-legend.tsx create mode 100644 apps/frontend/src/hooks/useQuotaCheck.ts create mode 100644 apps/frontend/src/hooks/useWeekStatus.ts create mode 100644 apps/frontend/src/middleware.ts create mode 100644 apps/frontend/src/utils/withAuth.tsx create mode 100644 avatar.svg diff --git a/apps/backend/prisma/migrations/20260109180915_reservation_enhancements/migration.sql b/apps/backend/prisma/migrations/20260109180915_reservation_enhancements/migration.sql new file mode 100644 index 0000000..4396b3d --- /dev/null +++ b/apps/backend/prisma/migrations/20260109180915_reservation_enhancements/migration.sql @@ -0,0 +1,20 @@ +-- AlterEnum +ALTER TYPE "ReservationStatus" ADD VALUE 'SANCTIONED'; + +-- AlterTable +ALTER TABLE "Period" ADD COLUMN "isOpen" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "sanctionPoints" INTEGER NOT NULL DEFAULT 0; + +-- CreateTable +CREATE TABLE "Settings" ( + "id" SERIAL NOT NULL, + "maxHoursPerWeek" DOUBLE PRECISION NOT NULL DEFAULT 8.0, + "maxHoursPerDay" DOUBLE PRECISION NOT NULL DEFAULT 4.0, + "minReservationMinutes" INTEGER NOT NULL DEFAULT 30, + "maxReservationMinutes" INTEGER NOT NULL DEFAULT 180, + "sanctionHourPenaltyPerPoint" DOUBLE PRECISION NOT NULL DEFAULT 1.0, + + CONSTRAINT "Settings_pkey" PRIMARY KEY ("id") +); diff --git a/apps/backend/prisma/migrations/20260116023247_kolis_fogadas/migration.sql b/apps/backend/prisma/migrations/20260116023247_kolis_fogadas/migration.sql new file mode 100644 index 0000000..07def91 --- /dev/null +++ b/apps/backend/prisma/migrations/20260116023247_kolis_fogadas/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Reservation" ADD COLUMN "needToBeLetIn" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/backend/prisma/migrations/20260116170512_add_sanction_records/migration.sql b/apps/backend/prisma/migrations/20260116170512_add_sanction_records/migration.sql new file mode 100644 index 0000000..5d3d485 --- /dev/null +++ b/apps/backend/prisma/migrations/20260116170512_add_sanction_records/migration.sql @@ -0,0 +1,30 @@ +/* + Warnings: + + - You are about to drop the column `sanctionPoints` on the `User` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "User" DROP COLUMN "sanctionPoints"; + +-- CreateTable +CREATE TABLE "SanctionRecord" ( + "id" SERIAL NOT NULL, + "userId" INTEGER, + "bandId" INTEGER, + "points" INTEGER NOT NULL, + "reason" TEXT NOT NULL, + "awardedBy" INTEGER NOT NULL, + "awardedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SanctionRecord_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "SanctionRecord" ADD CONSTRAINT "SanctionRecord_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SanctionRecord" ADD CONSTRAINT "SanctionRecord_bandId_fkey" FOREIGN KEY ("bandId") REFERENCES "Band"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SanctionRecord" ADD CONSTRAINT "SanctionRecord_awardedBy_fkey" FOREIGN KEY ("awardedBy") REFERENCES "ClubMembership"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 6dbc7ca..425bd9f 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -22,6 +22,7 @@ enum ReservationStatus { NORMAL OVERTIME ADMINMADE + SANCTIONED } enum BandMembershipStatus { @@ -30,11 +31,11 @@ enum BandMembershipStatus { } model User { - id Int @id @default(autoincrement()) - email String @unique - phone String? @unique - role Role @default(USER) - authSchId String? @unique + id Int @id @default(autoincrement()) + email String @unique + phone String? @unique + role Role @default(USER) + authSchId String? @unique fullName String clubMembershipUpdatedAt DateTime? bandMemberships BandMembership[] @@ -42,7 +43,8 @@ model User { DormResidency DormResidency? Post Post[] profilePicture ProfilePicture? - reservations Reservation[] @relation("Reservations") + reservations Reservation[] @relation("Reservations") + sanctionRecords SanctionRecord[] @relation("UserSanctions") } model ProfilePicture { @@ -70,17 +72,19 @@ model ClubMembership { isGateKeeper Boolean user User @relation(fields: [userId], references: [id]) gateKeepingRecords Reservation[] @relation("GateKeeping") + sanctionsAwarded SanctionRecord[] @relation("GateKeeperSanctions") } model Band { - id Int @id @default(autoincrement()) - name String - email String? - webPage String? - description String? - genres String[] - members BandMembership[] - reservations Reservation[] + id Int @id @default(autoincrement()) + name String + email String? + webPage String? + description String? + genres String[] + members BandMembership[] + reservations Reservation[] + sanctionRecords SanctionRecord[] @relation("BandSanctions") } model BandMembership { @@ -102,6 +106,7 @@ model Reservation { endTime DateTime gateKeeperId Int? status ReservationStatus + needToBeLetIn Boolean @default(false) band Band? @relation(fields: [bandId], references: [id]) gateKeeper ClubMembership? @relation("GateKeeping", fields: [gateKeeperId], references: [id]) user User? @relation("Reservations", fields: [userId], references: [id]) @@ -119,6 +124,7 @@ model Period { id Int @id @default(autoincrement()) startDate DateTime endDate DateTime + isOpen Boolean @default(false) } model Post { @@ -129,3 +135,27 @@ model Post { authorId Int User User @relation(fields: [authorId], references: [id]) } + +model Settings { + id Int @id @default(autoincrement()) + maxHoursPerWeek Float @default(8.0) + maxHoursPerDay Float @default(4.0) + minReservationMinutes Int @default(30) + maxReservationMinutes Int @default(180) + sanctionHourPenaltyPerPoint Float @default(1.0) +} + +model SanctionRecord { + id Int @id @default(autoincrement()) + userId Int? + bandId Int? + points Int + reason String + awardedBy Int + awardedAt DateTime @default(now()) + + user User? @relation("UserSanctions", fields: [userId], references: [id]) + band Band? @relation("BandSanctions", fields: [bandId], references: [id]) + gateKeeper ClubMembership @relation("GateKeeperSanctions", fields: [awardedBy], references: [id]) +} + diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index bca9681..4e9eeea 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -8,8 +8,11 @@ import { AuthModule } from './auth/auth.module'; import { BandsModule } from './bands/bandsModule'; import { CommentsModule } from './comments/comments.module'; import { MembershipsModule } from './memberships/memberships.module'; +import { PeriodsModule } from './periods/periods.module'; import { PostsModule } from './posts/posts.module'; import { ReservationsModule } from './reservations/reservations.module'; +import { SanctionRecordsModule } from './sanction-records/sanction-records.module'; +import { SettingsModule } from './settings/settings.module'; import { UsersModule } from './users/users.module'; @Module({ @@ -22,6 +25,9 @@ import { UsersModule } from './users/users.module'; AuthModule, MembershipsModule, PostsModule, + SettingsModule, + PeriodsModule, + SanctionRecordsModule, ConfigModule.forRoot(), ], controllers: [AppController], diff --git a/apps/backend/src/bands/bands.service.ts b/apps/backend/src/bands/bands.service.ts index 4d1e44b..5801cae 100644 --- a/apps/backend/src/bands/bands.service.ts +++ b/apps/backend/src/bands/bands.service.ts @@ -24,7 +24,10 @@ export class BandsService { async findOne(id: number): Promise { try { - const res = await this.prisma.band.findUniqueOrThrow({ where: { id } }); + const res = await this.prisma.band.findUniqueOrThrow({ + where: { id }, + include: { members: true }, + }); return res; } catch (error) { throw new NotFoundException('No band found'); diff --git a/apps/backend/src/comments/comments.service.ts b/apps/backend/src/comments/comments.service.ts index 8b2b156..f4421b9 100644 --- a/apps/backend/src/comments/comments.service.ts +++ b/apps/backend/src/comments/comments.service.ts @@ -11,12 +11,22 @@ import { Comment } from './entities/comment.entity'; export class CommentsService { constructor(private readonly prisma: PrismaService) {} - create(createCommentDto: CreateCommentDto) { - return this.prisma.comment.create({ + async create(createCommentDto: CreateCommentDto) { + const comment = await this.prisma.comment.create({ data: { ...createCommentDto, }, }); + + // If the new comment blocks reservations, delete any that overlap its window + if (!createCommentDto.isReservable) { + await this.deleteOverlappingReservations( + new Date(createCommentDto.startTime), + new Date(createCommentDto.endTime) + ); + } + + return comment; } findAll(page?: number, pageSize?: number): Promise> { @@ -65,7 +75,7 @@ export class CommentsService { async update(id: number, updateCommentDto: UpdateCommentDto) { try { - return await this.prisma.comment.update({ + const updated = await this.prisma.comment.update({ where: { id, }, @@ -73,6 +83,13 @@ export class CommentsService { ...updateCommentDto, }, }); + + // If the updated comment now blocks reservations, purge any overlapping ones + if (updated.isReservable === false) { + await this.deleteOverlappingReservations(new Date(updated.startTime), new Date(updated.endTime)); + } + + return updated; } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError) { if (e.code === 'P2025') { @@ -99,4 +116,24 @@ export class CommentsService { } } } + + /** + * Deletes all reservations whose time window overlaps with [startTime, endTime]. + * Called automatically when a non-reservable comment is created or updated. + * A reservation overlaps if it starts before the window ends AND ends after the window starts. + * + * @returns the number of reservations deleted + */ + async deleteOverlappingReservations(startTime: Date, endTime: Date): Promise { + const { count } = await this.prisma.reservation.deleteMany({ + where: { + AND: [ + { startTime: { lt: endTime } }, // reservation starts before comment ends + { endTime: { gt: startTime } }, // reservation ends after comment starts + { status: { not: 'ADMINMADE' } }, // admin reservations are never auto-deleted + ], + }, + }); + return count; + } } diff --git a/apps/backend/src/periods/dto/create-period.dto.ts b/apps/backend/src/periods/dto/create-period.dto.ts new file mode 100644 index 0000000..7bb41b7 --- /dev/null +++ b/apps/backend/src/periods/dto/create-period.dto.ts @@ -0,0 +1,13 @@ +import { IsBoolean, IsDateString, IsOptional } from 'class-validator'; + +export class CreatePeriodDto { + @IsDateString() + startDate: string; + + @IsDateString() + endDate: string; + + @IsOptional() + @IsBoolean() + isOpen?: boolean; +} diff --git a/apps/backend/src/periods/dto/update-period.dto.ts b/apps/backend/src/periods/dto/update-period.dto.ts new file mode 100644 index 0000000..a0eb726 --- /dev/null +++ b/apps/backend/src/periods/dto/update-period.dto.ts @@ -0,0 +1,15 @@ +import { IsBoolean, IsDateString, IsOptional } from 'class-validator'; + +export class UpdatePeriodDto { + @IsOptional() + @IsDateString() + startDate?: string; + + @IsOptional() + @IsDateString() + endDate?: string; + + @IsOptional() + @IsBoolean() + isOpen?: boolean; +} diff --git a/apps/backend/src/periods/periods.controller.ts b/apps/backend/src/periods/periods.controller.ts new file mode 100644 index 0000000..3bac3c8 --- /dev/null +++ b/apps/backend/src/periods/periods.controller.ts @@ -0,0 +1,47 @@ +import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth } from '@nestjs/swagger'; + +import { CreatePeriodDto } from './dto/create-period.dto'; +import { UpdatePeriodDto } from './dto/update-period.dto'; +import { PeriodsService } from './periods.service'; + +@Controller('periods') +export class PeriodsController { + constructor(private readonly periodsService: PeriodsService) {} + + @Post() + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) + create(@Body() createPeriodDto: CreatePeriodDto) { + return this.periodsService.create(createPeriodDto); + } + + @Get() + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) + findAll() { + return this.periodsService.findAll(); + } + + @Get(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) + findOne(@Param('id', ParseIntPipe) id: number) { + return this.periodsService.findOne(id); + } + + @Patch(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) + update(@Param('id', ParseIntPipe) id: number, @Body() updatePeriodDto: UpdatePeriodDto) { + return this.periodsService.update(id, updatePeriodDto); + } + + @Delete(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) + remove(@Param('id', ParseIntPipe) id: number) { + return this.periodsService.remove(id); + } +} diff --git a/apps/backend/src/periods/periods.module.ts b/apps/backend/src/periods/periods.module.ts new file mode 100644 index 0000000..3b4c3d4 --- /dev/null +++ b/apps/backend/src/periods/periods.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { PeriodsController } from './periods.controller'; +import { PeriodsService } from './periods.service'; + +@Module({ + controllers: [PeriodsController], + providers: [PeriodsService], + exports: [PeriodsService], +}) +export class PeriodsModule {} diff --git a/apps/backend/src/periods/periods.service.ts b/apps/backend/src/periods/periods.service.ts new file mode 100644 index 0000000..2a67bd9 --- /dev/null +++ b/apps/backend/src/periods/periods.service.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from 'nestjs-prisma'; + +import { CreatePeriodDto } from './dto/create-period.dto'; +import { UpdatePeriodDto } from './dto/update-period.dto'; + +@Injectable() +export class PeriodsService { + constructor(private prisma: PrismaService) {} + + async create(createPeriodDto: CreatePeriodDto) { + return this.prisma.period.create({ + data: createPeriodDto, + }); + } + + async findAll() { + return this.prisma.period.findMany({ + orderBy: { startDate: 'asc' }, + }); + } + + async findOne(id: number) { + return this.prisma.period.findUnique({ + where: { id }, + }); + } + + async update(id: number, updatePeriodDto: UpdatePeriodDto) { + return this.prisma.period.update({ + where: { id }, + data: updatePeriodDto, + }); + } + + async remove(id: number) { + return this.prisma.period.delete({ + where: { id }, + }); + } +} diff --git a/apps/backend/src/reservations/entities/reservation.entity.ts b/apps/backend/src/reservations/entities/reservation.entity.ts index 6c29282..9efc77f 100644 --- a/apps/backend/src/reservations/entities/reservation.entity.ts +++ b/apps/backend/src/reservations/entities/reservation.entity.ts @@ -1,5 +1,5 @@ import { ReservationStatus } from '@prisma/client'; -import { IsDate, IsEnum, IsNotEmpty, IsNumber, IsOptional } from 'class-validator'; +import { IsBoolean, IsDate, IsEnum, IsNotEmpty, IsNumber, IsOptional } from 'class-validator'; export class Reservation { @IsNotEmpty() @@ -7,8 +7,10 @@ export class Reservation { id: number; @IsNumber() + @IsOptional() userId: number; + @IsOptional() @IsNumber() bandId: number; @@ -24,7 +26,10 @@ export class Reservation { @IsOptional() gateKeeperId: number; - @IsNotEmpty() + @IsOptional() @IsEnum(ReservationStatus) status: ReservationStatus; + + @IsBoolean() + needToBeLetIn: boolean; } diff --git a/apps/backend/src/reservations/reservations.service.ts b/apps/backend/src/reservations/reservations.service.ts index 00f5f9b..37e6a2d 100644 --- a/apps/backend/src/reservations/reservations.service.ts +++ b/apps/backend/src/reservations/reservations.service.ts @@ -1,5 +1,5 @@ -import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; +import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; +import { Prisma, ReservationStatus } from '@prisma/client'; import { PrismaService } from 'nestjs-prisma'; import { PaginationDto } from '../dto/pagination.dto'; @@ -11,12 +11,153 @@ import { Reservation } from './entities/reservation.entity'; export class ReservationsService { constructor(private readonly prisma: PrismaService) {} - create(createReservationDto: CreateReservationDto) { + async create(createReservationDto: CreateReservationDto) { + // Validate the reservation + await this.validateReservation(createReservationDto); + + // Determine status + const status = await this.determineReservationStatus(createReservationDto); + return this.prisma.reservation.create({ data: { ...createReservationDto, + status: createReservationDto.status || status, + }, + }); + } + + private async validateReservation(dto: CreateReservationDto | UpdateReservationDto) { + // Skip validation if times aren't provided (for partial updates) + if (!dto.startTime || !dto.endTime) { + return; + } + + const startTime = new Date(dto.startTime); + const endTime = new Date(dto.endTime); + + // 1. Validate 15-minute intervals + if (startTime.getMinutes() % 15 !== 0 || endTime.getMinutes() % 15 !== 0) { + throw new BadRequestException( + 'Kezdő és befejező időpont 15 perces intervallumokra kerekítve kell legyen (:00, :15, :30, :45)' + ); + } + + // 2. Validate duration (30 min - 3 hours) + const durationMs = endTime.getTime() - startTime.getTime(); + const minDuration = 30 * 60 * 1000; + const maxDuration = 3 * 60 * 60 * 1000; + + if (durationMs < minDuration) { + throw new BadRequestException('Foglalás minimum 30 perc hosszú kell legyen'); + } + if (durationMs > maxDuration) { + throw new BadRequestException('Foglalás maximum 3 óra hosszú lehet'); + } + + // 3. Validate exclusive user OR band (only check for CreateReservationDto) + const hasUserId = 'userId' in dto && dto.userId !== undefined && dto.userId !== null; + const hasBandId = 'bandId' in dto && dto.bandId !== undefined && dto.bandId !== null; + + if (!hasUserId && !hasBandId) { + throw new BadRequestException('Felhasználó vagy banda megadása kötelező'); + } + if (hasUserId && hasBandId) { + throw new BadRequestException('Csak felhasználó VAGY banda adható meg, nem mindkettő'); + } + } + + private async determineReservationStatus(dto: CreateReservationDto): Promise { + // Admin-made reservations keep their ADMINMADE status + if (dto.status === ReservationStatus.ADMINMADE) { + return ReservationStatus.ADMINMADE; + } + + if (!dto.userId) { + return ReservationStatus.NORMAL; // Band reservations default to NORMAL + } + + // Check user sanctions - now calculated from SanctionRecord table + const user = await this.prisma.user.findUnique({ + where: { id: dto.userId }, + include: { + sanctionRecords: true, + }, + }); + + if (!user) { + return ReservationStatus.NORMAL; + } + + // Calculate total sanction points from records + const sanctionPoints = user.sanctionRecords.reduce((sum, record) => sum + record.points, 0); + + if (sanctionPoints > 0) { + return ReservationStatus.SANCTIONED; + } + + // Check quota for overtime + const settings = await this.prisma.settings.findFirst(); + if (!settings) { + return ReservationStatus.NORMAL; + } + + // Get user's reservations this week + const now = new Date(dto.startTime); + const dayOfWeek = (now.getDay() + 6) % 7; // Monday = 0 + const weekStart = new Date(now); + weekStart.setDate(now.getDate() - dayOfWeek); + weekStart.setHours(0, 0, 0, 0); + const weekEnd = new Date(weekStart); + weekEnd.setDate(weekStart.getDate() + 7); + + // Get day boundaries + const dayStart = new Date(now); + dayStart.setHours(0, 0, 0, 0); + const dayEnd = new Date(dayStart); + dayEnd.setDate(dayStart.getDate() + 1); + + const weeklyReservations = await this.prisma.reservation.findMany({ + where: { + userId: dto.userId, + startTime: { gte: weekStart, lt: weekEnd }, + status: { not: ReservationStatus.OVERTIME }, + }, + }); + + const dailyReservations = await this.prisma.reservation.findMany({ + where: { + userId: dto.userId, + startTime: { gte: dayStart, lt: dayEnd }, + status: { not: ReservationStatus.OVERTIME }, }, }); + + const weeklyHours = weeklyReservations.reduce((total, r) => { + const duration = new Date(r.endTime).getTime() - new Date(r.startTime).getTime(); + return total + duration / (1000 * 60 * 60); + }, 0); + + const dailyHours = dailyReservations.reduce((total, r) => { + const duration = new Date(r.endTime).getTime() - new Date(r.startTime).getTime(); + return total + duration / (1000 * 60 * 60); + }, 0); + + const adjustedWeeklyLimit = Math.max( + 0, + settings.maxHoursPerWeek - sanctionPoints * settings.sanctionHourPenaltyPerPoint + ); + const adjustedDailyLimit = Math.max( + 0, + settings.maxHoursPerDay - (sanctionPoints * settings.sanctionHourPenaltyPerPoint) / 2 + ); + const newDuration = (new Date(dto.endTime).getTime() - new Date(dto.startTime).getTime()) / (1000 * 60 * 60); + + // Check both weekly AND daily limits + if (weeklyHours + newDuration > adjustedWeeklyLimit || dailyHours + newDuration > adjustedDailyLimit) { + return ReservationStatus.OVERTIME; + } + + return ReservationStatus.NORMAL; } findAll(page?: number, pageSize?: number): Promise> { @@ -24,6 +165,10 @@ export class ReservationsService { const reservations = this.prisma.reservation.findMany({ skip: hasPagination ? (page - 1) * pageSize : undefined, take: hasPagination ? pageSize : undefined, + include: { + user: true, + band: true, + }, orderBy: { startTime: 'asc', }, @@ -52,6 +197,10 @@ export class ReservationsService { where: { id, }, + include: { + user: true, + band: true, + }, }); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError) { @@ -64,6 +213,23 @@ export class ReservationsService { } async update(id: number, updateReservationDto: UpdateReservationDto) { + const existing = await this.findOne(id); + + // Validate if time fields are being updated + if (updateReservationDto.startTime || updateReservationDto.endTime) { + const dto = { + ...existing, + ...updateReservationDto, + }; + await this.validateReservation(dto); + + // Recalculate status if time or user/band changes + if (dto.userId) { + const newStatus = await this.determineReservationStatus(dto as CreateReservationDto); + updateReservationDto.status = newStatus; + } + } + try { return await this.prisma.reservation.update({ where: { diff --git a/apps/backend/src/sanction-records/dto/create-sanction-record.dto.ts b/apps/backend/src/sanction-records/dto/create-sanction-record.dto.ts new file mode 100644 index 0000000..41f7e7d --- /dev/null +++ b/apps/backend/src/sanction-records/dto/create-sanction-record.dto.ts @@ -0,0 +1,23 @@ +import { IsNotEmpty, IsNumber, IsOptional, IsPositive, IsString } from 'class-validator'; + +export class CreateSanctionRecordDto { + @IsNumber() + @IsOptional() + userId?: number; + + @IsNumber() + @IsOptional() + bandId?: number; + + @IsNumber() + @IsPositive() + points: number; + + @IsString() + @IsNotEmpty() + reason: string; + + @IsNumber() + @IsPositive() + awardedBy: number; +} diff --git a/apps/backend/src/sanction-records/entities/sanction-record.entity.ts b/apps/backend/src/sanction-records/entities/sanction-record.entity.ts new file mode 100644 index 0000000..321d508 --- /dev/null +++ b/apps/backend/src/sanction-records/entities/sanction-record.entity.ts @@ -0,0 +1,29 @@ +import { IsNotEmpty, IsNumber, IsOptional, IsPositive, IsString } from 'class-validator'; + +export class SanctionRecord { + @IsNumber() + @IsPositive() + id: number; + + @IsNumber() + @IsOptional() + userId?: number; + + @IsNumber() + @IsOptional() + bandId?: number; + + @IsNumber() + @IsPositive() + points: number; + + @IsString() + @IsNotEmpty() + reason: string; + + @IsNumber() + @IsPositive() + awardedBy: number; + + awardedAt: Date; +} diff --git a/apps/backend/src/sanction-records/sanction-records.controller.ts b/apps/backend/src/sanction-records/sanction-records.controller.ts new file mode 100644 index 0000000..5438aa5 --- /dev/null +++ b/apps/backend/src/sanction-records/sanction-records.controller.ts @@ -0,0 +1,44 @@ +import { CurrentUser } from '@kir-dev/passport-authsch'; +import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth } from '@nestjs/swagger'; +import { User } from '@prisma/client'; + +import { CreateSanctionRecordDto } from './dto/create-sanction-record.dto'; +import { SanctionRecordsService } from './sanction-records.service'; + +@Controller('sanction-records') +export class SanctionRecordsController { + constructor(private readonly sanctionRecordsService: SanctionRecordsService) {} + + @Post() + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) + async create(@Body() dto: CreateSanctionRecordDto) { + return this.sanctionRecordsService.create(dto); + } + + @Get('me') + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) + async findForCurrentUser(@CurrentUser() user: User) { + return this.sanctionRecordsService.findForCurrentUser(user.authSchId); + } + + @Get('user/:userId') + async findByUserId(@Param('userId', ParseIntPipe) userId: number) { + return this.sanctionRecordsService.findByUserId(userId); + } + + @Get('band/:bandId') + async findByBandId(@Param('bandId', ParseIntPipe) bandId: number) { + return this.sanctionRecordsService.findByBandId(bandId); + } + + @Delete(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) + async delete(@Param('id', ParseIntPipe) id: number) { + return this.sanctionRecordsService.delete(id); + } +} diff --git a/apps/backend/src/sanction-records/sanction-records.module.ts b/apps/backend/src/sanction-records/sanction-records.module.ts new file mode 100644 index 0000000..59c8fd2 --- /dev/null +++ b/apps/backend/src/sanction-records/sanction-records.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { SanctionRecordsController } from './sanction-records.controller'; +import { SanctionRecordsService } from './sanction-records.service'; + +@Module({ + controllers: [SanctionRecordsController], + providers: [SanctionRecordsService], + exports: [SanctionRecordsService], +}) +export class SanctionRecordsModule {} diff --git a/apps/backend/src/sanction-records/sanction-records.service.ts b/apps/backend/src/sanction-records/sanction-records.service.ts new file mode 100644 index 0000000..df49a2f --- /dev/null +++ b/apps/backend/src/sanction-records/sanction-records.service.ts @@ -0,0 +1,158 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from 'nestjs-prisma'; + +import { CreateSanctionRecordDto } from './dto/create-sanction-record.dto'; + +@Injectable() +export class SanctionRecordsService { + constructor(private readonly prisma: PrismaService) {} + + async create(dto: CreateSanctionRecordDto) { + // Validate that either userId or bandId is provided, but not both + if (!dto.userId && !dto.bandId) { + throw new BadRequestException('Either userId or bandId must be provided'); + } + if (dto.userId && dto.bandId) { + throw new BadRequestException('Cannot sanction both user and band at the same time'); + } + + // Verify the gatekeeper exists + const gateKeeper = await this.prisma.clubMembership.findUnique({ + where: { id: dto.awardedBy }, + }); + if (!gateKeeper) { + throw new NotFoundException('GateKeeper not found'); + } + + // Create the sanction record + return this.prisma.sanctionRecord.create({ + data: { + userId: dto.userId, + bandId: dto.bandId, + points: dto.points, + reason: dto.reason, + awardedBy: dto.awardedBy, + }, + include: { + user: true, + band: true, + gateKeeper: { + include: { + user: true, + }, + }, + }, + }); + } + + async findByUserId(userId: number) { + return this.prisma.sanctionRecord.findMany({ + where: { userId }, + include: { + gateKeeper: { + include: { + user: true, + }, + }, + }, + orderBy: { awardedAt: 'desc' }, + }); + } + + async findByBandId(bandId: number) { + return this.prisma.sanctionRecord.findMany({ + where: { bandId }, + include: { + gateKeeper: { + include: { + user: true, + }, + }, + }, + orderBy: { awardedAt: 'desc' }, + }); + } + + async findForCurrentUser(authSchId: string) { + const user = await this.prisma.user.findUnique({ + where: { authSchId }, + include: { + sanctionRecords: { + include: { + gateKeeper: { + include: { + user: true, + }, + }, + }, + orderBy: { awardedAt: 'desc' }, + }, + bandMemberships: { + where: { status: 'ACCEPTED' }, + include: { + band: { + include: { + sanctionRecords: { + include: { + gateKeeper: { + include: { + user: true, + }, + }, + }, + orderBy: { awardedAt: 'desc' }, + }, + }, + }, + }, + }, + }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + // Combine user sanctions with band sanctions + const userSanctions = user.sanctionRecords.map((s) => ({ + ...s, + type: 'user' as const, + })); + + const bandSanctions = user.bandMemberships.flatMap( + (bm) => + bm.band?.sanctionRecords.map((s) => ({ + ...s, + type: 'band' as const, + bandName: bm.band?.name, + })) || [] + ); + + // Combine and sort by date + const allSanctions = [...userSanctions, ...bandSanctions].sort( + (a, b) => new Date(b.awardedAt).getTime() - new Date(a.awardedAt).getTime() + ); + + return { + userSanctions, + bandSanctions, + allSanctions, + totalUserPoints: userSanctions.reduce((sum, s) => sum + s.points, 0), + totalBandPoints: bandSanctions.reduce((sum, s) => sum + s.points, 0), + }; + } + + async delete(id: number) { + const record = await this.prisma.sanctionRecord.findUnique({ + where: { id }, + }); + + if (!record) { + throw new NotFoundException(`Sanction record with id ${id} not found`); + } + + return this.prisma.sanctionRecord.delete({ + where: { id }, + }); + } +} diff --git a/apps/backend/src/settings/dto/update-settings.dto.ts b/apps/backend/src/settings/dto/update-settings.dto.ts new file mode 100644 index 0000000..8d96411 --- /dev/null +++ b/apps/backend/src/settings/dto/update-settings.dto.ts @@ -0,0 +1,28 @@ +import { IsNumber, IsOptional, Min } from 'class-validator'; + +export class UpdateSettingsDto { + @IsOptional() + @IsNumber() + @Min(0) + maxHoursPerWeek?: number; + + @IsOptional() + @IsNumber() + @Min(0) + maxHoursPerDay?: number; + + @IsOptional() + @IsNumber() + @Min(1) + minReservationMinutes?: number; + + @IsOptional() + @IsNumber() + @Min(1) + maxReservationMinutes?: number; + + @IsOptional() + @IsNumber() + @Min(0) + sanctionHourPenaltyPerPoint?: number; +} diff --git a/apps/backend/src/settings/settings.controller.ts b/apps/backend/src/settings/settings.controller.ts new file mode 100644 index 0000000..ca38600 --- /dev/null +++ b/apps/backend/src/settings/settings.controller.ts @@ -0,0 +1,32 @@ +import { Body, Controller, Get, Param, ParseIntPipe, Patch, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth } from '@nestjs/swagger'; + +import { UpdateSettingsDto } from './dto/update-settings.dto'; +import { SettingsService } from './settings.service'; + +@Controller('settings') +export class SettingsController { + constructor(private readonly settingsService: SettingsService) {} + + @Get() + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) + findFirst() { + return this.settingsService.findFirst(); + } + + @Get(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) + findOne(@Param('id', ParseIntPipe) id: number) { + return this.settingsService.findOne(id); + } + + @Patch(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard('jwt')) + update(@Param('id', ParseIntPipe) id: number, @Body() updateSettingsDto: UpdateSettingsDto) { + return this.settingsService.update(id, updateSettingsDto); + } +} diff --git a/apps/backend/src/settings/settings.module.ts b/apps/backend/src/settings/settings.module.ts new file mode 100644 index 0000000..cb39426 --- /dev/null +++ b/apps/backend/src/settings/settings.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { SettingsController } from './settings.controller'; +import { SettingsService } from './settings.service'; + +@Module({ + controllers: [SettingsController], + providers: [SettingsService], + exports: [SettingsService], +}) +export class SettingsModule {} diff --git a/apps/backend/src/settings/settings.service.ts b/apps/backend/src/settings/settings.service.ts new file mode 100644 index 0000000..e8ca6e6 --- /dev/null +++ b/apps/backend/src/settings/settings.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from 'nestjs-prisma'; + +import { UpdateSettingsDto } from './dto/update-settings.dto'; + +@Injectable() +export class SettingsService { + constructor(private prisma: PrismaService) {} + + async findOne(id: number) { + return this.prisma.settings.findUnique({ + where: { id }, + }); + } + + async findFirst() { + // Enforce singleton: Get the first (and should be only) settings record + let settings = await this.prisma.settings.findFirst(); + + // If no settings exist, create default ones (singleton initialization) + if (!settings) { + settings = await this.prisma.settings.create({ + data: { + maxHoursPerWeek: 8.0, + maxHoursPerDay: 4.0, + minReservationMinutes: 30, + maxReservationMinutes: 180, + sanctionHourPenaltyPerPoint: 1.0, + }, + }); + } + + return settings; + } + + async update(id: number, updateSettingsDto: UpdateSettingsDto) { + // Only allow updating the first record to maintain singleton + const existing = await this.findFirst(); + return this.prisma.settings.update({ + where: { id: existing.id }, // Always update the singleton record + data: updateSettingsDto, + }); + } +} diff --git a/apps/frontend/declarations.d.ts b/apps/frontend/declarations.d.ts new file mode 100644 index 0000000..0d95b37 --- /dev/null +++ b/apps/frontend/declarations.d.ts @@ -0,0 +1,3 @@ +// NOTE: The declaration below was injected by `"framer"` +// see https://www.framer.com/docs/guides/handshake for more information. +declare module "https://framer.com/m/*"; diff --git a/apps/frontend/src/app/admin_panel/page.tsx b/apps/frontend/src/app/admin_panel/page.tsx index ec8945c..43d6ad4 100644 --- a/apps/frontend/src/app/admin_panel/page.tsx +++ b/apps/frontend/src/app/admin_panel/page.tsx @@ -1,9 +1,37 @@ -export default function AdminPanel() { +'use client'; +/** + * Example Admin Page + * + * This page demonstrates how to use the withAdminAuth HOC + * to protect admin-only pages. + */ + +import { withAdminAuth } from '@/utils/withAuth'; + +function AdminDashboard() { return ( -
-
-

Admin panel

+
+

Admin Dashboard

+ +
+
+

Settings Management

+

Configure system-wide settings, quotas, and limits

+
+ +
+

Period Management

+

Open or close weeks for reservations

+
+ +
+

Sanction Management

+

Manage user sanction points

+
); } + +// Protect this page - only ADMIN role can access +export default withAdminAuth(AdminDashboard); diff --git a/apps/frontend/src/app/my-reservations/page.tsx b/apps/frontend/src/app/my-reservations/page.tsx new file mode 100644 index 0000000..5edcd22 --- /dev/null +++ b/apps/frontend/src/app/my-reservations/page.tsx @@ -0,0 +1,235 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { useUser } from '@/hooks/useUser'; +import axiosApi from '@/lib/apiSetup'; +import { ClubMembership } from '@/types/member'; +import { Reservation } from '@/types/reservation'; +import { withGatekeeperAuth } from '@/utils/withAuth'; + +function MyReservationsPage() { + const { user } = useUser(); + const [reservations, setReservations] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [sanctionInputs, setSanctionInputs] = useState<{ [key: number]: string }>({}); + const [sanctionReasons, setSanctionReasons] = useState<{ [key: number]: string }>({}); + const [updatingReservation, setUpdatingReservation] = useState(null); + const [myGatekeeperId, setMyGatekeeperId] = useState(null); + + const fetchReservations = async () => { + try { + setLoading(true); + setError(null); + const res = await axiosApi.get('/reservations', { params: { page: -1, page_size: -1 } }); + const allReservations = res.data.data as Reservation[]; + + const gateKeeperQuery = await axiosApi.get('/memberships'); + // Memberships endpoint returns a plain array directly + const memberships = gateKeeperQuery.data || []; + const myGatekeeper = memberships.find((membership: ClubMembership) => membership.userId === user?.id); + setMyGatekeeperId(myGatekeeper?.id || null); + + // Filter for reservations where current user is the gatekeeper + const gateKeeperReservations = allReservations.filter( + (reservation) => reservation.gateKeeper?.id === myGatekeeper?.id + ); + + setReservations(gateKeeperReservations); + } catch (err) { + setError('Nem sikerült betölteni a beengedéseket'); + console.error('Error fetching reservations:', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchReservations(); + }, []); + + const handleSanctionInputChange = (reservationId: number, value: string) => { + setSanctionInputs((prev) => ({ ...prev, [reservationId]: value })); + }; + + const handleReasonChange = (reservationId: number, value: string) => { + setSanctionReasons((prev) => ({ ...prev, [reservationId]: value })); + }; + + const handleAwardSanctionPoints = async (reservation: Reservation) => { + const inputValue = sanctionInputs[reservation.id] || ''; + const pointsToAdd = parseInt(inputValue, 10); + const reason = sanctionReasons[reservation.id] || ''; + + if (isNaN(pointsToAdd) || pointsToAdd <= 0) { + alert('Kérlek adj meg egy pozitív egész számot!'); + return; + } + + if (!reason.trim()) { + alert('Kérlek add meg az indoklást!'); + return; + } + + if (!myGatekeeperId) { + alert('Nem található a beengedő azonosító'); + return; + } + + // Determine if this is a user or band sanction + const isUserReservation = Boolean(reservation.user?.id); + const isBandReservation = Boolean(reservation.band?.id); + + if (!isUserReservation && !isBandReservation) { + alert('Nem található felhasználó vagy banda a foglaláshoz'); + return; + } + + try { + setUpdatingReservation(reservation.id); + + // Use the new sanction-records API + await axiosApi.post('/sanction-records', { + userId: isUserReservation ? reservation.user?.id : undefined, + bandId: isBandReservation ? reservation.band?.id : undefined, + points: pointsToAdd, + reason: reason, + awardedBy: myGatekeeperId, + }); + + // Clear inputs and refresh reservations + setSanctionInputs((prev) => ({ ...prev, [reservation.id]: '' })); + setSanctionReasons((prev) => ({ ...prev, [reservation.id]: '' })); + await fetchReservations(); + + const targetName = isUserReservation ? reservation.user?.fullName : reservation.band?.name; + alert(`Sikeresen ${pointsToAdd} szankciós pontot adtál ${targetName} számára!\nIndoklás: ${reason}`); + } catch (err) { + console.error('Error awarding sanction points:', err); + alert('Hiba történt a szankciós pontok hozzáadása során'); + } finally { + setUpdatingReservation(null); + } + }; + + const formatDateTime = (date: Date | string) => { + return new Date(date).toLocaleString('hu-HU', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + }; + + const getStatusLabel = (status: string) => { + const labels: { [key: string]: string } = { + NORMAL: 'Normál', + OVERTIME: 'Túlóra', + ADMINMADE: 'Admin által létrehozva', + SANCTIONED: 'Szankcionált', + }; + return labels[status] || status; + }; + + if (loading) { + return ( +
+

Beengedéseim

+

Betöltés...

+
+ ); + } + + if (error) { + return ( +
+

Beengedéseim

+

{error}

+
+ ); + } + + return ( +
+
+

Beengedéseim

+
+ +
+ {reservations.length === 0 ? ( +

Nem található beengedésed

+ ) : ( +
+ {reservations.map((reservation) => { + const reserverName = reservation.user?.fullName || reservation.band?.name || 'Ismeretlen'; + const isBandReservation = !reservation.user; + + return ( + + + {reserverName} + + + {getStatusLabel(reservation.status)} + + + + + +
+

Kezdés:

+

{formatDateTime(reservation.startTime)}

+
+
+

Befejezés:

+

{formatDateTime(reservation.endTime)}

+
+
+ + +
+ handleSanctionInputChange(reservation.id, e.target.value)} + className='w-32 px-3 py-2 text-sm border border-orange-500 rounded-md focus:outline-none focus:ring-2 focus:ring-orange-500 dark:bg-zinc-800' + disabled={updatingReservation === reservation.id} + /> + handleReasonChange(reservation.id, e.target.value)} + className='flex-1 px-3 py-2 text-sm border border-orange-500 rounded-md focus:outline-none focus:ring-2 focus:ring-orange-500 dark:bg-zinc-800' + disabled={updatingReservation === reservation.id} + /> + +
+
+
+ ); + })} +
+ )} +
+
+ ); +} + +export default withGatekeeperAuth(MyReservationsPage); diff --git a/apps/frontend/src/app/profile/page.tsx b/apps/frontend/src/app/profile/page.tsx new file mode 100644 index 0000000..9260baa --- /dev/null +++ b/apps/frontend/src/app/profile/page.tsx @@ -0,0 +1,174 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { useUser } from '@/hooks/useUser'; +import axiosApi from '@/lib/apiSetup'; +import { withAuth } from '@/utils/withAuth'; + +type SanctionRecordWithDetails = { + id: number; + userId?: number; + bandId?: number; + points: number; + reason: string; + awardedBy: number; + awardedAt: string; + type: 'user' | 'band'; + bandName?: string; + gateKeeper?: { + user?: { + fullName: string; + }; + }; +}; + +type SanctionData = { + userSanctions: SanctionRecordWithDetails[]; + bandSanctions: SanctionRecordWithDetails[]; + allSanctions: SanctionRecordWithDetails[]; + totalUserPoints: number; + totalBandPoints: number; +}; + +function ProfilePage() { + const { user } = useUser(); + const [sanctionData, setSanctionData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchSanctions = async () => { + try { + setLoading(true); + const res = await axiosApi.get('/sanction-records/me'); + setSanctionData(res.data); + } catch (err) { + console.error('Error fetching sanctions:', err); + setError('Nem sikerült betölteni a szankciós előzményeket'); + } finally { + setLoading(false); + } + }; + + fetchSanctions(); + }, []); + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleString('hu-HU', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + }; + + if (loading) { + return ( +
+

Profilom

+

Betöltés...

+
+ ); + } + + if (error) { + return ( +
+

Profilom

+

{error}

+
+ ); + } + + const totalPoints = (sanctionData?.totalUserPoints || 0) + (sanctionData?.totalBandPoints || 0); + + return ( +
+
+

Profilom

+
+ +
+ {/* User Info Card */} + + + {user?.fullName} + {user?.email} + + +
+
+

+ {sanctionData?.totalUserPoints || 0} +

+

Saját szankciós pont

+
+
+

+ {sanctionData?.totalBandPoints || 0} +

+

Banda szankciós pont

+
+
+

{totalPoints}

+

Összes pont

+
+
+
+
+ + {/* Sanction History */} + + + Szankciós előzmények + A kapott szankciós pontok és indoklásaik + + + {sanctionData?.allSanctions.length === 0 ? ( +

Nincs szankciós előzmény

+ ) : ( + + + + Dátum + Típus + Pont + Indoklás + Beengedő + + + + {sanctionData?.allSanctions.map((sanction) => ( + + {formatDate(sanction.awardedAt)} + + + {sanction.type === 'user' ? 'Saját' : sanction.bandName || 'Banda'} + + + {sanction.points} + {sanction.reason} + {sanction.gateKeeper?.user?.fullName || 'Ismeretlen'} + + ))} + +
+ )} +
+
+
+
+ ); +} + +export default withAuth(ProfilePage); diff --git a/apps/frontend/src/app/reservation/page.tsx b/apps/frontend/src/app/reservation/page.tsx index d7910cf..a0011a3 100644 --- a/apps/frontend/src/app/reservation/page.tsx +++ b/apps/frontend/src/app/reservation/page.tsx @@ -1,9 +1,21 @@ +'use client'; + import Calendar from '@components/calendar/calendar'; +import { useRouter } from 'next/navigation'; + +import { Button } from '@/components/ui/button'; export default function ReservationPage() { + const router = useRouter(); + return (
-

Foglalások

+
+

Foglalások

+ +
); diff --git a/apps/frontend/src/app/stats/page.tsx b/apps/frontend/src/app/stats/page.tsx index 7680111..fc1df17 100644 --- a/apps/frontend/src/app/stats/page.tsx +++ b/apps/frontend/src/app/stats/page.tsx @@ -8,13 +8,14 @@ import axiosApi from '@/lib/apiSetup'; import { ClubMembership } from '@/types/member'; import { Reservation } from '@/types/reservation'; import { User } from '@/types/user'; +import { withGatekeeperAuth } from '@/utils/withAuth'; function getCurrentPeriodStart() { const now = new Date(); return new Date(now.getFullYear(), now.getMonth(), 1); } -export default function Stats() { +function Stats() { const { user: me } = useUser(); const [loading, setLoading] = useState(true); @@ -153,3 +154,5 @@ export default function Stats() {
); } + +export default withGatekeeperAuth(Stats); diff --git a/apps/frontend/src/components/calendar/add-panel.tsx b/apps/frontend/src/components/calendar/add-panel.tsx index 9feb912..7da66d1 100644 --- a/apps/frontend/src/components/calendar/add-panel.tsx +++ b/apps/frontend/src/components/calendar/add-panel.tsx @@ -55,7 +55,7 @@ export function AddPanel(props: AddEventProps) {

- {selected === 'reservation' ? 'Új foglalás létrehozása' : 'Új komment hozzáadása'} + {selected === 'reservation' ? 'Új foglalás létrehozása' : 'Új felhívás hozzáadása'}

@@ -77,7 +77,7 @@ export function AddPanel(props: AddEventProps) { : 'bg-white hover:bg-slate-200 dark:bg-zinc-700 dark:hover:bg-zinc-600 text-black dark:text-zinc-200' }`} > - Komment + Felhívás
diff --git a/apps/frontend/src/components/calendar/add-reservation.tsx b/apps/frontend/src/components/calendar/add-reservation.tsx index 81a4f3f..82a643b 100644 --- a/apps/frontend/src/components/calendar/add-reservation.tsx +++ b/apps/frontend/src/components/calendar/add-reservation.tsx @@ -1,7 +1,7 @@ -import { Input } from '@components/ui/input'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useUser } from '@/hooks/useUser'; +import { useWeekStatus } from '@/hooks/useWeekStatus'; import axiosApi from '@/lib/apiSetup'; import { submitReservation } from '@/lib/reservationSubmitter'; import { Band } from '@/types/band'; @@ -20,13 +20,13 @@ interface AddPanelProps { export default function AddReservation(props: AddPanelProps) { const { user: myUser, refetch: refetchUser } = useUser(); - const [bandName, setBandName] = useState(''); const [bands, setBands] = useState([]); - const [band, setBand] = useState(); - - const [userName, setUserName] = useState(''); const [users, setUsers] = useState([]); + + // Exclusive user OR band selection + const [selectedValue, setSelectedValue] = useState(''); const [user, setUser] = useState(); + const [band, setBand] = useState(); // Initialize with rounded times (15-minute intervals) const getRoundedDate = () => { @@ -41,83 +41,51 @@ export default function AddReservation(props: AddPanelProps) { const [startTime, setStartTime] = useState(getRoundedDate()); const [endTime, setEndTime] = useState(getRoundedDate()); - const [userSuggestions, setUserSuggestions] = useState([]); - const [bandSuggestions, setBandSuggestions] = useState([]); - const [showUserSuggestions, setShowUserSuggestions] = useState(false); - const [showBandSuggestions, setShowBandSuggestions] = useState(false); - const suggestionRef = useRef(null); - const [valid, setValid] = useState(true); const [errorMessage, setErrorMessage] = useState(''); + const [needToBeLetIn, setNeedToBeLetIn] = useState(false); const [adminOverride, setAdminOverride] = useState(false); + const [userBands, setUserBands] = useState([]); - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (suggestionRef.current && !suggestionRef.current.contains(event.target as Node)) { - setShowUserSuggestions(false); - setShowBandSuggestions(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); + // Check whether the week containing startTime is open for reservations (issue #58) + const weekStatus = useWeekStatus(startTime); + const weekIsLocked = !weekStatus.isOpen && myUser?.role !== 'ADMIN'; useEffect(() => { - axiosApi.get('/bands').then((res) => { - setBands(res.data); - }); - axiosApi.get('/users').then((res) => { - setUsers(res.data.users); - }); + axiosApi.get('/bands').then((res) => setBands(res.data)); + axiosApi.get('/users').then((res) => setUsers(res.data.users)); refetchUser(); - }, []); + }, [myUser]); - const handleBandNameChange = (e: React.ChangeEvent) => { - const value = e.target.value; - setBandName(value); - if (value.length > 0) { - const filteredSuggestions = bands.filter((band) => band.name.toLowerCase().includes(value.toLowerCase())); - setBandSuggestions(filteredSuggestions); - setShowBandSuggestions(true); - } else { - setBandSuggestions([]); - setShowBandSuggestions(false); + // Filter bands to only show user's bands (based on membership) + useEffect(() => { + if (myUser && bands.length > 0) { + const myBandIds = + myUser.role === 'ADMIN' + ? bands // Admins see all bands + : bands.filter((band) => band.members?.some((member) => member.userId === myUser.id)); + setUserBands(myBandIds); } - }; + }, [myUser, bands]); - const handleUserNameChange = (e: React.ChangeEvent) => { - if (!Array.isArray(users)) { - console.error('HIBA: users nem egy tömb!', users); - return; - } + const handleSelectionChange = (e: React.ChangeEvent) => { const value = e.target.value; - setUserName(value); - if (value.length > 0) { - const filteredSuggestions = users.filter((user) => user.fullName.toLowerCase().includes(value.toLowerCase())); - setUserSuggestions(filteredSuggestions); - setShowUserSuggestions(true); - } else { - setUserSuggestions([]); - setShowUserSuggestions(false); + setSelectedValue(value); + + if (value.startsWith('user-')) { + const userId = parseInt(value.replace('user-', '')); + const selectedUser = users.find((u) => u.id === userId); + setUser(selectedUser); + setBand(undefined); + } else if (value.startsWith('band-')) { + const bandId = parseInt(value.replace('band-', '')); + const selectedBand = bands.find((b) => b.id === bandId); + setBand(selectedBand); + setUser(undefined); } }; - const handleUserSuggestionClick = (suggestion: User) => { - setUser(suggestion); - setUserName(suggestion.fullName); - setShowUserSuggestions(false); - }; - - const handleBandSuggestionClick = (suggestion: Band) => { - setBand(suggestion); - setBandName(suggestion.name); - setShowBandSuggestions(false); - }; - const handleSubmit = async () => { const { message } = await submitReservation({ user, @@ -130,100 +98,86 @@ export default function AddReservation(props: AddPanelProps) { props.onGetData(); setUser(undefined); setBand(undefined); - setBandName(''); - setUserName(''); - setStartTime(new Date()); - setEndTime(new Date()); + setSelectedValue(''); + setStartTime(getRoundedDate()); + setEndTime(getRoundedDate()); setAdminOverride(false); props.onAddEvent(); }, setValid, adminOverride, + needToBeLetIn, }); if (message) { setErrorMessage(message); } }; - // For add-reservation.tsx - replace the return part with: return (
- {myUser?.role === 'ADMIN' ? ( - <> -
- -
- - {showUserSuggestions && userSuggestions.length > 0 && ( -
- {userSuggestions.map((suggestion) => ( - - ))} -
- )} -
-
- -
- - setAdminOverride(e.target.checked)} - className='h-4 w-4 accent-orange-500' - /> -
- - ) : null} - + {/* User OR Band Selection */}
-