Files
NextChair/src/app/(staff)/staff/appointments/[id]/page.tsx
T
Stefan Lazic ec5583f972 Phase 6
Owner Administration is complete. Summary of this final stretch of work:

Seed data extended with an inactive staff member, a pending invitation, a redeemed-reward fixture, a service with no eligible staff, and a future-appointment fixture for conflict testing — all idempotent, verified against the dev DB.

Testing brought to 179 unit / 86 integration / 27 E2E tests, all passing together on a freshly seeded, disposable Postgres:

Unit tests for every new pure validator (business hours, categories/services, booking rules, loyalty config, staff creation).
Integration tests covering opening-hours conflicts, last-owner protection, staff deactivation/eligibility conflicts, loyalty redemption's atomic race-guard, and owner-vs-barber/business-isolation authorization — all against a real Postgres.
12 new Playwright owner journeys, which surfaced and fixed two genuine issues along the way: a cookie-clearing race condition in the sign-out-mid-test flow (a real, reproducible timing bug, not just a test artifact), and confirmation that seed fixtures upserted by deterministic ID go stale once "today" has moved on — now handled defensively.
Desktop responsiveness: converted the single-column Business Details, Booking Rules, and Loyalty Configuration forms to a genuine two-column layout at the lg: breakpoint (verified via measured element positions, not just screenshots), confirmed mobile stacking is unaffected.

Final verification: clean tsc, eslint, and next build, plus the full three-suite run against one fresh database as the authoritative pass.
2026-08-03 12:56:55 +01:00

284 lines
11 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 { 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 { 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 / Price
</span>
<p className="font-bold">
{totalDuration} min · £{totalPrice.toFixed(2)}
{discount > 0 && (
<span className="text-accent-loyalty"> (-£{discount.toFixed(2)} reward)</span>
)}
</p>
</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}/{loyalty.stampsRequired} 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">
&ldquo;{appointment.cancellationReason}&rdquo;
</p>
)}
</div>
)}
</Card>
<div className="flex flex-wrap gap-3">
<a
href={`/api/appointments/${appointment.id}/calendar.ics`}
className="rounded-jumbo 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-jumbo 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>
);
}