feat: add Hot Desk module (Phase 2) 4-desk booking with 2-week window, room layout, calendar, subtle unbooked-day alerts

This commit is contained in:
AI Assistant
2026-02-19 08:10:50 +02:00
parent 6cb655a79f
commit 3b1ba589f0
15 changed files with 1806 additions and 273 deletions
@@ -0,0 +1,161 @@
"use client";
import { useMemo } from "react";
import { cn } from "@/shared/lib/utils";
import type { DeskReservation } from "../types";
import { DESKS } from "../types";
import {
getMonday,
toDateKey,
formatDateShort,
getReservationsForDate,
isDateBookable,
} from "../services/reservation-service";
interface DeskCalendarProps {
/** The week anchor date */
weekStart: Date;
selectedDate: string;
reservations: DeskReservation[];
onSelectDate: (dateKey: string) => void;
onPrevWeek: () => void;
onNextWeek: () => void;
canGoPrev: boolean;
canGoNext: boolean;
}
export function DeskCalendar({
weekStart,
selectedDate,
reservations,
onSelectDate,
onPrevWeek,
onNextWeek,
canGoPrev,
canGoNext,
}: DeskCalendarProps) {
const monday = useMemo(() => getMonday(weekStart), [weekStart]);
const weekDays = useMemo(() => {
const days: string[] = [];
for (let i = 0; i < 5; i++) {
const d = new Date(monday);
d.setDate(monday.getDate() + i);
days.push(toDateKey(d));
}
return days;
}, [monday]);
const todayKey = toDateKey(new Date());
return (
<div className="flex flex-col gap-2">
{/* Week navigation */}
<div className="flex items-center justify-between">
<button
type="button"
onClick={onPrevWeek}
disabled={!canGoPrev}
className={cn(
"rounded-md px-2 py-1 text-xs font-medium transition-colors",
canGoPrev
? "text-muted-foreground hover:text-foreground hover:bg-muted/60"
: "text-muted-foreground/30 cursor-not-allowed",
)}
>
Săpt. anterioară
</button>
<button
type="button"
onClick={onNextWeek}
disabled={!canGoNext}
className={cn(
"rounded-md px-2 py-1 text-xs font-medium transition-colors",
canGoNext
? "text-muted-foreground hover:text-foreground hover:bg-muted/60"
: "text-muted-foreground/30 cursor-not-allowed",
)}
>
Săpt. următoare
</button>
</div>
{/* Day cells */}
<div className="grid grid-cols-5 gap-1.5">
{weekDays.map((dateKey) => {
const dayReservations = getReservationsForDate(dateKey, reservations);
const bookedCount = dayReservations.length;
const totalDesks = DESKS.length;
const isSelected = dateKey === selectedDate;
const isToday = dateKey === todayKey;
const bookable = isDateBookable(dateKey);
const isPast = dateKey < todayKey;
const hasNoBookings = bookedCount === 0 && !isPast;
return (
<button
key={dateKey}
type="button"
onClick={() => onSelectDate(dateKey)}
disabled={!bookable && !isPast}
className={cn(
"relative flex flex-col items-center gap-0.5 rounded-lg border px-2 py-2 text-center transition-all",
isSelected
? "border-primary/50 bg-primary/8 ring-1 ring-primary/20"
: "border-border/40 hover:border-border/70 hover:bg-muted/30",
isPast && !isSelected && "opacity-50",
!bookable && !isPast && "opacity-40 cursor-not-allowed",
)}
>
{/* Today dot */}
{isToday && (
<div className="absolute top-1 right-1 h-1.5 w-1.5 rounded-full bg-primary" />
)}
{/* Day name + date */}
<span
className={cn(
"text-[11px] font-medium leading-tight",
isSelected ? "text-primary" : "text-muted-foreground",
)}
>
{formatDateShort(dateKey)}
</span>
{/* Occupancy indicator */}
<div className="flex gap-0.5 mt-0.5">
{Array.from({ length: totalDesks }).map((_, i) => (
<div
key={i}
className={cn(
"h-1 w-3 rounded-full transition-colors",
i < bookedCount
? "bg-primary/60"
: hasNoBookings
? "bg-amber-400/40"
: "bg-muted-foreground/15",
)}
/>
))}
</div>
{/* Count label */}
<span
className={cn(
"text-[10px] leading-tight",
bookedCount === totalDesks
? "text-primary/70"
: hasNoBookings
? "text-amber-500/70"
: "text-muted-foreground/60",
)}
>
{bookedCount}/{totalDesks}
</span>
</button>
);
})}
</div>
</div>
);
}