Files
NextChair/src/app/(owner)/owner/appointments/[id]/page.tsx
T
2026-09-05 09:32:05 +01:00

271 lines
12 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 { CalendarPlus, Mail, Phone } from "lucide-react";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { auth } from "@/auth";
import { Card } from "@/components/ui/card";
import { StatusPill } from "@/components/ui/status-pill";
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 { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
import { getAppointmentById } from "@/services/scheduling/appointment.service";
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
import { AddStampButton } from "@/features/staff/components/add-stamp-button";
import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button";
import { ownerAppointmentActions } from "@/features/owner/actions/appointment-action-set";
import { RedeemRewardButton } from "@/features/owner/components/redeem-reward-button";
import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "Appointment Details" };
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",
};
const RESCHEDULABLE = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
export default async function OwnerAppointmentDetailsPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canView = await hasPermission(session.user.id, Permission.VIEW_ALL_STAFF_APPOINTMENTS);
if (!canView) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const { id } = await params;
const appointment = await getAppointmentById(prisma, id);
if (!appointment) notFound();
try {
// `id` is unused by assertAppointmentInBusiness (only checks businessId)
// — see src/features/staff/authorization.ts. context.userId stands in
// for it since a Super Administrator has no StaffProfile id to offer.
assertAppointmentInBusiness(appointment, { id: context.userId, businessId: context.businessId });
} catch (error) {
if (error instanceof StaffPermissionDeniedError) notFound();
throw error;
}
const [notes, loyaltyTransactions, loyalty, cancelledByUser] = await Promise.all([
prisma.customerNote.findMany({
where: { customerId: appointment.customerId },
orderBy: { createdAt: "desc" },
take: 5,
include: { author: { include: { user: { select: { name: true } } } } },
}),
prisma.loyaltyTransaction.findMany({
where: { appointmentId: appointment.id },
orderBy: { createdAt: "desc" },
}),
getLoyaltySummary(prisma, { customerId: appointment.customerId, businessId: context.businessId }),
appointment.cancelledByUserId
? prisma.user.findUnique({ where: { id: appointment.cancelledByUserId }, select: { name: true } })
: null,
]);
const tz = context.businessTimezone;
const totalDuration = appointment.services.reduce((sum, s) => sum + s.durationAtBookingMinutes, 0);
const subtotal = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
const discount = appointment.discountAmount ? Number(appointment.discountAmount) : 0;
const total = subtotal - discount;
const canRedeemReward =
loyalty?.availableReward &&
appointment.discountAmount === null &&
appointment.status !== "CANCELLED";
return (
<div className="pb-12">
<TopNav title="Appointment Details" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
<Card className="space-y-6 p-6">
<div className="flex items-center justify-between">
<StatusPill status={STATUS_MAP[appointment.status] ?? "confirmed"} />
<span className="text-muted text-[10px] font-bold tracking-widest uppercase">
Ref: {appointment.id.slice(-8)}
</span>
</div>
<div className="border-line space-y-1 border-t pt-4">
<Link href={`/owner/team/${appointment.staffId}`} className="text-muted text-xs font-bold tracking-widest uppercase underline">
{appointment.staff.user.name ?? "Staff"}
</Link>
<Link href={`/staff/clients/${appointment.customerId}`} className="block text-lg font-bold underline">
{appointment.customer.firstName} {appointment.customer.lastName}
</Link>
<p className="text-muted text-sm">
{appointment.customer.isGuest ? "Guest customer" : "Registered customer"}
</p>
</div>
<div className="flex flex-wrap gap-3">
{appointment.customer.phone && (
<a
href={`tel:${appointment.customer.phone}`}
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
>
<Phone className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Call
</a>
)}
<a
href={`mailto:${appointment.customer.email}`}
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
>
<Mail className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Email
</a>
</div>
<div className="border-line grid grid-cols-2 gap-4 border-t pt-4 text-sm">
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Services</span>
<p className="font-bold">{appointment.services.map((s) => s.service.name).join(" + ")}</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Date</span>
<p className="font-bold">
{appointment.startAt.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: tz })}
</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Time</span>
<p className="font-bold">
{appointment.startAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: tz })} –{" "}
{appointment.endAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: tz })}
</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Duration</span>
<p className="font-bold">{totalDuration} min</p>
</div>
</div>
<div className="border-line border-t pt-4 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted">Subtotal</span>
<span className="font-bold">£{subtotal.toFixed(2)}</span>
</div>
{discount > 0 && (
<div className="text-accent-loyalty flex items-center justify-between">
<span>Loyalty reward</span>
<span className="font-bold">-£{discount.toFixed(2)}</span>
</div>
)}
<div className="border-line mt-1 flex items-center justify-between border-t pt-1">
<span className="font-bold">Total</span>
<span className="font-bold">£{total.toFixed(2)}</span>
</div>
</div>
{appointment.notes && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Booking Notes</span>
<p className="text-muted mt-1 text-sm italic">{appointment.notes}</p>
</div>
)}
{notes.length > 0 && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Customer Notes</span>
<div className="mt-2 space-y-2">
{notes.map((n) => (
<p key={n.id} className="text-muted text-xs">
<span className="font-bold">{n.author.user.name ?? "Staff"}:</span> {n.body}
</p>
))}
</div>
</div>
)}
{loyalty?.isEnabled && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Loyalty</span>
<p className="text-sm font-bold">
{loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps
{loyalty.availableReward && <span className="text-accent-loyalty"> · Reward available</span>}
</p>
{loyaltyTransactions.length > 0 && (
<ul className="mt-2 space-y-1">
{loyaltyTransactions.map((t) => (
<li key={t.id} className="text-muted text-xs">
{t.amount > 0 ? "+" : ""}
{t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"} — {t.type.replace(/_/g, " ").toLowerCase()}
{t.reason ? ` (${t.reason})` : ""}
</li>
))}
</ul>
)}
</div>
)}
{appointment.status === "CANCELLED" && (
<div className="border-line border-t pt-4 text-sm">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Cancellation</span>
<p className="text-muted mt-1">
Cancelled {appointment.cancelledAt?.toLocaleString("en-GB", { timeZone: tz })}
{cancelledByUser ? ` by ${cancelledByUser.name}` : ""}
</p>
{appointment.cancellationReason && (
<p className="text-muted mt-1 italic">&ldquo;{appointment.cancellationReason}&rdquo;</p>
)}
</div>
)}
</Card>
<div className="flex flex-wrap gap-3">
<a
href={`/api/appointments/${appointment.id}/calendar.ics`}
className="rounded-md border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
>
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
</a>
{RESCHEDULABLE.has(appointment.status) && (
<Link
href={`/owner/appointments/${appointment.id}/reschedule`}
className="rounded-md border-line border bg-white px-5 py-3 text-sm font-bold"
>
Reschedule / Reassign
</Link>
)}
{appointment.status === "COMPLETED" && <BookFollowUpButton appointmentId={appointment.id} />}
</div>
<AppointmentStatusActions
appointmentId={appointment.id}
status={appointment.status}
actions={ownerAppointmentActions}
/>
<div className="flex flex-wrap gap-3">
{loyalty?.isEnabled && <AddStampButton customerId={appointment.customerId} appointmentId={appointment.id} />}
{canRedeemReward && loyalty.availableReward && (
<RedeemRewardButton
appointmentId={appointment.id}
rewardId={loyalty.availableReward.id}
rewardLabel={loyalty.availableReward.label}
subtotal={subtotal}
/>
)}
</div>
</main>
</div>
);
}