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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user