Revert "PHP Rebuild"

This reverts commit 2ef3654841.
This commit is contained in:
Stefan Lazic committed 2026-09-05 09:32:05 +01:00
1 parent a59f998f2b
commit 2aa8405006
690 files changed
+12045 -33943

No files matched your search

+184
View File
@@ -0,0 +1,184 @@
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>
);
}