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>
);
}
@@ -0,0 +1,137 @@
"use client";
import { DESKS, getDeskLabel } from "../types";
import type { DeskId, DeskReservation } from "../types";
import { getReservationForDesk } from "../services/reservation-service";
import { cn } from "@/shared/lib/utils";
interface DeskRoomLayoutProps {
selectedDate: string;
reservations: DeskReservation[];
onDeskClick: (deskId: DeskId) => void;
}
export function DeskRoomLayout({
selectedDate,
reservations,
onDeskClick,
}: DeskRoomLayoutProps) {
return (
<div className="flex flex-col items-center gap-3">
{/* Room container — styled like a top-down floor plan */}
<div className="relative w-full max-w-[340px] rounded-xl border border-border/60 bg-muted/20 p-5">
{/* Window indicator — top edge */}
<div className="absolute top-0 left-4 right-4 h-1.5 rounded-b-sm bg-muted-foreground/15" />
<div className="absolute top-0 left-6 right-6 flex justify-between">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="mt-0.5 h-0.5 w-3 rounded-full bg-muted-foreground/10"
/>
))}
</div>
{/* Central table */}
<div className="mx-auto mt-4 mb-4 flex flex-col items-center">
{/* Top row desks */}
<div className="flex gap-3 mb-2">
{DESKS.filter((d) => d.position.startsWith("top")).map((desk) => {
const reservation = getReservationForDesk(
desk.id,
selectedDate,
reservations,
);
return (
<DeskSlot
key={desk.id}
deskId={desk.id}
label={getDeskLabel(desk.id)}
reservation={reservation}
side="top"
onClick={() => onDeskClick(desk.id)}
/>
);
})}
</div>
{/* The table surface */}
<div className="h-12 w-full max-w-[280px] rounded-md border border-border/50 bg-muted/40" />
{/* Bottom row desks */}
<div className="flex gap-3 mt-2">
{DESKS.filter((d) => d.position.startsWith("bottom")).map(
(desk) => {
const reservation = getReservationForDesk(
desk.id,
selectedDate,
reservations,
);
return (
<DeskSlot
key={desk.id}
deskId={desk.id}
label={getDeskLabel(desk.id)}
reservation={reservation}
side="bottom"
onClick={() => onDeskClick(desk.id)}
/>
);
},
)}
</div>
</div>
</div>
</div>
);
}
interface DeskSlotProps {
deskId: DeskId;
label: string;
reservation: DeskReservation | undefined;
side: "top" | "bottom";
onClick: () => void;
}
function DeskSlot({ label, reservation, side, onClick }: DeskSlotProps) {
const isBooked = !!reservation;
return (
<button
type="button"
onClick={onClick}
className={cn(
"group relative flex w-[125px] cursor-pointer flex-col items-center rounded-lg border p-3 transition-all",
side === "top" ? "rounded-b-sm" : "rounded-t-sm",
isBooked
? "border-primary/30 bg-primary/8 hover:border-primary/50 hover:bg-primary/12"
: "border-dashed border-border/60 bg-background/60 hover:border-primary/40 hover:bg-muted/50",
)}
>
{/* Chair indicator */}
<div
className={cn(
"absolute left-1/2 -translate-x-1/2 h-1.5 w-8 rounded-full transition-colors",
side === "top" ? "-top-2.5" : "-bottom-2.5",
isBooked
? "bg-primary/40"
: "bg-muted-foreground/15 group-hover:bg-muted-foreground/25",
)}
/>
{/* Desk label */}
<span className="text-[11px] font-medium text-muted-foreground">
{label}
</span>
{/* Status */}
{isBooked ? (
<span className="mt-1 text-xs font-medium text-primary truncate max-w-full">
{reservation.userName}
</span>
) : (
<span className="mt-1 text-[11px] text-muted-foreground/50">Liber</span>
)}
</button>
);
}
@@ -0,0 +1,305 @@
"use client";
import { useState, useMemo, useCallback } from "react";
import { Card, CardContent } from "@/shared/components/ui/card";
import { useReservations } from "../hooks/use-reservations";
import { DeskRoomLayout } from "./desk-room-layout";
import { DeskCalendar } from "./desk-calendar";
import { ReservationDialog } from "./reservation-dialog";
import type { DeskId } from "../types";
import { DESKS } from "../types";
import {
toDateKey,
getMonday,
getReservationsForDate,
getReservationForDesk,
getUnbookedCurrentWeekDays,
formatDateRo,
formatDateShort,
MAX_ADVANCE_DAYS,
} from "../services/reservation-service";
import { cn } from "@/shared/lib/utils";
export function HotDeskModule() {
const { reservations, loading, addReservation, cancelReservation } =
useReservations();
const today = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}, []);
const todayKey = useMemo(() => toDateKey(today), [today]);
const [selectedDate, setSelectedDate] = useState(todayKey);
const [weekStartDate, setWeekStartDate] = useState(() => getMonday(today));
// Dialog state
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogDeskId, setDialogDeskId] = useState<DeskId>("desk-1");
// --- Week navigation ---
const currentMonday = useMemo(() => getMonday(today), [today]);
const maxDate = useMemo(() => {
const d = new Date(today);
d.setDate(d.getDate() + MAX_ADVANCE_DAYS);
return d;
}, [today]);
const canGoPrev = useMemo(() => {
return weekStartDate > currentMonday;
}, [weekStartDate, currentMonday]);
const canGoNext = useMemo(() => {
const nextMonday = new Date(weekStartDate);
nextMonday.setDate(nextMonday.getDate() + 7);
return nextMonday <= maxDate;
}, [weekStartDate, maxDate]);
const handlePrevWeek = useCallback(() => {
setWeekStartDate((prev) => {
const d = new Date(prev);
d.setDate(d.getDate() - 7);
if (d < currentMonday) return currentMonday;
return d;
});
}, [currentMonday]);
const handleNextWeek = useCallback(() => {
setWeekStartDate((prev) => {
const d = new Date(prev);
d.setDate(d.getDate() + 7);
return d;
});
}, []);
// --- Stats ---
const todayReservations = useMemo(
() => getReservationsForDate(todayKey, reservations),
[todayKey, reservations],
);
const selectedDayReservations = useMemo(
() => getReservationsForDate(selectedDate, reservations),
[selectedDate, reservations],
);
const unbookedDays = useMemo(
() => getUnbookedCurrentWeekDays(reservations),
[reservations],
);
// --- Desk click ---
const handleDeskClick = useCallback((deskId: DeskId) => {
setDialogDeskId(deskId);
setDialogOpen(true);
}, []);
const handleBook = useCallback(
async (userName: string, notes: string) => {
await addReservation(dialogDeskId, selectedDate, userName, notes);
},
[addReservation, dialogDeskId, selectedDate],
);
const handleCancelReservation = useCallback(
async (reservationId: string) => {
await cancelReservation(reservationId);
},
[cancelReservation],
);
const existingReservation = useMemo(
() => getReservationForDesk(dialogDeskId, selectedDate, reservations),
[dialogDeskId, selectedDate, reservations],
);
if (loading) {
return (
<div className="flex min-h-[30vh] items-center justify-center">
<p className="text-sm text-muted-foreground">Se încarcă...</p>
</div>
);
}
return (
<div className="space-y-5">
{/* Subtle alert for unbooked work days */}
{unbookedDays.length > 0 && (
<div className="flex items-start gap-2.5 rounded-lg border border-amber-500/20 bg-amber-500/5 px-3.5 py-2.5">
<div className="mt-0.5 h-1.5 w-1.5 shrink-0 rounded-full bg-amber-500/60" />
<p className="text-xs text-amber-600/80 dark:text-amber-400/70">
{unbookedDays.length === 1
? `${formatDateShort(unbookedDays[0] ?? "")} nu are nicio rezervare.`
: `${unbookedDays.length} zile din această săptămână nu au rezervări: ${unbookedDays.map((d) => formatDateShort(d)).join(", ")}.`}
</p>
</div>
)}
{/* Stats row */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Birouri" value={DESKS.length} sub="total cameră" />
<StatCard
label="Azi ocupate"
value={todayReservations.length}
sub={`din ${DESKS.length}`}
highlight={todayReservations.length === DESKS.length}
/>
<StatCard
label="Azi libere"
value={DESKS.length - todayReservations.length}
sub="disponibile"
/>
<StatCard
label="Săpt. curentă"
value={unbookedDays.length === 0 ? "✓" : unbookedDays.length}
sub={unbookedDays.length === 0 ? "acoperit" : "zile neacoperite"}
warn={unbookedDays.length > 0}
/>
</div>
{/* Main content: calendar + room layout */}
<div className="grid gap-5 lg:grid-cols-[1fr_380px]">
{/* Left: Calendar */}
<Card className="border-border/50">
<CardContent className="p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Calendar</h3>
<span className="text-xs text-muted-foreground">
{formatDateRo(selectedDate)}
</span>
</div>
<DeskCalendar
weekStart={weekStartDate}
selectedDate={selectedDate}
reservations={reservations}
onSelectDate={setSelectedDate}
onPrevWeek={handlePrevWeek}
onNextWeek={handleNextWeek}
canGoPrev={canGoPrev}
canGoNext={canGoNext}
/>
{/* Day detail table */}
{selectedDayReservations.length > 0 ? (
<div className="space-y-2">
<h4 className="text-xs font-medium text-muted-foreground">
Rezervări {formatDateRo(selectedDate)}
</h4>
<div className="rounded-lg border border-border/40 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/30 bg-muted/30">
<th className="px-3 py-1.5 text-left text-xs font-medium text-muted-foreground">
Birou
</th>
<th className="px-3 py-1.5 text-left text-xs font-medium text-muted-foreground">
Persoana
</th>
<th className="px-3 py-1.5 text-left text-xs font-medium text-muted-foreground">
Note
</th>
</tr>
</thead>
<tbody>
{selectedDayReservations.map((r) => (
<tr
key={r.id}
className="border-b border-border/20 last:border-0 hover:bg-muted/20 cursor-pointer transition-colors"
onClick={() => handleDeskClick(r.deskId)}
>
<td className="px-3 py-1.5 text-xs font-medium">
{DESKS.find((d) => d.id === r.deskId)?.label ??
r.deskId}
</td>
<td className="px-3 py-1.5 text-xs">{r.userName}</td>
<td className="px-3 py-1.5 text-xs text-muted-foreground truncate max-w-[120px]">
{r.notes || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
<p className="text-xs text-muted-foreground/60 text-center py-2">
Nicio rezervare în {formatDateRo(selectedDate)}.
</p>
)}
</CardContent>
</Card>
{/* Right: Room layout */}
<Card className="border-border/50">
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Cameră</h3>
<span className="text-xs text-muted-foreground">
{selectedDayReservations.length}/{DESKS.length} ocupate
</span>
</div>
<DeskRoomLayout
selectedDate={selectedDate}
reservations={reservations}
onDeskClick={handleDeskClick}
/>
<p className="text-[11px] text-muted-foreground/50 text-center">
Click pe un birou pentru a rezerva sau anula
</p>
</CardContent>
</Card>
</div>
{/* Reservation dialog */}
<ReservationDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
deskId={dialogDeskId}
dateKey={selectedDate}
existingReservation={existingReservation}
onBook={handleBook}
onCancel={handleCancelReservation}
/>
</div>
);
}
// --- Stat Card ---
interface StatCardProps {
label: string;
value: string | number;
sub: string;
highlight?: boolean;
warn?: boolean;
}
function StatCard({ label, value, sub, highlight, warn }: StatCardProps) {
return (
<Card
className={cn(
"border-border/40",
highlight && "border-primary/30 bg-primary/5",
warn && "border-amber-500/20 bg-amber-500/5",
)}
>
<CardContent className="p-3">
<p className="text-[11px] font-medium text-muted-foreground">{label}</p>
<p
className={cn(
"text-xl font-bold",
highlight && "text-primary",
warn && "text-amber-600 dark:text-amber-400",
)}
>
{value}
</p>
<p className="text-[10px] text-muted-foreground/60">{sub}</p>
</CardContent>
</Card>
);
}
@@ -0,0 +1,161 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/shared/components/ui/dialog";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { Textarea } from "@/shared/components/ui/textarea";
import type { DeskId, DeskReservation } from "../types";
import { getDeskLabel } from "../types";
import { formatDateRo, isDateBookable } from "../services/reservation-service";
interface ReservationDialogProps {
open: boolean;
onClose: () => void;
deskId: DeskId;
dateKey: string;
existingReservation: DeskReservation | undefined;
onBook: (userName: string, notes: string) => Promise<void>;
onCancel: (reservationId: string) => Promise<void>;
}
export function ReservationDialog({
open,
onClose,
deskId,
dateKey,
existingReservation,
onBook,
onCancel,
}: ReservationDialogProps) {
const [userName, setUserName] = useState("");
const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
const bookable = isDateBookable(dateKey);
const isBooked = !!existingReservation;
const handleBook = async () => {
if (!userName.trim()) return;
setSubmitting(true);
try {
await onBook(userName.trim(), notes.trim());
setUserName("");
setNotes("");
onClose();
} finally {
setSubmitting(false);
}
};
const handleCancel = async () => {
if (!existingReservation) return;
setSubmitting(true);
try {
await onCancel(existingReservation.id);
onClose();
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>
{getDeskLabel(deskId)} {formatDateRo(dateKey)}
</DialogTitle>
</DialogHeader>
{isBooked ? (
<div className="space-y-3 py-2">
<div className="rounded-lg border border-border/50 bg-muted/30 p-3 space-y-1.5">
<div className="text-sm">
<span className="text-muted-foreground">Rezervat de: </span>
<span className="font-medium">
{existingReservation.userName}
</span>
</div>
{existingReservation.notes && (
<div className="text-sm">
<span className="text-muted-foreground">Note: </span>
<span>{existingReservation.notes}</span>
</div>
)}
</div>
</div>
) : bookable ? (
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="userName">Nume</Label>
<Input
id="userName"
placeholder="Numele tău"
value={userName}
onChange={(e) => setUserName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleBook()}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="notes">Note (opțional)</Label>
<Textarea
id="notes"
placeholder="Ex: lucrez la proiect X, am nevoie de monitor extern..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
className="resize-none"
/>
</div>
</div>
) : (
<div className="py-4 text-center text-sm text-muted-foreground">
Această dată nu mai este disponibilă pentru rezervări.
</div>
)}
<DialogFooter>
{isBooked ? (
<>
<Button variant="outline" onClick={onClose} disabled={submitting}>
Închide
</Button>
<Button
variant="destructive"
onClick={handleCancel}
disabled={submitting}
>
{submitting ? "Se anulează..." : "Anulează rezervarea"}
</Button>
</>
) : bookable ? (
<>
<Button variant="outline" onClick={onClose} disabled={submitting}>
Renunță
</Button>
<Button
onClick={handleBook}
disabled={submitting || !userName.trim()}
>
{submitting ? "Se rezervă..." : "Rezervă"}
</Button>
</>
) : (
<Button variant="outline" onClick={onClose}>
Închide
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+17
View File
@@ -0,0 +1,17 @@
import type { ModuleConfig } from "@/core/module-registry/types";
export const hotDeskConfig: ModuleConfig = {
id: "hot-desk",
name: "Birouri Partajate",
description: "Rezervare birouri în camera partajată",
icon: "armchair",
route: "/hot-desk",
category: "management",
featureFlag: "module.hot-desk",
visibility: "all",
version: "0.1.0",
dependencies: [],
storageNamespace: "hot-desk",
navOrder: 33,
tags: ["birouri", "rezervare", "hot-desk"],
};
@@ -0,0 +1,77 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useStorage } from "@/core/storage";
import { v4 as uuid } from "uuid";
import type { DeskId, DeskReservation } from "../types";
const PREFIX = "res:";
export function useReservations() {
const storage = useStorage("hot-desk");
const [reservations, setReservations] = useState<DeskReservation[]>([]);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
setLoading(true);
const keys = await storage.list();
const results: DeskReservation[] = [];
for (const key of keys) {
if (key.startsWith(PREFIX)) {
const item = await storage.get<DeskReservation>(key);
if (item) results.push(item);
}
}
results.sort((a, b) => a.date.localeCompare(b.date));
setReservations(results);
setLoading(false);
}, [storage]);
useEffect(() => {
refresh();
}, [refresh]);
const addReservation = useCallback(
async (deskId: DeskId, date: string, userName: string, notes: string) => {
// Check for conflict
const existing = reservations.find(
(r) => r.deskId === deskId && r.date === date,
);
if (existing) {
throw new Error(`Biroul este deja rezervat pe ${date}`);
}
const now = new Date().toISOString();
const reservation: DeskReservation = {
id: uuid(),
deskId,
date,
userName,
notes,
visibility: "all",
createdAt: now,
updatedAt: now,
};
await storage.set(`${PREFIX}${reservation.id}`, reservation);
await refresh();
return reservation;
},
[storage, refresh, reservations],
);
const cancelReservation = useCallback(
async (id: string) => {
await storage.delete(`${PREFIX}${id}`);
await refresh();
},
[storage, refresh],
);
return {
reservations,
loading,
addReservation,
cancelReservation,
refresh,
};
}
+3
View File
@@ -0,0 +1,3 @@
export { hotDeskConfig } from "./config";
export { HotDeskModule } from "./components/hot-desk-module";
export type { DeskReservation, DeskId } from "./types";
@@ -0,0 +1,206 @@
import type { DeskId, DeskReservation } from "../types";
import { DESKS } from "../types";
/** Maximum number of days in advance a reservation can be made */
export const MAX_ADVANCE_DAYS = 14;
/**
* Check if a date string falls on a weekday (Mon-Fri).
*/
export function isWeekday(dateStr: string): boolean {
const day = new Date(dateStr).getDay();
return day >= 1 && day <= 5;
}
/**
* Format a date as YYYY-MM-DD.
*/
export function toDateKey(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
/**
* Get the Monday of the week containing `date`.
*/
export function getMonday(date: Date): Date {
const d = new Date(date);
const day = d.getDay();
const diff = day === 0 ? -6 : 1 - day;
d.setDate(d.getDate() + diff);
d.setHours(0, 0, 0, 0);
return d;
}
/**
* Build an array of weekday date keys for the week containing `date`.
*/
export function getWeekDays(date: Date): string[] {
const monday = getMonday(date);
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;
}
/**
* Build all bookable date keys: today + up to MAX_ADVANCE_DAYS, weekdays only.
*/
export function getBookableDates(): string[] {
const today = new Date();
today.setHours(0, 0, 0, 0);
const dates: string[] = [];
for (let i = 0; i <= MAX_ADVANCE_DAYS; i++) {
const d = new Date(today);
d.setDate(today.getDate() + i);
const key = toDateKey(d);
if (isWeekday(key)) {
dates.push(key);
}
}
return dates;
}
/**
* Check if a desk is available on a specific date.
*/
export function isDeskAvailable(
deskId: DeskId,
dateKey: string,
reservations: DeskReservation[],
): boolean {
return !reservations.some((r) => r.deskId === deskId && r.date === dateKey);
}
/**
* Check if a specific date can be booked (not in the past, within 2-week window, is a weekday).
*/
export function isDateBookable(dateKey: string): boolean {
const today = new Date();
today.setHours(0, 0, 0, 0);
const target = new Date(dateKey);
target.setHours(0, 0, 0, 0);
if (target < today) return false;
const diffMs = target.getTime() - today.getTime();
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
if (diffDays > MAX_ADVANCE_DAYS) return false;
return isWeekday(dateKey);
}
/**
* Get reservations for a specific date.
*/
export function getReservationsForDate(
dateKey: string,
reservations: DeskReservation[],
): DeskReservation[] {
return reservations.filter((r) => r.date === dateKey);
}
/**
* Get reservations for a specific desk on a date.
*/
export function getReservationForDesk(
deskId: DeskId,
dateKey: string,
reservations: DeskReservation[],
): DeskReservation | undefined {
return reservations.find((r) => r.deskId === deskId && r.date === dateKey);
}
/**
* Detect workdays in the current week that have zero reservations.
* Returns date keys that need attention.
*/
export function getUnbookedCurrentWeekDays(
reservations: DeskReservation[],
): string[] {
const today = new Date();
today.setHours(0, 0, 0, 0);
const weekDays = getWeekDays(today);
const todayKey = toDateKey(today);
return weekDays.filter((dateKey) => {
// Only check today and future days in the current week
if (dateKey < todayKey) return false;
const dayReservations = getReservationsForDate(dateKey, reservations);
return dayReservations.length === 0;
});
}
/**
* Count available desks for a date.
*/
export function countAvailableDesks(
dateKey: string,
reservations: DeskReservation[],
): number {
const booked = reservations.filter((r) => r.date === dateKey).length;
return DESKS.length - booked;
}
/**
* Format a date key to a human-friendly Romanian string.
*/
export function formatDateRo(dateKey: string): string {
const date = new Date(dateKey);
const days = [
"Duminică",
"Luni",
"Marți",
"Miercuri",
"Joi",
"Vineri",
"Sâmbătă",
];
const months = [
"ianuarie",
"februarie",
"martie",
"aprilie",
"mai",
"iunie",
"iulie",
"august",
"septembrie",
"octombrie",
"noiembrie",
"decembrie",
];
const dayName = days[date.getDay()] ?? "";
const monthName = months[date.getMonth()] ?? "";
return `${dayName}, ${date.getDate()} ${monthName}`;
}
/**
* Short format: "Lun 19 feb"
*/
export function formatDateShort(dateKey: string): string {
const date = new Date(dateKey);
const days = ["Dum", "Lun", "Mar", "Mie", "Joi", "Vin", "Sâm"];
const months = [
"ian",
"feb",
"mar",
"apr",
"mai",
"iun",
"iul",
"aug",
"sep",
"oct",
"noi",
"dec",
];
const dayName = days[date.getDay()] ?? "";
const monthName = months[date.getMonth()] ?? "";
return `${dayName} ${date.getDate()} ${monthName}`;
}
+38
View File
@@ -0,0 +1,38 @@
import type { Visibility } from "@/core/module-registry/types";
export type DeskId = "desk-1" | "desk-2" | "desk-3" | "desk-4";
export type DeskPosition =
| "top-left"
| "top-right"
| "bottom-left"
| "bottom-right";
export interface DeskDefinition {
id: DeskId;
label: string;
position: DeskPosition;
}
export interface DeskReservation {
id: string;
deskId: DeskId;
date: string; // YYYY-MM-DD
userName: string;
notes: string;
visibility: Visibility;
createdAt: string;
updatedAt: string;
}
export const DESKS: DeskDefinition[] = [
{ id: "desk-1", label: "Birou 1", position: "top-left" },
{ id: "desk-2", label: "Birou 2", position: "top-right" },
{ id: "desk-3", label: "Birou 3", position: "bottom-left" },
{ id: "desk-4", label: "Birou 4", position: "bottom-right" },
];
export function getDeskLabel(deskId: DeskId): string {
const desk = DESKS.find((d) => d.id === deskId);
return desk?.label ?? deskId;
}