Files
NextChair/legacy-nextjs/src/app/(staff)/staff/page.tsx
T
Stefan Lazic 2ef3654841
CI / ci (push) Canceled after 0s
PHP Rebuild
Initial PHP rebuild of the app
2026-09-05 08:21:33 +01:00

278 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)}`;
}