Header Update
CI / ci (push) Canceled after 0s

Updated the header of these pages to match the rest of the pages.
This commit is contained in:
Stefan Lazic committed 2026-09-12 17:24:47 +01:00
1 parent 1932cf7774
commit 7224c87cef
4 files changed
+216 -209

No files matched your search

+114 -113
View File
@@ -3,6 +3,7 @@ import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions"; import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums"; import { Permission } from "@/generated/prisma/enums";
@@ -70,132 +71,132 @@ export default async function OwnerReportsPage({
]); ]);
return ( return (
<div className="space-y-6 px-6 pt-12 pb-12"> <div className="pb-12">
<div> <TopNav title="Reports" onBack sticky className="bg-stone" />
<h1 className="text-2xl font-bold tracking-tight">Reports</h1> <main className="space-y-6 px-6 py-6">
<p className="text-muted text-sm"> <p className="text-muted text-sm">
{fromDate} to {toDate} {fromDate} to {toDate}
</p> </p>
</div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{PRESETS.map((preset) => ( {PRESETS.map((preset) => (
<Link <Link
key={preset.days} key={preset.days}
href={`/owner/reports?from=${addDays(today, -(preset.days - 1))}&to=${today}`} href={`/owner/reports?from=${addDays(today, -(preset.days - 1))}&to=${today}`}
className="border-line rounded-full border bg-white px-4 py-2 text-xs font-bold" className="border-line rounded-full border bg-white px-4 py-2 text-xs font-bold"
> >
Last {preset.label} Last {preset.label}
</Link> </Link>
))} ))}
</div>
<section className="space-y-3">
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Appointments</h2>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatTile label="Total" value={appointmentReport.totalAppointments} />
<StatTile label="Completed" value={appointmentReport.completed} hint={`${appointmentReport.completionRate}% of total`} />
<StatTile label="Cancelled" value={appointmentReport.cancelled} hint={`${appointmentReport.cancellationRate}% of total`} />
<StatTile label="No-shows" value={appointmentReport.noShow} hint={`${appointmentReport.noShowRate}% of total`} />
</div> </div>
<div className="grid grid-cols-3 gap-3">
<StatTile label="Gross revenue" value={`£${appointmentReport.grossRevenue}`} />
<StatTile label="Discounts given" value={`£${appointmentReport.discountsGiven}`} />
<StatTile label="Net revenue" value={`£${appointmentReport.netRevenue}`} />
</div>
</section>
<section className="space-y-3"> <section className="space-y-3">
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Services</h2> <h2 className="text-muted text-xs font-bold tracking-widest uppercase">Appointments</h2>
<Card className="divide-line divide-y px-5"> <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{serviceReport.length === 0 ? ( <StatTile label="Total" value={appointmentReport.totalAppointments} />
<p className="text-muted py-4 text-sm">No completed appointments in this range.</p> <StatTile label="Completed" value={appointmentReport.completed} hint={`${appointmentReport.completionRate}% of total`} />
) : ( <StatTile label="Cancelled" value={appointmentReport.cancelled} hint={`${appointmentReport.cancellationRate}% of total`} />
serviceReport.map((s) => ( <StatTile label="No-shows" value={appointmentReport.noShow} hint={`${appointmentReport.noShowRate}% of total`} />
<div key={s.serviceId} className="flex items-center justify-between py-3 text-sm"> </div>
<div> <div className="grid grid-cols-3 gap-3">
<p className="font-bold">{s.name}</p> <StatTile label="Gross revenue" value={`£${appointmentReport.grossRevenue}`} />
<p className="text-muted text-xs">{s.timesCompleted} completed</p> <StatTile label="Discounts given" value={`£${appointmentReport.discountsGiven}`} />
</div> <StatTile label="Net revenue" value={`£${appointmentReport.netRevenue}`} />
<span className="font-bold">£{s.revenue}</span> </div>
</div> </section>
))
)}
</Card>
</section>
<section className="space-y-3"> <section className="space-y-3">
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Staff</h2> <h2 className="text-muted text-xs font-bold tracking-widest uppercase">Services</h2>
<Card className="divide-line divide-y px-5">
{staffReport.length === 0 ? (
<p className="text-muted py-4 text-sm">No appointments in this range.</p>
) : (
staffReport.map((s) => (
<div key={s.staffId} className="flex items-center justify-between py-3 text-sm">
<div>
<p className="font-bold">{s.name}</p>
<p className="text-muted text-xs">
{s.completed} completed · {s.cancelled} cancelled · {s.noShow} no-show
</p>
</div>
<span className="font-bold">£{s.revenue}</span>
</div>
))
)}
</Card>
</section>
<section className="space-y-3">
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Customers</h2>
<div className="grid grid-cols-2 gap-3">
<StatTile label="New customers" value={customerReport.newCustomers} />
<StatTile label="Returning customers" value={customerReport.returningCustomers} />
</div>
{customerReport.topCustomers.length > 0 && (
<Card className="divide-line divide-y px-5"> <Card className="divide-line divide-y px-5">
{customerReport.topCustomers.map((c) => ( {serviceReport.length === 0 ? (
<div key={c.customerId} className="flex items-center justify-between py-3 text-sm"> <p className="text-muted py-4 text-sm">No completed appointments in this range.</p>
<div> ) : (
<p className="font-bold">{c.name}</p> serviceReport.map((s) => (
<p className="text-muted text-xs">{c.visits} visit{c.visits === 1 ? "" : "s"}</p> <div key={s.serviceId} className="flex items-center justify-between py-3 text-sm">
<div>
<p className="font-bold">{s.name}</p>
<p className="text-muted text-xs">{s.timesCompleted} completed</p>
</div>
<span className="font-bold">£{s.revenue}</span>
</div> </div>
<span className="font-bold">£{c.spend}</span> ))
</div> )}
))}
</Card> </Card>
)} </section>
</section>
<section className="space-y-3"> <section className="space-y-3">
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Loyalty</h2> <h2 className="text-muted text-xs font-bold tracking-widest uppercase">Staff</h2>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatTile label="Stamps issued" value={loyaltyReport.stampsIssued} />
<StatTile label="Stamps removed" value={loyaltyReport.stampsRemoved} />
<StatTile label="Rewards earned" value={loyaltyReport.rewardsEarned} />
<StatTile label="Rewards redeemed" value={loyaltyReport.rewardsRedeemed} />
</div>
{loyaltyReport.rewardsExpired > 0 && <StatTile label="Rewards expired" value={loyaltyReport.rewardsExpired} />}
</section>
<section className="space-y-3">
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Notifications</h2>
<div className="grid grid-cols-2 gap-3">
<StatTile label="Sent" value={notificationReport.totalSent} />
<StatTile label="Failed" value={notificationReport.totalFailed} />
</div>
{notificationReport.byChannel.length > 0 && (
<Card className="divide-line divide-y px-5"> <Card className="divide-line divide-y px-5">
{notificationReport.byChannel.map((c) => ( {staffReport.length === 0 ? (
<div key={c.channel} className="flex items-center justify-between py-3 text-sm"> <p className="text-muted py-4 text-sm">No appointments in this range.</p>
<span className="font-bold">{c.channel}</span> ) : (
<span className="text-muted"> staffReport.map((s) => (
{c.sent} sent{c.failed > 0 ? ` · ${c.failed} failed` : ""} <div key={s.staffId} className="flex items-center justify-between py-3 text-sm">
</span> <div>
</div> <p className="font-bold">{s.name}</p>
))} <p className="text-muted text-xs">
{s.completed} completed · {s.cancelled} cancelled · {s.noShow} no-show
</p>
</div>
<span className="font-bold">£{s.revenue}</span>
</div>
))
)}
</Card> </Card>
)} </section>
</section>
<section className="space-y-3">
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Customers</h2>
<div className="grid grid-cols-2 gap-3">
<StatTile label="New customers" value={customerReport.newCustomers} />
<StatTile label="Returning customers" value={customerReport.returningCustomers} />
</div>
{customerReport.topCustomers.length > 0 && (
<Card className="divide-line divide-y px-5">
{customerReport.topCustomers.map((c) => (
<div key={c.customerId} className="flex items-center justify-between py-3 text-sm">
<div>
<p className="font-bold">{c.name}</p>
<p className="text-muted text-xs">{c.visits} visit{c.visits === 1 ? "" : "s"}</p>
</div>
<span className="font-bold">£{c.spend}</span>
</div>
))}
</Card>
)}
</section>
<section className="space-y-3">
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Loyalty</h2>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatTile label="Stamps issued" value={loyaltyReport.stampsIssued} />
<StatTile label="Stamps removed" value={loyaltyReport.stampsRemoved} />
<StatTile label="Rewards earned" value={loyaltyReport.rewardsEarned} />
<StatTile label="Rewards redeemed" value={loyaltyReport.rewardsRedeemed} />
</div>
{loyaltyReport.rewardsExpired > 0 && <StatTile label="Rewards expired" value={loyaltyReport.rewardsExpired} />}
</section>
<section className="space-y-3">
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Notifications</h2>
<div className="grid grid-cols-2 gap-3">
<StatTile label="Sent" value={notificationReport.totalSent} />
<StatTile label="Failed" value={notificationReport.totalFailed} />
</div>
{notificationReport.byChannel.length > 0 && (
<Card className="divide-line divide-y px-5">
{notificationReport.byChannel.map((c) => (
<div key={c.channel} className="flex items-center justify-between py-3 text-sm">
<span className="font-bold">{c.channel}</span>
<span className="text-muted">
{c.sent} sent{c.failed > 0 ? ` · ${c.failed} failed` : ""}
</span>
</div>
))}
</Card>
)}
</section>
</main>
</div> </div>
); );
} }
+25 -22
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions"; import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums"; import { Permission } from "@/generated/prisma/enums";
@@ -24,28 +25,30 @@ export default async function OwnerServicesPage() {
const categories = await listCategories(prisma, context.businessId); const categories = await listCategories(prisma, context.businessId);
return ( return (
<div className="px-6 pt-12 pb-12 lg:max-w-3xl"> <div className="pb-12">
<h1 className="mb-6 text-2xl font-bold tracking-tight">Services</h1> <TopNav title="Services" onBack sticky className="bg-stone" />
<CatalogueManager <main className="px-6 py-6 lg:max-w-3xl">
categories={categories.map((c) => ({ <CatalogueManager
id: c.id, categories={categories.map((c) => ({
name: c.name, id: c.id,
isActive: c.isActive, name: c.name,
services: c.services.map((s) => ({ isActive: c.isActive,
id: s.id, services: c.services.map((s) => ({
categoryId: s.categoryId, id: s.id,
name: s.name, categoryId: s.categoryId,
description: s.description ?? "", name: s.name,
imageUrl: s.imageUrl, description: s.description ?? "",
durationMinutes: s.durationMinutes, imageUrl: s.imageUrl,
price: s.price.toString(), durationMinutes: s.durationMinutes,
bufferBeforeMinutes: s.bufferBeforeMinutes, price: s.price.toString(),
bufferAfterMinutes: s.bufferAfterMinutes, bufferBeforeMinutes: s.bufferBeforeMinutes,
isActive: s.isActive, bufferAfterMinutes: s.bufferAfterMinutes,
allowOnlineBooking: s.allowOnlineBooking, isActive: s.isActive,
})), allowOnlineBooking: s.allowOnlineBooking,
}))} })),
/> }))}
/>
</main>
</div> </div>
); );
} }
+75 -72
View File
@@ -3,6 +3,7 @@ import Link from "next/link";
import { Plus, Search, ShieldCheck, ShieldPlus } from "lucide-react"; import { Plus, Search, ShieldCheck, ShieldPlus } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions"; import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums"; import { Permission } from "@/generated/prisma/enums";
@@ -30,80 +31,82 @@ export default async function OwnerTeamPage({
const { q = "" } = await searchParams; const { q = "" } = await searchParams;
const staff = await listStaffForBusiness(prisma, { businessId: context.businessId, q, includeInactive: true }); const staff = await listStaffForBusiness(prisma, { businessId: context.businessId, q, includeInactive: true });
const headerAction = (
<div className="flex items-center gap-2">
<Link
href="/owner/team/add-administrator"
className="border-line text-ink flex h-10 w-10 items-center justify-center rounded-full border"
aria-label="Invite administrator"
title="Invite Administrator"
>
<ShieldPlus className="h-4 w-4" aria-hidden="true" />
</Link>
<Link
href="/owner/team/add"
className="bg-ink flex h-10 w-10 items-center justify-center rounded-full text-white"
aria-label="Add staff member"
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Link>
</div>
);
return ( return (
<div className="px-6 pt-12 pb-12 lg:max-w-4xl"> <div className="pb-12">
<div className="mb-6 flex items-center justify-between"> <TopNav title="Team" onBack sticky className="bg-stone" action={headerAction} />
<h1 className="text-2xl font-bold tracking-tight">Team</h1> <main className="px-6 py-6 lg:max-w-4xl">
<div className="flex items-center gap-2"> <form action="/owner/team" method="GET" className="relative mb-4">
<Link <Search className="text-muted absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2" aria-hidden="true" />
href="/owner/team/add-administrator" <label htmlFor="team-search" className="sr-only">
className="border-line text-ink flex h-10 w-10 items-center justify-center rounded-full border" Search staff by name or email
aria-label="Invite administrator" </label>
title="Invite Administrator" <input
> id="team-search"
<ShieldPlus className="h-4 w-4" aria-hidden="true" /> type="search"
</Link> name="q"
<Link defaultValue={q}
href="/owner/team/add" placeholder="Search by name or email"
className="bg-ink flex h-10 w-10 items-center justify-center rounded-full text-white" 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"
aria-label="Add staff member" />
> </form>
<Plus className="h-4 w-4" aria-hidden="true" />
</Link> <div className="grid gap-3 lg:grid-cols-2">
{staff.length === 0 ? (
<p className="text-muted col-span-full rounded-md border border-line bg-white p-5 text-center text-sm">
No staff match &ldquo;{q}&rdquo;.
</p>
) : (
staff.map((s) => (
<Link
key={s.id}
href={`/owner/team/${s.id}`}
className={cn(
"rounded-md border-line flex items-center gap-4 border bg-white p-5",
s.status === "INACTIVE" && "opacity-60",
)}
>
<div className="bg-stone text-muted flex h-12 w-12 shrink-0 items-center justify-center rounded-full text-sm font-bold">
{(s.user.name ?? "?").charAt(0)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold">{s.user.name}</p>
<p className="text-muted truncate text-xs">{s.user.email}</p>
<p className="text-accent mt-0.5 flex items-center gap-1 text-[10px] font-bold tracking-widest uppercase">
{s.role.name === "Administrator" && (
<ShieldCheck className="h-3 w-3 shrink-0" aria-hidden="true" />
)}
{s.role.name}
{s.status === "INACTIVE" && <span className="text-muted"> · Inactive</span>}
{s.status === "ACTIVE" && s.invitation && !s.invitation.acceptedAt && (
<span className="text-warning-foreground"> · Invite Pending</span>
)}
</p>
</div>
</Link>
))
)}
</div> </div>
</div> </main>
<form action="/owner/team" method="GET" className="relative mb-4">
<Search className="text-muted absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2" aria-hidden="true" />
<label htmlFor="team-search" className="sr-only">
Search staff by name or email
</label>
<input
id="team-search"
type="search"
name="q"
defaultValue={q}
placeholder="Search by name or email"
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="grid gap-3 lg:grid-cols-2">
{staff.length === 0 ? (
<p className="text-muted col-span-full rounded-md border border-line bg-white p-5 text-center text-sm">
No staff match &ldquo;{q}&rdquo;.
</p>
) : (
staff.map((s) => (
<Link
key={s.id}
href={`/owner/team/${s.id}`}
className={cn(
"rounded-md border-line flex items-center gap-4 border bg-white p-5",
s.status === "INACTIVE" && "opacity-60",
)}
>
<div className="bg-stone text-muted flex h-12 w-12 shrink-0 items-center justify-center rounded-full text-sm font-bold">
{(s.user.name ?? "?").charAt(0)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold">{s.user.name}</p>
<p className="text-muted truncate text-xs">{s.user.email}</p>
<p className="text-accent mt-0.5 flex items-center gap-1 text-[10px] font-bold tracking-widest uppercase">
{s.role.name === "Administrator" && (
<ShieldCheck className="h-3 w-3 shrink-0" aria-hidden="true" />
)}
{s.role.name}
{s.status === "INACTIVE" && <span className="text-muted"> · Inactive</span>}
{s.status === "ACTIVE" && s.invitation && !s.invitation.acceptedAt && (
<span className="text-warning-foreground"> · Invite Pending</span>
)}
</p>
</div>
</Link>
))
)}
</div>
</div> </div>
); );
} }
@@ -28,8 +28,8 @@ export default async function StaffCalendarSettingsPage() {
<TopNav title="Google Calendar" onBack sticky className="bg-stone" /> <TopNav title="Google Calendar" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6"> <main className="mx-auto max-w-2xl space-y-6 px-6 py-6">
<p className="text-muted text-sm"> <p className="text-muted text-sm">
One-way sync: {branding.name} stays the source of truth, and your confirmed appointments are pushed to {/* {branding.name} */}
Google Calendar. Nothing is ever imported back from Google. NextChair is where your bookings are managed. Connect your Google Calendar to automatically add confirmed appointments to your personal calendar.
</p> </p>
<CalendarConnectionPanel <CalendarConnectionPanel
configured={configured} configured={configured}