112 lines
4.0 KiB
TypeScript
112 lines
4.0 KiB
TypeScript
import type { Metadata } from "next";
|
|
import { notFound, redirect } from "next/navigation";
|
|
import { auth } from "@/auth";
|
|
import { Card } from "@/components/ui/card";
|
|
import { TopNav } from "@/components/navigation/top-nav";
|
|
import { prisma } from "@/lib/db/prisma";
|
|
import { hasPermission } from "@/lib/auth/permissions";
|
|
import { Permission } from "@/generated/prisma/enums";
|
|
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
|
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
|
|
import { ownerRescheduleAppointmentAction } from "@/features/owner/actions/appointments";
|
|
import { BarberDateTimePicker } from "@/features/booking/components/barber-date-time-picker";
|
|
|
|
export const metadata: Metadata = { title: "Reschedule / Reassign" };
|
|
export const dynamic = "force-dynamic";
|
|
|
|
const NON_RESCHEDULABLE = new Set(["COMPLETED", "CANCELLED", "NO_SHOW", "IN_PROGRESS"]);
|
|
|
|
/**
|
|
* Also the owner's appointment-reassignment surface — BarberDateTimePicker
|
|
* already lets the caller choose a different eligible barber as part of
|
|
* picking a new slot, so "reassign to another barber" and "reschedule to a
|
|
* new time" are the same underlying operation here, not two separate
|
|
* flows. See docs/phase-6-summary.md, "Appointment reassignment approach".
|
|
*/
|
|
export default async function OwnerRescheduleAppointmentPage({
|
|
params,
|
|
}: {
|
|
params: Promise<{ id: string }>;
|
|
}) {
|
|
const session = await auth();
|
|
if (!session?.user) redirect("/sign-in");
|
|
|
|
const canManage = await hasPermission(session.user.id, Permission.MANAGE_ALL_APPOINTMENTS);
|
|
if (!canManage) redirect("/staff");
|
|
|
|
const owner = await prisma.staffProfile.findUnique({
|
|
where: { userId: session.user.id },
|
|
include: { business: { select: { id: true, timezone: true } } },
|
|
});
|
|
if (!owner) redirect("/staff");
|
|
|
|
const { id } = await params;
|
|
const appointment = await getAppointmentById(prisma, id);
|
|
if (!appointment) notFound();
|
|
|
|
try {
|
|
assertAppointmentInBusiness(appointment, { id: owner.id, businessId: owner.businessId });
|
|
} catch (error) {
|
|
if (error instanceof StaffPermissionDeniedError) notFound();
|
|
throw error;
|
|
}
|
|
|
|
if (NON_RESCHEDULABLE.has(appointment.status)) {
|
|
redirect(`/owner/appointments/${id}`);
|
|
}
|
|
|
|
async function handleContinue(selection: { staffId?: string; date: string; startAt: string }) {
|
|
"use server";
|
|
const result = await ownerRescheduleAppointmentAction({
|
|
appointmentId: id,
|
|
staffId: selection.staffId,
|
|
startAt: selection.startAt,
|
|
});
|
|
if (result.success) {
|
|
redirect(`/owner/appointments/${id}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
return (
|
|
<div className="pb-6">
|
|
<TopNav title="Reschedule / Reassign" onBack sticky className="bg-stone" />
|
|
|
|
<Card className="mx-6 mt-4 flex items-center gap-3 p-4">
|
|
<div className="flex-1">
|
|
<p className="text-sm font-bold">
|
|
{appointment.services.map((s) => s.service.name).join(" + ")} for {appointment.customer.firstName}{" "}
|
|
{appointment.customer.lastName}
|
|
</p>
|
|
<p className="text-muted text-xs">
|
|
Currently with {appointment.staff.user.name} —{" "}
|
|
{appointment.startAt.toLocaleDateString("en-GB", {
|
|
weekday: "short",
|
|
day: "numeric",
|
|
month: "short",
|
|
timeZone: owner.business.timezone,
|
|
})}{" "}
|
|
·{" "}
|
|
{appointment.startAt.toLocaleTimeString("en-GB", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
timeZone: owner.business.timezone,
|
|
})}
|
|
</p>
|
|
</div>
|
|
</Card>
|
|
|
|
<div className="mt-4">
|
|
<BarberDateTimePicker
|
|
businessId={owner.businessId}
|
|
businessTimezone={owner.business.timezone}
|
|
serviceIds={appointment.services.map((s) => s.serviceId)}
|
|
initialStaffId={appointment.staffId}
|
|
onContinue={handleContinue}
|
|
continueLabel="Confirm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|