CI / ci (push) Canceled after 0s
Built No-show flag and block: Two new numbers under Booking Rules (blank means off). A symbol appears on the clients list, client page, calendar entries and appointment pages. Online booking is refused at the block number, and customers are told at the details step. Staff can still book a blocked customer in, and an owner can reset someone with a two-tap button. The count resets when they attend, and matches the same email or phone, so a guest can’t dodge it with a new profile. Email rules: staff and owners get email only for customer cancellations, schedule changes and invitations. Routine events (new booking, reschedule) are push-only. Nobody is told about their own action. A barber reassignment sends one customer email instead of two. Reward expiry sends one warning. Email redesign: your brand colour, a details card, Add to calendar and View on map links, a preferences link, hidden preview text, and dark mode. Replies now go to the business’s own email. WhatsApp “due now” queue: a list in time order with a highlighted next one, an owner setting for how many hours ahead (12–72), and a once-a-day push telling each barber how many reminders are waiting. Monitoring: /api/health now reports whether notifications are actually going out. The worker resets stuck deliveries and alerts the owner on the first setup failure (such as a bad SMTP login). The app emails your operator address about server errors, and the manager pushes that address to each business. Google scope: now only calendar.app.created. Events go to a calendar the app creates in the staff member’s Google account, called “<Business> bookings”.
353 lines
15 KiB
TypeScript
353 lines
15 KiB
TypeScript
import type { Metadata } from "next";
|
|
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import { CalendarX2, Download, Mail, Phone } from "lucide-react";
|
|
import { WhatsAppButton } from "@/features/staff/components/whatsapp-button";
|
|
import { notFound, redirect } from "next/navigation";
|
|
import { AppointmentCard } from "@/components/ui/appointment-card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card } from "@/components/ui/card";
|
|
import { EmptyState } from "@/components/feedback/empty-state";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { TopNav } from "@/components/navigation/top-nav";
|
|
import { prisma } from "@/lib/db/prisma";
|
|
import { clientDirectoryWhere } from "@/services/customer/client-directory";
|
|
import { hasPermission } from "@/lib/auth/permissions";
|
|
import { Permission } from "@/generated/prisma/enums";
|
|
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
|
import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
|
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
|
import { listAppointments } from "@/services/scheduling/appointment.service";
|
|
import { NoShowBadge } from "@/features/staff/components/no-show-badge";
|
|
import { ResetNoShowsButton } from "@/features/staff/components/reset-no-shows-button";
|
|
import { countNoShows, evaluateStanding, getNoShowThresholds } from "@/services/customer/no-show.service";
|
|
import { AddStampButton } from "@/features/staff/components/add-stamp-button";
|
|
import { CorrectStampButton } from "@/features/staff/components/correct-stamp-button";
|
|
import { CustomerNotes } from "@/features/staff/components/customer-notes";
|
|
import type { CustomerNoteItem } from "@/features/staff/components/customer-notes";
|
|
import type { AppointmentStatus } from "@/types/domain";
|
|
|
|
export const metadata: Metadata = { title: "Client 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",
|
|
};
|
|
|
|
export default async function StaffCustomerDetailsPage({
|
|
params,
|
|
}: {
|
|
params: Promise<{ id: string }>;
|
|
}) {
|
|
const context = await getActingBusinessContext();
|
|
if (!context) redirect("/home");
|
|
|
|
const { id } = await params;
|
|
const customer = await prisma.customerProfile.findUnique({
|
|
where: { id },
|
|
include: {
|
|
preferredBarber: { include: { user: { select: { name: true } } } },
|
|
user: { select: { image: true } },
|
|
},
|
|
});
|
|
if (!customer) notFound();
|
|
|
|
// The same rule the Clients list uses (clientDirectoryWhere), asked about this
|
|
// one person — so anyone the list shows opens, and anyone it hides (the
|
|
// Super Administrator, a barber with no bookings) 404s here too.
|
|
const inDirectory =
|
|
(await prisma.customerProfile.count({ where: { AND: [{ id }, clientDirectoryWhere(context.businessId)] } })) > 0;
|
|
if (!inDirectory) notFound();
|
|
|
|
// Only offered to those who may use it; the export route re-checks the same permission itself.
|
|
const canExport = await hasPermission(context.userId, Permission.MANAGE_BUSINESS_PROFILE);
|
|
|
|
const [upcoming, past, cancelled, notesRaw, loyalty] = await Promise.all([
|
|
listAppointments(prisma, {
|
|
customerId: id,
|
|
businessId: context.businessId,
|
|
statuses: ["PENDING", "CONFIRMED", "CHECKED_IN", "IN_PROGRESS"],
|
|
}),
|
|
listAppointments(prisma, {
|
|
customerId: id,
|
|
businessId: context.businessId,
|
|
statuses: ["COMPLETED"],
|
|
}),
|
|
listAppointments(prisma, {
|
|
customerId: id,
|
|
businessId: context.businessId,
|
|
statuses: ["CANCELLED", "NO_SHOW"],
|
|
}),
|
|
prisma.customerNote.findMany({
|
|
where: { customerId: id },
|
|
orderBy: { createdAt: "desc" },
|
|
include: { author: { include: { user: { select: { name: true } } } } },
|
|
}),
|
|
getLoyaltySummary(prisma, { customerId: id, businessId: context.businessId }),
|
|
]);
|
|
|
|
// Derived from the history already loaded above — no extra query beyond the two rule numbers.
|
|
const noShowStanding = evaluateStanding(
|
|
countNoShows([...past, ...cancelled], customer.noShowResetAt),
|
|
await getNoShowThresholds(prisma, context.businessId),
|
|
);
|
|
const canResetNoShows = await hasPermission(context.userId, Permission.MANAGE_ALL_APPOINTMENTS);
|
|
|
|
const loyaltyTransactions = loyalty?.accountId
|
|
? await getLoyaltyHistory(prisma, { loyaltyAccountId: loyalty.accountId, limit: 10 })
|
|
: [];
|
|
|
|
const notes: CustomerNoteItem[] = notesRaw.map((n) => ({
|
|
id: n.id,
|
|
body: n.body,
|
|
createdAt: n.createdAt.toISOString(),
|
|
updatedAt: n.updatedAt.toISOString(),
|
|
authorStaffId: n.authorStaffId,
|
|
authorName: n.author.user.name ?? "Staff",
|
|
}));
|
|
|
|
const tz = context.businessTimezone;
|
|
// The soonest upcoming booking — what a WhatsApp reminder from this page is about.
|
|
const now = new Date();
|
|
const nextBooking = upcoming
|
|
.filter((a) => a.startAt > now)
|
|
.sort((a, b) => a.startAt.getTime() - b.startAt.getTime())[0];
|
|
|
|
const renderList = (appointments: typeof upcoming, emptyLabel: string) => {
|
|
if (appointments.length === 0) {
|
|
return <EmptyState icon={CalendarX2} title={emptyLabel} />;
|
|
}
|
|
return (
|
|
<div className="space-y-3">
|
|
{appointments.map((a) => (
|
|
<AppointmentCard
|
|
key={a.id}
|
|
status={STATUS_MAP[a.status] ?? "confirmed"}
|
|
serviceName={a.services.map((s) => s.service.name).join(" + ")}
|
|
person={{ id: a.staffId, name: a.staff.user.name ?? "Barber" }}
|
|
formattedDate={a.startAt.toLocaleDateString("en-GB", {
|
|
weekday: "short",
|
|
day: "numeric",
|
|
month: "short",
|
|
timeZone: tz,
|
|
})}
|
|
formattedTime={a.startAt.toLocaleTimeString("en-GB", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
timeZone: tz,
|
|
})}
|
|
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>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="pb-12">
|
|
<TopNav title="Client Details" onBack sticky className="bg-stone" />
|
|
|
|
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6 lg:max-w-5xl lg:items-start lg:gap-6 lg:space-y-0 lg:[display:grid] lg:grid-cols-[360px_1fr]">
|
|
<Card className="space-y-5 p-6">
|
|
<div className="flex items-center gap-4">
|
|
{customer.user?.image ? (
|
|
<Image
|
|
src={customer.user.image}
|
|
alt=""
|
|
width={56}
|
|
height={56}
|
|
className="h-14 w-14 rounded-full object-cover"
|
|
/>
|
|
) : (
|
|
<div className="bg-stone text-muted flex h-14 w-14 items-center justify-center rounded-full text-lg font-bold">
|
|
{customer.firstName.charAt(0)}
|
|
{customer.lastName.charAt(0)}
|
|
</div>
|
|
)}
|
|
<div>
|
|
<p className="text-lg font-bold">
|
|
{customer.firstName} {customer.lastName}
|
|
</p>
|
|
<p className="text-muted text-xs">
|
|
{customer.isGuest ? "Guest customer" : "Registered customer"}
|
|
</p>
|
|
<NoShowBadge
|
|
className="mt-1"
|
|
count={noShowStanding.count}
|
|
flagged={noShowStanding.flagged}
|
|
blocked={noShowStanding.blocked}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{noShowStanding.count > 0 && (
|
|
<div className="border-line space-y-2 border-t pt-4 text-sm">
|
|
<p className="text-muted">
|
|
{noShowStanding.count} no-show{noShowStanding.count === 1 ? "" : "s"} since their last visit.
|
|
{noShowStanding.blocked && " Online booking is blocked for them — you can still book them in."}
|
|
</p>
|
|
{canResetNoShows && <ResetNoShowsButton customerId={customer.id} />}
|
|
</div>
|
|
)}
|
|
|
|
<div className="border-line space-y-1 border-t pt-4 text-sm">
|
|
<p className="text-muted">{customer.email}</p>
|
|
{customer.phone && <p className="text-muted">{customer.phone}</p>}
|
|
{customer.preferredBarber && (
|
|
<p className="text-muted">
|
|
Preferred barber: {customer.preferredBarber.user.name ?? "—"}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-line flex flex-wrap gap-3 border-t pt-4">
|
|
{customer.phone && (
|
|
<a
|
|
href={`tel:${customer.phone}`}
|
|
className="border-line flex h-12 min-w-[104px] flex-1 items-center justify-center gap-2 rounded-md border bg-white px-4 text-xs font-bold"
|
|
>
|
|
<Phone className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Call
|
|
</a>
|
|
)}
|
|
<a
|
|
href={`mailto:${customer.email}`}
|
|
className="border-line flex h-12 min-w-[104px] flex-1 items-center justify-center gap-2 rounded-md border bg-white px-4 text-xs font-bold"
|
|
>
|
|
<Mail className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Email
|
|
</a>
|
|
<WhatsAppButton
|
|
businessId={context.businessId}
|
|
businessName={context.businessName}
|
|
timezone={tz}
|
|
customer={customer}
|
|
appointment={
|
|
nextBooking
|
|
? {
|
|
id: nextBooking.id,
|
|
startAt: nextBooking.startAt,
|
|
staffName: nextBooking.staff.user.name ?? "your barber",
|
|
serviceNames: nextBooking.services.map((s) => s.service.name).join(" + "),
|
|
whatsappReminderSentAt: nextBooking.whatsappReminderSentAt,
|
|
}
|
|
: undefined
|
|
}
|
|
className="min-w-[104px] flex-1"
|
|
buttonClassName="h-12 w-full rounded-md"
|
|
/>
|
|
<Button asChild size="md" className="min-w-[104px] flex-1">
|
|
<Link href={`/staff/book?customerId=${customer.id}`}>Book Appointment</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
{loyalty?.isEnabled && loyalty.accountId && (
|
|
<div className="border-line border-t pt-4">
|
|
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
|
Loyalty
|
|
</span>
|
|
<p className="mb-3 text-sm font-bold">
|
|
{loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps
|
|
{loyalty.availableReward && (
|
|
<span className="text-accent-loyalty"> · Reward available</span>
|
|
)}
|
|
</p>
|
|
<div className="mb-3 flex flex-wrap gap-2">
|
|
<AddStampButton customerId={id} className="min-w-[140px] flex-1" />
|
|
<CorrectStampButton
|
|
loyaltyAccountId={loyalty.accountId}
|
|
className="min-w-[140px] flex-1"
|
|
/>
|
|
</div>
|
|
{loyaltyTransactions.length > 0 && (
|
|
<ul className="space-y-1">
|
|
{loyaltyTransactions.map((t) => (
|
|
<li key={t.id} className="text-muted text-xs">
|
|
{t.createdAt.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}{" "}
|
|
— {t.amount > 0 ? "+" : ""}
|
|
{t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"} — {t.type.replace(/_/g, " ").toLowerCase()}
|
|
{t.reason ? ` (${t.reason})` : ""}
|
|
{t.authoredByStaff?.user.name ? ` · ${t.authoredByStaff.user.name}` : ""}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="border-line grid grid-cols-3 gap-3 border-t pt-4 text-center">
|
|
<div>
|
|
<p className="text-lg font-bold">{past.length}</p>
|
|
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Visits</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-lg font-bold">{upcoming.length}</p>
|
|
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Upcoming</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-lg font-bold">{cancelled.length}</p>
|
|
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">
|
|
Cancelled
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{canExport && (
|
|
<div className="border-line border-t pt-4">
|
|
{/* A plain link, not next/link: the response is a file download, not a page. */}
|
|
<a
|
|
href={`/api/customers/${customer.id}/export`}
|
|
download
|
|
className="border-line flex h-12 w-full items-center justify-center gap-2 rounded-md border bg-white px-4 text-xs font-bold"
|
|
>
|
|
<Download className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Export client data (CSV)
|
|
</a>
|
|
<p className="text-muted mt-2 text-xs">
|
|
Profile, appointments, loyalty and messages sent. Private staff notes are not included.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
<div className="mt-4 space-y-4 lg:mt-0">
|
|
<Card className="p-6">
|
|
<h2 className="text-muted mb-4 text-sm font-bold tracking-widest uppercase">Notes</h2>
|
|
{/* A Super Administrator has no StaffProfile/staffId — passing "" is a
|
|
deliberate never-matches sentinel (see CustomerNotes' isOwn check),
|
|
so they can view every note but own none. Adding a note as a
|
|
Super Administrator fails gracefully server-side (see
|
|
addCustomerNoteAction: "You must be signed in as staff.") rather
|
|
than fabricating a staff identity for them. */}
|
|
<CustomerNotes customerId={customer.id} notes={notes} actingStaffId={context.staffId ?? ""} />
|
|
</Card>
|
|
|
|
<Tabs defaultValue="upcoming">
|
|
<TabsList>
|
|
<TabsTrigger value="upcoming">Upcoming</TabsTrigger>
|
|
<TabsTrigger value="past">Past</TabsTrigger>
|
|
<TabsTrigger value="cancelled">Cancelled</TabsTrigger>
|
|
</TabsList>
|
|
<TabsContent value="upcoming">
|
|
{renderList(upcoming, "No upcoming appointments")}
|
|
</TabsContent>
|
|
<TabsContent value="past">{renderList(past, "No past appointments")}</TabsContent>
|
|
<TabsContent value="cancelled">
|
|
{renderList(cancelled, "No cancelled appointments")}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|