import type { Metadata } from "next"; import { CalendarX2, Plus } from "lucide-react"; import Link from "next/link"; import { redirect } from "next/navigation"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/feedback/empty-state"; import { AppointmentCard } from "@/components/ui/appointment-card"; import { TopNav } from "@/components/navigation/top-nav"; import { prisma } from "@/lib/db/prisma"; import { getActingStaff } from "@/lib/staff/get-acting-staff"; import { calendarDateToUtcMidnight, minutesFromMidnight, todayIn, weekdayOf, } from "@/lib/time/business-time"; import type { CalendarDate } from "@/lib/time/business-time"; import { getAvailableRewardFlags } from "@/services/loyalty/loyalty.service"; import { listAppointments, selectCurrentAppointment } from "@/services/scheduling/appointment.service"; import { getUnreadNotificationCount } from "@/services/notifications/notification.service"; import { DateStrip } from "@/features/staff/components/date-strip"; import { DayCalendar } from "@/features/staff/components/day-calendar"; import type { CalendarAppointment, CalendarBlock, CalendarColumn } from "@/features/staff/components/day-calendar"; import { ViewSwitch } from "@/features/staff/components/view-switch"; import { BlockTimeButton } from "@/features/staff/components/block-time-button"; import { InlineNotificationBell } from "@/features/notifications/components/notification-bell"; import type { AppointmentStatus } from "@/types/domain"; export const metadata: Metadata = { title: "Calendar" }; export const dynamic = "force-dynamic"; const STATUS_MAP: Record = { PENDING: "pending_approval", CONFIRMED: "confirmed", CHECKED_IN: "arrived", IN_PROGRESS: "in_progress", COMPLETED: "completed", CANCELLED: "cancelled", NO_SHOW: "no_show", }; export default async function StaffCalendarPage({ searchParams, }: { searchParams: Promise<{ date?: string; view?: string }>; }) { const staff = await getActingStaff(); if (!staff) redirect("/home"); const { date: dateParam, view: viewParam } = await searchParams; const today = todayIn(staff.business.timezone); const date: CalendarDate = dateParam ?? today; const view = viewParam === "agenda" ? "agenda" : "day"; const weekday = weekdayOf(date); const dateUtcMidnight = calendarDateToUtcMidnight(date); const [workingHours, breakDays, timeOffRows, appointments] = await Promise.all([ prisma.workingHours.findUnique({ where: { staffId_dayOfWeek: { staffId: staff.id, dayOfWeek: weekday } }, }), prisma.breakDay.findMany({ where: { dayOfWeek: weekday, break: { staffId: staff.id } }, include: { break: true }, }), prisma.timeOff.findMany({ where: { staffId: staff.id, status: "APPROVED", startDate: { lte: dateUtcMidnight }, endDate: { gte: dateUtcMidnight }, }, }), listAppointments(prisma, { businessId: staff.businessId, staffId: staff.id, fromDate: date, toDate: date, businessTimezone: staff.business.timezone, }), ]); const wholeDayOff = timeOffRows.find((t) => t.startMinutes === null); const rewardFlags = await getAvailableRewardFlags(prisma, { businessId: staff.businessId, customerIds: [...new Set(appointments.map((a) => a.customerId))], }); const workingWindow = workingHours && !workingHours.isOff && workingHours.startMinutes !== null && workingHours.endMinutes !== null ? { startMinutes: workingHours.startMinutes, endMinutes: workingHours.endMinutes } : null; const blocks: CalendarBlock[] = [ ...breakDays.map((bd) => ({ id: `break-${bd.id}`, startMinutes: bd.break.startMinutes, endMinutes: bd.break.endMinutes, label: bd.break.label, kind: "break" as const, })), ...timeOffRows .filter((t) => t.startMinutes !== null && t.endMinutes !== null) .map((t) => ({ id: `timeoff-${t.id}`, startMinutes: t.startMinutes!, endMinutes: t.endMinutes!, label: t.reason ?? "Blocked", kind: "timeOff" as const, })), ]; const calendarAppointments: CalendarAppointment[] = appointments.map((a) => ({ id: a.id, startMinutes: minutesFromMidnight(a.startAt, staff.business.timezone), endMinutes: minutesFromMidnight(a.endAt, staff.business.timezone), customerName: `${a.customer.firstName} ${a.customer.lastName}`, serviceNames: a.services.map((s) => s.service.name).join(" + "), status: STATUS_MAP[a.status] ?? "confirmed", hasNotes: !!a.notes, hasAvailableReward: rewardFlags.get(a.customerId) ?? false, })); const currentTimeMinutes = date === today ? minutesFromMidnight(new Date(), staff.business.timezone) : null; const columns: CalendarColumn[] = [ { id: staff.id, name: staff.user.name ?? "You", avatarUrl: staff.user.image, hoursLabel: workingWindow ? formatRange(workingWindow.startMinutes, workingWindow.endMinutes) : null, wholeDayOff: false, appointments: calendarAppointments, blocks, workingWindow, }, ]; // Desktop-only: a "Right Now" panel beside the calendar so a wide screen // does more than center the mobile layout (see docs/phase-5-summary.md, // "Responsive Design"). Only meaningful when viewing today. const rightNow = date === today ? selectCurrentAppointment(appointments, new Date()) : null; const unreadNotificationCount = await getUnreadNotificationCount(prisma, staff.user.id); return (
} action={
} />
{wholeDayOff ? ( ) : view === "day" ? ( ) : calendarAppointments.length === 0 ? (
) : (
{calendarAppointments.map((a) => ( View } /> ))}
)}
{/* Desktop-only "Right Now" panel — a wide screen shows the current appointment alongside the calendar instead of requiring a separate tab, since there's room for both at once. */}
); } function formatRange(startMinutes: number, endMinutes: number): string { const fmt = (m: number) => { const h = Math.floor(m / 60); const min = m % 60; const period = h >= 12 ? "PM" : "AM"; const displayHour = h % 12 === 0 ? 12 : h % 12; return `${displayHour}:${String(min).padStart(2, "0")} ${period}`; }; return `${fmt(startMinutes)} – ${fmt(endMinutes)}`; }