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