Initial PHP rebuild of the app
This commit is contained in:
690 files changed
+33944
-12046
No files matched your search
@@ -0,0 +1,277 @@
|
||||
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)}`;
|
||||
}
|
||||
Reference in new issue
Block a user