booking-flow-finalized-design kindaaaa #7

Merged
jare2473 merged 20 commits from booking-flow-finalized-design into main 2025-09-30 10:50:54 +02:00
59 changed files with 4813 additions and 763 deletions

2
my-app/.gitignore vendored
View File

@@ -29,3 +29,5 @@ storybook-static
# Font files
public/caecilia/
public/the-sans/
deploy.sh

View File

@@ -0,0 +1,492 @@
{
"date": "2025-09-29",
"dayName": "måndag",
"bookings": [
{
"id": 1,
"room": "G10:1",
"time": "08:00-10:00",
"startTime": 0,
"endTime": 4,
"title": "study",
"details": "tid: 08-10"
},
{
"id": 2,
"room": "G10:2",
"time": "09:00-13:00",
"startTime": 2,
"endTime": 10,
"title": "REQ",
"details": "tid: 09-13"
},
{
"id": 3,
"room": "G10:3",
"time": "10:00-12:00",
"startTime": 4,
"endTime": 8,
"title": "plugg",
"details": "tid: 10-12"
},
{
"id": 4,
"room": "G10:4",
"time": "09:00-13:00",
"startTime": 2,
"endTime": 10,
"title": "Välkommen välkommen",
"details": "tid: 09-13"
},
{
"id": 5,
"room": "G10:5",
"time": "13:00-17:00",
"startTime": 10,
"endTime": 18,
"title": "REQ",
"details": "tid: 13-17"
},
{
"id": 6,
"room": "G10:6",
"time": "13:00-14:00",
"startTime": 10,
"endTime": 12,
"title": "FEEWC",
"details": "tid: 13-14"
},
{
"id": 7,
"room": "G10:6",
"time": "14:00-16:00",
"startTime": 12,
"endTime": 16,
"title": "metod",
"details": "tid: 14-16"
},
{
"id": 8,
"room": "G10:7",
"time": "10:00-13:00",
"startTime": 4,
"endTime": 10,
"title": "Project Management Team 5",
"details": "tid: 10-13"
},
{
"id": 9,
"room": "G10:7",
"time": "14:00-18:00",
"startTime": 12,
"endTime": 20,
"title": "Tenta plugg",
"details": "tid: 14-18"
},
{
"id": 10,
"room": "G5:1",
"time": "10:00-12:00",
"startTime": 4,
"endTime": 8,
"title": "Plugg G25",
"details": "tid: 10-12"
},
{
"id": 11,
"room": "G5:10",
"time": "08:00-11:00",
"startTime": 0,
"endTime": 6,
"title": "Grupp 43A",
"details": "tid: 08-11"
},
{
"id": 12,
"room": "G5:11",
"time": "11:00-15:00",
"startTime": 6,
"endTime": 14,
"title": "Plottest",
"details": "tid: 11-15"
},
{
"id": 13,
"room": "G5:12",
"time": "08:00-10:00",
"startTime": 0,
"endTime": 4,
"title": "Study",
"details": "tid: 08-10"
},
{
"id": 14,
"room": "G5:12",
"time": "12:00-14:00",
"startTime": 8,
"endTime": 12,
"title": "metod",
"details": "tid: 12-14"
},
{
"id": 15,
"room": "G5:13",
"time": "09:00-13:00",
"startTime": 2,
"endTime": 10,
"title": "DSB Grupp 25 krav intervju",
"details": "tid: 09-13"
},
{
"id": 16,
"room": "G5:13",
"time": "13:00-15:00",
"startTime": 10,
"endTime": 14,
"title": "Project Group meeting",
"details": "tid: 13-15"
},
{
"id": 17,
"room": "G5:13",
"time": "15:00-17:00",
"startTime": 14,
"endTime": 18,
"title": "Examensarbete",
"details": "tid: 15-17"
},
{
"id": 18,
"room": "G5:14",
"time": "09:00-12:00",
"startTime": 2,
"endTime": 8,
"title": "KP Plugg Krav",
"details": "tid: 09-12"
},
{
"id": 19,
"room": "G5:14",
"time": "12:00-16:00",
"startTime": 8,
"endTime": 16,
"title": "REQ Grupp 5",
"details": "tid: 12-16"
},
{
"id": 20,
"room": "G5:14",
"time": "16:00-17:00",
"startTime": 16,
"endTime": 18,
"title": "Metod g6",
"details": "tid: 16-17"
},
{
"id": 21,
"room": "G5:15",
"time": "09:00-14:00",
"startTime": 2,
"endTime": 12,
"title": "Krav grupp",
"details": "tid: 09-14"
},
{
"id": 22,
"room": "G5:15",
"time": "13:00-14:00",
"startTime": 10,
"endTime": 12,
"title": "Stats",
"details": "tid: 13-14"
},
{
"id": 23,
"room": "G5:15",
"time": "14:00-17:00",
"startTime": 12,
"endTime": 18,
"title": "Plugg TRIO",
"details": "tid: 14-17"
},
{
"id": 24,
"room": "G5:16",
"time": "11:00-13:00",
"startTime": 6,
"endTime": 10,
"title": "plugggg",
"details": "tid: 11-13"
},
{
"id": 25,
"room": "G5:17",
"time": "09:00-12:00",
"startTime": 2,
"endTime": 8,
"title": "Experiment KP G4",
"details": "tid: 09-12"
},
{
"id": 26,
"room": "G5:17",
"time": "14:00-17:00",
"startTime": 12,
"endTime": 18,
"title": "plugg",
"details": "tid: 14-17"
},
{
"id": 27,
"room": "G5:2",
"time": "12:00-16:00",
"startTime": 8,
"endTime": 16,
"title": "Grups testning",
"details": "tid: 12-16"
},
{
"id": 28,
"room": "G5:3",
"time": "09:00-12:00",
"startTime": 2,
"endTime": 8,
"title": "Experiment KP G4",
"details": "tid: 09-12"
},
{
"id": 29,
"room": "G5:3",
"time": "10:00-14:00",
"startTime": 4,
"endTime": 12,
"title": "KRAV meeting",
"details": "tid: 10-14"
},
{
"id": 30,
"room": "G5:4",
"time": "14:00-16:00",
"startTime": 12,
"endTime": 16,
"title": "P",
"details": "tid: 14-16"
},
{
"id": 31,
"room": "G5:5",
"time": "10:00-14:00",
"startTime": 4,
"endTime": 12,
"title": "msb METOD 38",
"details": "tid: 10-14"
},
{
"id": 32,
"room": "G5:6",
"time": "10:00-13:00",
"startTime": 4,
"endTime": 10,
"title": "Metod gr 50 REQ Group 5",
"details": "tid: 10-13"
},
{
"id": 33,
"room": "G5:6",
"time": "11:00-13:00",
"startTime": 6,
"endTime": 10,
"title": "REQ Group 5",
"details": "tid: 11-13"
},
{
"id": 34,
"room": "G5:7",
"time": "13:00-15:00",
"startTime": 10,
"endTime": 14,
"title": "LOCKED IN KRAV",
"details": "tid: 13-15"
},
{
"id": 35,
"room": "G5:8",
"time": "13:00-17:00",
"startTime": 10,
"endTime": 18,
"title": "REQ Study",
"details": "tid: 13-17"
},
{
"id": 36,
"room": "G5:8",
"time": "15:00-18:00",
"startTime": 14,
"endTime": 20,
"title": "plugg",
"details": "tid: 15-18"
},
{
"id": 37,
"room": "G5:9",
"time": "13:00-15:00",
"startTime": 10,
"endTime": 14,
"title": "Study",
"details": "tid: 13-15"
},
{
"id": 38,
"room": "G5:9",
"time": "14:00-18:00",
"startTime": 12,
"endTime": 20,
"title": "Stats Plugg",
"details": "tid: 14-18"
},
{
"id": 39,
"room": "G10:1",
"time": "10:00-13:00",
"startTime": 4,
"endTime": 10,
"title": "Test KP grupp 1",
"details": "tid: 10-13"
},
{
"id": 40,
"room": "G10:1",
"time": "12:00-15:00",
"startTime": 8,
"endTime": 14,
"title": "METOD grupp 37",
"details": "tid: 12-15"
},
{
"id": 41,
"room": "G10:1",
"time": "13:00-15:00",
"startTime": 10,
"endTime": 14,
"title": "G25 Krav Group meeting",
"details": "tid: 13-15"
},
{
"id": 42,
"room": "G10:1",
"time": "15:00-16:00",
"startTime": 14,
"endTime": 16,
"title": "IS plugg session",
"details": "tid: 15-16"
},
{
"id": 43,
"room": "G10:2",
"time": "15:00-17:00",
"startTime": 14,
"endTime": 18,
"title": "ERP dsb test",
"details": "tid: 15-17"
},
{
"id": 44,
"room": "G10:4",
"time": "13:00-15:00",
"startTime": 10,
"endTime": 14,
"title": "Group L",
"details": "tid: 13-15"
},
{
"id": 45,
"room": "G10:5",
"time": "17:00-21:00",
"startTime": 18,
"endTime": 26,
"title": "Kati se getires :)",
"details": "tid: 17-21"
},
{
"id": 46,
"room": "G5:11",
"time": "13:00-15:00",
"startTime": 10,
"endTime": 14,
"title": "Meeting",
"details": "tid: 13-15"
},
{
"id": 47,
"room": "G5:12",
"time": "10:00-12:00",
"startTime": 4,
"endTime": 8,
"title": "Grupp",
"details": "tid: 10-12"
},
{
"id": 48,
"room": "G5:13",
"time": "15:00-17:00",
"startTime": 14,
"endTime": 18,
"title": "Sifu",
"details": "tid: 15-17"
},
{
"id": 49,
"room": "G5:14",
"time": "14:00-17:00",
"startTime": 12,
"endTime": 18,
"title": "Plugg TRIO",
"details": "tid: 14-17"
},
{
"id": 50,
"room": "G5:15",
"time": "15:00-17:00",
"startTime": 14,
"endTime": 18,
"title": "working",
"details": "tid: 15-17"
},
{
"id": 51,
"room": "G5:16",
"time": "14:00-17:00",
"startTime": 12,
"endTime": 18,
"title": "IS 1 session",
"details": "tid: 14-17"
},
{
"id": 52,
"room": "G10:1",
"time": "17:00-20:00",
"startTime": 18,
"endTime": 24,
"title": "SUPCOM DB",
"details": "tid: 17-20"
},
{
"id": 53,
"room": "G5:12",
"time": "16:00-16:00",
"startTime": 16,
"endTime": 16,
"title": "DIFO grupp 43",
"details": "tid: 12-16"
},
{
"id": 54,
"room": "G5:11",
"time": "15:00-18:00",
"startTime": 14,
"endTime": 20,
"title": "Projektarbete Meeting",
"details": "tid: 15-18"
}
]
}

View File

@@ -0,0 +1,447 @@
{
"date": "2025-09-30",
"dayName": "tisdag",
"bookings": [
{
"id": 1,
"room": "G10:1",
"time": "08:00-12:00",
"startTime": 0,
"endTime": 8,
"title": "välkommen 2",
"details": "tid: 08-12"
},
{
"id": 2,
"room": "G10:2",
"time": "09:00-13:00",
"startTime": 2,
"endTime": 10,
"title": "Grupp 1",
"details": "tid: 09-13"
},
{
"id": 3,
"room": "G10:3",
"time": "08:00-12:00",
"startTime": 0,
"endTime": 8,
"title": "Välkommen",
"details": "tid: 08-12"
},
{
"id": 4,
"room": "G10:4",
"time": "09:00-13:00",
"startTime": 2,
"endTime": 10,
"title": "Plugg - LOGIK & SAK1",
"details": "tid: 09-13"
},
{
"id": 5,
"room": "G10:5",
"time": "10:00-14:00",
"startTime": 4,
"endTime": 12,
"title": "KRAV grupp 15a",
"details": "tid: 10-14"
},
{
"id": 6,
"room": "G10:6",
"time": "10:00-11:00",
"startTime": 4,
"endTime": 6,
"title": "Läsgrupp 8",
"details": "tid: 10-11"
},
{
"id": 7,
"room": "G10:6",
"time": "11:00-15:00",
"startTime": 6,
"endTime": 14,
"title": "p",
"details": "tid: 11-15"
},
{
"id": 8,
"room": "G10:7",
"time": "09:00-12:00",
"startTime": 2,
"endTime": 8,
"title": "Introduktion Kreativ Bokföring",
"details": "tid: 09-12"
},
{
"id": 9,
"room": "G10:7",
"time": "15:00-17:00",
"startTime": 14,
"endTime": 18,
"title": "G25 Krav",
"details": "tid: 15-17"
},
{
"id": 10,
"room": "G5:1",
"time": "09:00-11:00",
"startTime": 2,
"endTime": 6,
"title": "Drop-in studieavvägledning",
"details": "tid: 09-30-11"
},
{
"id": 11,
"room": "G5:10",
"time": "09:00-11:00",
"startTime": 2,
"endTime": 6,
"title": "Grou 05a",
"details": "tid: 09-11"
},
{
"id": 12,
"room": "G5:10",
"time": "11:00-15:00",
"startTime": 6,
"endTime": 14,
"title": "Ej bokningsbar DIFO labb",
"details": "tid: 11-11:30, tid: 11-15"
},
{
"id": 13,
"room": "G5:11",
"time": "11:00-15:00",
"startTime": 6,
"endTime": 14,
"title": "KRAVlösa",
"details": "tid: 11-15"
},
{
"id": 14,
"room": "G5:12",
"time": "09:00-12:00",
"startTime": 2,
"endTime": 8,
"title": "REQ Group 5",
"details": "tid: 09-12"
},
{
"id": 15,
"room": "G5:12",
"time": "12:00-16:00",
"startTime": 8,
"endTime": 16,
"title": "Grupp 34 Projektarbete",
"details": "tid: 12-16"
},
{
"id": 16,
"room": "G5:13",
"time": "10:00-12:00",
"startTime": 4,
"endTime": 8,
"title": "Metod 46",
"details": "tid: 10-12"
},
{
"id": 17,
"room": "G5:14",
"time": "09:00-10:00",
"startTime": 2,
"endTime": 4,
"title": "ERP Group work",
"details": "tid: 09-10"
},
{
"id": 18,
"room": "G5:14",
"time": "10:00-14:00",
"startTime": 4,
"endTime": 12,
"title": "metod",
"details": "tid: 10-14"
},
{
"id": 19,
"room": "G5:14",
"time": "14:00-15:00",
"startTime": 12,
"endTime": 14,
"title": "Group AE",
"details": "tid: 14-15"
},
{
"id": 20,
"room": "G5:15",
"time": "09:00-13:00",
"startTime": 2,
"endTime": 10,
"title": "Plugg TRIO",
"details": "tid: 09-13"
},
{
"id": 21,
"room": "G5:15",
"time": "14:00-17:00",
"startTime": 12,
"endTime": 18,
"title": "Plugg TRIO",
"details": "tid: 14-17"
},
{
"id": 22,
"room": "G5:16",
"time": "09:00-12:00",
"startTime": 2,
"endTime": 8,
"title": "User testing",
"details": "tid: 09-12"
},
{
"id": 23,
"room": "G5:17",
"time": "10:00-12:00",
"startTime": 4,
"endTime": 8,
"title": "sah",
"details": "tid: 10-12"
},
{
"id": 24,
"room": "G5:17",
"time": "12:00-16:00",
"startTime": 8,
"endTime": 16,
"title": "plugg KRAV projektarbete",
"details": "tid: 12-16, tid: 12-13"
},
{
"id": 25,
"room": "G5:17",
"time": "13:00-16:00",
"startTime": 10,
"endTime": 16,
"title": "Krav",
"details": "tid: 13-16"
},
{
"id": 26,
"room": "G5:2",
"time": "10:00-14:00",
"startTime": 4,
"endTime": 12,
"title": "APID G4 Experiment",
"details": "tid: 10-14"
},
{
"id": 27,
"room": "G5:2",
"time": "11:00-15:00",
"startTime": 6,
"endTime": 14,
"title": "Group 8",
"details": "tid: 11-15"
},
{
"id": 28,
"room": "G5:2",
"time": "14:00-15:00",
"startTime": 12,
"endTime": 14,
"title": "Sonic",
"details": "tid: 14-15"
},
{
"id": 29,
"room": "G5:2",
"time": "15:00-19:00",
"startTime": 14,
"endTime": 22,
"title": "plugg",
"details": "tid: 15-19"
},
{
"id": 30,
"room": "G5:3",
"time": "09:00-13:00",
"startTime": 2,
"endTime": 10,
"title": "plugg",
"details": "tid: 09-13"
},
{
"id": 31,
"room": "G5:4",
"time": "10:00-13:00",
"startTime": 4,
"endTime": 10,
"title": "msb IS1 Group 41",
"details": "tid: 10-13"
},
{
"id": 32,
"room": "G5:4",
"time": "11:00-15:00",
"startTime": 6,
"endTime": 14,
"title": "Plugg",
"details": "tid: 11-15"
},
{
"id": 33,
"room": "G5:5",
"time": "10:00-12:00",
"startTime": 4,
"endTime": 8,
"title": "Stats",
"details": "tid: 10-12"
},
{
"id": 34,
"room": "G5:5",
"time": "12:00-13:00",
"startTime": 8,
"endTime": 10,
"title": "plugg",
"details": "tid: 12-13"
},
{
"id": 35,
"room": "G5:6",
"time": "13:00-15:00",
"startTime": 10,
"endTime": 14,
"title": "MAJO assignemnt",
"details": "tid: 13-15"
},
{
"id": 36,
"room": "G5:6",
"time": "13:00-17:00",
"startTime": 10,
"endTime": 18,
"title": "plugg Group 21",
"details": "tid: 13-17, tid: 13-15"
},
{
"id": 37,
"room": "G5:7",
"time": "14:00-16:00",
"startTime": 12,
"endTime": 16,
"title": "Stats",
"details": "tid: 14-16"
},
{
"id": 38,
"room": "G10:1",
"time": "12:00-16:00",
"startTime": 8,
"endTime": 16,
"title": "plugg",
"details": "tid: 12-16"
},
{
"id": 39,
"room": "G10:2",
"time": "13:00-16:00",
"startTime": 10,
"endTime": 16,
"title": "KRAV grupp 33A",
"details": "tid: 12-16"
},
{
"id": 40,
"room": "G10:3",
"time": "15:00-17:00",
"startTime": 14,
"endTime": 18,
"title": "pluggiluring :D",
"details": "tid: 15-17"
},
{
"id": 41,
"room": "G10:4",
"time": "13:00-15:00",
"startTime": 10,
"endTime": 14,
"title": "G25 Krav",
"details": "tid: 13-15"
},
{
"id": 42,
"room": "G10:5",
"time": "14:00-18:00",
"startTime": 12,
"endTime": 20,
"title": "KRAV Plugg",
"details": "tid: 14-18"
},
{
"id": 43,
"room": "G10:6",
"time": "15:00-18:00",
"startTime": 14,
"endTime": 20,
"title": "TinyML project work",
"details": "tid: 15-18"
},
{
"id": 44,
"room": "G10:7",
"time": "15:00-18:00",
"startTime": 14,
"endTime": 20,
"title": "ERP Meeting",
"details": "tid: 15-18"
},
{
"id": 45,
"room": "G10:1",
"time": "16:00-19:00",
"startTime": 16,
"endTime": 22,
"title": "Plugg",
"details": "tid: 16-19"
},
{
"id": 46,
"room": "G5:11",
"time": "12:00-16:00",
"startTime": 8,
"endTime": 16,
"title": "Krav grupp grupparbete",
"details": "tid: 12-16"
},
{
"id": 47,
"room": "G5:12",
"time": "12:00-16:00",
"startTime": 8,
"endTime": 16,
"title": "HUGO",
"details": "tid: 12-16"
},
{
"id": 48,
"room": "G5:13",
"time": "13:00-14:00",
"startTime": 10,
"endTime": 12,
"title": "MAJO AE",
"details": "tid: 13-14"
},
{
"id": 49,
"room": "G5:8",
"time": "13:00-15:00",
"startTime": 10,
"endTime": 14,
"title": "IS1 plugg Group 21",
"details": "tid: 13-15"
}
]
}

View File

@@ -1,16 +1,43 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import AppRoutes from './AppRoutes'; // move the routing and loading logic here
import { SettingsProvider } from './context/SettingsContext';
import { SettingsProvider, useSettingsContext } from './context/SettingsContext';
import { ThemeProvider } from './context/ThemeContext';
import { BookingsListProvider } from './context/BookingContext';
import { NamePrompt } from './components/ui/NamePrompt';
function AppContent() {
const { shouldShowNamePrompt } = useSettingsContext();
const [showNamePrompt, setShowNamePrompt] = useState(false);
useEffect(() => {
setShowNamePrompt(shouldShowNamePrompt());
}, [shouldShowNamePrompt]);
const handleCloseNamePrompt = () => {
setShowNamePrompt(false);
};
return (
<>
<Router basename={import.meta.env.BASE_URL}>
<AppRoutes />
</Router>
<NamePrompt
isOpen={showNamePrompt}
onClose={handleCloseNamePrompt}
/>
</>
);
}
function App() {
return (
<ThemeProvider>
<SettingsProvider>
<Router basename={import.meta.env.BASE_URL}>
<AppRoutes />
</Router>
<BookingsListProvider>
<AppContent />
</BookingsListProvider>
</SettingsProvider>
</ThemeProvider>
);

View File

@@ -14,90 +14,33 @@ import { TestSession } from './pages/TestSession';
import Home from './pages/Home';
import CoursePage from './pages/CoursePage';
import Profile from './pages/Profile';
import RoomSchedules from './pages/RoomSchedules';
import { useSettingsContext } from './context/SettingsContext';
import { useBookingsListContext } from './context/BookingContext';
const AppRoutes = () => {
const location = useLocation();
const [loading, setLoading] = useState(false);
const [showSuccessBanner, setShowSuccessBanner] = useState(false);
const [lastCreatedBooking, setLastCreatedBooking] = useState(null);
const [showDeleteBanner, setShowDeleteBanner] = useState(false);
const [lastDeletedBooking, setLastDeletedBooking] = useState(null);
const [bookings, setBookings] = useState([
{
id: 1,
date: new CalendarDate(2025, 9, 3),
startTime: 4,
endTime: 6,
room: 'G5:7',
roomCategory: 'green',
title: 'Team standup',
participants: [
{ id: 2, name: 'Filip Norgren', username: 'fino2341', email: 'filip.norgren@dsv.su.se' },
{ id: 3, name: 'Hedvig Engelmark', username: 'heen9876', email: 'hedvig.engelmark@dsv.su.se' },
{ id: 4, name: 'Elin Rudling', username: 'elru4521', email: 'elin.rudling@dsv.su.se' }
]
},
{
id: 2,
date: new CalendarDate(2025, 9, 5),
startTime: 8,
endTime: 12,
room: 'G5:12',
roomCategory: 'red',
title: 'Project planning workshop',
participants: [
{ id: 5, name: 'Victor Magnusson', username: 'vima8734', email: 'victor.magnusson@dsv.su.se' },
{ id: 6, name: 'Ellen Britschgi', username: 'elbr5623', email: 'ellen.britschgi@dsv.su.se' },
{ id: 7, name: 'Anna Andersson', username: 'anan3457', email: 'anna.andersson@dsv.su.se' },
{ id: 8, name: 'Erik Larsson', username: 'erla7892', email: 'erik.larsson@dsv.su.se' },
{ id: 9, name: 'Sofia Karlsson', username: 'soka1245', email: 'sofia.karlsson@dsv.su.se' },
{ id: 10, name: 'Magnus Nilsson', username: 'mani6789', email: 'magnus.nilsson@dsv.su.se' }
]
},
{
id: 3,
date: new CalendarDate(2025, 9, 4),
startTime: 2,
endTime: 3,
room: 'G5:3',
roomCategory: 'blue',
title: '1:1 with supervisor',
participants: [
{ id: 251, name: 'Arjohn Emilsson', username: 'arem1532', email: 'arjohn.emilsson@dsv.su.se' }
]
},
{
id: 4,
date: new CalendarDate(2025, 9, 6),
startTime: 6,
endTime: 8,
room: 'G5:15',
roomCategory: 'yellow',
title: 'Study group session',
participants: [
{ id: 11, name: 'Emma Johansson', username: 'emjo4512', email: 'emma.johansson@dsv.su.se' },
{ id: 12, name: 'Oskar Pettersson', username: 'ospe3698', email: 'oskar.pettersson@dsv.su.se' }
]
}
]);
function addBooking(newBooking) {
setBookings([...bookings, newBooking]);
setLastCreatedBooking(newBooking);
setShowSuccessBanner(true);
}
function updateBooking(updatedBooking) {
setBookings(bookings.map(booking =>
booking.id === updatedBooking.id ? updatedBooking : booking
));
}
function deleteBooking(bookingToDelete) {
setBookings(bookings.filter(booking => booking.id !== bookingToDelete.id));
setLastDeletedBooking(bookingToDelete);
setShowDeleteBanner(true);
}
// Get bookings data and functions from context
const {
bookings,
addBooking,
updateBooking,
deleteBooking,
showSuccessBanner,
setShowSuccessBanner,
lastCreatedBooking,
showDeleteBanner,
setShowDeleteBanner,
lastDeletedBooking,
showLeaveBanner,
setShowLeaveBanner,
lastLeftBooking,
showUpdateBanner,
setShowUpdateBanner,
lastUpdatedBooking
} = useBookingsListContext();
useEffect(() => {
// Reset scroll position on route change
@@ -119,8 +62,9 @@ const AppRoutes = () => {
<Route path="test-session" element={<TestSession />} />
<Route path="/" element={<Layout />}>
<Route index element={<RoomBooking bookings={bookings} showSuccessBanner={showSuccessBanner} lastCreatedBooking={lastCreatedBooking} onDismissBanner={() => setShowSuccessBanner(false)} onBookingUpdate={updateBooking} onBookingDelete={deleteBooking} showDeleteBanner={showDeleteBanner} lastDeletedBooking={lastDeletedBooking} onDismissDeleteBanner={() => setShowDeleteBanner(false)} />} />
<Route index element={<RoomBooking bookings={bookings} showSuccessBanner={showSuccessBanner} lastCreatedBooking={lastCreatedBooking} onDismissBanner={() => setShowSuccessBanner(false)} onBookingUpdate={updateBooking} onBookingDelete={deleteBooking} showDeleteBanner={showDeleteBanner} lastDeletedBooking={lastDeletedBooking} onDismissDeleteBanner={() => setShowDeleteBanner(false)} showLeaveBanner={showLeaveBanner} lastLeftBooking={lastLeftBooking} onDismissLeaveBanner={() => setShowLeaveBanner(false)} showUpdateBanner={showUpdateBanner} lastUpdatedBooking={lastUpdatedBooking} onDismissUpdateBanner={() => setShowUpdateBanner(false)} />} />
<Route path="new-booking" element={<NewBooking addBooking={addBooking} />} />
<Route path="new-booking/:roomType" element={<NewBooking addBooking={addBooking} />} />
<Route path="booking-details" element={<BookingDetails addBooking={addBooking} />} />
<Route path="booking-confirmation" element={<BookingConfirmation addBooking={addBooking} />} />
<Route path="course-schedule" element={<CourseSchedule />} />
@@ -129,6 +73,7 @@ const AppRoutes = () => {
<Route path="home" element={<Home />} />
<Route path="course/:courseId" element={<CoursePage />} />
<Route path="profile" element={<Profile />} />
<Route path="room-schedules" element={<RoomSchedules />} />
</Route>
</Routes>
</>

View File

@@ -1,246 +1,170 @@
import React, { useState, useEffect, useRef } from 'react';
import { Button } from 'react-aria-components';
import styles from './BookingCard.module.css';
import { convertDateObjectToString } from '../../helpers';
import Dropdown from '../ui/Dropdown';
import { BookingTitleField } from '../forms/BookingTitleField';
import { ParticipantsSelector } from '../forms/ParticipantsSelector';
import React, { useState, useEffect } from 'react';
import { BookingProvider } from '../../context/BookingContext';
import { PEOPLE } from '../../constants/bookingConstants';
function BookingCard({ booking, onClick, isExpanded, onBookingUpdate, onBookingDelete }) {
const [selectedLength, setSelectedLength] = useState(null);
const [calculatedEndTime, setCalculatedEndTime] = useState(null);
const [editedTitle, setEditedTitle] = useState('');
const [editedParticipants, setEditedParticipants] = useState([]);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
import { useBookingCardState } from '../../hooks/useBookingCardState';
import { useBookingActions } from '../../hooks/useBookingActions';
import { useResponsiveMode } from '../../hooks/useResponsiveMode';
// Calculate current booking length and available hours
const currentLength = booking.endTime - booking.startTime;
const maxAvailableTime = 16; // Max booking slots
const hoursAvailable = Math.min(maxAvailableTime - booking.startTime, 8);
import { BookingCardHeader } from './BookingCardHeader';
import { BookingCardTabs } from './BookingCardTabs';
import { RoomInfoContent } from './RoomInfoContent';
import { BookingFormContent } from './BookingFormContent';
import { ParticipantBookingContent } from './ParticipantBookingContent';
import { BookingCardModal } from './BookingCardModal';
// Initialize state when card expands
import styles from './BookingCard.module.css';
function BookingCard({
booking,
onClick,
isExpanded,
onBookingUpdate,
onBookingDelete,
editMode = 'inline',
isOptionsExpanded,
onOptionsToggle
}) {
const [isModalOpen, setIsModalOpen] = useState(false);
const isParticipantBooking = booking.isParticipantBooking === true;
// Custom hooks
const bookingState = useBookingCardState(booking, isExpanded, isModalOpen);
const { effectiveEditMode } = useResponsiveMode(editMode, isExpanded, isModalOpen, onClick, setIsModalOpen);
const actions = useBookingActions(booking, bookingState, onBookingUpdate, onBookingDelete, onClick, effectiveEditMode, setIsModalOpen, onOptionsToggle, isOptionsExpanded);
// Reset activeView when options accordion closes
useEffect(() => {
if (isExpanded) {
setSelectedLength(currentLength);
setCalculatedEndTime(booking.endTime);
setEditedTitle(booking.title);
setEditedParticipants(booking.participants || []);
if (!isOptionsExpanded && !isExpanded) {
bookingState.setActiveView('closed');
}
}, [isExpanded, booking, currentLength]);
}, [isOptionsExpanded, isExpanded, bookingState]);
// Create a local booking context for the components
const localBookingContext = {
title: editedTitle,
setTitle: setEditedTitle,
participants: editedParticipants,
handleParticipantChange: (participantId) => {
const participant = PEOPLE.find(p => p.id === participantId);
if (participant && !editedParticipants.find(p => p.id === participantId)) {
setEditedParticipants(participants => [...participants, participant]);
}
},
handleRemoveParticipant: (participantToRemove) => {
setEditedParticipants(participants =>
participants.filter(p => p.id !== participantToRemove.id)
);
}
title: bookingState.editedTitle,
setTitle: bookingState.setEditedTitle,
participants: bookingState.editedParticipants,
handleParticipantChange: actions.handleParticipantChange,
handleRemoveParticipant: actions.handleRemoveParticipant
};
const bookingLengths = [
{ value: 1, label: "30 min" },
{ value: 2, label: "1 h" },
{ value: 3, label: "1.5 h" },
{ value: 4, label: "2 h" },
{ value: 5, label: "2.5 h" },
{ value: 6, label: "3 h" },
{ value: 7, label: "3.5 h" },
{ value: 8, label: "4 h" },
];
const disabledOptions = {
1: !(hoursAvailable > 0),
2: !(hoursAvailable > 1),
3: !(hoursAvailable > 2),
4: !(hoursAvailable > 3),
5: !(hoursAvailable > 4),
6: !(hoursAvailable > 5),
7: !(hoursAvailable > 6),
8: !(hoursAvailable > 7),
};
function handleLengthChange(event) {
const lengthValue = event.target.value === "" ? null : parseInt(event.target.value);
setSelectedLength(lengthValue);
if (lengthValue !== null) {
const newEndTime = booking.startTime + lengthValue;
setCalculatedEndTime(newEndTime);
} else {
setCalculatedEndTime(booking.endTime);
}
}
function handleSave() {
if (selectedLength !== null && onBookingUpdate) {
const updatedBooking = {
...booking,
title: editedTitle,
participants: editedParticipants,
endTime: calculatedEndTime
};
onBookingUpdate(updatedBooking);
}
onClick(); // Close the expanded view
}
function handleCancel() {
setSelectedLength(currentLength);
setCalculatedEndTime(booking.endTime);
setEditedTitle(booking.title);
setEditedParticipants(booking.participants || []);
onClick(); // Close the expanded view
}
function handleDelete() {
setShowDeleteConfirm(true);
}
function confirmDelete() {
if (onBookingDelete) {
onBookingDelete(booking);
}
setShowDeleteConfirm(false);
}
function cancelDelete() {
setShowDeleteConfirm(false);
}
function getTimeFromIndex(timeIndex) {
const totalHalfHoursFromStart = timeIndex;
const totalMinutes = 8 * 60 + totalHalfHoursFromStart * 30; // 8:00 as base
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours}:${minutes === 0 ? '00' : '30'}`;
}
function formatParticipants(participants) {
if (!participants || participants.length === 0) return null;
if (participants.length === 1) {
return participants[0].name;
} else if (participants.length === 2) {
return `${participants[0].name} and ${participants[1].name}`;
} else {
const remaining = participants.length - 2;
return `${participants[0].name}, ${participants[1].name} and ${remaining} more`;
}
}
// Render the expanded content
const renderExpandedContent = (isModal = false) => (
<BookingProvider value={localBookingContext}>
{!isModal && (
<BookingCardTabs
activeView={bookingState.activeView}
isInExpandedView={true}
onRoomInfo={() => actions.handleRoomInfo(effectiveEditMode, isOptionsExpanded, onOptionsToggle, isExpanded)}
onManageBooking={() => actions.handleManageBooking(effectiveEditMode, isOptionsExpanded, onOptionsToggle, isExpanded)}
setActiveView={bookingState.setActiveView}
/>
)}
{bookingState.activeView === 'lokalinfo' ? (
<RoomInfoContent
booking={booking}
showCloseButton={true}
onClose={() => {
bookingState.setActiveView('hantera');
onClick();
}}
/>
) : bookingState.activeView === 'hantera' ? (
isParticipantBooking ? (
<ParticipantBookingContent
booking={booking}
showDeleteConfirm={bookingState.showDeleteConfirm}
onRemoveSelf={actions.handleRemoveSelf}
onCancel={actions.handleCancel}
onSetShowDeleteConfirm={bookingState.setShowDeleteConfirm}
onCancelDelete={actions.cancelDelete}
/>
) : (
<BookingFormContent
booking={booking}
calculatedEndTime={bookingState.calculatedEndTime}
showDeleteConfirm={bookingState.showDeleteConfirm}
hasChanges={actions.hasChanges}
onLengthChange={actions.handleLengthChange}
onSave={actions.handleSave}
onCancel={actions.handleCancel}
onDelete={actions.handleDelete}
onConfirmDelete={actions.confirmDelete}
onCancelDelete={actions.cancelDelete}
/>
)
) : null}
</BookingProvider>
);
return (
<div className={`${styles.card} ${isExpanded ? styles.expanded : ''}`}>
<div className={styles.header} onClick={!isExpanded ? onClick : undefined}>
<div className={styles.leftSection}>
<span className={styles.date}>{convertDateObjectToString(booking.date)}</span>
<div className={styles.titleRow}>
<h3 className={styles.title}>{booking.title}</h3>
</div>
<div className={styles.roomAndParticipants}>
<span className={styles.room}>{booking.room}</span>
{booking.participants && booking.participants.length > 0 && (
<p className={styles.participants}>{formatParticipants(booking.participants)}</p>
)}
</div>
</div>
<div className={styles.timeSection}>
<div className={styles.startTime}>{getTimeFromIndex(booking.startTime)}</div>
<div className={styles.endTime}>{getTimeFromIndex(calculatedEndTime || booking.endTime)}</div>
</div>
</div>
{isExpanded && (
<BookingProvider value={localBookingContext}>
<div className={styles.expandedContent}>
<div className={styles.formSection}>
<BookingTitleField compact={true} />
<>
<div className={`${styles.cardWrapper} ${isExpanded ? styles.expanded : ''} ${isOptionsExpanded ? styles.optionsExpanded : ''}`}>
<div className={styles.card}>
<BookingCardHeader
booking={booking}
calculatedEndTime={bookingState.calculatedEndTime}
isExpanded={isExpanded}
activeView={bookingState.activeView}
onOptionsToggle={onOptionsToggle}
onClick={onClick}
/>
{isExpanded && effectiveEditMode === 'inline' && !bookingState.isRoomInfoModalOpen && (
<div className={styles.expandedContent}>
{renderExpandedContent()}
</div>
<div className={styles.formSection}>
<ParticipantsSelector compact={true} />
</div>
<div className={styles.editSection}>
<label className={styles.label}>Ändra längd</label>
<Dropdown
options={bookingLengths}
disabledOptions={disabledOptions}
onChange={handleLengthChange}
value={selectedLength || ""}
placeholder={{
value: "",
label: "Välj bokningslängd"
}}
)}
{isOptionsExpanded && !isExpanded && (
<div className={styles.optionsContent}>
<BookingCardTabs
activeView={bookingState.activeView}
isInExpandedView={false}
onRoomInfo={() => actions.handleRoomInfo(effectiveEditMode, isOptionsExpanded, onOptionsToggle, isExpanded)}
onManageBooking={() => actions.handleManageBooking(effectiveEditMode, isOptionsExpanded, onOptionsToggle, isExpanded)}
setActiveView={bookingState.setActiveView}
/>
</div>
{!showDeleteConfirm ? (
<div className={styles.buttonSection}>
<Button
className={styles.deleteButton}
onPress={handleDelete}
>
Radera
</Button>
<Button
className={styles.cancelButton}
onPress={handleCancel}
>
Avbryt
</Button>
<Button
className={`${styles.saveButton} ${selectedLength === null ? styles.disabledButton : ''}`}
onPress={handleSave}
isDisabled={selectedLength === null}
>
{selectedLength !== null ? 'Spara ändringar' : 'Välj längd först'}
</Button>
</div>
) : (
<div className={styles.confirmationSection}>
<div className={styles.confirmationMessage}>
<span className={styles.warningIcon}></span>
<p>Är du säker att du vill radera denna bokning?</p>
<p className={styles.bookingDetails}>
"{booking.title}" den {booking.date.day}/{booking.date.month} kl. {getTimeFromIndex(booking.startTime)}
</p>
</div>
<div className={styles.confirmationButtons}>
<Button
className={styles.confirmDeleteButton}
onPress={confirmDelete}
>
Ja, radera
</Button>
<Button
className={styles.cancelDeleteButton}
onPress={cancelDelete}
>
Avbryt
</Button>
</div>
</div>
)}
)}
</div>
{isParticipantBooking && booking.createdBy && !isExpanded && (
<div className={styles.banner}>
Tillagd av {booking.createdBy.name}
</div>
</BookingProvider>
)}
</div>
)}
</div>
{/* Room information modal */}
<BookingCardModal
isOpen={bookingState.isRoomInfoModalOpen}
onClose={() => {
bookingState.setIsRoomInfoModalOpen(false);
bookingState.setActiveView('closed');
}}
title="Lokalinformation"
booking={booking}
calculatedEndTime={bookingState.calculatedEndTime}
>
<RoomInfoContent booking={booking} />
</BookingCardModal>
{/* Full edit modal - shown after selecting "Hantera bokning" */}
<BookingCardModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
bookingState.setActiveView('closed');
}}
title={isParticipantBooking ? "Visa bokning" : "Redigera bokning"}
booking={booking}
calculatedEndTime={bookingState.calculatedEndTime}
>
{renderExpandedContent(true)}
</BookingCardModal>
</>
);
}

View File

@@ -1,56 +1,107 @@
.card {
border: var(--border-width-thin) solid var(--border-light);
padding: var(--card-padding);
.cardWrapper {
width: 100%;
border-radius: var(--border-radius-md);
background: var(--bg-primary);
transition: var(--transition-medium);
box-shadow: var(--shadow-lg);
/*max-width: 400px;*/
/*flex: 1;*/
}
.card:hover {
cursor: pointer;
border-color: var(--color-primary);
box-shadow: var(--shadow-xl);
/*transform: translateY(-2px);*/
.card {
border: var(--border-width-thin) solid var(--border-light);
padding: 0.8rem 1rem;
width: 100%;
background: var(--bg-primary);
/*transition: var(--transition-medium);*/
}
@media (hover: hover) {
.cardWrapper:hover:not(.expanded) .card {
cursor: pointer;
border-color: var(--border-medium);
/*box-shadow: var(--shadow-xl);*/
/*transform: translateY(-2px);*/
}
}
.banner {
background-color: var(--bg-secondary);
color: #6b7280;
font-size: 0.8rem;
padding: 0.25rem 1rem;
border-left: var(--border-width-thin) solid var(--border-light);
border-right: var(--border-width-thin) solid var(--border-light);
border-bottom: var(--border-width-thin) solid var(--border-light);
/*border-bottom-left-radius: var(--border-radius-md);
border-bottom-right-radius: var(--border-radius-md);*/
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
height: 100%;
flex-direction: column;
width: 100%;
}
.leftSection {
flex: 1;
.cardWrapper:not(.expanded) .header {
cursor: pointer;
}
.date {
text-transform: uppercase;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--text-muted);
letter-spacing: 1px;
margin-bottom: var(--spacing-sm);
display: block;
}
.titleRow {
.topSection {
display: flex;
align-items: center;
gap: var(--spacing-md);
margin-bottom: var(--spacing-sm);
flex-direction: row;
justify-content: space-between;
align-items: flex-end;
/*background-color: lightblue;*/
}
.bottomSection {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-end;
width: 100%;
/*background-color: lightgreen;*/
}
.createdByIndicator {
font-size: var(--font-size-sm);
color: var(--text-muted);
font-weight: var(--font-weight-normal);
font-style: italic;
}
.title {
margin: 0;
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-bold);
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary);
line-height: 1.3;
}
.time {
font-size: 1.1rem;
font-weight: 600;
color: #111827;
white-space: nowrap;
}
.participants,
.createdBy {
margin: 0;
font-size: 1rem;
color: #6b7280;
padding: 0.25rem 0;
}
.roomBadge {
background-color: var(--su-blue);
color: white;
padding: 0.25rem 0.75rem;
/*border-radius: 10rem;*/
font-size: 1rem;
font-weight: 500;
white-space: nowrap;
}
.room {
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-xl);
@@ -65,32 +116,8 @@
gap: var(--spacing-lg);
}
.timeSection {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 0.2rem;
}
.startTime {
font-size: 1.6rem;
font-weight: var(--font-weight-normal);
.time {
color: var(--text-primary);
line-height: 1;
}
.endTime {
font-size: 1.6rem;
font-weight: var(--font-weight-normal);
color: var(--text-tertiary);
line-height: 1;
}
.participants {
margin: 0;
font-size: var(--font-size-md);
color: var(--text-muted);
}
/* Expanded card styles */
@@ -99,6 +126,12 @@
box-shadow: var(--shadow-xl);
}
.expanded .card {
outline: 2px solid #3b82f6;
outline-offset: -1px;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
}
.expanded:hover {
transform: none;
}
@@ -108,15 +141,43 @@
}
.expandedContent {
margin-top: 1.5rem;
padding-top: 1.5rem;
padding-top:1rem;
margin-top:1rem;
border-top: 1px solid #E5E5E5;
}
.divider {
border: none;
border-top: 1px solid var(--border-medium);
margin: 1rem 0;
margin-bottom: 1.5rem;
}
.formSection {
margin-bottom: 1.5rem;
}
.readOnlySection {
margin-bottom: 1.5rem;
}
.readOnlyField {
margin-bottom: 1rem;
}
.readOnlyField:last-child {
margin-bottom: 0;
}
.createdByText,
.participantsText,
.timeText {
margin: 0;
font-size: var(--font-size-base);
color: var(--text-primary);
font-weight: var(--font-weight-medium);
}
.editSection {
margin-bottom: 1.5rem;
}
@@ -126,7 +187,17 @@
font-size: 0.8rem;
color: #717171;
font-weight: 500;
margin-bottom: 0.5rem;
/*margin-bottom: 0.5rem;*/
text-transform: uppercase;
letter-spacing: 0.5px;
}
.compactElementHeading {
font-size: 0.75rem;
color: var(--text-tertiary);
font-weight: 500;
margin-bottom: 0.4rem;
margin-top: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
@@ -164,11 +235,11 @@
.cancelButton {
flex: 2;
background-color: white;
background-color: var(--bg-primary);
height: 3rem;
color: #374151;
color: var(--text-primary);
font-weight: 600;
border: 2px solid #d1d5db;
border: 1px solid var(--border-light);
border-radius: 0.5rem;
transition: all 0.2s ease;
cursor: pointer;
@@ -176,12 +247,12 @@
}
.cancelButton:hover {
background-color: #f9fafb;
border-color: #9ca3af;
background-color: var(--bg-secondary);
border-color: var(--border-medium);
}
.cancelButton:active {
background-color: #e5e7eb;
background-color: var(--bg-tertiary);
transform: translateY(1px);
}
@@ -461,4 +532,116 @@
.cancelDeleteButton[data-focused] {
outline: 2px solid #2563EB;
outline-offset: -1px;
}
/* Options expanded card styles */
.optionsExpanded {
border-color: var(--color-primary);
box-shadow: var(--shadow-xl);
}
.optionsExpanded .card {
outline: 2px solid #3b82f6;
outline-offset: -1px;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
}
.optionsExpanded:hover {
transform: none;
}
.optionsExpanded .header {
cursor: default;
}
/* Inline options accordion styles */
.optionsContent {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-light);
animation: slideDown 0.2s ease-out;
}
@keyframes slideDown {
from {
max-height: 0;
opacity: 0;
}
to {
max-height: 200px;
opacity: 1;
}
}
/* Room information styles */
.roomInfoContent {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.roomImageContainer {
width: 100%;
overflow: hidden;
background-color: var(--bg-secondary);
}
.roomImage {
width: 100%;
height: 200px;
object-fit: cover;
background-color: #f3f4f6;
}
.roomDetails {
display: flex;
flex-direction: column;
gap: 1rem;
}
.roomTitle {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
}
.roomInfoGrid {
display: grid;
gap: 0.75rem;
}
.roomInfoItem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid var(--border-light);
}
.roomInfoItem:last-child {
border-bottom: none;
}
.roomInfoLabel {
font-weight: 500;
color: var(--text-secondary);
flex-shrink: 0;
}
.roomInfoValue {
color: var(--text-primary);
text-align: right;
margin-left: 1rem;
}
.roomInfoActions {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border-light);
display: flex;
justify-content: center;
}

View File

@@ -0,0 +1,42 @@
import React from 'react';
import styles from './BookingCard.module.css';
import { getTimeFromIndex } from '../../utils/bookingUtils';
import { ParticipantsDisplay } from './ParticipantsDisplay';
export function BookingCardHeader({
booking,
calculatedEndTime,
isExpanded,
activeView,
onOptionsToggle,
onClick
}) {
const handleClick = !isExpanded ? onOptionsToggle : (activeView === 'closed' ? onClick : undefined);
return (
<div className={styles.header} onClick={handleClick}>
<div className={styles.topSection}>
<div className={styles.titleRow}>
<h3 className={styles.title}>{booking.title}</h3>
</div>
<div className={styles.time}>
{getTimeFromIndex(booking.startTime)} {getTimeFromIndex(calculatedEndTime || booking.endTime)}
</div>
</div>
<div className={styles.bottomSection}>
{booking.participants && booking.participants.length > 0 && (
<div className={styles.participants}>
<ParticipantsDisplay
participants={booking.participants}
isParticipantBooking={booking.isParticipantBooking}
createdBy={booking.createdBy}
/>
</div>
)}
<div className={styles.roomBadge}>
{booking.room}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import React from 'react';
import { Button, Dialog, Heading, Modal } from 'react-aria-components';
import { convertDateObjectToString } from '../../helpers';
import styles from './BookingCardModal.module.css';
export function BookingCardModal({
isOpen,
onClose,
children,
title = "Redigera bokning",
booking,
calculatedEndTime
}) {
function getTimeFromIndex(timeIndex) {
const totalHalfHoursFromStart = timeIndex;
const totalMinutes = 8 * 60 + totalHalfHoursFromStart * 30; // 8:00 as base
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours}:${minutes === 0 ? '00' : '30'}`;
}
return (
<Modal
isOpen={isOpen}
isDismissable
onOpenChange={(open) => !open && onClose && onClose()}
className={styles.modal}
>
<Dialog className={styles.dialog}>
<div className={styles.header}>
<div className={styles.titleSection}>
<Heading slot="title" className={styles.title}>{booking.title}</Heading>
{booking && (
<div className={styles.bookingInfo}>
<div className={styles.bookingDetails}>
<span className={styles.date}>{convertDateObjectToString(booking.date)}</span>
<span className={styles.time}>
{getTimeFromIndex(booking.startTime)} {getTimeFromIndex(calculatedEndTime || booking.endTime)}
</span>
<span className={styles.room}>{booking.room}</span>
</div>
</div>
)}
</div>
<Button
className={styles.closeButton}
onPress={onClose}
aria-label="Close modal"
>
×
</Button>
</div>
<div className={styles.content}>
{children}
</div>
</Dialog>
</Modal>
);
}

View File

@@ -0,0 +1,127 @@
.modal {
--overlay-background: transparent;
padding: 0;
z-index: 1000;
position: relative;
}
.dialog {
background: var(--bg-primary);
border-radius: 0.75rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
max-width: 500px;
width: 100%;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 1.5rem 1.5rem 0;
border-bottom: 1px solid var(--border-light);
padding-bottom: 1rem;
}
.titleSection {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
}
.bookingInfo {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.bookingTitle {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary);
}
.bookingDetails {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.date {
font-size: 0.9rem;
color: var(--text-secondary);
font-weight: 500;
}
.time {
font-size: 0.9rem;
color: var(--text-secondary);
font-weight: 500;
}
.room {
background-color: var(--su-blue);
color: white;
padding: 0.2rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.85rem;
font-weight: 500;
}
.closeButton {
background: none;
border: none;
font-size: 1.5rem;
color: var(--text-secondary);
cursor: pointer;
padding: 0.5rem;
border-radius: 0.375rem;
line-height: 1;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
}
.closeButton:hover {
background-color: var(--bg-secondary);
color: var(--text-primary);
}
.content {
padding: 1.5rem;
overflow-y: auto;
flex: 1;
}
@media (max-width: 768px) {
.modal {
padding: 0.5rem;
}
.dialog {
max-height: 95vh;
}
.header {
padding: 1rem 1rem 0;
padding-bottom: 0.75rem;
}
.content {
padding: 1rem;
}
}

View File

@@ -0,0 +1,160 @@
import React, { useState } from 'react';
import { BookingProvider } from '../../context/BookingContext';
import { PEOPLE } from '../../constants/bookingConstants';
import { useBookingCardState } from '../../hooks/useBookingCardState';
import { useBookingActions } from '../../hooks/useBookingActions';
import { useResponsiveMode } from '../../hooks/useResponsiveMode';
import { BookingCardHeader } from './BookingCardHeader';
import { BookingCardTabs } from './BookingCardTabs';
import { RoomInfoContent } from './RoomInfoContent';
import { BookingFormContent } from './BookingFormContent';
import { ParticipantBookingContent } from './ParticipantBookingContent';
import { BookingCardModal } from './BookingCardModal';
import styles from './BookingCard.module.css';
function BookingCard({
booking,
onClick,
isExpanded,
onBookingUpdate,
onBookingDelete,
editMode = 'inline',
isOptionsExpanded,
onOptionsToggle
}) {
const [isModalOpen, setIsModalOpen] = useState(false);
const isParticipantBooking = booking.isParticipantBooking === true;
// Custom hooks
const bookingState = useBookingCardState(booking, isExpanded, isModalOpen);
const { effectiveEditMode } = useResponsiveMode(editMode, isExpanded, isModalOpen, onClick, setIsModalOpen);
const actions = useBookingActions(booking, bookingState, onBookingUpdate, onBookingDelete, onClick, effectiveEditMode, setIsModalOpen);
// Create a local booking context for the components
const localBookingContext = {
title: bookingState.editedTitle,
setTitle: bookingState.setEditedTitle,
participants: bookingState.editedParticipants,
handleParticipantChange: actions.handleParticipantChange,
handleRemoveParticipant: actions.handleRemoveParticipant
};
// Render the expanded content
const renderExpandedContent = () => (
<BookingProvider value={localBookingContext}>
<BookingCardTabs
activeView={bookingState.activeView}
isInExpandedView={true}
onRoomInfo={() => actions.handleRoomInfo(effectiveEditMode, isOptionsExpanded, onOptionsToggle, isExpanded)}
onManageBooking={() => actions.handleManageBooking(effectiveEditMode, isOptionsExpanded, onOptionsToggle, isExpanded)}
setActiveView={bookingState.setActiveView}
/>
{bookingState.activeView === 'lokalinfo' ? (
<RoomInfoContent
booking={booking}
showCloseButton={true}
onClose={() => {
bookingState.setActiveView('hantera');
onClick();
}}
/>
) : bookingState.activeView === 'hantera' ? (
isParticipantBooking ? (
<ParticipantBookingContent
booking={booking}
showDeleteConfirm={bookingState.showDeleteConfirm}
onRemoveSelf={actions.handleRemoveSelf}
onCancel={actions.handleCancel}
onSetShowDeleteConfirm={bookingState.setShowDeleteConfirm}
onCancelDelete={actions.cancelDelete}
/>
) : (
<BookingFormContent
booking={booking}
calculatedEndTime={bookingState.calculatedEndTime}
showDeleteConfirm={bookingState.showDeleteConfirm}
hasChanges={actions.hasChanges}
onLengthChange={actions.handleLengthChange}
onSave={actions.handleSave}
onCancel={actions.handleCancel}
onDelete={actions.handleDelete}
onConfirmDelete={actions.confirmDelete}
onCancelDelete={actions.cancelDelete}
/>
)
) : null}
</BookingProvider>
);
return (
<>
<div className={`${styles.cardWrapper} ${isExpanded ? styles.expanded : ''} ${isOptionsExpanded ? styles.optionsExpanded : ''}`}>
<div className={styles.card}>
<BookingCardHeader
booking={booking}
calculatedEndTime={bookingState.calculatedEndTime}
isExpanded={isExpanded}
activeView={bookingState.activeView}
onOptionsToggle={onOptionsToggle}
onClick={onClick}
/>
{isExpanded && effectiveEditMode === 'inline' && !bookingState.isRoomInfoModalOpen && (
<div className={styles.expandedContent}>
{renderExpandedContent()}
</div>
)}
{isOptionsExpanded && !isExpanded && (
<div className={styles.optionsContent}>
<BookingCardTabs
activeView={bookingState.activeView}
isInExpandedView={false}
onRoomInfo={() => actions.handleRoomInfo(effectiveEditMode, isOptionsExpanded, onOptionsToggle, isExpanded)}
onManageBooking={() => actions.handleManageBooking(effectiveEditMode, isOptionsExpanded, onOptionsToggle, isExpanded)}
setActiveView={bookingState.setActiveView}
/>
</div>
)}
</div>
{isParticipantBooking && booking.createdBy && !isExpanded && (
<div className={styles.banner}>
Tillagd av {booking.createdBy.name}
</div>
)}
</div>
{/* Room information modal */}
<BookingCardModal
isOpen={bookingState.isRoomInfoModalOpen}
onClose={() => {
bookingState.setIsRoomInfoModalOpen(false);
bookingState.setActiveView('hantera');
}}
title="Lokalinformation"
booking={booking}
calculatedEndTime={bookingState.calculatedEndTime}
>
<RoomInfoContent booking={booking} />
</BookingCardModal>
{/* Full edit modal - shown after selecting "Hantera bokning" */}
<BookingCardModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title={isParticipantBooking ? "Visa bokning" : "Redigera bokning"}
booking={booking}
calculatedEndTime={bookingState.calculatedEndTime}
>
{renderExpandedContent()}
</BookingCardModal>
</>
);
}
export default BookingCard;

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { Button } from 'react-aria-components';
import styles from './BookingCardTabs.module.css';
export function BookingCardTabs({
activeView,
isInExpandedView = false,
onRoomInfo,
onManageBooking,
setActiveView
}) {
const containerClass = isInExpandedView && activeView !== 'closed'
? styles.tabButtons
: styles.tabButtonsNoBorder;
return (
<div className={containerClass}>
<Button
className={`${styles.tabButton} ${activeView === 'lokalinfo' ? styles.activeTab : ''}`}
onPress={(e) => {
// Remove focus on touch devices to prevent persistent styling
if (e.target) e.target.blur();
if (isInExpandedView && activeView === 'lokalinfo') {
// Close content, show only tab buttons
setActiveView('closed');
} else {
onRoomInfo();
}
}}
>
Lokalinformation
</Button>
<Button
className={`${styles.tabButton} ${activeView === 'hantera' ? styles.activeTab : ''}`}
onPress={(e) => {
// Remove focus on touch devices to prevent persistent styling
if (e.target) e.target.blur();
if (isInExpandedView && activeView === 'hantera') {
// Close content, show only tab buttons
setActiveView('closed');
} else {
onManageBooking();
}
}}
>
Hantera bokning
</Button>
</div>
);
}

View File

@@ -0,0 +1,86 @@
/* Container styles */
.tabButtons {
display: flex;
flex-direction: row;
gap: 0.5rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--border-light);
padding-bottom: 1rem;
}
.tabButtonsNoBorder {
display: flex;
flex-direction: row;
gap: 0.5rem;
}
/* Button styles */
.tabButton {
background: var(--bg-secondary);
border: 1px solid var(--border-light);
color: var(--text-primary);
padding: 0.75rem 1rem;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
border-radius: var(--border-radius-sm);
flex: 1;
}
.tabButton:hover {
border-color: var(--color-primary);
background-color: var(--bg-primary);
}
.tabButton:focus {
outline: none;
}
.tabButton:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.tabButton:active {
background-color: var(--bg-secondary);
transform: none;
}
/* Touch device specific styles */
@media (hover: none) {
.tabButton:hover {
background-color: var(--bg-secondary);
border-color: var(--border-light);
}
.tabButton:focus {
outline: none;
background-color: var(--bg-secondary);
}
.tabButton:focus-visible {
outline: none;
}
.activeTab:hover {
background: #6b7280 !important;
color: white !important;
border-color: #4b5563 !important;
}
}
/* Active tab styling */
.activeTab {
background: #6b7280 !important;
color: white !important;
border-color: #4b5563 !important;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
}
.activeTab:hover {
background: #6b7280 !important;
color: white !important;
border-color: #4b5563 !important;
}

View File

@@ -0,0 +1,81 @@
import React from 'react';
import { Button } from 'react-aria-components';
import styles from './BookingCard.module.css';
import Dropdown from '../ui/Dropdown';
import { BookingTitleField } from '../forms/BookingTitleField';
import { ParticipantsSelector } from '../forms/ParticipantsSelector';
import { Label } from '../ui/Label';
import { ConfirmationDialog } from './ConfirmationDialog';
import { createBookingLengthOptions } from '../../utils/bookingUtils';
export function BookingFormContent({
booking,
calculatedEndTime,
showDeleteConfirm,
hasChanges,
onLengthChange,
onSave,
onCancel,
onDelete,
onConfirmDelete,
onCancelDelete
}) {
const bookingLengths = createBookingLengthOptions(booking);
const disabledOptions = {};
return (
<>
<div className={styles.formSection}>
<BookingTitleField compact={true} />
</div>
<div className={styles.formSection}>
<ParticipantsSelector compact={true} />
</div>
<div className={styles.editSection}>
<Label>Ändra längd</Label>
<Dropdown
options={bookingLengths}
disabledOptions={disabledOptions}
onChange={onLengthChange}
value={calculatedEndTime || booking.endTime}
placeholder={null}
/>
</div>
<hr className={styles.divider} />
{!showDeleteConfirm ? (
<div className={styles.buttonSection}>
<Button
className={styles.deleteButton}
onPress={onDelete}
>
Radera
</Button>
<Button
className={styles.cancelButton}
onPress={onCancel}
>
Avbryt
</Button>
<Button
className={`${styles.saveButton} ${!hasChanges() ? styles.disabledButton : ''}`}
onPress={onSave}
isDisabled={!hasChanges()}
>
Spara
</Button>
</div>
) : (
<ConfirmationDialog
booking={booking}
isParticipantBooking={false}
onConfirm={onConfirmDelete}
onCancel={onCancelDelete}
/>
)}
</>
);
}

View File

@@ -0,0 +1,73 @@
import React from 'react';
import { Button, Dialog, Heading, Modal } from 'react-aria-components';
import { convertDateObjectToString } from '../../helpers';
import styles from './BookingOptionsModal.module.css';
export function BookingOptionsModal({
isOpen,
onClose,
booking,
onRoomInfo,
onManageBooking
}) {
function getTimeFromIndex(timeIndex) {
const totalHalfHoursFromStart = timeIndex;
const totalMinutes = 8 * 60 + totalHalfHoursFromStart * 30; // 8:00 as base
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours}:${minutes === 0 ? '00' : '30'}`;
}
return (
<Modal
isOpen={isOpen}
isDismissable
onOpenChange={(open) => !open && onClose && onClose()}
className={styles.modal}
>
<Dialog className={styles.dialog}>
<div className={styles.header}>
<div className={styles.titleSection}>
<Heading slot="title" className={styles.title}>{booking?.title}</Heading>
{booking && (
<div className={styles.bookingInfo}>
<div className={styles.bookingDetails}>
<span className={styles.date}>{convertDateObjectToString(booking.date)}</span>
<span className={styles.time}>
{getTimeFromIndex(booking.startTime)} {getTimeFromIndex(booking.endTime)}
</span>
<span className={styles.room}>{booking.room}</span>
</div>
</div>
)}
</div>
<Button
className={styles.closeButton}
onPress={onClose}
aria-label="Close modal"
>
×
</Button>
</div>
<div className={styles.content}>
<div className={styles.buttonGroup}>
<Button
className={styles.optionButton}
onPress={onRoomInfo}
>
Lokalinformation
</Button>
<Button
className={styles.optionButton}
onPress={onManageBooking}
>
Hantera bokning
</Button>
</div>
</div>
</Dialog>
</Modal>
);
}

View File

@@ -0,0 +1,144 @@
.modal {
--overlay-background: transparent;
padding: 0;
z-index: 1000;
position: relative;
}
.dialog {
background: var(--bg-primary);
border-radius: 0.75rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
max-width: 400px;
width: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 1.5rem 1.5rem 0;
border-bottom: 1px solid var(--border-light);
padding-bottom: 1rem;
}
.titleSection {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.title {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary);
}
.bookingInfo {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.bookingDetails {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.date {
font-size: 0.85rem;
color: var(--text-secondary);
font-weight: 500;
}
.time {
font-size: 0.85rem;
color: var(--text-secondary);
font-weight: 500;
}
.room {
background-color: var(--su-blue);
color: white;
padding: 0.15rem 0.4rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
}
.closeButton {
background: none;
border: none;
font-size: 1.5rem;
color: var(--text-secondary);
cursor: pointer;
padding: 0.5rem;
border-radius: 0.375rem;
line-height: 1;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
flex-shrink: 0;
}
.closeButton:hover {
background-color: var(--bg-secondary);
color: var(--text-primary);
}
.content {
padding: 1.5rem;
}
.buttonGroup {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.optionButton {
background: var(--bg-primary);
border: 2px solid var(--border-light);
color: var(--text-primary);
padding: 1rem 1.5rem;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
}
.optionButton:hover {
border-color: var(--color-primary);
background-color: var(--bg-secondary);
}
.optionButton:focus {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
@media (max-width: 768px) {
.modal {
padding: 0.5rem;
}
.header {
padding: 1rem 1rem 0;
padding-bottom: 0.75rem;
}
.content {
padding: 1rem;
}
}

View File

@@ -3,9 +3,12 @@ import { CalendarDate } from '@internationalized/date';
import styles from './BookingsList.module.css';
import BookingCard from './BookingCard';
import NotificationBanner from '../common/NotificationBanner';
import { useSettingsContext } from '../../context/SettingsContext';
function BookingsList({ bookings, handleEditBooking, onBookingUpdate, onBookingDelete, showSuccessBanner, lastCreatedBooking, onDismissBanner, showDeleteBanner, lastDeletedBooking, onDismissDeleteBanner, showDevelopmentBanner, showBookingConfirmationBanner, showBookingDeleteBanner }) {
function BookingsList({ bookings, handleEditBooking, onBookingUpdate, onBookingDelete, showSuccessBanner, lastCreatedBooking, onDismissBanner, showDeleteBanner, lastDeletedBooking, onDismissDeleteBanner, showLeaveBanner, lastLeftBooking, onDismissLeaveBanner, showDevelopmentBanner, showBookingConfirmationBanner, showBookingDeleteBanner, showUpdateBanner, lastUpdatedBooking, onDismissUpdateBanner }) {
const { settings } = useSettingsContext();
const [expandedBookingId, setExpandedBookingId] = useState(null);
const [optionsExpandedBookingId, setOptionsExpandedBookingId] = useState(null);
// Sort bookings by date (earliest first)
const sortedBookings = [...bookings].sort((a, b) => {
@@ -15,10 +18,45 @@ function BookingsList({ bookings, handleEditBooking, onBookingUpdate, onBookingD
return dateA - dateB;
});
// Group bookings by date
const groupedBookings = sortedBookings.reduce((groups, booking) => {
const dateKey = `${booking.date.year}-${booking.date.month}-${booking.date.day}`;
if (!groups[dateKey]) {
groups[dateKey] = [];
}
groups[dateKey].push(booking);
console.log("HEY:");
console.log(groups);
return groups;
}, {});
const groupedBookingsArray = Object.values(groupedBookings);
console.log("GROUPED BOOKINGS ARRAY:");
console.log(groupedBookingsArray);
function handleBookingClick(booking) {
// Close any expanded options accordion when expanding a card
setOptionsExpandedBookingId(null);
setExpandedBookingId(expandedBookingId === booking.id ? null : booking.id);
}
function handleOptionsToggle(booking) {
// Close any expanded card when opening options accordion
setExpandedBookingId(null);
setOptionsExpandedBookingId(optionsExpandedBookingId === booking.id ? null : booking.id);
}
function formatDateHeader(dateObj) {
const days = ['SÖNDAG', 'MÅNDAG', 'TISDAG', 'ONSDAG', 'TORSDAG', 'FREDAG', 'LÖRDAG'];
const months = ['JANUARI', 'FEBRUARI', 'MARS', 'APRIL', 'MAJ', 'JUNI', 'JULI', 'AUGUSTI', 'SEPTEMBER', 'OKTOBER', 'NOVEMBER', 'DECEMBER'];
const date = new Date(dateObj.year, dateObj.month - 1, dateObj.day);
const dayName = days[date.getDay()];
const monthName = months[dateObj.month - 1];
return `${dayName} ${dateObj.day} ${monthName} ${dateObj.year}`;
}
return (
<div className={styles.bookingsListContainer}>
{showSuccessBanner && (
@@ -37,6 +75,22 @@ function BookingsList({ bookings, handleEditBooking, onBookingUpdate, onBookingD
showCloseButton={true}
/>
)}
{showLeaveBanner && (
<NotificationBanner
variant="leave"
booking={lastLeftBooking}
onClose={onDismissLeaveBanner}
showCloseButton={true}
/>
)}
{showUpdateBanner && (
<NotificationBanner
variant="update"
booking={lastUpdatedBooking}
onClose={onDismissUpdateBanner}
showCloseButton={true}
/>
)}
{showDevelopmentBanner && (
<NotificationBanner
variant="development"
@@ -71,17 +125,27 @@ function BookingsList({ bookings, handleEditBooking, onBookingUpdate, onBookingD
/>
)}
<div className={styles.bookingsContainer}>
{sortedBookings.length > 0 ? (
{Object.keys(groupedBookings).length > 0 ? (
<>
{sortedBookings.map((booking, index) => (
<BookingCard
key={index}
booking={booking}
onClick={() => handleBookingClick(booking)}
isExpanded={expandedBookingId === booking.id}
onBookingUpdate={onBookingUpdate}
onBookingDelete={onBookingDelete}
/>
{Object.entries(groupedBookings).map(([dateKey, dayBookings]) => (
<div key={dateKey} className={styles.dateGroup}>
<h2 className={styles.dateHeader}>
{formatDateHeader(dayBookings[0].date)}
</h2>
{dayBookings.map((booking, index) => (
<BookingCard
key={booking.id}
booking={booking}
onClick={() => handleBookingClick(booking)}
isExpanded={expandedBookingId === booking.id}
onBookingUpdate={onBookingUpdate}
onBookingDelete={onBookingDelete}
editMode={settings.bookingCardEditMode}
isOptionsExpanded={optionsExpandedBookingId === booking.id}
onOptionsToggle={() => handleOptionsToggle(booking)}
/>
))}
</div>
))}
</>
) : (

View File

@@ -1,13 +1,16 @@
.bookingsListContainer {
padding-bottom: var(--spacing-3xl);
display: flex;
flex-direction: column;
flex-direction: row;
flex-wrap: wrap;
}
.bookingsContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
width: 100%;
display: grid;
grid-template-columns: repeat(2, 1fr);
flex-wrap: wrap;
column-gap: 2rem;
}
.message {
@@ -63,4 +66,39 @@
opacity: 1;
transform: translateY(0);
}
}
.dateGroup {
margin-bottom: 2rem;
flex: 1;
min-width: 250px;
max-width: 500px;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.dateGroup:last-child {
margin-bottom: 0;
}
.dateHeader {
width: fit-content;
font-size: 0.875rem;
font-weight: 600;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 0.25rem 0;
padding: 0;
}
@media screen and (max-width: 768px) {
.bookingsContainer {
grid-template-columns: 1fr;
}
.dateGroup {
max-width: 100%;
}
}

View File

@@ -0,0 +1,47 @@
import React from 'react';
import { Button } from 'react-aria-components';
import styles from './BookingCard.module.css';
import { getTimeFromIndex } from '../../utils/bookingUtils';
export function ConfirmationDialog({
booking,
isParticipantBooking = false,
onConfirm,
onCancel
}) {
const isLeaveDialog = isParticipantBooking;
return (
<div className={styles.confirmationSection}>
<div className={styles.confirmationMessage}>
<span className={styles.warningIcon}></span>
<p>
{isLeaveDialog
? "Är du säker på att du vill lämna denna bokning?"
: "Är du säker på att du vill radera denna bokning?"
}
</p>
<p className={styles.bookingDetails}>
{isLeaveDialog
? `Du kommer inte längre att vara med på "${booking.title}" den ${booking.date.day}/${booking.date.month}`
: `"${booking.title}" den ${booking.date.day}/${booking.date.month} kl. ${getTimeFromIndex(booking.startTime)}`
}
</p>
</div>
<div className={styles.confirmationButtons}>
<Button
className={styles.confirmDeleteButton}
onPress={onConfirm}
>
{isLeaveDialog ? "Ja, lämna bokning" : "Ja, radera"}
</Button>
<Button
className={styles.cancelDeleteButton}
onPress={onCancel}
>
Avbryt
</Button>
</div>
</div>
);
}

View File

@@ -8,11 +8,11 @@
animation: slideDown 0.2s ease-out;
width: 100%;
flex-basis: 100%;
max-width: none;
position: relative;
z-index: 1;
}
/* Arrow pointing to left card */
.arrowLeft::before {
content: '';

View File

@@ -9,7 +9,6 @@ import { ParticipantsSelector } from '../forms/ParticipantsSelector';
import { useBookingContext } from '../../context/BookingContext';
import { useSettingsContext } from '../../context/SettingsContext';
import { generateId } from '../../utils/bookingUtils';
import { USER } from '../../constants/bookingConstants';
import styles from './InlineModalBookingForm.module.css';
import extendedStyles from './InlineModalExtendedBookingForm.module.css';
@@ -41,6 +40,7 @@ export function InlineModalExtendedBookingForm({
const navigate = useNavigate();
const booking = useBookingContext();
const { getCurrentUser, getDefaultBookingTitle } = useSettingsContext();
const currentUser = getCurrentUser();
// Initialize with pre-selected end time if available, or auto-select if only 30 min available
const initialEndTimeIndex = booking.selectedBookingLength > 0 ? startTimeIndex + booking.selectedBookingLength :
@@ -144,9 +144,12 @@ export function InlineModalExtendedBookingForm({
// Check if user has selected an end time (including pre-selected)
const hasSelectedEndTime = selectedEndTimeIndex !== null;
// Check if user has added at least one additional participant (beyond themselves)
const hasRequiredParticipants = booking.participants.length > 0;
const handleSave = () => {
if (hasSelectedEndTime && addBooking) {
if (hasSelectedEndTime && hasRequiredParticipants && addBooking) {
console.log('Booking context state:', {
title: booking.title,
participants: booking.participants,
@@ -158,9 +161,9 @@ export function InlineModalExtendedBookingForm({
const roomToBook = booking.selectedRoom !== "allRooms" ? booking.selectedRoom : booking.assignedRoom;
// Include the current user as a participant if not already added
const allParticipants = booking.participants.find(p => p.id === USER.id)
const allParticipants = booking.participants.find(p => p.id === currentUser.id)
? booking.participants
: [USER, ...booking.participants];
: [currentUser, ...booking.participants];
const finalTitle = booking.title !== "" ? booking.title : getDefaultBookingTitle();
@@ -201,9 +204,9 @@ export function InlineModalExtendedBookingForm({
</p>
{(() => {
// Include the current user as a participant if not already added
const allParticipants = booking.participants.find(p => p.id === USER.id)
const allParticipants = booking.participants.find(p => p.id === currentUser.id)
? booking.participants
: [USER, ...booking.participants];
: [currentUser, ...booking.participants];
const startTime = getTimeFromIndex(booking.selectedStartIndex);
const endTime = getTimeFromIndex(booking.selectedEndIndex);
@@ -306,6 +309,15 @@ export function InlineModalExtendedBookingForm({
{/* Participants Field - Compact */}
<div className={extendedStyles.section}>
<ParticipantsSelector compact={true} />
{/*!hasRequiredParticipants && (
<div style={{
fontSize: '0.8rem',
color: '#6c757d',
marginTop: '0.5rem'
}}>
💡 Lägg till minst en deltagare
</div>
)*/}
</div>
</div>
@@ -317,11 +329,13 @@ export function InlineModalExtendedBookingForm({
Avbryt
</Button>
<Button
className={`${styles.saveButton} ${!hasSelectedEndTime ? styles.disabledButton : ''}`}
onPress={hasSelectedEndTime ? handleSave : undefined}
isDisabled={!hasSelectedEndTime}
className={`${styles.saveButton} ${(!hasSelectedEndTime || !hasRequiredParticipants) ? styles.disabledButton : ''}`}
onPress={(hasSelectedEndTime && hasRequiredParticipants) ? handleSave : undefined}
isDisabled={!hasSelectedEndTime || !hasRequiredParticipants}
>
{hasSelectedEndTime ? 'Boka' : 'Välj sluttid först'}
{!hasSelectedEndTime ? 'Välj sluttid först' :
!hasRequiredParticipants ? 'Minst en till deltagare krävs' :
'Boka'}
</Button>
</div>
</div>

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { Button } from 'react-aria-components';
import styles from './BookingCard.module.css';
import { Label } from '../ui/Label';
import { ConfirmationDialog } from './ConfirmationDialog';
export function ParticipantBookingContent({
booking,
showDeleteConfirm,
onRemoveSelf,
onCancel,
onSetShowDeleteConfirm,
onCancelDelete
}) {
return (
<>
<div className={styles.readOnlySection}>
<div className={styles.readOnlyField}>
<Label>Bokning skapad av</Label>
<p className={styles.createdByText}>{booking.createdBy?.name}</p>
</div>
<div className={styles.readOnlyField}>
<Label>Deltagare</Label>
<p className={styles.participantsText}>
{booking.participants
?.filter(p => p.id !== booking.createdBy?.id)
?.map(p => p.name)
?.join(', ')}
</p>
</div>
</div>
{!showDeleteConfirm ? (
<div className={styles.buttonSection}>
<Button
className={styles.deleteButton}
onPress={() => onSetShowDeleteConfirm(true)}
>
Lämna bokning
</Button>
<Button
className={styles.cancelButton}
onPress={onCancel}
>
Stäng
</Button>
</div>
) : (
<ConfirmationDialog
booking={booking}
isParticipantBooking={true}
onConfirm={onRemoveSelf}
onCancel={onCancelDelete}
/>
)}
</>
);
}

View File

@@ -0,0 +1,27 @@
import React from 'react';
export function ParticipantsDisplay({ participants, isParticipantBooking = false, createdBy = null }) {
if (!participants || participants.length === 0) return null;
const formatName = (participant, index) => {
const firstName = participant.name.split(' ')[0];
return <span key={`participant-${participant.id}-${index}`}>{firstName}</span>;
};
if (participants.length === 1) {
return formatName(participants[0], 0);
} else if (participants.length === 2) {
return (
<>
{formatName(participants[0], 0)} and {formatName(participants[1], 1)}
</>
);
} else {
const remaining = participants.length - 2;
return (
<>
{formatName(participants[0], 0)}, {formatName(participants[1], 1)} and {remaining} more
</>
);
}
}

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { Button } from 'react-aria-components';
import styles from './BookingCard.module.css';
export function RoomInfoContent({ booking, showCloseButton = false, onClose }) {
return (
<div className={styles.roomInfoContent}>
<div className={styles.roomImageContainer}>
<img
src={`./grupprum.jpg`}
alt={`${booking.room} room`}
className={styles.roomImage}
onError={(e) => {
// Fallback to a default room image if specific room image doesn't exist
e.target.src = '/images/rooms/default-room.jpg';
}}
/>
</div>
<div className={styles.roomDetails}>
<h3 className={styles.roomTitle}>Rum: {booking.room}</h3>
<div className={styles.roomInfoGrid}>
<div className={styles.roomInfoItem}>
<span className={styles.roomInfoLabel}>Kategori:</span>
<span className={styles.roomInfoValue}>Litet grupprum</span>
</div>
<div className={styles.roomInfoItem}>
<span className={styles.roomInfoLabel}>Kapacitet:</span>
<span className={styles.roomInfoValue}>5 personer</span>
</div>
<div className={styles.roomInfoItem}>
<span className={styles.roomInfoLabel}>Utrustning:</span>
<span className={styles.roomInfoValue}>TV, Tangentbord, Whiteboard</span>
</div>
<div className={styles.roomInfoItem}>
<span className={styles.roomInfoLabel}>Övrig info:</span>
<span className={styles.roomInfoValue}>En stol trasig</span>
</div>
</div>
{showCloseButton && (
<div className={styles.roomInfoActions}>
<Button
className={styles.cancelButton}
onPress={onClose}
>
Stäng
</Button>
</div>
)}
</div>
</div>
);
}

View File

@@ -8,11 +8,21 @@ const BANNER_VARIANTS = {
title: 'Bokning bekräftad:',
className: 'success'
},
update: {
icon: '✓',
title: 'Bokning uppdaterad:',
className: 'success'
},
delete: {
icon: '🗑️',
title: 'Bokning raderad:',
className: 'delete'
},
leave: {
icon: '👋',
title: 'Du har lämnat bokningen:',
className: 'leave'
},
development: {
icon: '🔧',
title: 'Visar testdata för utveckling',

View File

@@ -7,6 +7,16 @@
align-items: center;
justify-content: space-between;
box-shadow: var(--shadow-lg);
position: sticky;
z-index: 100;
width: 100%;
top: 1rem;
}
@media screen and (max-width: 1023px) {
.banner {
top: 5rem;
}
}
.bannerContent {
@@ -111,6 +121,25 @@
color: var(--notification-error-details);
}
/* Leave variant styles */
.leave {
background: var(--notification-warning-bg);
border: var(--border-width-thin) solid var(--notification-warning-border);
}
.leaveIcon {
background: var(--notification-warning-icon-bg);
color: var(--notification-warning-icon-text);
}
.leaveTitle {
color: var(--notification-warning-title);
}
.leaveDetails {
color: var(--notification-warning-details);
}
/* Development variant styles */
.development {
background: var(--notification-warning-bg);
@@ -174,4 +203,4 @@
opacity: 1;
transform: translateY(0);
}
}
}

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { useBookingContext } from '../../context/BookingContext';
import { useSettingsContext } from '../../context/SettingsContext';
import { Label } from '../ui/Label';
import styles from './BookingTitleField.module.css';
export function BookingTitleField({ compact = false, hideLabel = false }) {
@@ -10,7 +11,7 @@ export function BookingTitleField({ compact = false, hideLabel = false }) {
return (
<>
{!hideLabel && (
<h3 className={compact ? styles.compactElementHeading : styles.elementHeading}>Titel bokning</h3>
<Label>Titel bokning</Label>
)}
<input
type="text"

View File

@@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect } from 'react';
import { PEOPLE, USER } from '../../constants/bookingConstants';
import { useBookingContext } from '../../context/BookingContext';
import { useSettingsContext } from '../../context/SettingsContext';
import { Label } from '../ui/Label';
import styles from './ParticipantsSelector.module.css';
export function ParticipantsSelector({ compact = false, hideLabel = false }) {
@@ -170,7 +171,7 @@ export function ParticipantsSelector({ compact = false, hideLabel = false }) {
return (
<div className={compact ? styles.compactContainer : styles.container}>
{!hideLabel && (
<h3 className={compact ? styles.compactElementHeading : styles.elementHeading}>Deltagare</h3>
<Label>Deltagare</Label>
)}
{/* Search Input */}
@@ -288,25 +289,29 @@ export function ParticipantsSelector({ compact = false, hideLabel = false }) {
{/* Selected Participants */}
<div className={styles.selectedParticipants}>
{/* Default User (Non-deletable) */}
<div className={`${styles.participantChip} ${styles.defaultUserChip}`}>
<span className={styles.participantName}>{getCurrentUser().name}</span>
</div>
{/* Additional Participants (Deletable) */}
{booking.participants.map((participant, index) => (
<button
key={index}
className={`${styles.participantChip} ${styles.clickableChip}`}
onClick={() => handleRemoveParticipant(participant)}
type="button"
title={`Remove ${participant.name}`}
aria-label={`Remove ${participant.name} from participants`}
>
<span className={styles.participantName}>{participant.name}</span>
<span className={styles.removeIcon}>×</span>
</button>
))}
{booking.participants.map((participant, index) => {
const isCurrentUser = participant.id === getCurrentUser().id;
return isCurrentUser ? (
/* Current user appears as non-removable pill */
<div key={index} className={`${styles.participantChip} ${styles.defaultUserChip}`}>
<span className={styles.participantName}>{participant.name}</span>
</div>
) : (
/* Other participants are removable */
<button
key={index}
className={`${styles.participantChip} ${styles.clickableChip}`}
onClick={() => handleRemoveParticipant(participant)}
type="button"
title={`Remove ${participant.name}`}
aria-label={`Remove ${participant.name} from participants`}
>
<span className={styles.participantName}>{participant.name}</span>
<span className={styles.removeIcon}>×</span>
</button>
);
})}
</div>
</div>
);

View File

@@ -4,17 +4,36 @@ import { useBookingContext } from '../../context/BookingContext';
import { useSettingsContext } from '../../context/SettingsContext';
import styles from './RoomSelectionField.module.css';
export function RoomSelectionField({ clean = false }) {
export function RoomSelectionField({ clean = false, roomTypeFilter = null }) {
const booking = useBookingContext();
const { settings } = useSettingsContext();
// Generate room options based on settings
// Generate room options based on settings and optional filter
const roomOptions = useMemo(() => {
return Array.from({ length: settings.numberOfRooms }, (_, i) => ({
value: `G5:${i + 1}`,
label: `G5:${i + 1}`,
}));
}, [settings.numberOfRooms]);
let rooms = [];
if (!roomTypeFilter || roomTypeFilter === 'litet-grupprum') {
// Add small rooms G5:1-15
rooms = rooms.concat(
Array.from({ length: settings.numberOfSmallGroupRooms }, (_, i) => ({
value: `G5:${i + 1}`,
label: `G5:${i + 1}`,
}))
);
}
if (!roomTypeFilter || roomTypeFilter === 'stort-grupprum') {
// Add large rooms G10:1-7
rooms = rooms.concat(
Array.from({ length: settings.numberOfLargeGroupRooms }, (_, i) => ({
value: `G10:${i + 1}`,
label: `G10:${i + 1}`,
}))
);
}
return rooms;
}, [settings.numberOfRooms, roomTypeFilter]);
return (
<div>

View File

@@ -70,7 +70,7 @@ const Navigation = () => {
<div className={styles.top}>
<div className={styles.left}>
<Link to="/" className={styles.logo}>
<img src="su-logo-white.svg" alt="Logo" />
<img src="/su-logo-white.svg" alt="Logo" />
</Link>
<span className={styles.brandText}>Studentportalen</span>
</div>

View File

@@ -0,0 +1,12 @@
import React from 'react';
import styles from './PageContainer.module.css';
const PageContainer = ({ children, className = '' }) => {
return (
<div className={`${styles.pageContainer} ${className}`}>
{children}
</div>
);
};
export default PageContainer;

View File

@@ -0,0 +1,12 @@
.pageContainer {
max-width: 1200px;
margin: 0 auto;
padding: var(--spacing-2xl);
min-height: 100vh;
}
@media (max-width: 768px) {
.pageContainer {
padding: var(--spacing-lg);
}
}

View File

@@ -0,0 +1,27 @@
import React from 'react';
import styles from './PageHeader.module.css';
const PageHeader = ({ title, subtitle, imageUrl, breadcrumbs }) => {
const headerClass = `${styles.header} ${imageUrl ? styles.withImage : styles.withoutImage}`;
return (
<div className={headerClass}>
{imageUrl && (
<div className={styles.imageContainer}>
<img className={styles.image} src={imageUrl} alt={title} />
</div>
)}
<div className={styles.textContent}>
{breadcrumbs && (
<div className={styles.breadcrumbsContainer}>
{breadcrumbs}
</div>
)}
<h1 className={styles.pageHeading}>{title}</h1>
{subtitle && <div className={styles.subtitle}>{subtitle}</div>}
</div>
</div>
);
};
export default PageHeader;

View File

@@ -0,0 +1,58 @@
.header {
/*margin-bottom: var(--spacing-xl);*/
padding: var(--spacing-2xl);
border-bottom: 1px solid var(--border-light);
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
}
.imageContainer {
flex-shrink: 0;
}
.image {
width: 100%;
aspect-ratio: 7 / 3;
object-fit: cover;
}
.textContent {
flex: 1;
}
.breadcrumbsContainer {
margin-bottom: var(--spacing-md);
}
.pageHeading {
color: var(--text-primary);
margin: 0;
font-size: 2rem;
font-weight: var(--font-weight-bold);
line-height: 1.2;
}
.subtitle {
color: var(--text-secondary);
font-size: 1.1rem;
font-weight: var(--font-weight-medium);
}
/* Variant with image */
.withImage {
/* Default styles for image variant */
padding: 0;
}
.withImage .textContent {
padding: var(--spacing-2xl);
padding-top: var(--spacing-md);
}
@media (min-width: 750px) {
.image {
aspect-ratio: 11/3;
}
}

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { Link } from 'react-router-dom';
import styles from './Breadcrumbs.module.css';
const Breadcrumbs = ({ items }) => {
return (
<nav className={styles.breadcrumbs} aria-label="Breadcrumb">
<ol className={styles.breadcrumbList}>
{items.map((item, index) => (
<li key={index} className={styles.breadcrumbItem}>
{index < items.length - 1 ? (
<Link to={item.path} className={styles.breadcrumbLink}>
{item.label}
</Link>
) : (
<span className={styles.breadcrumbCurrent} aria-current="page">
{item.label}
</span>
)}
{index < items.length - 1 && (
<span className={styles.breadcrumbSeparator} aria-hidden="true">
</span>
)}
</li>
))}
</ol>
</nav>
);
};
export default Breadcrumbs;

View File

@@ -0,0 +1,40 @@
.breadcrumbs {
margin-bottom: 0;
}
.breadcrumbList {
display: flex;
align-items: center;
list-style: none;
margin: 0;
padding: 0;
font-size: 0.875rem;
color: var(--text-secondary);
}
.breadcrumbItem {
display: flex;
align-items: center;
}
.breadcrumbLink {
color: var(--text-secondary);
text-decoration: none;
transition: color 0.2s ease;
}
.breadcrumbLink:hover {
color: var(--text-primary);
text-decoration: underline;
}
.breadcrumbCurrent {
color: var(--text-primary);
font-weight: 500;
}
.breadcrumbSeparator {
margin: 0 0.5rem;
color: var(--text-tertiary);
font-size: 0.75rem;
}

View File

@@ -0,0 +1,10 @@
import React from 'react';
import styles from './Label.module.css';
export function Label({ children, className = '', ...props }) {
return (
<h3 className={`${styles.label} ${className}`} {...props}>
{children}
</h3>
);
}

View File

@@ -0,0 +1,9 @@
.label {
font-size: 0.75rem;
color: var(--text-tertiary);
font-weight: 500;
margin-bottom: 0.4rem;
margin-top: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}

View File

@@ -0,0 +1,107 @@
import React, { useState } from 'react';
import { Button, Dialog, Heading, Modal } from 'react-aria-components';
import { useSettingsContext } from '../../context/SettingsContext';
import styles from './NamePrompt.module.css';
export function NamePrompt({ isOpen, onClose }) {
const { updateSettings } = useSettingsContext();
const [name, setName] = useState('');
const [error, setError] = useState('');
// Helper function to generate username from name
const generateUsername = (fullName) => {
const nameParts = fullName.trim().toLowerCase().split(' ');
if (nameParts.length >= 2) {
const firstName = nameParts[0];
const lastName = nameParts[nameParts.length - 1];
// Take first 2 chars of first name + first 2 chars of last name + random 4 digits
const randomDigits = Math.floor(1000 + Math.random() * 9000);
return `${firstName.substring(0, 2)}${lastName.substring(0, 2)}${randomDigits}`;
} else {
// Fallback for single name
const randomDigits = Math.floor(1000 + Math.random() * 9000);
return `${nameParts[0].substring(0, 4)}${randomDigits}`;
}
};
// Helper function to generate email from username
const generateEmail = (username) => {
return `${username}@student.su.se`;
};
const handleSubmit = (e) => {
e.preventDefault();
if (!name.trim()) {
setError('Vänligen ange ditt namn');
return;
}
const trimmedName = name.trim();
const generatedUsername = generateUsername(trimmedName);
const generatedEmail = generateEmail(generatedUsername);
// Update settings with name, username, and email
updateSettings({
currentUserName: trimmedName,
currentUserUsername: generatedUsername,
currentUserEmail: generatedEmail
});
localStorage.setItem('hasSeenNamePrompt', 'true');
onClose();
// Refresh the page to ensure all components get the updated user data
window.location.reload();
};
const handleInputChange = (e) => {
setName(e.target.value);
if (error) setError('');
};
return (
<Modal
isOpen={isOpen}
isDismissable={false}
className={styles.modal}
>
<Dialog className={styles.dialog}>
<div className={styles.content}>
<Heading slot="title" className={styles.title}>
Välkommen!
</Heading>
<p className={styles.description}>
För att komma igång behöver vi veta vad du heter. Ditt namn kommer att visas i bokningar och deltagarlistan.
</p>
<form onSubmit={handleSubmit} className={styles.form}>
<div className={styles.inputGroup}>
<label htmlFor="userName" className={styles.label}>
Ditt namn
</label>
<input
id="userName"
type="text"
value={name}
onChange={handleInputChange}
className={`${styles.input} ${error ? styles.inputError : ''}`}
placeholder="Ange ditt för- och efternamn"
autoFocus
/>
{error && <span className={styles.errorMessage}>{error}</span>}
</div>
<div className={styles.footer}>
<Button
type="submit"
className={styles.submitButton}
>
Fortsätt
</Button>
</div>
</form>
</div>
</Dialog>
</Modal>
);
}

View File

@@ -0,0 +1,132 @@
.modal {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
inset: 0;
z-index: 100;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.dialog {
background: white;
border-radius: 12px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
max-width: 480px;
width: 90vw;
padding: 0;
border: none;
overflow: hidden;
}
.content {
padding: 32px;
}
.title {
font-size: 24px;
font-weight: 600;
margin: 0 0 16px 0;
color: #111827;
text-align: center;
}
.description {
font-size: 16px;
line-height: 1.5;
color: #6b7280;
margin: 0 0 24px 0;
text-align: center;
}
.form {
display: flex;
flex-direction: column;
gap: 24px;
}
.inputGroup {
display: flex;
flex-direction: column;
gap: 8px;
}
.label {
font-size: 14px;
font-weight: 500;
color: #374151;
}
.input {
padding: 12px 16px;
border: 2px solid #d1d5db;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.inputError {
border-color: #ef4444;
}
.inputError:focus {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
.errorMessage {
font-size: 14px;
color: #ef4444;
margin-top: 4px;
}
.footer {
display: flex;
justify-content: center;
}
.submitButton {
background: #3b82f6;
color: white;
border: none;
border-radius: 8px;
padding: 12px 32px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
}
.submitButton:hover {
background: #2563eb;
}
.submitButton:active {
transform: translateY(1px);
}
.submitButton:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
}
@media (max-width: 640px) {
.content {
padding: 24px;
}
.title {
font-size: 20px;
}
.description {
font-size: 15px;
}
}

View File

@@ -26,7 +26,6 @@
.container:active,
.container[data-pressed] {
background-color: var(--timecard-active-bg);
transform: translateY(1px);
box-shadow: var(--timecard-active-shadow);
transition: all 0.1s ease;
}
@@ -86,6 +85,11 @@
width: 165px;
}
.unavailableSlot:active {
background-color: var(--timecard-unavailable-bg);
box-shadow: none;
}
.modalFooter {
height: fit-content;
width: 100%;
@@ -141,13 +145,11 @@
.saveButton:active {
background-color: var(--modal-save-active-bg);
transform: translateY(1px);
box-shadow: var(--modal-save-active-shadow);
}
.cancelButton:active {
background-color: var(--modal-cancel-active-bg);
transform: translateY(1px);
}
.timeSpan {

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import TimeCard from './TimeCard';
import { InlineBookingForm } from '../booking/InlineBookingForm';
@@ -11,12 +11,30 @@ import modalStyles from '../booking/BookingModal.module.css';
import { useBookingContext } from '../../context/BookingContext';
import { useSettingsContext } from '../../context/SettingsContext';
const SLOT_GROUPING_SIZE = 8;
export function TimeCardContainer({ addBooking }) {
export function TimeCardContainer({ addBooking, forceOneColumn = false }) {
const navigate = useNavigate();
const booking = useBookingContext();
const { settings } = useSettingsContext();
const [slotGroupingSize, setSlotGroupingSize] = useState(8);
const LARGE_BREAKPOINT = 1400;
useEffect(() => {
const updateGroupingSize = () => {
const width = window.innerWidth;
if (width >= LARGE_BREAKPOINT + 1) {
setSlotGroupingSize(8); // 3 columns (24÷8=3)
} else if (width >= 769) {
setSlotGroupingSize(12); // 2 columns (24÷12=2)
} else {
setSlotGroupingSize(24); // 1 column (24÷24=1)
}
};
updateGroupingSize();
window.addEventListener('resize', updateGroupingSize);
return () => window.removeEventListener('resize', updateGroupingSize);
}, []);
// Check booking form type
const useInlineForm = settings.bookingFormType === 'inline';
@@ -59,161 +77,273 @@ export function TimeCardContainer({ addBooking }) {
function slotIndiciesToColumns(originalArray) {
let newArray = [];
let currentColumn = -1;
originalArray.map(index => {
if (index % SLOT_GROUPING_SIZE == 0) {
newArray.push(new Array(0));
currentColumn++;
const width = window.innerWidth;
// Force single column if forceOneColumn prop is true
if (forceOneColumn) {
return [originalArray];
}
if (width >= 769 && width <= LARGE_BREAKPOINT) {
// For medium screens: group in pairs first, then distribute pairs across 2 columns
const pairs = [];
for (let i = 0; i < originalArray.length; i += 2) {
pairs.push([originalArray[i], originalArray[i + 1]]);
}
newArray[currentColumn].push(originalArray[index]);
});
return newArray;
// Distribute pairs across 2 columns (6 pairs per column)
const column1 = pairs.slice(0, 6).flat();
const column2 = pairs.slice(6, 12).flat();
return [column1, column2];
} else {
// For other screen sizes: use original grouping logic
let newArray = [];
let currentColumn = -1;
originalArray.map(index => {
if (index % slotGroupingSize == 0) {
newArray.push(new Array(0));
currentColumn++;
}
newArray[currentColumn].push(originalArray[index]);
});
return newArray;
}
}
const renderColumn = (column, columnIndex) => {
const width = window.innerWidth;
if (width >= 769 && width <= LARGE_BREAKPOINT && !forceOneColumn) {
// For medium screens: render pairs in rows
const pairs = [];
for (let i = 0; i < column.length; i += 2) {
pairs.push([column[i], column[i + 1]]);
}
return (
<div key={columnIndex} className={styles.column}>
{pairs.map((pair, pairIndex) => {
const elements = [];
// Render the pair
elements.push(
<div key={pairIndex} className={styles.pairRow}>
{pair.map(slotIndex => renderTimeCard(slotIndex))}
</div>
);
// Check if we need to show inline form after this pair
const selectedPairStart = Math.floor(booking.selectedStartIndex / 2) * 2;
const pairStart = pair[0];
const shouldShowForm = (useInlineForm || useInlineModal || useInlineModalExtended || useInlineModalExtendedNoLabels) &&
booking.selectedStartIndex !== null &&
pairStart === selectedPairStart;
if (shouldShowForm) {
const isLeftCard = booking.selectedStartIndex === selectedPairStart;
elements.push(renderInlineForm(pairIndex, isLeftCard));
}
return elements;
}).flat()}
</div>
);
} else if (width < 769 || forceOneColumn) {
// For mobile or forced single column: render pairs with spacing between every 4 pairs
const pairs = [];
for (let i = 0; i < column.length; i += 2) {
pairs.push([column[i], column[i + 1]]);
}
return (
<div key={columnIndex} className={styles.column}>
{pairs.map((pair, pairIndex) => {
const elements = [];
// Render the pair
elements.push(
<div key={pairIndex}>
<div className={styles.pairRow}>
{pair.map(slotIndex => renderTimeCard(slotIndex))}
</div>
{/* Add spacing after every 4th pair */}
{(pairIndex + 1) % 4 === 0 && pairIndex < pairs.length - 1 && (
<div className={styles.groupSpacer}></div>
)}
</div>
);
// Check if we need to show inline form after this pair
const selectedPairStart = Math.floor(booking.selectedStartIndex / 2) * 2;
const pairStart = pair[0];
const shouldShowForm = (useInlineForm || useInlineModal || useInlineModalExtended || useInlineModalExtendedNoLabels) &&
booking.selectedStartIndex !== null &&
pairStart === selectedPairStart;
if (shouldShowForm) {
const isLeftCard = booking.selectedStartIndex === selectedPairStart;
elements.push(renderInlineForm(pairIndex, isLeftCard));
}
return elements;
}).flat()}
</div>
);
} else {
// For large screens: render normally with original logic
return (
<div key={columnIndex} className={styles.column}>
{column.map(slotIndex => {
const elements = [];
elements.push(renderTimeCard(slotIndex));
// Add inline booking form after the pair that contains the selected time card
// Cards are laid out in pairs: (0,1), (2,3), (4,5), etc.
if ((useInlineForm || useInlineModal || useInlineModalExtended || useInlineModalExtendedNoLabels) && booking.selectedStartIndex !== null) {
const selectedPairStart = Math.floor(booking.selectedStartIndex / 2) * 2;
const selectedPairEnd = selectedPairStart + 1;
// Show form after the second card of the pair that contains the selected card
if (slotIndex === selectedPairEnd && (booking.selectedStartIndex === selectedPairStart || booking.selectedStartIndex === selectedPairEnd)) {
const isLeftCard = booking.selectedStartIndex === selectedPairStart;
elements.push(renderInlineForm(slotIndex, isLeftCard));
}
}
return elements;
}).flat()}
</div>
);
}
};
const renderInlineForm = (key, isLeftCard) => {
if (useInlineForm) {
return (
<InlineBookingForm
key={`form-${key}`}
startTimeIndex={booking.selectedStartIndex}
hoursAvailable={booking.selectedEndIndex - booking.selectedStartIndex}
onClose={() => booking.resetTimeSelections()}
arrowPointsLeft={isLeftCard}
/>
);
} else if (useInlineModal) {
return (
<InlineModalBookingForm
key={`form-${key}`}
startTimeIndex={booking.selectedStartIndex}
hoursAvailable={booking.selectedEndIndex - booking.selectedStartIndex}
endTimeIndex={booking.selectedEndIndex}
setEndTimeIndex={booking.setSelectedEndIndex}
onClose={() => booking.resetTimeSelections()}
onNavigateToDetails={handleNavigateToDetails}
arrowPointsLeft={isLeftCard}
/>
);
} else if (useInlineModalExtended) {
return (
<InlineModalExtendedBookingForm
key={`form-${key}-${booking.selectedStartIndex}`}
startTimeIndex={booking.selectedStartIndex}
hoursAvailable={booking.selectedEndIndex - booking.selectedStartIndex}
endTimeIndex={booking.selectedEndIndex}
setEndTimeIndex={booking.setSelectedEndIndex}
onClose={() => booking.resetTimeSelections()}
onNavigateToDetails={handleNavigateToDetails}
addBooking={addBooking}
arrowPointsLeft={isLeftCard}
/>
);
} else if (useInlineModalExtendedNoLabels) {
return (
<InlineModalExtendedBookingFormNoLabels
key={`form-${key}`}
startTimeIndex={booking.selectedStartIndex}
hoursAvailable={booking.selectedEndIndex - booking.selectedStartIndex}
endTimeIndex={booking.selectedEndIndex}
setEndTimeIndex={booking.setSelectedEndIndex}
onClose={() => booking.resetTimeSelections()}
onNavigateToDetails={handleNavigateToDetails}
arrowPointsLeft={isLeftCard}
/>
);
}
return null;
};
const renderTimeCard = (slotIndex) => {
let maxConsecutive = 0;
let roomId = "";
console.log(booking);
if (booking.currentRoom) {
const consecutive = countConsecutiveFromSlot(booking.currentRoom.times, slotIndex);
if (consecutive > maxConsecutive) {
maxConsecutive = consecutive;
}
} else {
booking.timeSlotsByRoom.forEach(room => {
const consecutive = countConsecutiveFromSlot(room.times, slotIndex);
if (consecutive > maxConsecutive) {
maxConsecutive = consecutive;
roomId = room.roomId;
}
});
}
if (booking.selectedBookingLength !== 0 && booking.selectedBookingLength <= maxConsecutive) {
maxConsecutive = booking.selectedBookingLength;
}
/* Set time card state here: */
let timeCardState = "unavailableSlot";
// If a booking length is pre-selected, only show slots that can accommodate the exact length
if (booking.selectedBookingLength !== 0) {
// Check if this slot can accommodate the selected booking length
const actualConsecutive = booking.currentRoom ?
countConsecutiveFromSlot(booking.currentRoom.times, slotIndex) :
Math.max(...booking.timeSlotsByRoom.map(room => countConsecutiveFromSlot(room.times, slotIndex)));
if (actualConsecutive >= booking.selectedBookingLength) {
timeCardState = "availableSlot";
}
} else {
// No pre-selected length, show if any time is available
if (maxConsecutive > 0) {
timeCardState = "availableSlot";
}
}
return (
<TimeCard
key={slotIndex}
startTimeIndex={slotIndex}
hoursAvailable={maxConsecutive}
handleClick={() => {
if (booking.selectedStartIndex === slotIndex) {
// If clicking on already selected card, close the form
booking.resetTimeSelections();
} else {
// Otherwise, select this card
booking.handleTimeCardClick(slotIndex, maxConsecutive, roomId);
}
}}
selected={slotIndex === booking.selectedStartIndex}
state={timeCardState}
handleTimeCardHover={() => {}}
handleTimeCardExit={booking.handleTimeCardExit}
/>
);
};
return (
<div>
<div className={styles.columnContainer}>
{slotIndiciesToColumns(slotIndices).map((column, index) => {
return (
<div key={index} className={styles.column}>
{column.map(slotIndex => {
let maxConsecutive = 0;
let roomId = "";
if (booking.currentRoom) {
const consecutive = countConsecutiveFromSlot(booking.currentRoom.times, slotIndex);
if (consecutive > maxConsecutive) {
maxConsecutive = consecutive;
}
} else {
booking.timeSlotsByRoom.forEach(room => {
const consecutive = countConsecutiveFromSlot(room.times, slotIndex);
if (consecutive > maxConsecutive) {
maxConsecutive = consecutive;
roomId = room.roomId;
}
});
}
if (booking.selectedBookingLength !== 0 && booking.selectedBookingLength <= maxConsecutive) {
maxConsecutive = booking.selectedBookingLength;
}
/* Set time card state here: */
let timeCardState = "unavailableSlot";
// If a booking length is pre-selected, only show slots that can accommodate the exact length
if (booking.selectedBookingLength !== 0) {
// Check if this slot can accommodate the selected booking length
const actualConsecutive = booking.currentRoom ?
countConsecutiveFromSlot(booking.currentRoom.times, slotIndex) :
Math.max(...booking.timeSlotsByRoom.map(room => countConsecutiveFromSlot(room.times, slotIndex)));
if (actualConsecutive >= booking.selectedBookingLength) {
timeCardState = "availableSlot";
}
} else {
// No pre-selected length, show if any time is available
if (maxConsecutive > 0) {
timeCardState = "availableSlot";
}
}
const elements = [];
elements.push(
<TimeCard
key={slotIndex}
startTimeIndex={slotIndex}
hoursAvailable={maxConsecutive}
handleClick={() => {
if (booking.selectedStartIndex === slotIndex) {
// If clicking on already selected card, close the form
booking.resetTimeSelections();
} else {
// Otherwise, select this card
booking.handleTimeCardClick(slotIndex, maxConsecutive, roomId);
}
}}
selected={slotIndex === booking.selectedStartIndex}
state={timeCardState}
handleTimeCardHover={() => {}}
handleTimeCardExit={booking.handleTimeCardExit}
/>
);
// Add inline booking form after the pair that contains the selected time card
// Cards are laid out in pairs: (0,1), (2,3), (4,5), etc.
if ((useInlineForm || useInlineModal || useInlineModalExtended || useInlineModalExtendedNoLabels) && booking.selectedStartIndex !== null) {
const selectedPairStart = Math.floor(booking.selectedStartIndex / 2) * 2;
const selectedPairEnd = selectedPairStart + 1;
// Show form after the second card of the pair that contains the selected card
if (slotIndex === selectedPairEnd && (booking.selectedStartIndex === selectedPairStart || booking.selectedStartIndex === selectedPairEnd)) {
const isLeftCard = booking.selectedStartIndex === selectedPairStart;
if (useInlineForm) {
elements.push(
<InlineBookingForm
key={`form-${slotIndex}`}
startTimeIndex={booking.selectedStartIndex}
hoursAvailable={booking.selectedEndIndex - booking.selectedStartIndex}
onClose={() => booking.resetTimeSelections()}
arrowPointsLeft={isLeftCard}
/>
);
} else if (useInlineModal) {
elements.push(
<InlineModalBookingForm
key={`form-${slotIndex}`}
startTimeIndex={booking.selectedStartIndex}
hoursAvailable={booking.selectedEndIndex - booking.selectedStartIndex}
endTimeIndex={booking.selectedEndIndex}
setEndTimeIndex={booking.setSelectedEndIndex}
onClose={() => booking.resetTimeSelections()}
onNavigateToDetails={handleNavigateToDetails}
arrowPointsLeft={isLeftCard}
/>
);
} else if (useInlineModalExtended) {
elements.push(
<InlineModalExtendedBookingForm
key={`form-${slotIndex}-${booking.selectedStartIndex}`}
startTimeIndex={booking.selectedStartIndex}
hoursAvailable={booking.selectedEndIndex - booking.selectedStartIndex}
endTimeIndex={booking.selectedEndIndex}
setEndTimeIndex={booking.setSelectedEndIndex}
onClose={() => booking.resetTimeSelections()}
onNavigateToDetails={handleNavigateToDetails}
addBooking={addBooking}
arrowPointsLeft={isLeftCard}
/>
);
} else if (useInlineModalExtendedNoLabels) {
elements.push(
<InlineModalExtendedBookingFormNoLabels
key={`form-${slotIndex}`}
startTimeIndex={booking.selectedStartIndex}
hoursAvailable={booking.selectedEndIndex - booking.selectedStartIndex}
endTimeIndex={booking.selectedEndIndex}
setEndTimeIndex={booking.setSelectedEndIndex}
onClose={() => booking.resetTimeSelections()}
onNavigateToDetails={handleNavigateToDetails}
arrowPointsLeft={isLeftCard}
/>
);
}
}
}
return elements;
}).flat()}
</div>
)
})}
{slotIndiciesToColumns(slotIndices).map((column, index) => renderColumn(column, index))}
</div>
{/* Show modal when a time slot is selected and using modal form type */}

View File

@@ -1,7 +1,7 @@
.columnContainer {
display: flex;
flex-direction: row;
gap: 3rem;
gap: 2rem;
height: fit-content
}
@@ -35,10 +35,63 @@
display: none; /* Chrome, Safari, Opera */
}
@media (max-width: 1200px) {
.pairRow {
display: flex;
flex-direction: row;
gap: 0.5rem;
width: 100%;
justify-content: center;
}
/* Medium screens - 2 columns with 6 rows each */
@media (max-width: 1400px) and (min-width: 769px) {
.columnContainer {
display: flex;
flex-direction: row;
gap: 3rem;
justify-content: center;
}
.column {
min-width: 300px;
width: 350px;
display: flex;
flex-direction: column;
gap: 0.5rem;
height: fit-content;
align-items: flex-start;
justify-content: flex-start;
}
.pairRow {
display: flex;
flex-direction: row;
gap: 0.5rem;
width: 100%;
}
.pairRow > * {
/*flex: 0 0 135px;*/
/*width: 135px;*/
}
}
/* Small screens - single column */
@media (max-width: 768px) {
.columnContainer {
display: flex;
flex-direction: column;
gap: 3rem;
}
.column {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
}
.groupSpacer {
height: 1.5rem;
}
}

View File

@@ -1,4 +1,6 @@
export const NUMBER_OF_ROOMS = 15;
export const NUMBER_OF_SMALL_GROUP_ROOMS = 17;
export const NUMBER_OF_LARGE_GROUP_ROOMS = 7;
export const CHANCE_OF_AVAILABILITY = 0.5;
export const DEFAULT_BOOKING_TITLE = "Jacobs bokning";
@@ -295,4 +297,47 @@ export const DEFAULT_DISABLED_OPTIONS = {
6: false,
7: false,
8: true,
};
};
export const RANDOM_BOOKING_NAMES = [
"Projektmöte grupp 7",
"Tentapluggang",
"Metodarbete",
"Grupparbete IS1",
"Redovisning projekt",
"Handledning",
"Kravanalys meeting",
"Pluggsession statistik",
"Gruppstudier DIFO",
"Projektplanering",
"Kodgranskning",
"Retrospektiv möte",
"Scrum planning",
"Design workshop",
"Testning och debug",
"Databasdesign",
"Rapport skrivning",
"Presentationsövning",
"Peer review",
"Brainstorm session",
"Algoritm genomgång",
"UX research möte",
"Prototyping",
"Stakeholder meeting",
"Code review session",
"Agile standup",
"Requirements workshop",
"System arkitektur",
"User testing",
"Demo förberedelse",
"Krisgrupp - allt är sönder",
"Panikprogrammering",
"Buggjakt extreme edition",
"Kaffepaus (viktigt möte)",
"Stack Overflow support group",
"Deadline depression circle",
"Merge conflict therapy",
"Git blame shame session",
"Procrastination workshop",
"Är det fredag än? mötet"
];

View File

@@ -1,7 +1,12 @@
import React, { createContext, useContext } from 'react';
import React, { createContext, useContext, useState } from 'react';
import { CalendarDate } from '@internationalized/date';
import { useSettingsContext } from './SettingsContext';
import { RANDOM_BOOKING_NAMES, PEOPLE } from '../constants/bookingConstants';
const BookingContext = createContext(null);
const BookingsListContext = createContext(null);
// Provider for individual booking forms (existing functionality)
export function BookingProvider({ children, value }) {
return (
<BookingContext.Provider value={value}>
@@ -10,10 +15,194 @@ export function BookingProvider({ children, value }) {
);
}
// Provider for managing the list of all bookings
export function BookingsListProvider({ children }) {
const { getCurrentUser, getEffectiveToday } = useSettingsContext();
const currentUser = getCurrentUser();
const today = getEffectiveToday();
// Helper functions for random generation
const getRandomElement = (array) => array[Math.floor(Math.random() * array.length)];
const getRandomInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
const getRoomCategory = (roomName) => {
const roomNumber = parseInt(roomName.split(':')[1]);
if (roomName.startsWith('G5:')) {
// Small rooms G5:1-15
if (roomNumber >= 1 && roomNumber <= 4) return 'green';
if (roomNumber >= 5 && roomNumber <= 8) return 'red';
if (roomNumber >= 9 && roomNumber <= 12) return 'blue';
if (roomNumber >= 13 && roomNumber <= 15) return 'yellow';
} else if (roomName.startsWith('G10:')) {
// Large rooms G10:1-7
return 'purple';
}
return 'green';
};
const generateRandomRoom = (isLargeRoom) => {
if (isLargeRoom) {
const roomNum = getRandomInt(1, 7);
return `G10:${roomNum}`;
} else {
const roomNum = getRandomInt(1, 15);
return `G5:${roomNum}`;
}
};
const generateRandomParticipants = (currentUser, isLargeRoom, isCurrentUserBooking) => {
const maxParticipants = isLargeRoom ? 10 : 5;
const minParticipants = 2; // At least creator + 1 participant
const numParticipants = getRandomInt(minParticipants, maxParticipants);
// Get random people from PEOPLE array
const shuffledPeople = [...PEOPLE].sort(() => Math.random() - 0.5);
const selectedPeople = shuffledPeople.slice(0, numParticipants - 1); // -1 because we'll add current user or creator
if (isCurrentUserBooking) {
// Current user is the creator, add them first
return [currentUser, ...selectedPeople];
} else {
// Someone else is the creator, current user may or may not be a participant
const creator = selectedPeople[0];
const otherParticipants = selectedPeople.slice(1);
// 50% chance current user is a participant in other people's bookings
if (Math.random() < 0.5) {
return [creator, currentUser, ...otherParticipants];
} else {
return [creator, ...otherParticipants];
}
}
};
// Generate bookings relative to current date
const generateInitialBookings = () => {
const numBookings = getRandomInt(2, 10);
const bookings = [];
for (let i = 0; i < numBookings; i++) {
const isLargeRoom = Math.random() < 0.2; // 20% chance of large room
const isCurrentUserBooking = Math.random() > 0.2; // 80% current user's bookings, 20% others'
const room = generateRandomRoom(isLargeRoom);
const participants = generateRandomParticipants(currentUser, isLargeRoom, isCurrentUserBooking);
const creator = participants[0];
// Random date within next 14 days
const daysFromNow = getRandomInt(0, 13);
const date = today.add({ days: daysFromNow });
// Random time slots (8:00-17:30, in 30-minute slots)
const startTimeSlot = getRandomInt(0, 19); // 8:00-17:30
const duration = getRandomInt(1, Math.min(4, 19 - startTimeSlot)); // 30min to 2h, don't exceed 17:30
const endTimeSlot = startTimeSlot + duration;
const booking = {
id: i + 1,
date: date,
startTime: startTimeSlot,
endTime: endTimeSlot,
room: room,
roomCategory: getRoomCategory(room),
title: getRandomElement(RANDOM_BOOKING_NAMES),
participants: participants,
createdBy: creator,
isParticipantBooking: !isCurrentUserBooking && participants.includes(currentUser)
};
bookings.push(booking);
}
// Sort by date and time
return bookings.sort((a, b) => {
if (a.date.compare(b.date) !== 0) {
return a.date.compare(b.date);
}
return a.startTime - b.startTime;
});
};
// Initial bookings data (relative to current date)
const [bookings, setBookings] = useState(() => generateInitialBookings());
// Banner states (moved from AppRoutes)
const [showSuccessBanner, setShowSuccessBanner] = useState(false);
const [lastCreatedBooking, setLastCreatedBooking] = useState(null);
const [showDeleteBanner, setShowDeleteBanner] = useState(false);
const [lastDeletedBooking, setLastDeletedBooking] = useState(null);
const [showLeaveBanner, setShowLeaveBanner] = useState(false);
const [lastLeftBooking, setLastLeftBooking] = useState(null);
const [showUpdateBanner, setShowUpdateBanner] = useState(false);
const [lastUpdatedBooking, setLastUpdatedBooking] = useState(null);
// Booking management functions (moved from AppRoutes)
function addBooking(newBooking) {
setBookings([...bookings, newBooking]);
setLastCreatedBooking(newBooking);
setShowSuccessBanner(true);
}
function updateBooking(updatedBooking) {
setBookings(bookings.map(booking =>
booking.id === updatedBooking.id ? updatedBooking : booking
));
setLastUpdatedBooking(updatedBooking);
setShowUpdateBanner(true);
}
function deleteBooking(bookingToDelete, actionType = 'delete') {
setBookings(bookings.filter(booking => booking.id !== bookingToDelete.id));
if (actionType === 'leave') {
setLastLeftBooking(bookingToDelete);
setShowLeaveBanner(true);
} else {
setLastDeletedBooking(bookingToDelete);
setShowDeleteBanner(true);
}
}
const value = {
bookings,
addBooking,
updateBooking,
deleteBooking,
showSuccessBanner,
setShowSuccessBanner,
lastCreatedBooking,
showDeleteBanner,
setShowDeleteBanner,
lastDeletedBooking,
showLeaveBanner,
setShowLeaveBanner,
lastLeftBooking,
showUpdateBanner,
setShowUpdateBanner,
lastUpdatedBooking
};
return (
<BookingsListContext.Provider value={value}>
{children}
</BookingsListContext.Provider>
);
}
// Hook for individual booking forms (existing functionality)
export function useBookingContext() {
const context = useContext(BookingContext);
if (!context) {
throw new Error('useBookingContext must be used within a BookingProvider');
}
return context;
}
// Hook for accessing the list of all bookings
export function useBookingsListContext() {
const context = useContext(BookingsListContext);
if (!context) {
throw new Error('useBookingsListContext must be used within a BookingsListProvider');
}
return context;
}

View File

@@ -1,9 +1,36 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { today, getLocalTimeZone, CalendarDate } from '@internationalized/date';
import { USER } from '../constants/bookingConstants';
import {
USER,
CHANCE_OF_AVAILABILITY,
NUMBER_OF_SMALL_GROUP_ROOMS,
NUMBER_OF_LARGE_GROUP_ROOMS,
} from '../constants/bookingConstants';
const SettingsContext = createContext();
// Single source of truth for default settings
const DEFAULT_SETTINGS = {
mockToday: null,
bookingRangeDays: 14,
roomAvailabilityChance: CHANCE_OF_AVAILABILITY,
numberOfRooms: 5,
numberOfSmallGroupRooms: NUMBER_OF_SMALL_GROUP_ROOMS,
numberOfLargeGroupRooms: NUMBER_OF_LARGE_GROUP_ROOMS,
earliestTimeSlot: 0,
latestTimeSlot: 23,
currentUserName: USER.name,
currentUserUsername: USER.username,
currentUserEmail: USER.email,
showDevelopmentBanner: false,
showBookingConfirmationBanner: false,
showBookingDeleteBanner: false,
bookingFormType: 'inline-modal-extended',
showFiltersAlways: true,
newBookingLayoutVariant: false,
bookingCardEditMode: 'inline',
};
export const useSettingsContext = () => {
const context = useContext(SettingsContext);
if (!context) {
@@ -20,59 +47,32 @@ export const SettingsProvider = ({ children }) => {
try {
const parsed = JSON.parse(saved);
return {
// Set defaults first
mockToday: null,
bookingRangeDays: 14,
roomAvailabilityChance: 0.7,
numberOfRooms: 5,
earliestTimeSlot: 0,
latestTimeSlot: 23,
currentUserName: USER.name,
showDevelopmentBanner: false,
showBookingConfirmationBanner: false,
showBookingDeleteBanner: false,
bookingFormType: 'inline', // 'modal' or 'inline'
showFiltersAlways: false, // Show filter dropdowns always or behind toggle button
// Then override with saved values
// Start with defaults
...DEFAULT_SETTINGS,
// Override with saved values
...parsed,
// Convert date strings back to DateValue objects
mockToday: parsed.mockToday ? new Date(parsed.mockToday) : null,
// Ensure currentUserName has a fallback
// Ensure user fields have fallbacks
currentUserName: parsed.currentUserName || USER.name,
currentUserUsername: parsed.currentUserUsername || USER.username,
currentUserEmail: parsed.currentUserEmail || USER.email,
};
} catch (e) {
console.warn('Failed to parse saved settings:', e);
}
}
return {
// Use mock date if set, otherwise real today
mockToday: null,
// Days in the future users can book
bookingRangeDays: 14,
// Room availability percentage
roomAvailabilityChance: 0.7,
// Number of rooms
numberOfRooms: 5,
// Earliest booking time (in half-hour slots from 8:00)
earliestTimeSlot: 0, // 8:00
// Latest booking time
latestTimeSlot: 23, // 19:30 (last slot ending at 20:00)
// Current user settings
currentUserName: USER.name,
// Development banner toggle
showDevelopmentBanner: false,
// Booking confirmation banner toggle
showBookingConfirmationBanner: false,
// Booking delete banner toggle
showBookingDeleteBanner: false,
// Booking form type
bookingFormType: 'inline', // 'modal' or 'inline'
// Filter display mode
showFiltersAlways: false, // Show filter dropdowns always or behind toggle button
};
return DEFAULT_SETTINGS;
});
// Check if user should see name prompt
const shouldShowNamePrompt = () => {
const hasSeenPrompt = localStorage.getItem('hasSeenNamePrompt');
const hasCustomName = settings.currentUserName !== USER.name;
return !hasSeenPrompt && !hasCustomName;
};
// Save settings to localStorage whenever they change
useEffect(() => {
const toSave = {
@@ -102,20 +102,7 @@ export const SettingsProvider = ({ children }) => {
};
const resetSettings = () => {
setSettings({
mockToday: null,
bookingRangeDays: 14,
roomAvailabilityChance: 0.7,
numberOfRooms: 5,
earliestTimeSlot: 0,
latestTimeSlot: 23,
currentUserName: USER.name, // This will reset to "Jacob Reinikainen"
showDevelopmentBanner: false,
showBookingConfirmationBanner: false,
showBookingDeleteBanner: false,
bookingFormType: 'inline',
showFiltersAlways: false,
});
setSettings(DEFAULT_SETTINGS);
localStorage.removeItem('calendarSettings');
};
@@ -124,8 +111,8 @@ export const SettingsProvider = ({ children }) => {
return {
id: USER.id,
name: settings.currentUserName,
username: USER.username,
email: USER.email
username: settings.currentUserUsername,
email: settings.currentUserEmail
};
};
@@ -143,6 +130,7 @@ export const SettingsProvider = ({ children }) => {
getEffectiveToday,
getCurrentUser,
getDefaultBookingTitle,
shouldShowNamePrompt,
}}>
{children}
</SettingsContext.Provider>

View File

@@ -0,0 +1,147 @@
import { useCallback } from 'react';
import { PEOPLE } from '../constants/bookingConstants';
import { hasBookingChanges } from '../utils/bookingUtils';
export function useBookingActions(booking, bookingState, onBookingUpdate, onBookingDelete, onClick, effectiveEditMode, setIsModalOpen, onOptionsToggle, isOptionsExpanded) {
const {
calculatedEndTime,
setCalculatedEndTime,
editedTitle,
setEditedTitle,
editedParticipants,
setEditedParticipants,
resetState,
setShowDeleteConfirm,
setActiveView,
setIsRoomInfoModalOpen
} = bookingState;
const handleLengthChange = useCallback((event) => {
const endTimeValue = event.target.value === "" ? null : parseInt(event.target.value);
setCalculatedEndTime(endTimeValue);
if (endTimeValue === null) {
setCalculatedEndTime(booking.endTime);
}
}, [booking.endTime, setCalculatedEndTime]);
const handleParticipantChange = useCallback((participantId) => {
const participant = PEOPLE.find(p => p.id === participantId);
if (participant && !editedParticipants.find(p => p.id === participantId)) {
setEditedParticipants(participants => [...participants, participant]);
}
}, [editedParticipants, setEditedParticipants]);
const handleRemoveParticipant = useCallback((participantToRemove) => {
setEditedParticipants(participants =>
participants.filter(p => p.id !== participantToRemove.id)
);
}, [setEditedParticipants]);
const hasChanges = useCallback(() => {
return hasBookingChanges(booking, editedTitle, editedParticipants, calculatedEndTime);
}, [booking, editedTitle, editedParticipants, calculatedEndTime]);
const handleSave = useCallback(() => {
if (hasChanges() && onBookingUpdate) {
const updatedBooking = {
...booking,
title: editedTitle,
participants: editedParticipants,
endTime: calculatedEndTime
};
onBookingUpdate(updatedBooking);
}
if (effectiveEditMode === 'modal') {
setIsModalOpen(false);
setActiveView('closed');
// Close options accordion if it's expanded
if (isOptionsExpanded) {
onOptionsToggle();
}
} else {
onClick(); // Close the expanded view
}
}, [hasChanges, onBookingUpdate, booking, editedTitle, editedParticipants, calculatedEndTime, effectiveEditMode, setIsModalOpen, onClick, setActiveView, onOptionsToggle, isOptionsExpanded]);
const handleCancel = useCallback(() => {
resetState();
if (effectiveEditMode === 'modal') {
setIsModalOpen(false);
setActiveView('closed');
} else {
onClick(); // Close the expanded view
}
}, [resetState, effectiveEditMode, setIsModalOpen, onClick, setActiveView]);
const handleDelete = useCallback(() => {
setShowDeleteConfirm(true);
}, [setShowDeleteConfirm]);
const confirmDelete = useCallback(() => {
if (onBookingDelete) {
onBookingDelete(booking);
}
setShowDeleteConfirm(false);
}, [onBookingDelete, booking, setShowDeleteConfirm]);
const cancelDelete = useCallback(() => {
setShowDeleteConfirm(false);
}, [setShowDeleteConfirm]);
const handleRemoveSelf = useCallback(() => {
if (onBookingDelete) {
onBookingDelete(booking, 'leave');
}
}, [onBookingDelete, booking]);
const handleRoomInfo = useCallback((effectiveEditMode, isOptionsExpanded, onOptionsToggle, isExpanded) => {
setActiveView('lokalinfo');
if (effectiveEditMode === 'modal') {
// For modal mode, don't close accordion - keep it for when modal closes
setIsRoomInfoModalOpen(true);
} else {
// For inline mode
if (isOptionsExpanded) {
onOptionsToggle();
}
if (!isExpanded) {
onClick();
}
}
}, [setActiveView, setIsRoomInfoModalOpen, onClick]);
const handleManageBooking = useCallback((effectiveEditMode, isOptionsExpanded, onOptionsToggle, isExpanded) => {
setActiveView('hantera');
if (effectiveEditMode === 'modal') {
setIsModalOpen(true);
// Don't close the options accordion for modal mode - keep it for when modal closes
} else {
if (isOptionsExpanded) {
onOptionsToggle(); // Close the options accordion only for inline mode
}
if (!isExpanded) {
onClick(); // Open inline expansion
}
}
}, [setActiveView, setIsModalOpen, onClick]);
return {
handleLengthChange,
handleParticipantChange,
handleRemoveParticipant,
hasChanges,
handleSave,
handleCancel,
handleDelete,
confirmDelete,
cancelDelete,
handleRemoveSelf,
handleRoomInfo,
handleManageBooking
};
}

View File

@@ -0,0 +1,41 @@
import { useState, useEffect } from 'react';
export function useBookingCardState(booking, isExpanded, isModalOpen) {
const [calculatedEndTime, setCalculatedEndTime] = useState(null);
const [editedTitle, setEditedTitle] = useState('');
const [editedParticipants, setEditedParticipants] = useState([]);
const [activeView, setActiveView] = useState('closed'); // 'hantera', 'lokalinfo', or 'closed'
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isRoomInfoModalOpen, setIsRoomInfoModalOpen] = useState(false);
// Initialize state when card expands or modal opens
useEffect(() => {
if (isExpanded || isModalOpen) {
setCalculatedEndTime(booking.endTime);
setEditedTitle(booking.title);
setEditedParticipants(booking.participants || []);
}
}, [isExpanded, isModalOpen, booking]);
const resetState = () => {
setCalculatedEndTime(booking.endTime);
setEditedTitle(booking.title);
setEditedParticipants(booking.participants || []);
};
return {
calculatedEndTime,
setCalculatedEndTime,
editedTitle,
setEditedTitle,
editedParticipants,
setEditedParticipants,
activeView,
setActiveView,
showDeleteConfirm,
setShowDeleteConfirm,
isRoomInfoModalOpen,
setIsRoomInfoModalOpen,
resetState
};
}

View File

@@ -0,0 +1,48 @@
import { useState, useEffect } from 'react';
export function useResponsiveMode(editMode, isExpanded, isModalOpen, onClick, setIsModalOpen) {
const [windowWidth, setWindowWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1024);
const [previousWidth, setPreviousWidth] = useState(windowWidth);
// Handle window resize for responsive mode
useEffect(() => {
const handleResize = () => {
setPreviousWidth(windowWidth);
setWindowWidth(window.innerWidth);
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [windowWidth]);
// Determine effective edit mode based on settings and screen width
const effectiveEditMode = editMode === 'responsive'
? (windowWidth > 780 ? 'modal' : 'inline')
: editMode;
// Handle mode transitions when window is resized
useEffect(() => {
if (editMode === 'responsive') {
const wasInlineMode = previousWidth <= 780;
const isNowModalMode = windowWidth > 780;
const wasModalMode = previousWidth > 780;
const isNowInlineMode = windowWidth <= 780;
// If card was expanded inline and window becomes wide, switch to modal
if (isExpanded && wasInlineMode && isNowModalMode) {
setIsModalOpen(true);
onClick(); // Close inline expansion
}
// If modal was open and window becomes narrow, switch to inline
else if (isModalOpen && wasModalMode && isNowInlineMode) {
setIsModalOpen(false);
onClick(); // Open inline expansion
}
}
}, [windowWidth, previousWidth, editMode, isExpanded, isModalOpen, onClick, setIsModalOpen]);
return {
windowWidth,
effectiveEditMode
};
}

View File

@@ -196,6 +196,63 @@ export function BookingSettings() {
<strong>Show Filter Button:</strong> Filters are hidden behind a collapsible button
</div>
</div>
<div className={styles.setting}>
<label htmlFor="newBookingLayoutVariant">
<strong>New Booking Page Layout</strong>
<span className={styles.description}>
Choose between stacked layout or side-by-side layout on medium screens
</span>
</label>
<div className={styles.toggleGroup}>
<input
id="newBookingLayoutVariant"
type="checkbox"
checked={settings.newBookingLayoutVariant}
onChange={(e) => updateSettings({ newBookingLayoutVariant: e.target.checked })}
className={styles.toggle}
/>
<span className={styles.toggleStatus}>
{settings.newBookingLayoutVariant ? 'Side-by-Side' : 'Stacked'}
</span>
</div>
<div className={styles.description} style={{marginTop: '0.5rem', fontSize: '0.85rem'}}>
<strong>Stacked:</strong> Image and header at top, booking times below<br/>
<strong>Side-by-Side:</strong> Image/header on left, booking times on right (medium screens)
</div>
</div>
<div className={styles.setting}>
<label htmlFor="bookingCardEditMode">
<strong>Booking Card Edit Mode</strong>
<span className={styles.description}>
Choose how booking cards open when clicked for editing
</span>
</label>
<select
id="bookingCardEditMode"
value={settings.bookingCardEditMode}
onChange={(e) => updateSettings({ bookingCardEditMode: e.target.value })}
className={styles.select}
>
<option value="inline">Inline Expansion</option>
<option value="modal">Modal Popup</option>
<option value="responsive">Responsive (Modal on Desktop)</option>
</select>
<div className={styles.currentStatus}>
Current: <strong>
{settings.bookingCardEditMode === 'inline' ? 'Inline Expansion' :
settings.bookingCardEditMode === 'modal' ? 'Modal Popup' :
settings.bookingCardEditMode === 'responsive' ? 'Responsive (Modal on Desktop)' :
'Unknown'}
</strong>
</div>
<div className={styles.description} style={{marginTop: '0.5rem', fontSize: '0.85rem'}}>
<strong>Inline Expansion:</strong> Card expands directly in the list for editing<br/>
<strong>Modal Popup:</strong> Always opens in a centered modal dialog<br/>
<strong>Responsive:</strong> Inline on mobile (&le;780px), modal on desktop (&gt;780px)
</div>
</div>
</div>
<div className={styles.section}>

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import styles from './NewBooking.module.css';
import { TimeCardContainer } from '../components/ui/TimeCardContainer';
import { BookingDatePicker } from '../components/forms/BookingDatePicker';
@@ -7,8 +8,12 @@ import { BookingLengthField } from '../components/forms/BookingLengthField';
import { useBookingState } from '../hooks/useBookingState';
import { BookingProvider } from '../context/BookingContext';
import { useSettingsContext } from '../context/SettingsContext';
import PageHeader from '../components/layout/PageHeader';
import PageContainer from '../components/layout/PageContainer';
import Breadcrumbs from '../components/ui/Breadcrumbs';
export function NewBooking({ addBooking }) {
const { roomType } = useParams();
const { getEffectiveToday, settings } = useSettingsContext();
const booking = useBookingState(addBooking, getEffectiveToday());
const [showFilters, setShowFilters] = useState(false);
@@ -20,6 +25,31 @@ export function NewBooking({ addBooking }) {
// Check if we should use inline form (hide title and participants from main form)
const useInlineForm = settings.bookingFormType === 'inline';
// Get page title, subtitle, and image based on room type
const getPageInfo = () => {
if (roomType === 'litet-grupprum') {
return {
title: 'Litet grupprum',
subtitle: 'Plats för upp till 5 personer',
imageUrl: '/grupprum.jpg'
};
} else if (roomType === 'stort-grupprum') {
return {
title: 'Stort grupprum',
subtitle: 'Plats för upp till 10 personer',
imageUrl: '/stort-grupprum.jpg'
};
} else {
return {
title: 'Boka grupprum',
subtitle: 'Välj från tillgängliga rum',
imageUrl: '/grupprum.jpg'
};
}
};
const { title: pageTitle, subtitle: pageSubtitle, imageUrl } = getPageInfo();
// Check if any filters are active
const hasActiveFilters = booking.selectedRoom !== "allRooms" || booking.selectedBookingLength > 0;
@@ -56,73 +86,166 @@ export function NewBooking({ addBooking }) {
setShowFilters(false);
};
const breadcrumbItems = [
{ label: 'Lokalbokning', path: '/' },
{ label: 'Ny bokning', path: '/new-booking' }
];
return (
<BookingProvider value={booking}>
<div className={styles.pageContainer}>
<h2>Boka litet grupprum</h2>
<div className={styles.formContainer}>
<main style={{ flex: 1 }}>
<div className={styles.bookingTimesContainer}>
<BookingDatePicker />
{/* Filter Section */}
<div className={styles.headerAndFilter}>
<div className={styles.filtersSection}>
{settings.showFiltersAlways ? (
/* Always-visible filters */
<div className={styles.filtersContent}>
<div className={styles.filtersRow}>
<RoomSelectionField />
<BookingLengthField />
</div>
</div>
) : (
/* Toggle button with collapsible filters */
<>
<button
className={`${styles.filterButton} ${hasActiveFilters ? styles.activeFilter : ''}`}
onClick={() => setShowFilters(!showFilters)}
>
<span>{getFilterText()}</span>
<span className={`${styles.chevron} ${showFilters ? styles.chevronUp : styles.chevronDown}`}>
{showFilters ? '▲' : '▼'}
</span>
</button>
<div className={`${styles.newBookingPageContainer} ${settings.newBookingLayoutVariant ? styles.sideBySide : ''}`}>
{settings.newBookingLayoutVariant ? (
/* Side-by-side layout */
<>
<div className={styles.headerSection}>
<PageHeader
title={pageTitle}
subtitle={pageSubtitle}
imageUrl={imageUrl}
breadcrumbs={<Breadcrumbs items={breadcrumbItems} />}
/>
</div>
<div className={styles.contentSection}>
<PageContainer>
<div className={styles.formContainer}>
<main style={{ flex: 1 }}>
<div className={styles.bookingTimesContainer}>
<BookingDatePicker />
{/* Collapsible Filter Content */}
{showFilters && (
<div className={styles.filtersContent}>
<div className={styles.filtersRow}>
<RoomSelectionField />
<BookingLengthField />
</div>
{hasActiveFilters && (
<div className={styles.resetSection}>
<button
className={styles.resetButton}
onClick={handleResetFilters}
>
Rensa filter
</button>
{/* Filter Section */}
<div className={styles.headerAndFilter}>
<div className={styles.filtersSection}>
{settings.showFiltersAlways ? (
/* Always-visible filters */
<div className={styles.filtersContent}>
<div className={styles.filtersRow}>
<RoomSelectionField roomTypeFilter={roomType} />
<BookingLengthField />
</div>
</div>
) : (
/* Toggle button with collapsible filters */
<>
<button
className={`${styles.filterButton} ${hasActiveFilters ? styles.activeFilter : ''}`}
onClick={() => setShowFilters(!showFilters)}
>
<span>{getFilterText()}</span>
<span className={`${styles.chevron} ${showFilters ? styles.chevronUp : styles.chevronDown}`}>
{showFilters ? '▲' : '▼'}
</span>
</button>
{/* Collapsible Filter Content */}
{showFilters && (
<div className={styles.filtersContent}>
<div className={styles.filtersRow}>
<RoomSelectionField roomTypeFilter={roomType} />
<BookingLengthField />
</div>
{hasActiveFilters && (
<div className={styles.resetSection}>
<button
className={styles.resetButton}
onClick={handleResetFilters}
>
Rensa filter
</button>
</div>
)}
</div>
)}
</>
)}
</div>
)}
</>
)}
<h3 className={styles.elementHeading} style={{ padding: "0 0.5rem" }}>
Välj starttid
</h3>
</div>
<div>
<TimeCardContainer addBooking={addBooking} forceOneColumn={settings.newBookingLayoutVariant} />
</div>
</div>
</main>
</div>
<h3 className={styles.elementHeading} style={{ padding: "0 0.5rem" }}>
Välj starttid
</h3>
</div>
<div>
<TimeCardContainer addBooking={addBooking} />
</div>
</PageContainer>
</div>
</main>
</div>
</>
) : (
/* Stacked layout (original) */
<>
<PageHeader
title={pageTitle}
subtitle={pageSubtitle}
imageUrl={imageUrl}
breadcrumbs={<Breadcrumbs items={breadcrumbItems} />}
/>
<div className={styles.formContainer}>
<main style={{ flex: 1 }}>
<div className={styles.bookingTimesContainer}>
<BookingDatePicker />
{/* Filter Section */}
<div className={styles.headerAndFilter}>
<div className={styles.filtersSection}>
{settings.showFiltersAlways ? (
/* Always-visible filters */
<div className={styles.filtersContent}>
<div className={styles.filtersRow}>
<RoomSelectionField roomTypeFilter={roomType} />
<BookingLengthField />
</div>
</div>
) : (
/* Toggle button with collapsible filters */
<>
<button
className={`${styles.filterButton} ${hasActiveFilters ? styles.activeFilter : ''}`}
onClick={() => setShowFilters(!showFilters)}
>
<span>{getFilterText()}</span>
<span className={`${styles.chevron} ${showFilters ? styles.chevronUp : styles.chevronDown}`}>
{showFilters ? '▲' : '▼'}
</span>
</button>
{/* Collapsible Filter Content */}
{showFilters && (
<div className={styles.filtersContent}>
<div className={styles.filtersRow}>
<RoomSelectionField roomTypeFilter={roomType} />
<BookingLengthField />
</div>
{hasActiveFilters && (
<div className={styles.resetSection}>
<button
className={styles.resetButton}
onClick={handleResetFilters}
>
Rensa filter
</button>
</div>
)}
</div>
)}
</>
)}
</div>
<h3 className={styles.elementHeading} style={{ padding: "0 0.5rem" }}>
Välj starttid
</h3>
</div>
<div>
<TimeCardContainer addBooking={addBooking} forceOneColumn={false} />
</div>
</div>
</main>
</div>
</>
)}
</div>
</BookingProvider>
);

View File

@@ -1,10 +1,19 @@
.pageContainer {
padding: var(--container-padding);
/*padding: var(--container-padding);*/
background-color: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
}
.roomImage {
width: 100%;
height: auto;
margin-bottom: 1rem;
object-fit: cover;
aspect-ratio: 7/3;
}
.formContainer {
/*padding: 1rem;*/
min-height: 100%;
@@ -26,13 +35,12 @@
}
.bookingTimesContainer {
margin-top: 2rem;
border-radius: 0.3rem;
outline: 1px solid var(--border-light);
display: flex;
flex-direction: column;
align-items: center;
background-color: var(--bg-secondary);
background-color: var(--bg-primary);
padding-bottom: 2rem;
}
@@ -293,4 +301,37 @@
opacity: 1;
transform: translateY(0);
}
}
/* Side-by-side layout variant */
.newBookingPageContainer {
/*padding: var(--spacing-lg);*/
}
.newBookingPageContainer.sideBySide {
display: flex;
flex-direction: column;
}
@media (min-width: 769px) and (max-width: 1400px) {
.newBookingPageContainer.sideBySide {
display: flex;
flex-direction: row;
gap: 2rem;
align-items: flex-start;
}
.newBookingPageContainer.sideBySide .headerSection {
flex: 0 0 400px;
max-width: 400px;
}
.newBookingPageContainer.sideBySide .contentSection {
flex: 1;
min-width: 0;
}
.newBookingPageContainer.sideBySide .bookingTimesContainer {
margin-top: 0;
}
}

View File

@@ -6,6 +6,8 @@ import styles from './Profile.module.css';
const Profile = () => {
const { getCurrentUser } = useSettingsContext();
const user = getCurrentUser();
console.log(user, "Current User Data");
// Helper function to get user's initials
const getInitials = (name) => {

View File

@@ -5,8 +5,10 @@ import BookingsList from '../components/booking/BookingsList';
import Card from '../components/ui/Card';
import { useSettingsContext } from '../context/SettingsContext';
import { USER } from '../constants/bookingConstants';
import PageHeader from '../components/layout/PageHeader';
import PageContainer from '../components/layout/PageContainer';
export function RoomBooking({ bookings, showSuccessBanner, lastCreatedBooking, onDismissBanner, onBookingUpdate, onBookingDelete, showDeleteBanner, lastDeletedBooking, onDismissDeleteBanner }) {
export function RoomBooking({ bookings, showSuccessBanner, lastCreatedBooking, onDismissBanner, onBookingUpdate, onBookingDelete, showDeleteBanner, lastDeletedBooking, onDismissDeleteBanner, showLeaveBanner, lastLeftBooking, onDismissLeaveBanner, showUpdateBanner, lastUpdatedBooking, onDismissUpdateBanner }) {
const { settings } = useSettingsContext();
useEffect(() => {
@@ -23,50 +25,47 @@ export function RoomBooking({ bookings, showSuccessBanner, lastCreatedBooking, o
}
return (
<div className={styles.pageContainer}>
{isTestSessionActive && (
<div className={styles.welcomeSection}>
<div className={styles.welcomeContent}>
<h1 className={styles.welcomeTitle}>Välkommen, {settings.currentUserName}!</h1>
<p className={styles.welcomeSubtitle}>Hantera dina bokningar och reservera nya lokaler</p>
</div>
<>
<PageHeader title="Lokalbokning" subtitle="Reservera lokaler för möten och studier" />
<PageContainer>
<h2 className={styles.sectionHeading}>Ny bokning</h2>
<div className={styles.roomCategoryCards}>
<Link to='/new-booking/litet-grupprum'>
<Card imageUrl="./grupprum.jpg" header="Litet grupprum" subheader="Plats för 5 personer" />
</Link>
<Link to='/new-booking/stort-grupprum'>
<Card imageUrl="./stort-grupprum.jpg" header="Stort grupprum" subheader="Plats för 10 personer" />
</Link>
</div>
)}
<div className={styles.header}>
<h1 className={styles.pageHeading}>Lokalbokning</h1>
<div className={styles.subtitle}>Reservera lokaler för möten och studier</div>
</div>
<h2 className={styles.sectionHeading}>Ny bokning</h2>
<hr className={styles.sectionDivider} />
<div className={styles.roomCategoryCards}>
<Link to='/new-booking'>
<Card imageUrl="./grupprum.jpg" header="Litet grupprum" subheader="Plats för 5 personer" />
</Link>
<Link to='/new-booking'>
<Card imageUrl="./stort-grupprum.jpg" header="Stort grupprum" subheader="Plats för 10 personer" />
</Link>
</div>
<hr className={styles.sectionDivider} />
<section id="bookings">
<h2 className={styles.sectionHeading}>Mina bokingar</h2>
<BookingsList
bookings={bookings}
handleEditBooking={handleEditBooking}
onBookingUpdate={onBookingUpdate}
onBookingDelete={onBookingDelete}
showSuccessBanner={showSuccessBanner}
lastCreatedBooking={lastCreatedBooking}
onDismissBanner={onDismissBanner}
showDeleteBanner={showDeleteBanner}
lastDeletedBooking={lastDeletedBooking}
onDismissDeleteBanner={onDismissDeleteBanner}
showDevelopmentBanner={settings.showDevelopmentBanner}
showBookingConfirmationBanner={settings.showBookingConfirmationBanner}
showBookingDeleteBanner={settings.showBookingDeleteBanner}
/>
</section>
</div>
<section id="bookings">
<h2 className={styles.sectionHeading}>Mina bokingar</h2>
<BookingsList
bookings={bookings}
handleEditBooking={handleEditBooking}
onBookingUpdate={onBookingUpdate}
onBookingDelete={onBookingDelete}
showSuccessBanner={showSuccessBanner}
lastCreatedBooking={lastCreatedBooking}
onDismissBanner={onDismissBanner}
showDeleteBanner={showDeleteBanner}
lastDeletedBooking={lastDeletedBooking}
onDismissDeleteBanner={onDismissDeleteBanner}
showLeaveBanner={showLeaveBanner}
lastLeftBooking={lastLeftBooking}
onDismissLeaveBanner={onDismissLeaveBanner}
showUpdateBanner={showUpdateBanner}
lastUpdatedBooking={lastUpdatedBooking}
onDismissUpdateBanner={onDismissUpdateBanner}
showDevelopmentBanner={settings.showDevelopmentBanner}
showBookingConfirmationBanner={settings.showBookingConfirmationBanner}
showBookingDeleteBanner={settings.showBookingDeleteBanner}
/>
</section>
</PageContainer>
</>
);
}

View File

@@ -0,0 +1,241 @@
import React, { useState } from 'react';
import styles from './RoomSchedules.module.css';
import PageHeader from '../components/layout/PageHeader';
import PageContainer from '../components/layout/PageContainer';
const RoomSchedules = () => {
const [selectedBooking, setSelectedBooking] = useState(null);
const [showModal, setShowModal] = useState(false);
const timeSlots = [
'08:00', '09:00', '10:00', '11:00', '12:00', '13:00', '14:00',
'15:00', '16:00', '17:00', '18:00', '19:00', '20:00', '21:00',
'22:00', '23:00', '00:00'
];
const rooms = [
{
name: 'Lilla hörsalen',
bookings: [
{
id: 1,
course: 'MDI S2 grp 7',
time: '08:00 - 08:30',
instructor: 'Anna Andersson',
participants: 12,
description: 'Medicinsk datavetenskap seminarium'
},
{
id: 2,
course: 'MDI S2 grp 7',
time: '08:30 - 09:00',
instructor: 'Anna Andersson',
participants: 12,
description: 'Medicinsk datavetenskap seminarium'
},
{
id: 3,
course: 'INTROPROG F12',
time: '09:00 - 09:30',
instructor: 'Erik Larsson',
participants: 25,
description: 'Introduktion till programmering - föreläsning'
}
]
},
{
name: 'Aula NOD',
bookings: [
{
id: 4,
course: 'MDI S2 grp 7',
time: '08:00 - 08:30',
instructor: 'Anna Andersson',
participants: 12,
description: 'Medicinsk datavetenskap seminarium'
},
{
id: 5,
course: 'MDI S2 grp 7',
time: '08:30 - 09:00',
instructor: 'Anna Andersson',
participants: 12,
description: 'Medicinsk datavetenskap seminarium'
},
{
id: 6,
course: 'INTROPROG F12',
time: '09:00 - 09:30',
instructor: 'Erik Larsson',
participants: 25,
description: 'Introduktion till programmering - föreläsning'
},
{
id: 7,
course: 'DB L6 grp 2',
time: '10:00 - 12:45',
instructor: 'Sofia Karlsson',
participants: 18,
description: 'Databaser - laboration 6'
},
{
id: 8,
course: 'INTROPROG F12',
time: '13:00 - 14:00',
instructor: 'Erik Larsson',
participants: 25,
description: 'Introduktion till programmering - föreläsning'
}
]
},
{
name: 'G5:7',
bookings: [
{
id: 9,
course: 'Team standup',
time: '10:00 - 12:00',
instructor: 'Magnus Nilsson',
participants: 5,
description: 'Daglig standup för utvecklingsteam'
}
]
},
{
name: 'G5:12',
bookings: [
{
id: 10,
course: 'Project planning',
time: '16:00 - 20:00',
instructor: 'Emma Johansson',
participants: 8,
description: 'Projektplanering för kommande sprint'
}
]
}
];
const getBookingForTimeSlot = (room, timeSlot) => {
return room.bookings.find(booking => {
const [startTime] = booking.time.split(' - ');
return startTime === timeSlot;
});
};
const getBookingSpan = (booking) => {
const [startTime, endTime] = booking.time.split(' - ');
const startHour = parseInt(startTime.split(':')[0]);
const endHour = parseInt(endTime.split(':')[0]);
const startMinutes = parseInt(startTime.split(':')[1]);
const endMinutes = parseInt(endTime.split(':')[1]);
const startTotalMinutes = startHour * 60 + startMinutes;
const endTotalMinutes = endHour * 60 + endMinutes;
const durationMinutes = endTotalMinutes - startTotalMinutes;
return Math.max(1, Math.round(durationMinutes / 60));
};
const handleBookingClick = (booking, roomName) => {
setSelectedBooking({ ...booking, room: roomName });
setShowModal(true);
};
const closeModal = () => {
setShowModal(false);
setSelectedBooking(null);
};
return (
<PageContainer>
<PageHeader
title="Room Schedules"
subtitle="View current room bookings and availability"
/>
<div className={styles.ScheduleWrapper}>
<div className={styles.TimeColumn}>
<div className={styles.TimeHeader}></div>
{timeSlots.map((time, index) => (
<div key={index} className={styles.TimeSlot}>
{time}
</div>
))}
</div>
<div className={styles.RoomsContainer}>
{rooms.map((room, roomIndex) => (
<div key={roomIndex} className={styles.RoomColumn}>
<div className={styles.RoomHeader}>
{room.name}
</div>
{timeSlots.map((timeSlot, timeIndex) => {
const booking = getBookingForTimeSlot(room, timeSlot);
const isOccupied = booking !== undefined;
const span = booking ? getBookingSpan(booking) : 1;
return (
<div
key={timeIndex}
className={`${styles.ScheduleSlot} ${isOccupied ? styles.Occupied : ''}`}
style={{
gridRowEnd: isOccupied ? `span ${span}` : 'span 1'
}}
>
{isOccupied && (
<div
className={styles.BookingInfo}
onClick={() => handleBookingClick(booking, room.name)}
>
<div className={styles.CourseName}>{booking.course}</div>
<div className={styles.BookingTime}>{booking.time}</div>
</div>
)}
</div>
);
})}
</div>
))}
</div>
</div>
{showModal && selectedBooking && (
<div className={styles.ModalOverlay} onClick={closeModal}>
<div className={styles.Modal} onClick={(e) => e.stopPropagation()}>
<div className={styles.ModalHeader}>
<h2>{selectedBooking.course}</h2>
<button className={styles.CloseButton} onClick={closeModal}>
×
</button>
</div>
<div className={styles.ModalContent}>
<div className={styles.DetailRow}>
<span className={styles.Label}>Room:</span>
<span>{selectedBooking.room}</span>
</div>
<div className={styles.DetailRow}>
<span className={styles.Label}>Time:</span>
<span>{selectedBooking.time}</span>
</div>
<div className={styles.DetailRow}>
<span className={styles.Label}>Instructor:</span>
<span>{selectedBooking.instructor}</span>
</div>
<div className={styles.DetailRow}>
<span className={styles.Label}>Participants:</span>
<span>{selectedBooking.participants}</span>
</div>
<div className={styles.DetailRow}>
<span className={styles.Label}>Description:</span>
<span>{selectedBooking.description}</span>
</div>
</div>
</div>
</div>
)}
</PageContainer>
);
};
export default RoomSchedules;

View File

@@ -0,0 +1,242 @@
.ScheduleWrapper {
display: flex;
overflow-x: auto;
border: 1px solid #e0e0e0;
border-radius: 8px;
background: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
max-height: 80vh;
overflow-y: auto;
}
.TimeColumn {
flex-shrink: 0;
width: 80px;
border-right: 1px solid #e0e0e0;
background: #f8f9fa;
}
.TimeHeader {
height: 60px;
border-bottom: 1px solid #e0e0e0;
background: #f0f0f0;
position: sticky;
top: 0;
z-index: 15;
}
.TimeSlot {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid #e0e0e0;
font-size: 0.9rem;
font-weight: 500;
color: #666;
}
.RoomsContainer {
display: flex;
flex: 1;
min-width: 0;
}
.RoomColumn {
flex-shrink: 0;
width: 200px;
border-right: 1px solid #e0e0e0;
display: grid;
grid-template-rows: 60px repeat(17, 60px);
}
.RoomColumn:last-child {
border-right: none;
}
.RoomHeader {
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid #e0e0e0;
background: #f0f0f0;
font-weight: 600;
font-size: 1rem;
text-align: center;
padding: 0 1rem;
position: sticky;
top: 0;
z-index: 10;
}
.ScheduleSlot {
border-bottom: 1px solid #e0e0e0;
background: #f9f9f9;
position: relative;
}
.ScheduleSlot.Occupied {
background: #e3f2fd;
border: 1px solid #2196f3;
margin: 1px;
border-radius: 4px;
}
.BookingInfo {
padding: 8px;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
}
.BookingInfo:hover {
background: rgba(33, 150, 243, 0.1);
transform: scale(1.02);
}
.CourseName {
font-weight: 600;
font-size: 0.9rem;
color: #1976d2;
margin-bottom: 4px;
line-height: 1.2;
}
.BookingTime {
font-size: 0.8rem;
color: #666;
font-weight: 500;
}
/* Modal styles */
.ModalOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.Modal {
background: blue;
border-radius: 8px;
padding: 0;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.ModalHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid #e0e0e0;
background: #f8f9fa;
border-radius: 8px 8px 0 0;
}
.ModalHeader h2 {
margin: 0;
font-size: 1.25rem;
color: #1976d2;
}
.CloseButton {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #666;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s ease;
}
.CloseButton:hover {
background: #e0e0e0;
}
.ModalContent {
padding: 1.5rem;
}
.DetailRow {
display: flex;
margin-bottom: 1rem;
align-items: flex-start;
}
.DetailRow:last-child {
margin-bottom: 0;
}
.Label {
font-weight: 600;
min-width: 100px;
color: #666;
margin-right: 1rem;
}
/* Mobile scrolling */
@media (max-width: 768px) {
.ScheduleWrapper {
overflow-x: scroll;
-webkit-overflow-scrolling: touch;
}
.RoomColumn {
width: 180px;
}
.TimeColumn {
width: 70px;
}
.RoomHeader {
font-size: 0.9rem;
padding: 0 0.5rem;
}
.CourseName {
font-size: 0.8rem;
}
.BookingTime {
font-size: 0.7rem;
}
.Modal {
width: 95%;
margin: 1rem;
}
.ModalHeader {
padding: 1rem;
}
.ModalContent {
padding: 1rem;
}
.Label {
min-width: 80px;
font-size: 0.9rem;
}
}

View File

@@ -10,10 +10,10 @@
color: var(--text-primary);
background-color: var(--bg-secondary);
padding: var(--spacing-md);
border: 1px solid var(--border-light);
border-bottom: 1px solid var(--border-light);
position: sticky;
width: 100%;
top: 1rem;
top: 0;
z-index: 10;
display: flex;
flex-direction: row;

View File

@@ -298,7 +298,7 @@
/* Button disabled states */
--button-disabled-bg: #f8f9fa;
--button-disabled-text: #adb5bd;
--button-disabled-text: #797e83;
--button-disabled-border: #dee2e6;
/* Additional color variants */

View File

@@ -1,5 +1,12 @@
import React from 'react';
import { today, getLocalTimeZone } from '@internationalized/date';
import { NUMBER_OF_ROOMS, CHANCE_OF_AVAILABILITY } from '../constants/bookingConstants';
import { NUMBER_OF_ROOMS, CHANCE_OF_AVAILABILITY, USER } from '../constants/bookingConstants';
// Simple seeded random number generator
const seededRandom = (seed) => {
const x = Math.sin(seed) * 10000;
return x - Math.floor(x);
};
export const generateInitialRooms = (chanceOfAvailability = CHANCE_OF_AVAILABILITY, numberOfRooms = NUMBER_OF_ROOMS, earliestSlot = 0, latestSlot = 23) => {
return [...Array(numberOfRooms)].map((room, index) => ({
@@ -87,4 +94,51 @@ export const isDateUnavailable = (date, effectiveToday, bookingRangeDays = 14) =
date.compare(interval[0]) >= 0 &&
date.compare(interval[1]) <= 0
);
};
};
export function getParticipantNames(participants) {
if (!participants || participants.length === 0) return null;
const getFirstName = (participant) => participant.name.split(' ')[0];
if (participants.length === 1) {
return getFirstName(participants[0]);
} else if (participants.length === 2) {
return `${getFirstName(participants[0])} and ${getFirstName(participants[1])}`;
} else {
const remaining = participants.length - 2;
return `${getFirstName(participants[0])}, ${getFirstName(participants[1])} and ${remaining} more`;
}
}
export function createBookingLengthOptions(booking, maxAvailableTime = 16) {
const hoursAvailable = Math.min(maxAvailableTime - booking.startTime, 8);
const bookingLengths = [];
for (let i = 1; i <= hoursAvailable; i++) {
const endTimeIndex = booking.startTime + i;
const endTime = getTimeFromIndex(endTimeIndex);
const durationLabel = i === 1 ? "30 min" :
i === 2 ? "1 h" :
i === 3 ? "1.5 h" :
i === 4 ? "2 h" :
i === 5 ? "2.5 h" :
i === 6 ? "3 h" :
i === 7 ? "3.5 h" :
i === 8 ? "4 h" : `${i * 0.5} h`;
bookingLengths.push({
value: endTimeIndex,
label: `${endTime} · ${durationLabel}`
});
}
return bookingLengths;
}
export function hasBookingChanges(originalBooking, editedTitle, editedParticipants, calculatedEndTime) {
const titleChanged = editedTitle !== originalBooking.title;
const participantsChanged = JSON.stringify(editedParticipants) !== JSON.stringify(originalBooking.participants || []);
const endTimeChanged = calculatedEndTime !== originalBooking.endTime;
return titleChanged || participantsChanged || endTimeChanged;
}