booking-flow-finalized-design kindaaaa #7
2
my-app/.gitignore
vendored
2
my-app/.gitignore
vendored
@@ -29,3 +29,5 @@ storybook-static
|
||||
# Font files
|
||||
public/caecilia/
|
||||
public/the-sans/
|
||||
|
||||
deploy.sh
|
||||
492
my-app/bookings-2025-09-29.json
Normal file
492
my-app/bookings-2025-09-29.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
447
my-app/bookings-2025-09-30.json
Normal file
447
my-app/bookings-2025-09-30.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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 på 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
42
my-app/src/components/booking/BookingCardHeader.jsx
Normal file
42
my-app/src/components/booking/BookingCardHeader.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
my-app/src/components/booking/BookingCardModal.jsx
Normal file
60
my-app/src/components/booking/BookingCardModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
my-app/src/components/booking/BookingCardModal.module.css
Normal file
127
my-app/src/components/booking/BookingCardModal.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
160
my-app/src/components/booking/BookingCardRefactored.jsx
Normal file
160
my-app/src/components/booking/BookingCardRefactored.jsx
Normal 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;
|
||||
52
my-app/src/components/booking/BookingCardTabs.jsx
Normal file
52
my-app/src/components/booking/BookingCardTabs.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
my-app/src/components/booking/BookingCardTabs.module.css
Normal file
86
my-app/src/components/booking/BookingCardTabs.module.css
Normal 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;
|
||||
}
|
||||
81
my-app/src/components/booking/BookingFormContent.jsx
Normal file
81
my-app/src/components/booking/BookingFormContent.jsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
73
my-app/src/components/booking/BookingOptionsModal.jsx
Normal file
73
my-app/src/components/booking/BookingOptionsModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
my-app/src/components/booking/BookingOptionsModal.module.css
Normal file
144
my-app/src/components/booking/BookingOptionsModal.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
47
my-app/src/components/booking/ConfirmationDialog.jsx
Normal file
47
my-app/src/components/booking/ConfirmationDialog.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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: '';
|
||||
|
||||
@@ -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>
|
||||
|
||||
58
my-app/src/components/booking/ParticipantBookingContent.jsx
Normal file
58
my-app/src/components/booking/ParticipantBookingContent.jsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
my-app/src/components/booking/ParticipantsDisplay.jsx
Normal file
27
my-app/src/components/booking/ParticipantsDisplay.jsx
Normal 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
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
52
my-app/src/components/booking/RoomInfoContent.jsx
Normal file
52
my-app/src/components/booking/RoomInfoContent.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 på bokning</h3>
|
||||
<Label>Titel på bokning</Label>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
12
my-app/src/components/layout/PageContainer.jsx
Normal file
12
my-app/src/components/layout/PageContainer.jsx
Normal 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;
|
||||
12
my-app/src/components/layout/PageContainer.module.css
Normal file
12
my-app/src/components/layout/PageContainer.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
27
my-app/src/components/layout/PageHeader.jsx
Normal file
27
my-app/src/components/layout/PageHeader.jsx
Normal 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;
|
||||
58
my-app/src/components/layout/PageHeader.module.css
Normal file
58
my-app/src/components/layout/PageHeader.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
32
my-app/src/components/ui/Breadcrumbs.jsx
Normal file
32
my-app/src/components/ui/Breadcrumbs.jsx
Normal 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;
|
||||
40
my-app/src/components/ui/Breadcrumbs.module.css
Normal file
40
my-app/src/components/ui/Breadcrumbs.module.css
Normal 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;
|
||||
}
|
||||
10
my-app/src/components/ui/Label.jsx
Normal file
10
my-app/src/components/ui/Label.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
my-app/src/components/ui/Label.module.css
Normal file
9
my-app/src/components/ui/Label.module.css
Normal 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;
|
||||
}
|
||||
107
my-app/src/components/ui/NamePrompt.jsx
Normal file
107
my-app/src/components/ui/NamePrompt.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
my-app/src/components/ui/NamePrompt.module.css
Normal file
132
my-app/src/components/ui/NamePrompt.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
147
my-app/src/hooks/useBookingActions.js
Normal file
147
my-app/src/hooks/useBookingActions.js
Normal 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
|
||||
};
|
||||
}
|
||||
41
my-app/src/hooks/useBookingCardState.js
Normal file
41
my-app/src/hooks/useBookingCardState.js
Normal 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
|
||||
};
|
||||
}
|
||||
48
my-app/src/hooks/useResponsiveMode.js
Normal file
48
my-app/src/hooks/useResponsiveMode.js
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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 (≤780px), modal on desktop (>780px)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
241
my-app/src/pages/RoomSchedules.jsx
Normal file
241
my-app/src/pages/RoomSchedules.jsx
Normal 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;
|
||||
242
my-app/src/pages/RoomSchedules.module.css
Normal file
242
my-app/src/pages/RoomSchedules.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user