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”.
221 lines
8.9 KiB
TypeScript
221 lines
8.9 KiB
TypeScript
import type { Metadata } from "next";
|
|
import { Plus, Search, Upload, Users } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { redirect } from "next/navigation";
|
|
import { EmptyState } from "@/components/feedback/empty-state";
|
|
import { TopNav } from "@/components/navigation/top-nav";
|
|
import { Permission } from "@/generated/prisma/enums";
|
|
import { hasPermission } from "@/lib/auth/permissions";
|
|
import { prisma } from "@/lib/db/prisma";
|
|
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
|
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
|
|
import { clientDirectoryWhere } from "@/services/customer/client-directory";
|
|
import { matchesCustomerSearch } from "@/services/customer/customer.service";
|
|
import { getLoyaltySummariesForCustomers } from "@/services/loyalty/loyalty.service";
|
|
import { NoShowBadge } from "@/features/staff/components/no-show-badge";
|
|
import { countNoShows, evaluateStanding, getNoShowThresholds } from "@/services/customer/no-show.service";
|
|
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
|
|
import { cn } from "@/lib/utils/cn";
|
|
|
|
export const metadata: Metadata = { title: "Clients" };
|
|
export const dynamic = "force-dynamic";
|
|
|
|
const UPCOMING_STATUSES = ["PENDING", "CONFIRMED", "CHECKED_IN"];
|
|
|
|
export default async function StaffClientsPage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{ q?: string; sort?: string }>;
|
|
}) {
|
|
const context = await getActingBusinessContext();
|
|
if (!context) redirect("/home");
|
|
|
|
const { q = "", sort } = await searchParams;
|
|
|
|
const [unreadNotificationCount, canImport] = await Promise.all([
|
|
getUnreadNotificationCount(prisma, context.userId),
|
|
hasPermission(context.userId, Permission.MANAGE_BUSINESS_PROFILE),
|
|
]);
|
|
|
|
// Who counts as a client is defined once, in clientDirectoryWhere (shared with
|
|
// the detail page so the two can't disagree). `select`-scoped rather than a
|
|
// full row + appointment `include`: the card only ever reads status/startAt
|
|
// off each appointment, never the service lines/notes/discount a full
|
|
// include would pull down for every appointment of every client on every
|
|
// page view (see the Phase 7 performance pass).
|
|
const customers = await prisma.customerProfile.findMany({
|
|
where: clientDirectoryWhere(context.businessId),
|
|
select: {
|
|
id: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
email: true,
|
|
phone: true,
|
|
noShowResetAt: true,
|
|
appointments: {
|
|
where: { businessId: context.businessId },
|
|
orderBy: { startAt: "desc" },
|
|
select: { status: true, startAt: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
const noShowThresholds = await getNoShowThresholds(prisma, context.businessId);
|
|
|
|
const loyaltySummaries = await getLoyaltySummariesForCustomers(prisma, {
|
|
businessId: context.businessId,
|
|
customerIds: customers.map((c) => c.id),
|
|
});
|
|
|
|
const filtered = customers.filter((c) => matchesCustomerSearch(c, q));
|
|
|
|
const sorted = [...filtered].sort((a, b) => {
|
|
if (sort === "recent") {
|
|
const aLatest = a.appointments[0]?.startAt.getTime() ?? 0;
|
|
const bLatest = b.appointments[0]?.startAt.getTime() ?? 0;
|
|
return bLatest - aLatest;
|
|
}
|
|
return `${a.lastName} ${a.firstName}`.localeCompare(`${b.lastName} ${b.firstName}`);
|
|
});
|
|
|
|
const now = new Date();
|
|
|
|
return (
|
|
<div className="pb-8">
|
|
<TopNav
|
|
title="Clients"
|
|
sticky
|
|
className="bg-stone"
|
|
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
|
|
action={
|
|
<div className="flex gap-2">
|
|
{canImport && (
|
|
<Link
|
|
href="/staff/clients/import"
|
|
className="border-line flex h-10 w-10 items-center justify-center rounded-full border bg-white"
|
|
aria-label="Import clients from a spreadsheet"
|
|
>
|
|
<Upload className="h-4 w-4" aria-hidden="true" />
|
|
</Link>
|
|
)}
|
|
<Link
|
|
href="/staff/clients/add"
|
|
className="bg-ink flex h-10 w-10 items-center justify-center rounded-full text-white"
|
|
aria-label="Add client"
|
|
>
|
|
<Plus className="h-4 w-4" aria-hidden="true" />
|
|
</Link>
|
|
</div>
|
|
}
|
|
/>
|
|
|
|
<div className="px-6 pt-6">
|
|
<form action="/staff/clients" method="GET" className="relative mb-4">
|
|
{sort && <input type="hidden" name="sort" value={sort} />}
|
|
<Search
|
|
className="text-muted absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2"
|
|
aria-hidden="true"
|
|
/>
|
|
<label htmlFor="client-search" className="sr-only">
|
|
Search clients by name, email, or phone
|
|
</label>
|
|
<input
|
|
id="client-search"
|
|
type="search"
|
|
name="q"
|
|
defaultValue={q}
|
|
placeholder="Search by name, email, or phone"
|
|
className="border-line focus:ring-ink w-full rounded-xl border bg-white py-3 pr-4 pl-11 text-sm outline-none focus:ring-2"
|
|
/>
|
|
</form>
|
|
|
|
<div className="mb-4 flex gap-2">
|
|
<Link
|
|
href={`/staff/clients?q=${encodeURIComponent(q)}&sort=name`}
|
|
className={cn(
|
|
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
|
sort !== "recent" ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
|
)}
|
|
>
|
|
Alphabetical
|
|
</Link>
|
|
<Link
|
|
href={`/staff/clients?q=${encodeURIComponent(q)}&sort=recent`}
|
|
className={cn(
|
|
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
|
sort === "recent" ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
|
)}
|
|
>
|
|
Recent
|
|
</Link>
|
|
</div>
|
|
|
|
{sorted.length === 0 ? (
|
|
<EmptyState
|
|
icon={Users}
|
|
title={q ? "No matching clients" : "No clients yet"}
|
|
description={
|
|
q
|
|
? "Try a different name, email, or phone number."
|
|
: "Clients will show up here once they've booked with you."
|
|
}
|
|
/>
|
|
) : (
|
|
<div className="grid gap-3 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
|
{sorted.map((customer) => {
|
|
const completedVisits = customer.appointments.filter(
|
|
(a) => a.status === "COMPLETED",
|
|
).length;
|
|
const lastVisit = customer.appointments.find((a) => a.status === "COMPLETED");
|
|
const upcoming = customer.appointments.find(
|
|
(a) => UPCOMING_STATUSES.includes(a.status) && a.startAt > now,
|
|
);
|
|
const loyalty = loyaltySummaries.get(customer.id);
|
|
const noShow = evaluateStanding(
|
|
countNoShows(customer.appointments, customer.noShowResetAt),
|
|
noShowThresholds,
|
|
);
|
|
|
|
return (
|
|
<Link
|
|
key={customer.id}
|
|
href={`/staff/clients/${customer.id}`}
|
|
className="rounded-md border-line hover:bg-stone flex items-center gap-3 border bg-white px-5 py-4"
|
|
>
|
|
<div className="bg-stone text-muted flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-sm font-bold">
|
|
{customer.firstName.charAt(0)}
|
|
{customer.lastName.charAt(0)}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="flex items-center gap-2 text-sm font-bold">
|
|
<span className="truncate">
|
|
{customer.firstName} {customer.lastName}
|
|
</span>
|
|
<NoShowBadge count={noShow.count} flagged={noShow.flagged} blocked={noShow.blocked} />
|
|
</p>
|
|
<p className="text-muted truncate text-xs">
|
|
{customer.phone ?? customer.email}
|
|
{lastVisit &&
|
|
` · Last visit ${lastVisit.startAt.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`}
|
|
</p>
|
|
<p className="text-muted mt-0.5 text-[10px] font-bold tracking-widest uppercase">
|
|
{completedVisits} visit{completedVisits === 1 ? "" : "s"}
|
|
{upcoming && <span className="text-accent"> · Upcoming</span>}
|
|
{loyalty && (
|
|
<span className={loyalty.hasAvailableReward ? "text-accent-loyalty" : ""}>
|
|
{" "}
|
|
· {loyalty.currentStamps}/{loyalty.rewardThreshold} stamps
|
|
</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|