booking-flow-finalized-design kindaaaa #7
@@ -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} />} />
|
||||
|
||||
@@ -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 på att du vill lämna denna bokning?</p>
|
||||
<p className={styles.bookingDetails}>
|
||||
Du kommer inte längre att vara med på "{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 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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
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;
|
||||
42
my-app/src/components/booking/BookingCardTabs.jsx
Normal file
42
my-app/src/components/booking/BookingCardTabs.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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) => {
|
||||
@@ -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>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
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,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);
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
border: 1px solid var(--timecard-unavailable-border);
|
||||
height: 50px;
|
||||
width: 165px;
|
||||
background: red;
|
||||
}
|
||||
|
||||
.modalFooter {
|
||||
|
||||
@@ -71,8 +71,8 @@
|
||||
}
|
||||
|
||||
.pairRow > * {
|
||||
flex: 0 0 135px;
|
||||
width: 135px;
|
||||
/*flex: 0 0 135px;*/
|
||||
/*width: 135px;*/
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
142
my-app/src/hooks/useBookingActions.js
Normal file
142
my-app/src/hooks/useBookingActions.js
Normal 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
|
||||
};
|
||||
}
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user