diff --git a/bff/src/main/resources/application-development.yaml b/bff/src/main/resources/application-development.yaml index ead2087..0f55830 100644 --- a/bff/src/main/resources/application-development.yaml +++ b/bff/src/main/resources/application-development.yaml @@ -17,4 +17,7 @@ spring.security.oauth2.client: # Lift the restrictions imposed by __Host- prefix during development # Ideally we keep it on, but it breaks in Chromium on Linux -server.servlet.session.cookie.name: studentportalen-bff-session +server.servlet.session.cookie: + name: studentportalen-bff-session + # Disable secure flag for HTTP development - Safari strictly enforces this + secure: false diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8684d09..e747cdd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,7 +12,8 @@ "openapi-fetch": "^0.13.5", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router": "^7.4.1" + "react-router": "^7.4.1", + "temporal-polyfill": "^0.3.0" }, "devDependencies": { "@eslint/js": "^9.21.0", @@ -3726,6 +3727,21 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/temporal-polyfill": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/temporal-polyfill/-/temporal-polyfill-0.3.0.tgz", + "integrity": "sha512-qNsTkX9K8hi+FHDfHmf22e/OGuXmfBm9RqNismxBrnSmZVJKegQ+HYYXT+R7Ha8F/YSm2Y34vmzD4cxMu2u95g==", + "license": "MIT", + "dependencies": { + "temporal-spec": "0.3.0" + } + }, + "node_modules/temporal-spec": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/temporal-spec/-/temporal-spec-0.3.0.tgz", + "integrity": "sha512-n+noVpIqz4hYgFSMOSiINNOUOMFtV5cZQNCmmszA6GiVFVRt3G7AqVyhXjhCSmowvQn+NsGn+jMDMKJYHd3bSQ==", + "license": "ISC" + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", diff --git a/frontend/package.json b/frontend/package.json index 154b092..d4b65bf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,8 @@ "openapi-fetch": "^0.13.5", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router": "^7.4.1" + "react-router": "^7.4.1", + "temporal-polyfill": "^0.3.0" }, "devDependencies": { "@eslint/js": "^9.21.0", diff --git a/frontend/src/Studentportalen.tsx b/frontend/src/Studentportalen.tsx index 11ad6ed..a076c96 100644 --- a/frontend/src/Studentportalen.tsx +++ b/frontend/src/Studentportalen.tsx @@ -1,5 +1,5 @@ import { BrowserRouter, Route, Routes } from "react-router"; -import ComponentLibrary from "./studentportalen/ComponentLibrary.tsx"; +import ComponentLibrary from "./studentportalen/ComponentLibrary/ComponentLibrary"; import Layout from "./studentportalen/Layout.tsx"; export default function Studentportalen() { diff --git a/frontend/src/components/Choicebox/Choicebox.tsx b/frontend/src/components/Choicebox/Choicebox.tsx new file mode 100644 index 0000000..61c3a95 --- /dev/null +++ b/frontend/src/components/Choicebox/Choicebox.tsx @@ -0,0 +1,93 @@ +import type { InputHTMLAttributes } from "react"; +import clsx from "clsx"; + +export interface ChoiceboxProps + extends Omit, "type"> { + primaryText: string; + secondaryText?: string; + unavailable?: boolean; + fitContent?: boolean; +} + +const baseClasses = clsx( + "flex items-center gap-(--spacing-md)", + "border-[length:var(--border-width-sm)]", + "rounded-(--border-radius-md)", + "p-(--padding-md)", +); + +const availableClasses = clsx( + "bg-sky-20 border-sky-100", + "cursor-pointer", + // Hover + "hover:bg-sky-35", + // Focus + "focus-within:bg-sky-20 focus-within:border-primary", + "focus-within:outline focus-within:outline-sky-100 focus-within:outline-[length:var(--border-width-lg)]", + // Selected + "has-[:checked]:bg-sky-70 has-[:checked]:border-secondary", + // Selected + Focus + "has-[:checked]:focus-within:bg-sky-35", +); + +const unavailableClasses = clsx( + "bg-base-canvas border-base-ink-soft", + "cursor-not-allowed", +); + +const radioClasses = clsx( + "appearance-none shrink-0", + "w-[var(--font-size-body-md)] h-[var(--font-size-body-md)]", + "rounded-full", + "border-[length:var(--border-width-sm)] border-base-ink-medium", + "bg-base-canvas", + "outline-none", + // Selected + "checked:border-primary", + "checked:bg-[radial-gradient(circle,var(--color-primary)_50%,var(--color-base-canvas)_50%)]", +); + +export default function Choicebox({ + primaryText, + secondaryText, + unavailable = false, + fitContent = false, + className = "", + disabled, + ...props +}: ChoiceboxProps) { + const isDisabled = unavailable || disabled; + const textColorClass = unavailable + ? "text-base-ink-placeholder" + : "text-primary"; + + return ( + + ); +} diff --git a/frontend/src/components/Combobox/Combobox.tsx b/frontend/src/components/Combobox/Combobox.tsx index 7a980cb..d6ca63b 100644 --- a/frontend/src/components/Combobox/Combobox.tsx +++ b/frontend/src/components/Combobox/Combobox.tsx @@ -25,6 +25,7 @@ export interface ComboboxProps { onChange?: (value: string | string[]) => void; /** Called when search term changes. When provided, local filtering is disabled (assumes API filtering). */ onSearchChange?: (term: string) => void; + error?: boolean; } const widthClasses: Record = { @@ -77,6 +78,7 @@ export default function Combobox({ value, onChange, onSearchChange, + error = false, }: ComboboxProps) { // Convert value (undefined | string | string[]) to always be an array const selectedValues: string[] = @@ -226,6 +228,7 @@ export default function Combobox({ fullWidth={fullWidth || !!customWidth} customWidth={customWidth} Icon={SearchIcon} + error={error} /> {isOpen && ( diff --git a/frontend/src/components/Dropdown/Dropdown.tsx b/frontend/src/components/Dropdown/Dropdown.tsx new file mode 100644 index 0000000..14130e8 --- /dev/null +++ b/frontend/src/components/Dropdown/Dropdown.tsx @@ -0,0 +1,217 @@ +import { useId, type SelectHTMLAttributes } from "react"; +import clsx from "clsx"; +import { ChevronDownIcon } from "../Icon/Icon"; + +export type DropdownSize = "sm" | "md" | "lg"; + +export interface DropdownProps + extends Omit< + SelectHTMLAttributes, + "size" | "onChange" | "value" | "defaultValue" + > { + options: T[]; + getOptionValue: (option: T) => string; + getOptionLabel: (option: T) => string; + size?: DropdownSize; + error?: boolean; + fullWidth?: boolean; + customWidth?: string; + label: string; + hideLabel?: boolean; + message?: string; + placeholder?: string; + value: string; + onChange: (value: string, option: T | undefined) => void; +} + +const wrapperSizeClasses: Record = { + sm: clsx("h-(--control-height-sm)", "rounded-(--border-radius-sm)"), + md: clsx("h-(--control-height-md)", "rounded-(--border-radius-sm)"), + lg: clsx("h-(--control-height-lg)", "rounded-(--border-radius-md)"), +}; + +const textSizeClasses: Record = { + sm: "body-normal-md", + md: "body-normal-md", + lg: "body-normal-lg", +}; + +const iconContainerSizeClasses: Record = { + sm: clsx("w-(--control-height-sm) h-(--control-height-sm)"), + md: clsx("w-(--control-height-md) h-(--control-height-md)"), + lg: clsx("w-(--control-height-lg) h-(--control-height-lg)"), +}; + +// Right padding to reserve space for the chevron icon +const chevronSpacingClasses: Record = { + sm: "pr-(--control-height-sm)", + md: "pr-(--control-height-md)", + lg: "pr-(--control-height-lg)", +}; + +const baseClasses = + "relative inline-flex items-center bg-base-canvas border-[length:var(--border-width-sm)] border-base-ink-medium min-w-[110px]"; + +const defaultStateClasses = clsx( + "hover:border-base-ink-placeholder", + "focus-within:border-primary", + "focus-within:outline focus-within:outline-sky-100 focus-within:outline-[length:var(--border-width-lg)]", +); + +const errorStateClasses = clsx( + "border-fire-100", + "outline outline-fire-100 outline-[length:var(--border-width-sm)]", + "focus-within:border-primary", + "focus-within:outline focus-within:outline-sky-100 focus-within:outline-[length:var(--border-width-lg)]", +); + +export default function Dropdown({ + options, + getOptionValue, + getOptionLabel, + size = "md", + error = false, + fullWidth = false, + customWidth, + label, + hideLabel = false, + message, + placeholder, + className = "", + value, + onChange, + ...props +}: DropdownProps) { + const selectId = useId(); + + const handleChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + const option = options.find((o) => getOptionValue(o) === newValue); + onChange(newValue, option); + }; + + // Derived values + const hasValue = value !== ""; + const useFixedWidth = fullWidth || customWidth; + const widthStyle = customWidth ? { width: customWidth } : undefined; + const stateClasses = error ? errorStateClasses : defaultStateClasses; + const showVisibleLabel = !hideLabel; + + // Find longest label for auto-width sizing, otherwise the dropdown will + // resize when different options are selected + const allLabels = [ + placeholder, + ...options.map((o) => getOptionLabel(o)), + ].filter(Boolean); + const longestLabel = allLabels.reduce( + (a, b) => (a!.length > b!.length ? a : b), + "", + ); + + // Invisible element that sets minimum width based on longest option. + // Includes right padding to account for the absolutely positioned chevron. + // A non-breaking space is appended to compensate for native select text + // rendering differences. + const autoWidthSizer = !useFixedWidth && ( + + ); + + // Dropdown arrow icon - absolutely positioned on the right + const chevron = ( +
+ +
+ ); + + // Native select with appearance-none for custom styling while retaining + // native browser behavior and mobile OS pickers. + // Read here for more details: + // https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Forms/Advanced_form_styling#selects_and_datalists + // New styling techniques are not widely supported yet: + // https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Forms/Customizable_select + const selectElement = ( + + ); + + // The styled dropdown control + const dropdownField = ( +
+ {autoWidthSizer} + {chevron} + {selectElement} +
+ ); + + if (!showVisibleLabel && !message) { + return dropdownField; + } + + return ( +
+ {showVisibleLabel && ( + + )} + {dropdownField} + {message && ( + {message} + )} +
+ ); +} diff --git a/frontend/src/components/Icon/Icon.tsx b/frontend/src/components/Icon/Icon.tsx index 1c0589b..16eb8bd 100644 --- a/frontend/src/components/Icon/Icon.tsx +++ b/frontend/src/components/Icon/Icon.tsx @@ -94,3 +94,19 @@ export function CheckmarkIcon({ ); } + +export function ChevronDownIcon({ + size = "inherit", + className, + ...props +}: IconProps) { + return ( + + + + ); +} diff --git a/frontend/src/components/InlineModal/InlineModal.tsx b/frontend/src/components/InlineModal/InlineModal.tsx new file mode 100644 index 0000000..0a303c1 --- /dev/null +++ b/frontend/src/components/InlineModal/InlineModal.tsx @@ -0,0 +1,83 @@ +import type { HTMLAttributes, ReactNode } from "react"; +import clsx from "clsx"; + +export type ArrowPosition = "left" | "right"; + +export interface InlineModalProps extends HTMLAttributes { + arrowPosition?: ArrowPosition; + arrowOffset?: number; + children: ReactNode; +} + +const baseClasses = clsx( + "relative", + "w-full max-w-[450px] md:max-w-none", + "bg-sky-20", + "border-[length:var(--border-width-sm)] border-sky-100", + "rounded-(--border-radius-lg)", + "p-(--padding-lg)", +); + +const contentClasses = clsx("flex flex-col", "gap-(--spacing-lg)"); + +// Position arrow so its center is 48px from the edge +// Arrow is 20px wide, so offset is 48 - 10 = 38px +const arrowPositionClasses: Record = { + left: "left-[38px]", + right: "right-[38px]", +}; + +/** + * A panel with an arrow pointing to a related element above. + * + * Despite the name, this is not a modal, it doesn't block interaction or overlay + * the page. It is used to show contextual content (like a form) tied to a specific + * trigger element while keeping surrounding context visible. + * + * - vs Modal dialog: This keeps surrounding context visible, use a modal when you + * want to block interaction. + * - vs plain div: This adds the arrow indicator and callout styling. + */ +export default function InlineModal({ + arrowPosition = "left", + arrowOffset, + children, + className = "", + ...props +}: InlineModalProps) { + const useCustomOffset = arrowOffset !== undefined; + + return ( +
+ {/* Arrow pointing up */} + + {/* Fill triangle - extends below viewBox to cover any artifacts */} + + {/* Stroke only on diagonal edges - stops before bottom */} + + + + {/* Content */} +
{children}
+
+ ); +} + +export function InlineModalDivider() { + return
; +} diff --git a/frontend/src/components/ParticipantPicker/ParticipantPicker.tsx b/frontend/src/components/ParticipantPicker/ParticipantPicker.tsx index 8497b7c..99a0dfb 100644 --- a/frontend/src/components/ParticipantPicker/ParticipantPicker.tsx +++ b/frontend/src/components/ParticipantPicker/ParticipantPicker.tsx @@ -20,6 +20,8 @@ export interface ParticipantPickerProps customWidth?: string; /** Called when search term changes. When provided, local filtering is disabled (assumes API filtering). */ onSearchChange?: (term: string) => void; + error?: boolean; + message?: string; } const widthClasses: Record = { @@ -43,6 +45,8 @@ export default function ParticipantPicker({ fullWidth = false, customWidth, onSearchChange, + error = false, + message, className, style, ...props @@ -80,6 +84,7 @@ export default function ParticipantPicker({ fullWidth multiple onSearchChange={onSearchChange} + error={error} /> {selectedOptions.length > 0 && (
@@ -93,6 +98,9 @@ export default function ParticipantPicker({ ))}
)} + {message && ( + {message} + )} ); } diff --git a/frontend/src/components/StartTimeGrid/BookingForm.tsx b/frontend/src/components/StartTimeGrid/BookingForm.tsx new file mode 100644 index 0000000..4e56ecf --- /dev/null +++ b/frontend/src/components/StartTimeGrid/BookingForm.tsx @@ -0,0 +1,176 @@ +import { useState } from "react"; +import { InlineModalDivider } from "../InlineModal/InlineModal"; +import TextInput from "../TextInput/TextInput"; +import ParticipantPicker from "../ParticipantPicker/ParticipantPicker"; +import Dropdown from "../Dropdown/Dropdown"; +import Button from "../Button/Button"; +import type { EndTimeOption } from "./useGroupRoomBooking"; + +export interface BookingFormData { + endTime: string; + title: string; + participants: string[]; +} + +export interface BookingFormProps { + endTimeOptions: EndTimeOption[]; + participantOptions: T[]; + getOptionValue: (option: T) => string; + getOptionLabel: (option: T) => string; + getOptionSubtitle?: (option: T) => string | undefined; + roomName?: string; + /** Minimum number of participants required for a booking */ + minimumParticipants: number; + onSubmit: (data: BookingFormData) => void; + onCancel: () => void; + /** Shows loading state and disables form interactions */ + loading?: boolean; + /** General API error message to display */ + error?: string; + /** Field-level API errors from server validation */ + fieldErrors?: Record; +} + +export default function BookingForm({ + endTimeOptions, + participantOptions, + getOptionValue, + getOptionLabel, + getOptionSubtitle, + roomName, + minimumParticipants, + onSubmit, + onCancel, + loading = false, + error, + fieldErrors = {}, +}: BookingFormProps) { + const [endTime, setEndTime] = useState(""); + const [title, setTitle] = useState(""); + const [participants, setParticipants] = useState([]); + const [localErrors, setLocalErrors] = useState({ + endTime: false, + title: false, + participants: false, + }); + + const handleSubmit = () => { + const newErrors = { + endTime: !endTime, + title: !title.trim(), + participants: participants.length < minimumParticipants, + }; + setLocalErrors(newErrors); + + if (!newErrors.endTime && !newErrors.title && !newErrors.participants) { + onSubmit({ endTime, title, participants }); + } + }; + + // Merge local validation errors with API field errors + const hasEndTimeError = localErrors.endTime || !!fieldErrors.endTime; + const hasTitleError = localErrors.title || !!fieldErrors.title; + const hasParticipantsError = + localErrors.participants || !!fieldErrors.participants; + + const getEndTimeMessage = () => { + if (fieldErrors.endTime) return fieldErrors.endTime; + if (localErrors.endTime) return "Sluttid är obligatoriskt"; + return undefined; + }; + + const getTitleMessage = () => { + if (fieldErrors.title) return fieldErrors.title; + if (localErrors.title) return "Titel är obligatoriskt"; + return undefined; + }; + + const getParticipantsMessage = () => { + if (fieldErrors.participants) return fieldErrors.participants; + if (localErrors.participants) + return `Minst ${minimumParticipants} deltagare krävs`; + return undefined; + }; + + return ( + <> + {error && ( +
+ {error} +
+ )} + o.value.toString().slice(0, 5)} + getOptionLabel={(o) => o.label} + value={endTime} + onChange={(v) => { + setEndTime(v); + if (v) setLocalErrors((prev) => ({ ...prev, endTime: false })); + }} + placeholder="Välj sluttid" + label="Sluttid" + fullWidth + error={hasEndTimeError} + message={getEndTimeMessage()} + disabled={loading} + /> + { + setTitle(e.target.value); + if (e.target.value) + setLocalErrors((prev) => ({ ...prev, title: false })); + }} + error={hasTitleError} + message={getTitleMessage()} + disabled={loading} + /> + { + setParticipants(value); + if (value.length >= minimumParticipants) + setLocalErrors((prev) => ({ ...prev, participants: false })); + }} + placeholder="Sök deltagare" + label="Deltagare" + fullWidth + error={hasParticipantsError} + message={getParticipantsMessage()} + /> + {roomName && ( +
+ Rum +

{roomName}

+
+ )} + +
+ + +
+ + ); +} diff --git a/frontend/src/components/StartTimeGrid/StartTimeGrid.tsx b/frontend/src/components/StartTimeGrid/StartTimeGrid.tsx new file mode 100644 index 0000000..38c7103 --- /dev/null +++ b/frontend/src/components/StartTimeGrid/StartTimeGrid.tsx @@ -0,0 +1,88 @@ +import { ReactNode } from "react"; +import { Temporal } from "temporal-polyfill"; +import Choicebox from "../Choicebox/Choicebox"; +import InlineModal from "../InlineModal/InlineModal"; + +export interface TimeSlot { + time: Temporal.PlainTime; + label: string; + unavailable?: boolean; +} + +export interface StartTimeGridProps { + timeSlots: TimeSlot[]; + selectedTime: Temporal.PlainTime | null; + onChange: (time: Temporal.PlainTime) => void; + heading?: string; + status?: string; + children?: ReactNode; +} + +export default function StartTimeGrid({ + timeSlots, + selectedTime, + onChange, + heading = "Välj starttid", + status = "Visar alla lediga tider", + children, +}: StartTimeGridProps) { + // Track which column was selected for arrow positioning + const selectedIndex = timeSlots.findIndex( + (s) => selectedTime && s.time.equals(selectedTime), + ); + const selectedColumn = selectedIndex >= 0 ? selectedIndex % 2 : 0; + + // Split into rows of 2 + const rows: TimeSlot[][] = []; + for (let i = 0; i < timeSlots.length; i += 2) { + rows.push(timeSlots.slice(i, i + 2)); + } + + return ( +
+
+ {heading} + + {status} + +
+ +
+ {rows.map((row, rowIndex) => ( +
+
+ {row.map((slot) => { + const timeString = slot.time.toString().slice(0, 5); + return ( + onChange(slot.time)} + /> + ); + })} +
+ + {selectedTime && + row.some((slot) => slot.time.equals(selectedTime)) && + children && ( + + {children} + + )} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/StartTimeGrid/useGroupRoomBooking.ts b/frontend/src/components/StartTimeGrid/useGroupRoomBooking.ts new file mode 100644 index 0000000..e4ba926 --- /dev/null +++ b/frontend/src/components/StartTimeGrid/useGroupRoomBooking.ts @@ -0,0 +1,370 @@ +import { useState, useMemo } from "react"; +import { Temporal } from "temporal-polyfill"; + +/** A booking with ISO 8601 datetime strings */ +export interface Booking { + start: string; // e.g. "2025-12-20T10:00:00" + end: string; // e.g. "2025-12-20T12:00:00" +} + +/** A bookable room with its current bookings */ +export interface Room { + id: string; + name: string; + capacity: number; + bookings: Booking[]; +} + +/** + * Configuration for the booking system. + */ +export interface BookingContext { + rooms: Room[]; + /** ISO 8601 duration, e.g. "PT4H" for 4 hours */ + maxBookableLength: string; + /** Time of day, e.g. "08:00:00" */ + earliestBookingTime: string; + /** Time of day, e.g. "20:00:00" */ + latestBookingTime: string; + minimumParticipants: number; + maxDaysInFuture: number; +} + +/** Internal booking with Temporal types */ +interface ParsedBooking { + start: Temporal.PlainDateTime; + end: Temporal.PlainDateTime; +} + +/** Internal room with Temporal types */ +interface ParsedRoom { + id: string; + name: string; + capacity: number; + bookings: ParsedBooking[]; +} + +/** Internal context with Temporal types */ +interface ParsedContext { + rooms: ParsedRoom[]; + maxBookableHours: number; + earliestBookingTime: Temporal.PlainTime; + latestBookingTime: Temporal.PlainTime; + minimumParticipants: number; + maxDaysInFuture: number; +} + +/** Parse API context to Temporal types */ +function parseContext(context: BookingContext): ParsedContext { + return { + rooms: context.rooms.map((room) => ({ + ...room, + bookings: room.bookings.map((b) => ({ + start: Temporal.PlainDateTime.from(b.start), + end: Temporal.PlainDateTime.from(b.end), + })), + })), + maxBookableHours: + Temporal.Duration.from(context.maxBookableLength).hours || 4, + earliestBookingTime: Temporal.PlainTime.from(context.earliestBookingTime), + latestBookingTime: Temporal.PlainTime.from(context.latestBookingTime), + minimumParticipants: context.minimumParticipants, + maxDaysInFuture: context.maxDaysInFuture, + }; +} + +/** A selectable time slot in the booking grid */ +export interface TimeSlot { + /** Time of day */ + time: Temporal.PlainTime; + /** Display label, e.g. "Upp till 2 h" */ + label: string; + /** True if no rooms are available at this time */ + unavailable?: boolean; +} + +function minutesToTime(minutes: number): Temporal.PlainTime { + return Temporal.PlainTime.from({ + hour: Math.floor(minutes / 60), + minute: minutes % 60, + }); +} + +/** Check if a date string is today */ +function isToday(date: string): boolean { + return Temporal.PlainDate.from(date).equals(Temporal.Now.plainDateISO()); +} + +/** Get current time in minutes since midnight */ +function getCurrentTimeMinutes(): number { + const now = Temporal.Now.plainTimeISO(); + return now.hour * 60 + now.minute; +} + +/** + * Generate time slots for the booking grid. + * Each slot shows the maximum bookable duration across all (or filtered) rooms. + * Past time slots are marked as unavailable. + */ +function calculateTimeSlots( + context: ParsedContext, + date: string, + roomId?: string, +): TimeSlot[] { + const maxHours = context.maxBookableHours; + const earliestMinutes = + context.earliestBookingTime.hour * 60 + context.earliestBookingTime.minute; + const latestMinutes = + context.latestBookingTime.hour * 60 + context.latestBookingTime.minute; + const roomsToCheck = roomId + ? context.rooms.filter((r) => r.id === roomId) + : context.rooms; + + const checkingToday = isToday(date); + const currentMinutes = checkingToday ? getCurrentTimeMinutes() : 0; + + const slots: TimeSlot[] = []; + + for (let mins = earliestMinutes; mins < latestMinutes; mins += 30) { + const slotTime = minutesToTime(mins); + + // Mark past slots as unavailable + if (checkingToday && mins <= currentMinutes) { + slots.push({ time: slotTime, label: "", unavailable: true }); + continue; + } + + let maxAvailableMinutes = 0; + + for (const room of roomsToCheck) { + let availableMinutes = Math.min(latestMinutes - mins, maxHours * 60); + + for (const booking of room.bookings) { + if (booking.start.toPlainDate().toString() !== date) continue; + const bookingStartTime = booking.start.toPlainTime(); + const bookingEndTime = booking.end.toPlainTime(); + const bookingStart = + bookingStartTime.hour * 60 + bookingStartTime.minute; + const bookingEnd = bookingEndTime.hour * 60 + bookingEndTime.minute; + + if (mins >= bookingStart && mins < bookingEnd) { + availableMinutes = 0; + break; + } + if (bookingStart > mins && bookingStart < mins + availableMinutes) { + availableMinutes = bookingStart - mins; + } + } + maxAvailableMinutes = Math.max(maxAvailableMinutes, availableMinutes); + } + + if (maxAvailableMinutes === 0) { + slots.push({ time: slotTime, label: "", unavailable: true }); + } else { + const hours = maxAvailableMinutes / 60; + const label = + hours >= 1 ? `Upp till ${hours} h` : `${maxAvailableMinutes} minuter`; + slots.push({ time: slotTime, label }); + } + } + + return slots; +} + +/** + * Find the room with the longest available duration at the given start time. + * Used when no specific room is filtered. + */ +function findBestRoom( + context: ParsedContext, + date: string, + startTime: Temporal.PlainTime, +): ParsedRoom | null { + const maxHours = context.maxBookableHours; + const latestMinutes = + context.latestBookingTime.hour * 60 + context.latestBookingTime.minute; + const startMinutes = startTime.hour * 60 + startTime.minute; + + let bestRoom: ParsedRoom | null = null; + let bestDuration = 0; + + for (const room of context.rooms) { + let availableMinutes = Math.min( + latestMinutes - startMinutes, + maxHours * 60, + ); + let roomAvailable = true; + + for (const booking of room.bookings) { + if (booking.start.toPlainDate().toString() !== date) continue; + const bookingStartTime = booking.start.toPlainTime(); + const bookingEndTime = booking.end.toPlainTime(); + const bookingStart = bookingStartTime.hour * 60 + bookingStartTime.minute; + const bookingEnd = bookingEndTime.hour * 60 + bookingEndTime.minute; + + if (startMinutes >= bookingStart && startMinutes < bookingEnd) { + roomAvailable = false; + break; + } + if ( + bookingStart > startMinutes && + bookingStart < startMinutes + availableMinutes + ) { + availableMinutes = bookingStart - startMinutes; + } + } + + if (roomAvailable && availableMinutes > bestDuration) { + bestDuration = availableMinutes; + bestRoom = room; + } + } + + return bestRoom; +} + +/** An end time option with value and display label */ +export interface EndTimeOption { + /** Time value */ + value: Temporal.PlainTime; + /** Display label, e.g. "12:30 · 1,5 h" */ + label: string; +} + +/** Format duration in minutes to Swedish display format */ +function formatDuration(minutes: number): string { + if (minutes < 60) { + return `${minutes} min`; + } + const hours = minutes / 60; + if (Number.isInteger(hours)) { + return `${hours} h`; + } + // Use comma as decimal separator for Swedish + return `${hours.toString().replace(".", ",")} h`; +} + +/** + * Calculate valid end time options for the booking form. + * Returns 30-minute increments up to the next booking or max duration. + */ +function calculateEndTimeOptions( + context: ParsedContext, + date: string, + startTime: Temporal.PlainTime, + roomId: string, +): EndTimeOption[] { + const maxHours = context.maxBookableHours; + const latestMinutes = + context.latestBookingTime.hour * 60 + context.latestBookingTime.minute; + const startMinutes = startTime.hour * 60 + startTime.minute; + const room = context.rooms.find((r) => r.id === roomId); + if (!room) return []; + + let maxEndMinutes = Math.min(startMinutes + maxHours * 60, latestMinutes); + + for (const booking of room.bookings) { + if (booking.start.toPlainDate().toString() !== date) continue; + const bookingStartTime = booking.start.toPlainTime(); + const bookingEndTime = booking.end.toPlainTime(); + const bookingStart = bookingStartTime.hour * 60 + bookingStartTime.minute; + const bookingEnd = bookingEndTime.hour * 60 + bookingEndTime.minute; + + // If start time is during a booking, no options available + if (startMinutes >= bookingStart && startMinutes < bookingEnd) { + return []; + } + + if (bookingStart > startMinutes && bookingStart < maxEndMinutes) { + maxEndMinutes = bookingStart; + } + } + + const options: EndTimeOption[] = []; + for (let mins = startMinutes + 30; mins <= maxEndMinutes; mins += 30) { + const time = minutesToTime(mins); + const duration = formatDuration(mins - startMinutes); + options.push({ + value: time, + label: `${time.toString().slice(0, 5)} · ${duration}`, + }); + } + return options; +} + +/** + * Hook for managing group room booking state and derived data. + * + * Handles: + * - Room filtering + * - Time slot availability calculation + * - Automatic room selection (picks room with longest available duration) + * - End time options based on selected start time and room + * + * @param context - Booking configuration from backend + * @param date - The date to show bookings for (ISO format, e.g. "2025-12-20") + * + * @returns + * - `roomFilter` / `setRoomFilter` - Filter to show only specific room's availability + * - `roomOptions` - Options for the room filter dropdown + * - `timeSlots` - Available time slots for the StartTimeGrid + * - `selectedTime` / `setSelectedTime` - Currently selected start time + * - `bookedRoom` - The room that will be booked (filtered room or best available) + * - `endTimeOptions` - Valid end times for the booking form dropdown + * - `clearSelection` - Reset the selected time + */ +export function useGroupRoomBooking(context: BookingContext, date: string) { + const [roomFilter, setRoomFilter] = useState(""); + const [selectedTime, setSelectedTime] = useState( + null, + ); + + const parsedContext = useMemo(() => parseContext(context), [context]); + + const roomOptions = useMemo( + () => [ + { value: "", label: "Alla rum" }, + ...context.rooms.map((room) => ({ value: room.id, label: room.name })), + ], + [context.rooms], + ); + + const timeSlots = useMemo( + () => calculateTimeSlots(parsedContext, date, roomFilter || undefined), + [parsedContext, date, roomFilter], + ); + + const bookedRoom = useMemo(() => { + if (roomFilter) { + return parsedContext.rooms.find((r) => r.id === roomFilter) || null; + } + if (selectedTime) { + return findBestRoom(parsedContext, date, selectedTime); + } + return null; + }, [parsedContext, date, roomFilter, selectedTime]); + + const endTimeOptions = useMemo(() => { + if (!selectedTime || !bookedRoom) return []; + return calculateEndTimeOptions( + parsedContext, + date, + selectedTime, + bookedRoom.id, + ); + }, [parsedContext, date, selectedTime, bookedRoom]); + + const clearSelection = () => setSelectedTime(null); + + return { + roomFilter, + setRoomFilter, + roomOptions, + timeSlots, + selectedTime, + setSelectedTime, + bookedRoom, + endTimeOptions, + clearSelection, + }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index a1412fe..4c759f5 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -142,8 +142,10 @@ /* Spacing */ --spacing-sm: 8px; --spacing-md: 12px; + --spacing-ml: 16px; --spacing-lg: 24px; --spacing-xl: 32px; + --spacing-xxl: 48px; /* Control heights */ --control-height-sm: 32px; @@ -168,6 +170,17 @@ /* Text input default width */ --text-input-default-width-md: 194px; --text-input-default-width-lg: 218px; + + /* Layout */ + --max-page-width: 900px; + + /* Arrow */ + --arrow-width: 20px; + + /* Breakpoints */ + --breakpoint-sm: 500px; + --breakpoint-md: 800px; + --breakpoint-lg: 1179px; } .dark { diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts new file mode 100644 index 0000000..d82f7d8 --- /dev/null +++ b/frontend/src/lib/errors.ts @@ -0,0 +1,23 @@ +/** + * API error response following RFC 7807 Problem Details format. + */ +export interface ApiError { + type: string; + title: string; + detail: string; + status: number; + violations?: Record; +} + +/** + * Type guard to check if an error object is an ApiError. + */ +export function isApiError(error: unknown): error is ApiError { + return ( + typeof error === "object" && + error !== null && + "type" in error && + "title" in error && + typeof (error as ApiError).type === "string" + ); +} diff --git a/frontend/src/studentportalen/ComponentLibrary.tsx b/frontend/src/studentportalen/ComponentLibrary.tsx deleted file mode 100644 index 79a9db2..0000000 --- a/frontend/src/studentportalen/ComponentLibrary.tsx +++ /dev/null @@ -1,460 +0,0 @@ -import { useState, useEffect } from "react"; -import Button from "../components/Button/Button"; -import TextInput from "../components/TextInput/TextInput"; -import { SearchIcon } from "../components/Icon/Icon"; -import ListItem from "../components/ListItem/ListItem"; -import SearchResultList from "../components/SearchResultList/SearchResultList"; -import Combobox from "../components/Combobox/Combobox"; -import ListCard from "../components/ListCard/ListCard"; -import ParticipantPicker from "../components/ParticipantPicker/ParticipantPicker"; - -interface Person { - value: string; - label: string; - subtitle: string; -} - -const peopleOptions: Person[] = [ - { value: "1", label: "Lennart Johansson", subtitle: "lejo1891" }, - { value: "2", label: "Mats Rubarth", subtitle: "matsrub1891" }, - { value: "3", label: "Daniel Tjernström", subtitle: "datj1891" }, - { value: "4", label: "Johan Mjällby", subtitle: "jomj1891" }, - { value: "5", label: "Krister Nordin", subtitle: "krno1891" }, - { value: "6", label: "Kurre Hamrin", subtitle: "kuha1891" }, -]; - -const getPersonValue = (person: Person) => person.value; -const getPersonLabel = (person: Person) => person.label; -const getPersonSubtitle = (person: Person) => person.subtitle; - -export default function ComponentLibrary() { - const [darkMode, setDarkMode] = useState(() => { - return document.documentElement.classList.contains("dark"); - }); - const [selectedPerson, setSelectedPerson] = useState(""); - const [selectedPeople, setSelectedPeople] = useState([]); - const [participants, setParticipants] = useState([]); - - useEffect(() => { - if (darkMode) { - document.documentElement.classList.add("dark"); - } else { - document.documentElement.classList.remove("dark"); - } - }, [darkMode]); - - return ( - <> -

Component Library

- -
-

Dark Mode

- -
- -
-

Button Variants

-
- - - - -
-
- -
-

Button Sizes

-
- - - -
-
-
-

Text Input Sizes

-
- - - -
-
- -
-

Text Input with Icon

-
- - - -
-
- -
-

Text Input States

-
- - -
-
- -
-

Text Input With/Without Placeholder

-
- - -
-
- -
-

Text Input Width Options

-
- - -
-
- -
-

Text Input with Label

-
- - -
-
- -
-

Text Input with Label and Message

-
- - -
-
- -
-

List Item

-
- - - - -
-
- -
-

List Item - Title Only

-
- - - -
-
- -
-

SearchResultList

-
- -
-
- -
-

SearchResultList - Empty

-
- -
-
- -
-

Combobox - Single Select

-
- setSelectedPerson(v as string)} - placeholder="Sök..." - label="Välj person" - /> -

- Selected:{" "} - {selectedPerson - ? peopleOptions.find((p) => p.value === selectedPerson)?.label - : "None"} -

-
-
- -
-

Combobox - Multi Select

-
- setSelectedPeople(v as string[])} - placeholder="Sök..." - label="Välj personer" - multiple - /> -

- Selected:{" "} - {selectedPeople.length > 0 - ? selectedPeople - .map((v) => peopleOptions.find((p) => p.value === v)?.label) - .join(", ") - : "None"} -

-
-
- -
-

Combobox - Sizes

-
- - - -
-
- -
-

Combobox - Custom Width

-
- - -
-
- -
-

ListCard

-
- {}} /> - {}} /> - -
-
- -
-

ParticipantPicker

- -
- -
-

ParticipantPicker - Sizes

-
- - - -
-
- -
-

ParticipantPicker - Custom Width

-
- - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - ); -} diff --git a/frontend/src/studentportalen/ComponentLibrary/ButtonSection.tsx b/frontend/src/studentportalen/ComponentLibrary/ButtonSection.tsx new file mode 100644 index 0000000..25d473c --- /dev/null +++ b/frontend/src/studentportalen/ComponentLibrary/ButtonSection.tsx @@ -0,0 +1,26 @@ +import Button from "../../components/Button/Button"; + +export default function ButtonSection() { + return ( + <> +
+

Button Variants

+
+ + + + +
+
+ +
+

Button Sizes

+
+ + + +
+
+ + ); +} diff --git a/frontend/src/studentportalen/ComponentLibrary/ChoiceboxSection.tsx b/frontend/src/studentportalen/ComponentLibrary/ChoiceboxSection.tsx new file mode 100644 index 0000000..0057978 --- /dev/null +++ b/frontend/src/studentportalen/ComponentLibrary/ChoiceboxSection.tsx @@ -0,0 +1,104 @@ +import { useState } from "react"; +import Choicebox from "../../components/Choicebox/Choicebox"; + +export default function ChoiceboxSection() { + const [selectedTime, setSelectedTime] = useState(""); + + return ( + <> +
+

States

+
+ + + +
+
+ +
+

Interactive

+
+ setSelectedTime(e.target.value)} + /> + setSelectedTime(e.target.value)} + /> + setSelectedTime(e.target.value)} + /> + +
+

+ Vald tid: {selectedTime || "Ingen"} +

+
+ +
+

Fit Content

+
+ + + +
+
+ + ); +} diff --git a/frontend/src/studentportalen/ComponentLibrary/ComboboxSection.tsx b/frontend/src/studentportalen/ComponentLibrary/ComboboxSection.tsx new file mode 100644 index 0000000..b311e5c --- /dev/null +++ b/frontend/src/studentportalen/ComponentLibrary/ComboboxSection.tsx @@ -0,0 +1,125 @@ +import { useState } from "react"; +import Combobox from "../../components/Combobox/Combobox"; +import { + peopleOptions, + getPersonValue, + getPersonLabel, + getPersonSubtitle, +} from "./data"; + +export default function ComboboxSection() { + const [selectedPerson, setSelectedPerson] = useState(""); + const [selectedPeople, setSelectedPeople] = useState([]); + + return ( + <> +
+

Combobox - Single Select

+
+ setSelectedPerson(v as string)} + placeholder="Sök..." + label="Välj person" + /> +

+ Selected:{" "} + {selectedPerson + ? peopleOptions.find((p) => p.value === selectedPerson)?.label + : "None"} +

+
+
+ +
+

Combobox - Multi Select

+
+ setSelectedPeople(v as string[])} + placeholder="Sök..." + label="Välj personer" + multiple + /> +

+ Selected:{" "} + {selectedPeople.length > 0 + ? selectedPeople + .map((v) => peopleOptions.find((p) => p.value === v)?.label) + .join(", ") + : "None"} +

+
+
+ +
+

Combobox - Sizes

+
+ + + +
+
+ +
+

Combobox - Custom Width

+
+ + +
+
+ + ); +} diff --git a/frontend/src/studentportalen/ComponentLibrary/ComponentLibrary.tsx b/frontend/src/studentportalen/ComponentLibrary/ComponentLibrary.tsx new file mode 100644 index 0000000..adb1d00 --- /dev/null +++ b/frontend/src/studentportalen/ComponentLibrary/ComponentLibrary.tsx @@ -0,0 +1,74 @@ +import { useState, useEffect } from "react"; +import Sidebar from "./Sidebar"; +import { type ComponentCategory } from "./componentCategories"; +import ButtonSection from "./ButtonSection"; +import TextInputSection from "./TextInputSection"; +import DropdownSection from "./DropdownSection"; +import ListItemSection from "./ListItemSection"; +import SearchResultListSection from "./SearchResultListSection"; +import ComboboxSection from "./ComboboxSection"; +import ListCardSection from "./ListCardSection"; +import ParticipantPickerSection from "./ParticipantPickerSection"; +import InlineModalSection from "./InlineModalSection"; +import ChoiceboxSection from "./ChoiceboxSection"; +import StartTimeGridSection from "./StartTimeGridSection"; + +export default function ComponentLibrary() { + const [darkMode, setDarkMode] = useState(() => { + return document.documentElement.classList.contains("dark"); + }); + const [selectedCategory, setSelectedCategory] = + useState("Button"); + + useEffect(() => { + if (darkMode) { + document.documentElement.classList.add("dark"); + } else { + document.documentElement.classList.remove("dark"); + } + }, [darkMode]); + + const renderContent = () => { + switch (selectedCategory) { + case "Button": + return ; + case "TextInput": + return ; + case "Dropdown": + return ; + case "ListItem": + return ; + case "SearchResultList": + return ; + case "Combobox": + return ; + case "ListCard": + return ; + case "ParticipantPicker": + return ; + case "InlineModal": + return ; + case "Choicebox": + return ; + case "StartTimeGrid": + return ; + default: + return null; + } + }; + + return ( +
+ setDarkMode(!darkMode)} + /> +
+

{selectedCategory}

+ {renderContent()} +
+
+ ); +} diff --git a/frontend/src/studentportalen/ComponentLibrary/DropdownSection.tsx b/frontend/src/studentportalen/ComponentLibrary/DropdownSection.tsx new file mode 100644 index 0000000..75fbdeb --- /dev/null +++ b/frontend/src/studentportalen/ComponentLibrary/DropdownSection.tsx @@ -0,0 +1,167 @@ +import { useState } from "react"; +import Dropdown from "../../components/Dropdown/Dropdown"; +import { peopleOptions, getPersonValue, getPersonLabel } from "./data"; + +export default function DropdownSection() { + const [sizeSmall, setSizeSmall] = useState(""); + const [sizeMedium, setSizeMedium] = useState(""); + const [sizeLarge, setSizeLarge] = useState(""); + const [stateDefault, setStateDefault] = useState(""); + const [stateError, setStateError] = useState(""); + const [withLabel, setWithLabel] = useState(""); + const [withLabelError, setWithLabelError] = useState(""); + const [withMessage, setWithMessage] = useState(""); + const [withMessageError, setWithMessageError] = useState(""); + const [fullWidth, setFullWidth] = useState(""); + const [customWidth, setCustomWidth] = useState(""); + + return ( + <> +
+

Dropdown Sizes

+
+ setSizeSmall(v)} + /> + setSizeMedium(v)} + /> + setSizeLarge(v)} + /> +
+
+ +
+

Dropdown States

+
+ setStateDefault(v)} + /> + setStateError(v)} + /> +
+
+ +
+

Dropdown with Label

+
+ setWithLabel(v)} + /> + setWithLabelError(v)} + /> +
+
+ +
+

Dropdown with Label and Message

+
+ setWithMessage(v)} + /> + setWithMessageError(v)} + /> +
+
+ +
+

Dropdown Width Options

+
+ setFullWidth(v)} + /> + setCustomWidth(v)} + /> +
+
+ + ); +} diff --git a/frontend/src/studentportalen/ComponentLibrary/InlineModalSection.tsx b/frontend/src/studentportalen/ComponentLibrary/InlineModalSection.tsx new file mode 100644 index 0000000..1d14b49 --- /dev/null +++ b/frontend/src/studentportalen/ComponentLibrary/InlineModalSection.tsx @@ -0,0 +1,51 @@ +import InlineModal, { + InlineModalDivider, +} from "../../components/InlineModal/InlineModal"; +import TextInput from "../../components/TextInput/TextInput"; +import Button from "../../components/Button/Button"; + +export default function InlineModalSection() { + return ( + <> +
+

Arrow Left

+ + + +
+ + +
+
+
+ +
+

Arrow Right

+ + + +
+ + +
+
+
+ + ); +} diff --git a/frontend/src/studentportalen/ComponentLibrary/ListCardSection.tsx b/frontend/src/studentportalen/ComponentLibrary/ListCardSection.tsx new file mode 100644 index 0000000..ce12717 --- /dev/null +++ b/frontend/src/studentportalen/ComponentLibrary/ListCardSection.tsx @@ -0,0 +1,14 @@ +import ListCard from "../../components/ListCard/ListCard"; + +export default function ListCardSection() { + return ( +
+

ListCard

+
+ {}} /> + {}} /> + +
+
+ ); +} diff --git a/frontend/src/studentportalen/ComponentLibrary/ListItemSection.tsx b/frontend/src/studentportalen/ComponentLibrary/ListItemSection.tsx new file mode 100644 index 0000000..eca3995 --- /dev/null +++ b/frontend/src/studentportalen/ComponentLibrary/ListItemSection.tsx @@ -0,0 +1,26 @@ +import ListItem from "../../components/ListItem/ListItem"; + +export default function ListItemSection() { + return ( + <> +
+

List Item

+
+ + + + +
+
+ +
+

List Item - Title Only

+
+ + + +
+
+ + ); +} diff --git a/frontend/src/studentportalen/ComponentLibrary/ParticipantPickerSection.tsx b/frontend/src/studentportalen/ComponentLibrary/ParticipantPickerSection.tsx new file mode 100644 index 0000000..111fabf --- /dev/null +++ b/frontend/src/studentportalen/ComponentLibrary/ParticipantPickerSection.tsx @@ -0,0 +1,101 @@ +import { useState } from "react"; +import ParticipantPicker from "../../components/ParticipantPicker/ParticipantPicker"; +import { + peopleOptions, + getPersonValue, + getPersonLabel, + getPersonSubtitle, +} from "./data"; + +export default function ParticipantPickerSection() { + const [participants, setParticipants] = useState([]); + + return ( + <> +
+

ParticipantPicker

+ +
+ +
+

ParticipantPicker - Sizes

+
+ + + +
+
+ +
+

ParticipantPicker - Custom Width

+
+ + +
+
+ + ); +} diff --git a/frontend/src/studentportalen/ComponentLibrary/SearchResultListSection.tsx b/frontend/src/studentportalen/ComponentLibrary/SearchResultListSection.tsx new file mode 100644 index 0000000..d6237c9 --- /dev/null +++ b/frontend/src/studentportalen/ComponentLibrary/SearchResultListSection.tsx @@ -0,0 +1,40 @@ +import SearchResultList from "../../components/SearchResultList/SearchResultList"; +import { + peopleOptions, + getPersonValue, + getPersonLabel, + getPersonSubtitle, +} from "./data"; + +export default function SearchResultListSection() { + return ( + <> +
+

SearchResultList

+
+ +
+
+ +
+

SearchResultList - Empty

+
+ +
+
+ + ); +} diff --git a/frontend/src/studentportalen/ComponentLibrary/Sidebar.tsx b/frontend/src/studentportalen/ComponentLibrary/Sidebar.tsx new file mode 100644 index 0000000..934bad9 --- /dev/null +++ b/frontend/src/studentportalen/ComponentLibrary/Sidebar.tsx @@ -0,0 +1,48 @@ +import clsx from "clsx"; +import Button from "../../components/Button/Button"; +import { + componentCategories, + type ComponentCategory, +} from "./componentCategories"; + +interface SidebarProps { + selectedCategory: ComponentCategory; + onSelectCategory: (category: ComponentCategory) => void; + darkMode: boolean; + onToggleDarkMode: () => void; +} + +export default function Sidebar({ + selectedCategory, + onSelectCategory, + darkMode, + onToggleDarkMode, +}: SidebarProps) { + return ( + + ); +} diff --git a/frontend/src/studentportalen/ComponentLibrary/StartTimeGridSection.tsx b/frontend/src/studentportalen/ComponentLibrary/StartTimeGridSection.tsx new file mode 100644 index 0000000..a514bee --- /dev/null +++ b/frontend/src/studentportalen/ComponentLibrary/StartTimeGridSection.tsx @@ -0,0 +1,92 @@ +import Dropdown from "../../components/Dropdown/Dropdown"; +import StartTimeGrid from "../../components/StartTimeGrid/StartTimeGrid"; +import BookingForm from "../../components/StartTimeGrid/BookingForm"; +import { useGroupRoomBooking } from "../../components/StartTimeGrid/useGroupRoomBooking"; +import { + peopleOptions, + getPersonValue, + getPersonLabel, + getPersonSubtitle, +} from "./data"; + +const sampleContext = { + rooms: [ + { + id: "1", + name: "G5:1", + capacity: 5, + bookings: [ + { start: "2025-12-20T10:00:00", end: "2025-12-20T12:00:00" }, + { start: "2025-12-20T14:00:00", end: "2025-12-20T16:00:00" }, + ], + }, + { + id: "2", + name: "G5:2", + capacity: 5, + bookings: [{ start: "2025-12-20T09:00:00", end: "2025-12-20T11:00:00" }], + }, + { + id: "3", + name: "G5:3", + capacity: 5, + bookings: [ + { start: "2025-12-20T08:00:00", end: "2025-12-20T10:00:00" }, + { start: "2025-12-20T13:00:00", end: "2025-12-20T15:00:00" }, + ], + }, + ], + maxBookableLength: "PT4H", + earliestBookingTime: "08:00:00", + latestBookingTime: "20:00:00", + minimumParticipants: 2, + maxDaysInFuture: 14, +}; + +export default function StartTimeGridSection() { + const booking = useGroupRoomBooking(sampleContext, "2025-12-20"); + + return ( +
+

Default

+ +
+ o.value} + getOptionLabel={(o) => o.label} + value={booking.roomFilter} + onChange={booking.setRoomFilter} + label="Filtrera på rum" + hideLabel + /> +
+ + + { + console.log("Booked:", { + roomId: booking.bookedRoom?.id, + startTime: booking.selectedTime, + ...data, + }); + booking.clearSelection(); + }} + onCancel={booking.clearSelection} + /> + +
+ ); +} diff --git a/frontend/src/studentportalen/ComponentLibrary/TextInputSection.tsx b/frontend/src/studentportalen/ComponentLibrary/TextInputSection.tsx new file mode 100644 index 0000000..bbc200b --- /dev/null +++ b/frontend/src/studentportalen/ComponentLibrary/TextInputSection.tsx @@ -0,0 +1,128 @@ +import TextInput from "../../components/TextInput/TextInput"; +import { SearchIcon } from "../../components/Icon/Icon"; + +export default function TextInputSection() { + return ( + <> +
+

Text Input Sizes

+
+ + + +
+
+ +
+

Text Input with Icon

+
+ + + +
+
+ +
+

Text Input States

+
+ + +
+
+ +
+

Text Input With/Without Placeholder

+
+ + +
+
+ +
+

Text Input Width Options

+
+ + +
+
+ +
+

Text Input with Label

+
+ + +
+
+ +
+

Text Input with Label and Message

+
+ + +
+
+ + ); +} diff --git a/frontend/src/studentportalen/ComponentLibrary/componentCategories.ts b/frontend/src/studentportalen/ComponentLibrary/componentCategories.ts new file mode 100644 index 0000000..f313695 --- /dev/null +++ b/frontend/src/studentportalen/ComponentLibrary/componentCategories.ts @@ -0,0 +1,15 @@ +export const componentCategories = [ + "Button", + "TextInput", + "Dropdown", + "ListItem", + "SearchResultList", + "Combobox", + "ListCard", + "ParticipantPicker", + "InlineModal", + "Choicebox", + "StartTimeGrid", +] as const; + +export type ComponentCategory = (typeof componentCategories)[number]; diff --git a/frontend/src/studentportalen/ComponentLibrary/data.ts b/frontend/src/studentportalen/ComponentLibrary/data.ts new file mode 100644 index 0000000..0883c85 --- /dev/null +++ b/frontend/src/studentportalen/ComponentLibrary/data.ts @@ -0,0 +1,18 @@ +export interface Person { + value: string; + label: string; + subtitle: string; +} + +export const peopleOptions: Person[] = [ + { value: "1", label: "Lennart Johansson", subtitle: "lejo1891" }, + { value: "2", label: "Mats Rubarth", subtitle: "matsrub1891" }, + { value: "3", label: "Daniel Tjernström", subtitle: "datj1891" }, + { value: "4", label: "Johan Mjällby", subtitle: "jomj1891" }, + { value: "5", label: "Krister Nordin", subtitle: "krno1891" }, + { value: "6", label: "Kurre Hamrin", subtitle: "kuha1891" }, +]; + +export const getPersonValue = (person: Person) => person.value; +export const getPersonLabel = (person: Person) => person.label; +export const getPersonSubtitle = (person: Person) => person.subtitle; diff --git a/frontend/src/studentportalen/layout.css b/frontend/src/studentportalen/layout.css index af17ddb..d936948 100644 --- a/frontend/src/studentportalen/layout.css +++ b/frontend/src/studentportalen/layout.css @@ -1,6 +1,10 @@ #layout { min-height: 100vh; + padding-bottom: 5em; } + main { + max-width: var(--max-page-width); + margin: 0 auto; padding: 0 1em; }