278 lines
10 KiB
TypeScript
278 lines
10 KiB
TypeScript
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<string, AppointmentStatus> = {
|
||
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 (
|
||
<div className="pb-6">
|
||
<TopNav
|
||
title="Calendar"
|
||
sticky
|
||
className="bg-stone"
|
||
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
|
||
action={
|
||
<div className="flex gap-2">
|
||
<BlockTimeButton date={date} />
|
||
<Button asChild size="sm">
|
||
<Link href="/staff/book">
|
||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||
New
|
||
</Link>
|
||
</Button>
|
||
</div>
|
||
}
|
||
/>
|
||
|
||
<DateStrip
|
||
selectedDate={date}
|
||
businessTimezone={staff.business.timezone}
|
||
basePath="/staff"
|
||
view={view}
|
||
/>
|
||
|
||
<div className="mt-4 px-6">
|
||
<ViewSwitch view={view} dayHref={`/staff?date=${date}&view=day`} agendaHref={`/staff?date=${date}&view=agenda`} />
|
||
</div>
|
||
|
||
<div className="mt-4 lg:flex lg:items-start lg:gap-6 lg:px-6">
|
||
<div className="lg:flex-1">
|
||
{wholeDayOff ? (
|
||
<EmptyState
|
||
icon={CalendarX2}
|
||
title="You're off this day"
|
||
description={wholeDayOff.reason ?? "Approved time off."}
|
||
/>
|
||
) : view === "day" ? (
|
||
<DayCalendar columns={columns} currentTimeMinutes={currentTimeMinutes} />
|
||
) : calendarAppointments.length === 0 ? (
|
||
<div className="px-6 lg:px-0">
|
||
<EmptyState
|
||
icon={CalendarX2}
|
||
title="No appointments today"
|
||
description="Your schedule is clear for this day."
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4 px-6 lg:px-0">
|
||
{calendarAppointments.map((a) => (
|
||
<AppointmentCard
|
||
key={a.id}
|
||
status={a.status}
|
||
serviceName={a.serviceNames}
|
||
person={{ id: a.id, name: a.customerName }}
|
||
personLabel="for"
|
||
formattedTime={formatRange(a.startMinutes, a.endMinutes)}
|
||
hasNotes={a.hasNotes}
|
||
hasAvailableReward={a.hasAvailableReward}
|
||
actions={
|
||
<Link
|
||
href={`/staff/appointments/${a.id}`}
|
||
className="border-line rounded-lg border px-4 py-2 text-xs font-bold tracking-widest uppercase"
|
||
>
|
||
View
|
||
</Link>
|
||
}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 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. */}
|
||
<aside className="hidden shrink-0 lg:block lg:w-80">
|
||
<div className="rounded-md border-line sticky top-6 border bg-white p-5">
|
||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||
Right Now
|
||
</span>
|
||
{rightNow ? (
|
||
<div className="mt-3 space-y-3">
|
||
<AppointmentCard
|
||
status={STATUS_MAP[rightNow.status] ?? "confirmed"}
|
||
serviceName={rightNow.services.map((s) => s.service.name).join(" + ")}
|
||
person={{ id: rightNow.customerId, name: `${rightNow.customer.firstName} ${rightNow.customer.lastName}` }}
|
||
personLabel="for"
|
||
formattedTime={formatRange(
|
||
minutesFromMidnight(rightNow.startAt, staff.business.timezone),
|
||
minutesFromMidnight(rightNow.endAt, staff.business.timezone),
|
||
)}
|
||
/>
|
||
<Link
|
||
href={`/staff/appointments/${rightNow.id}`}
|
||
className="border-line block rounded-lg border px-4 py-2 text-center text-xs font-bold tracking-widest uppercase"
|
||
>
|
||
Open Details
|
||
</Link>
|
||
</div>
|
||
) : (
|
||
<p className="text-muted mt-3 text-sm">Nothing in progress or up next right now.</p>
|
||
)}
|
||
<Link
|
||
href="/staff/current"
|
||
className="text-accent mt-4 block text-center text-xs font-bold tracking-widest uppercase underline"
|
||
>
|
||
Full Current Appointment View
|
||
</Link>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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)}`;
|
||
}
|