booking-flow-finalized-design kindaaaa #7

Merged
jare2473 merged 20 commits from booking-flow-finalized-design into main 2025-09-30 10:50:54 +02:00
27 changed files with 1667 additions and 355 deletions
Showing only changes of commit 2e0b15ab04 - Show all commits

View File

@@ -23,6 +23,10 @@ const AppRoutes = () => {
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);
const [bookings, setBookings] = useState([
{
id: 1,
@@ -113,12 +117,20 @@ const AppRoutes = () => {
setBookings(bookings.map(booking =>
booking.id === updatedBooking.id ? updatedBooking : booking
));
setLastUpdatedBooking(updatedBooking);
setShowUpdateBanner(true);
}
function deleteBooking(bookingToDelete) {
function deleteBooking(bookingToDelete, actionType = 'delete') {
setBookings(bookings.filter(booking => booking.id !== bookingToDelete.id));
setLastDeletedBooking(bookingToDelete);
setShowDeleteBanner(true);
if (actionType === 'leave') {
setLastLeftBooking(bookingToDelete);
setShowLeaveBanner(true);
} else {
setLastDeletedBooking(bookingToDelete);
setShowDeleteBanner(true);
}
}
useEffect(() => {
@@ -141,7 +153,7 @@ 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="booking-details" element={<BookingDetails addBooking={addBooking} />} />
<Route path="booking-confirmation" element={<BookingConfirmation addBooking={addBooking} />} />

View File

@@ -1,361 +1,163 @@
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 } from 'react';
import { BookingProvider } from '../../context/BookingContext';
import { Label } from '../ui/Label';
import { PEOPLE, USER } from '../../constants/bookingConstants';
function BookingCard({ booking, onClick, isExpanded, onBookingUpdate, onBookingDelete }) {
// Check if this is a participant booking (user was added by someone else)
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;
const [selectedLength, setSelectedLength] = useState(null);
const [calculatedEndTime, setCalculatedEndTime] = useState(null);
const [editedTitle, setEditedTitle] = useState('');
const [editedParticipants, setEditedParticipants] = useState([]);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// 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);
// Initialize state when card expands
useEffect(() => {
if (isExpanded) {
setSelectedLength(currentLength);
setCalculatedEndTime(booking.endTime);
setEditedTitle(booking.title);
setEditedParticipants(booking.participants || []);
}
}, [isExpanded, booking, currentLength]);
// 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: 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 = [];
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}`
});
}
// No disabled options needed since we only generate available options
const disabledOptions = {};
function handleLengthChange(event) {
const endTimeValue = event.target.value === "" ? null : parseInt(event.target.value);
setCalculatedEndTime(endTimeValue);
if (endTimeValue !== null) {
const newLength = endTimeValue - booking.startTime;
setSelectedLength(newLength);
} else {
setSelectedLength(currentLength);
setCalculatedEndTime(booking.endTime);
}
}
// Check if any changes have been made
const hasChanges = () => {
const titleChanged = editedTitle !== booking.title;
const participantsChanged = JSON.stringify(editedParticipants) !== JSON.stringify(booking.participants || []);
const endTimeChanged = calculatedEndTime !== booking.endTime;
return titleChanged || participantsChanged || endTimeChanged;
};
function handleSave() {
if (hasChanges() && 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 handleRemoveSelf() {
// For participant bookings, remove the current user from participants
// This effectively "leaves" the booking
if (isParticipantBooking && onBookingDelete) {
onBookingDelete(booking);
}
}
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;
const formatName = (participant, index) => {
// Bold the booker's name (creator in participant bookings, or current user in regular bookings)
const isBooker = isParticipantBooking
? (booking.createdBy && participant.id === booking.createdBy.id)
: (participant.id === USER.id);
// 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}
/>
)}
const firstName = participant.name.split(' ')[0];
/*if (isBooker) {
return <strong key={`participant-${participant.id}-${index}`}>{firstName}</strong>;
}*/
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
</>
);
}
}
{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 : ''}`}>
<div className={styles.card}>
<div className={styles.header} onClick={!isExpanded ? onClick : undefined}>
<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}>{formatParticipants(booking.participants)}</div>
)}
<div className={styles.roomBadge}>
{booking.room}
</div>
</div>
</div>
{isExpanded && (
<div className={styles.expandedContent}>
<BookingProvider value={localBookingContext}>
{isParticipantBooking ? (
// Participant booking view - read-only with remove self option
<>
<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={() => setShowDeleteConfirm(true)}
>
Lämna bokning
</Button>
<Button
className={styles.cancelButton}
onPress={handleCancel}
>
Stäng
</Button>
</div>
) : (
<div className={styles.confirmationSection}>
<div className={styles.confirmationMessage}>
<span className={styles.warningIcon}></span>
<p>Är du säker att du vill lämna denna bokning?</p>
<p className={styles.bookingDetails}>
Du kommer inte längre att vara med "{booking.title}" den {booking.date.day}/{booking.date.month}
</p>
</div>
<div className={styles.confirmationButtons}>
<Button
className={styles.confirmDeleteButton}
onPress={handleRemoveSelf}
>
Ja, lämna bokning
</Button>
<Button
className={styles.cancelDeleteButton}
onPress={cancelDelete}
>
Avbryt
</Button>
</div>
</div>
)}
</>
) : (
// Regular booking view - editable
<>
<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={handleLengthChange}
value={calculatedEndTime || booking.endTime}
placeholder={null}
/>
</div>
<hr className={styles.divider} />
{!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} ${!hasChanges() ? styles.disabledButton : ''}`}
onPress={handleSave}
isDisabled={!hasChanges()}
>
Spara
</Button>
</div>
) : (
<div className={styles.confirmationSection}>
<div className={styles.confirmationMessage}>
<span className={styles.warningIcon}></span>
<p>Är du säker att du vill radera denna bokning?</p>
<p className={styles.bookingDetails}>
"{booking.title}" den {booking.date.day}/{booking.date.month} kl. {getTimeFromIndex(booking.startTime)}
</p>
</div>
<div className={styles.confirmationButtons}>
<Button
className={styles.confirmDeleteButton}
onPress={confirmDelete}
>
Ja, radera
</Button>
<Button
className={styles.cancelDeleteButton}
onPress={cancelDelete}
>
Avbryt
</Button>
</div>
</div>
)}
</>
)}
</BookingProvider>
</div>
)}
<>
<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}
/>
</div>
{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>
</div>
{/* Room information modal */}
<BookingCardModal
isOpen={bookingState.isRoomInfoModalOpen}
onClose={() => {
bookingState.setIsRoomInfoModalOpen(false);
bookingState.setActiveView('closed');
}}
title="Lokalinformation"
booking={booking}
calculatedEndTime={bookingState.calculatedEndTime}
>
<RoomInfoContent booking={booking} />
</BookingCardModal>
{/* Full edit modal - shown after selecting "Hantera bokning" */}
<BookingCardModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
bookingState.setActiveView('closed');
}}
title={isParticipantBooking ? "Visa bokning" : "Redigera bokning"}
booking={booking}
calculatedEndTime={bookingState.calculatedEndTime}
>
{renderExpandedContent(true)}
</BookingCardModal>
</>
);
}

View File

@@ -41,6 +41,10 @@
width: 100%;
}
.cardWrapper:not(.expanded) .header {
cursor: pointer;
}
.topSection {
display: flex;
flex-direction: row;
@@ -231,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;
@@ -243,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);
}
@@ -528,4 +532,205 @@
.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;
}
}
.optionButtons {
display: flex;
flex-direction: row;
gap: 0.75rem;
}
.optionButton {
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;
width: 100%;
box-sizing: border-box;
border-radius: var(--border-radius-sm);
}
.optionButton:hover {
border-color: var(--color-primary);
background-color: var(--bg-secondary);
}
.optionButton:focus {
outline: none;
}
.optionButton:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Tab buttons for expanded view */
.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;
}
.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;
}
.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;
}
/* Room information styles */
.roomInfoContent {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.roomImageContainer {
width: 100%;
overflow: hidden;
background-color: var(--bg-secondary);
}
.roomImage {
width: 100%;
height: 200px;
object-fit: cover;
background-color: #f3f4f6;
}
.roomDetails {
display: flex;
flex-direction: column;
gap: 1rem;
}
.roomTitle {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
}
.roomInfoGrid {
display: grid;
gap: 0.75rem;
}
.roomInfoItem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid var(--border-light);
}
.roomInfoItem:last-child {
border-bottom: none;
}
.roomInfoLabel {
font-weight: 500;
color: var(--text-secondary);
flex-shrink: 0;
}
.roomInfoValue {
color: var(--text-primary);
text-align: right;
margin-left: 1rem;
}
.roomInfoActions {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border-light);
display: flex;
justify-content: center;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { Button } from 'react-aria-components';
import styles from './BookingCard.module.css';
export function BookingCardTabs({
activeView,
isInExpandedView = false,
onRoomInfo,
onManageBooking,
setActiveView
}) {
return (
<div className={isInExpandedView ? (activeView === 'closed' ? styles.tabButtonsNoBorder : styles.tabButtons) : styles.optionButtons}>
<Button
className={`${isInExpandedView ? styles.tabButton : styles.optionButton} ${activeView === 'lokalinfo' ? styles.activeTab : ''}`}
onPress={() => {
if (isInExpandedView && activeView === 'lokalinfo') {
// Close content, show only tab buttons
setActiveView('closed');
} else {
onRoomInfo();
}
}}
>
Lokalinformation
</Button>
<Button
className={`${isInExpandedView ? styles.tabButton : styles.optionButton} ${activeView === 'hantera' ? styles.activeTab : ''}`}
onPress={() => {
if (isInExpandedView && activeView === 'hantera') {
// Close content, show only tab buttons
setActiveView('closed');
} else {
onManageBooking();
}
}}
>
Hantera bokning
</Button>
</div>
);
}

View File

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

View File

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

View File

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

View File

@@ -3,9 +3,12 @@ import { CalendarDate } from '@internationalized/date';
import styles from './BookingsList.module.css';
import BookingCard from './BookingCard';
import NotificationBanner from '../common/NotificationBanner';
import { useSettingsContext } from '../../context/SettingsContext';
function BookingsList({ bookings, handleEditBooking, onBookingUpdate, onBookingDelete, showSuccessBanner, lastCreatedBooking, onDismissBanner, showDeleteBanner, lastDeletedBooking, onDismissDeleteBanner, showDevelopmentBanner, showBookingConfirmationBanner, showBookingDeleteBanner }) {
function BookingsList({ bookings, handleEditBooking, onBookingUpdate, onBookingDelete, showSuccessBanner, lastCreatedBooking, onDismissBanner, showDeleteBanner, lastDeletedBooking, onDismissDeleteBanner, showLeaveBanner, lastLeftBooking, onDismissLeaveBanner, showDevelopmentBanner, showBookingConfirmationBanner, showBookingDeleteBanner, showUpdateBanner, lastUpdatedBooking, onDismissUpdateBanner }) {
const { settings } = useSettingsContext();
const [expandedBookingId, setExpandedBookingId] = useState(null);
const [optionsExpandedBookingId, setOptionsExpandedBookingId] = useState(null);
// Sort bookings by date (earliest first)
const sortedBookings = [...bookings].sort((a, b) => {
@@ -35,6 +38,10 @@ function BookingsList({ bookings, handleEditBooking, onBookingUpdate, onBookingD
setExpandedBookingId(expandedBookingId === booking.id ? null : booking.id);
}
function handleOptionsToggle(booking) {
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'];
@@ -64,6 +71,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"
@@ -113,6 +136,9 @@ function BookingsList({ bookings, handleEditBooking, onBookingUpdate, onBookingD
isExpanded={expandedBookingId === booking.id}
onBookingUpdate={onBookingUpdate}
onBookingDelete={onBookingDelete}
editMode={settings.bookingCardEditMode}
isOptionsExpanded={optionsExpandedBookingId === booking.id}
onOptionsToggle={() => handleOptionsToggle(booking)}
/>
))}
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,10 @@
align-items: center;
justify-content: space-between;
box-shadow: var(--shadow-lg);
position: sticky;
top: 5rem;
z-index: 100;
width: 100%;
}
.bannerContent {
@@ -111,6 +115,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);

View File

@@ -84,6 +84,7 @@
border: 1px solid var(--timecard-unavailable-border);
height: 50px;
width: 165px;
background: red;
}
.modalFooter {

View File

@@ -71,8 +71,8 @@
}
.pairRow > * {
flex: 0 0 135px;
width: 135px;
/*flex: 0 0 135px;*/
/*width: 135px;*/
}
}

View File

@@ -34,6 +34,7 @@ export const SettingsProvider = ({ children }) => {
bookingFormType: 'inline', // 'modal' or 'inline'
showFiltersAlways: false, // Show filter dropdowns always or behind toggle button
newBookingLayoutVariant: false, // false = stacked, true = side-by-side
bookingCardEditMode: 'inline', // 'inline', 'modal', or 'responsive'
// Then override with saved values
...parsed,
// Convert date strings back to DateValue objects
@@ -118,6 +119,8 @@ export const SettingsProvider = ({ children }) => {
showBookingDeleteBanner: false,
bookingFormType: 'inline',
showFiltersAlways: false,
newBookingLayoutVariant: false,
bookingCardEditMode: 'inline',
});
localStorage.removeItem('calendarSettings');
};

View File

@@ -0,0 +1,142 @@
import { useCallback } from 'react';
import { PEOPLE } from '../constants/bookingConstants';
import { hasBookingChanges } from '../utils/bookingUtils';
export function useBookingActions(booking, bookingState, onBookingUpdate, onBookingDelete, onClick, effectiveEditMode, setIsModalOpen) {
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);
} else {
onClick(); // Close the expanded view
}
}, [hasChanges, onBookingUpdate, booking, editedTitle, editedParticipants, calculatedEndTime, effectiveEditMode, setIsModalOpen, onClick]);
const handleCancel = useCallback(() => {
resetState();
if (effectiveEditMode === 'modal') {
setIsModalOpen(false);
setActiveView('closed');
} else {
onClick(); // Close the expanded view
}
}, [resetState, effectiveEditMode, setIsModalOpen, onClick, setActiveView]);
const handleDelete = useCallback(() => {
setShowDeleteConfirm(true);
}, [setShowDeleteConfirm]);
const confirmDelete = useCallback(() => {
if (onBookingDelete) {
onBookingDelete(booking);
}
setShowDeleteConfirm(false);
}, [onBookingDelete, booking, setShowDeleteConfirm]);
const cancelDelete = useCallback(() => {
setShowDeleteConfirm(false);
}, [setShowDeleteConfirm]);
const handleRemoveSelf = useCallback(() => {
if (onBookingDelete) {
onBookingDelete(booking, 'leave');
}
}, [onBookingDelete, booking]);
const handleRoomInfo = useCallback((effectiveEditMode, isOptionsExpanded, onOptionsToggle, isExpanded) => {
setActiveView('lokalinfo');
if (effectiveEditMode === 'modal') {
// For modal mode, don't close accordion - keep it for when modal closes
setIsRoomInfoModalOpen(true);
} else {
// For inline mode
if (isOptionsExpanded) {
onOptionsToggle();
}
if (!isExpanded) {
onClick();
}
}
}, [setActiveView, setIsRoomInfoModalOpen, onClick]);
const handleManageBooking = useCallback((effectiveEditMode, isOptionsExpanded, onOptionsToggle, isExpanded) => {
setActiveView('hantera');
if (effectiveEditMode === 'modal') {
setIsModalOpen(true);
// Don't close the options accordion for modal mode - keep it for when modal closes
} else {
if (isOptionsExpanded) {
onOptionsToggle(); // Close the options accordion only for inline mode
}
if (!isExpanded) {
onClick(); // Open inline expansion
}
}
}, [setActiveView, setIsModalOpen, onClick]);
return {
handleLengthChange,
handleParticipantChange,
handleRemoveParticipant,
hasChanges,
handleSave,
handleCancel,
handleDelete,
confirmDelete,
cancelDelete,
handleRemoveSelf,
handleRoomInfo,
handleManageBooking
};
}

View File

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

View File

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

View File

@@ -221,6 +221,38 @@ export function BookingSettings() {
<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}>

View File

@@ -8,7 +8,7 @@ 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(() => {
@@ -61,6 +61,12 @@ export function RoomBooking({ bookings, showSuccessBanner, lastCreatedBooking, o
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}

View File

@@ -1,5 +1,6 @@
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';
export const generateInitialRooms = (chanceOfAvailability = CHANCE_OF_AVAILABILITY, numberOfRooms = NUMBER_OF_ROOMS, earliestSlot = 0, latestSlot = 23) => {
return [...Array(numberOfRooms)].map((room, index) => ({
@@ -87,4 +88,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;
}