Fixed an issue where media manager only shows files uploaded in specific sections. Fixed an issue with icon picker names. Removed jumbo rounding and a few other style tweaks.
297 lines
12 KiB
TypeScript
297 lines
12 KiB
TypeScript
import type { Metadata } from "next";
|
||
import { CalendarPlus, Mail, Phone } from "lucide-react";
|
||
import Link from "next/link";
|
||
import { notFound, redirect } from "next/navigation";
|
||
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 { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
||
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
||
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
||
import { assertOwnAppointment, 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 { 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 StaffAppointmentDetailsPage({
|
||
params,
|
||
}: {
|
||
params: Promise<{ id: string }>;
|
||
}) {
|
||
const staff = await getActingStaff();
|
||
if (!staff) redirect("/home");
|
||
|
||
const { id } = await params;
|
||
const appointment = await getAppointmentById(prisma, id);
|
||
if (!appointment) notFound();
|
||
|
||
try {
|
||
assertOwnAppointment(appointment, staff);
|
||
} 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: staff.businessId }),
|
||
appointment.cancelledByUserId
|
||
? prisma.user.findUnique({
|
||
where: { id: appointment.cancelledByUserId },
|
||
select: { name: true },
|
||
})
|
||
: null,
|
||
]);
|
||
|
||
const tz = staff.business.timezone;
|
||
const totalDuration = appointment.services.reduce(
|
||
(sum, s) => sum + s.durationAtBookingMinutes,
|
||
0,
|
||
);
|
||
const totalPrice = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
|
||
const discount = appointment.discountAmount ? Number(appointment.discountAmount) : 0;
|
||
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={`/staff/clients/${appointment.customerId}`}
|
||
className="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">£{totalPrice.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">£{(totalPrice - discount).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("_", " ").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">
|
||
“{appointment.cancellationReason}”
|
||
</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={`/staff/appointments/${appointment.id}/reschedule`}
|
||
className="rounded-md border-line border bg-white px-5 py-3 text-sm font-bold"
|
||
>
|
||
Reschedule
|
||
</Link>
|
||
)}
|
||
{appointment.status === "COMPLETED" && (
|
||
<BookFollowUpButton appointmentId={appointment.id} />
|
||
)}
|
||
</div>
|
||
|
||
<AppointmentStatusActions appointmentId={appointment.id} status={appointment.status} />
|
||
|
||
<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={totalPrice}
|
||
/>
|
||
)}
|
||
</div>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|