Initial PHP rebuild of the app
This commit is contained in:
690 files changed
+33944
-12046
No files matched your search
@@ -1,184 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Search, 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 { prisma } from "@/lib/db/prisma";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
|
||||
import { matchesCustomerSearch } from "@/services/customer/customer.service";
|
||||
import { getLoyaltySummariesForCustomers } from "@/services/loyalty/loyalty.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 = await getUnreadNotificationCount(prisma, context.userId);
|
||||
|
||||
// Scoped via the appointment relation, not "every CustomerProfile row" —
|
||||
// a real business boundary even though this is a single-business v1 app
|
||||
// (see docs/phase-5-summary.md). `select`-scoped rather than the full
|
||||
// row + full appointment `include` this used to fetch — the card only
|
||||
// ever reads status/startAt off each appointment, never the service
|
||||
// lines/notes/discount that a full include would also 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: { appointments: { some: { businessId: context.businessId } } },
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
appointments: {
|
||||
where: { businessId: context.businessId },
|
||||
orderBy: { startAt: "desc" },
|
||||
select: { status: true, startAt: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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 lg:max-w-5xl">
|
||||
<TopNav
|
||||
title="Clients"
|
||||
sticky
|
||||
className="bg-stone"
|
||||
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
|
||||
/>
|
||||
|
||||
<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">
|
||||
{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);
|
||||
|
||||
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="truncate text-sm font-bold">
|
||||
{customer.firstName} {customer.lastName}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user