UI Updates
Updated some pages pages to be more consistent.
This commit is contained in:
24 files changed
+901
-616
No files matched your search
@@ -55,21 +55,26 @@ test.describe.serial("owner administration journeys", () => {
|
|||||||
await page.goto("/owner/calendar");
|
await page.goto("/owner/calendar");
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: "Marcus Reed", exact: true })).toBeVisible();
|
// The staff filter pills are avatar+name+hours cards now, so their
|
||||||
await expect(page.getByRole("link", { name: "Leo Vance", exact: true })).toBeVisible();
|
// accessible name is "Marcus Reed 10:00 AM – 7:00 PM" etc — a
|
||||||
|
// substring match (Playwright's default, no `exact`) still uniquely
|
||||||
|
// identifies each pill's link.
|
||||||
|
await expect(page.getByRole("link", { name: "Marcus Reed" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: "Leo Vance" })).toBeVisible();
|
||||||
|
|
||||||
// Filtering to one staff member narrows the day view to a single column.
|
// Filtering to one staff member narrows the day view to a single column.
|
||||||
await page.getByRole("link", { name: "Leo Vance", exact: true }).click();
|
await page.getByRole("link", { name: "Leo Vance" }).click();
|
||||||
await expect(page).toHaveURL(/staffId=/);
|
await expect(page).toHaveURL(/staffId=/);
|
||||||
// The single remaining day-view column is headed by a <p> with the staff member's exact name
|
// The single remaining day-view column is headed by a <p> with the staff member's exact name
|
||||||
// (the filter pill link has the same text, so scope to the paragraph specifically).
|
// — the staff filter pill above it is *also* an avatar+name+hours card with its own <p> of the
|
||||||
await expect(page.locator("p").getByText("Leo Vance", { exact: true })).toBeVisible();
|
// same text now, so scope to the grid itself (data-testid="calendar-grid") to disambiguate.
|
||||||
|
await expect(page.getByTestId("calendar-grid").locator("p").getByText("Leo Vance", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole("link", { name: "Agenda", exact: true }).click();
|
await page.getByRole("link", { name: "Agenda", exact: true }).click();
|
||||||
await expect(page).toHaveURL(/view=agenda/);
|
await expect(page).toHaveURL(/view=agenda/);
|
||||||
await expect(page.getByRole("link", { name: "Day", exact: true })).toBeVisible();
|
await expect(page.getByRole("link", { name: "Day", exact: true })).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole("link", { name: "All Staff", exact: true }).click();
|
await page.getByRole("link", { name: "All Staff" }).click();
|
||||||
await expect(page).not.toHaveURL(/staffId=/);
|
await expect(page).not.toHaveURL(/staffId=/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ import { AppointmentCard } from "@/components/ui/appointment-card";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { EmptyState } from "@/components/feedback/empty-state";
|
import { EmptyState } from "@/components/feedback/empty-state";
|
||||||
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
import { listAppointments } from "@/services/scheduling/appointment.service";
|
import { listAppointments } from "@/services/scheduling/appointment.service";
|
||||||
|
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
|
||||||
import { BookAgainButton } from "@/features/booking/components/book-again-button";
|
import { BookAgainButton } from "@/features/booking/components/book-again-button";
|
||||||
import { CancelAppointmentDialog } from "@/features/booking/components/cancel-appointment-dialog";
|
import { CancelAppointmentDialog } from "@/features/booking/components/cancel-appointment-dialog";
|
||||||
|
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
|
||||||
import type { AppointmentStatus } from "@/types/domain";
|
import type { AppointmentStatus } from "@/types/domain";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Appointments" };
|
export const metadata: Metadata = { title: "Appointments" };
|
||||||
@@ -36,41 +39,49 @@ export default async function AppointmentsPage() {
|
|||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-2xl px-6 pt-12">
|
<div className="pb-12">
|
||||||
<h1 className="mb-6 text-2xl font-bold tracking-tight">Appointments</h1>
|
<TopNav title="Appointments" sticky className="bg-stone" />
|
||||||
<EmptyState
|
<div className="mx-auto max-w-2xl px-6 pt-6">
|
||||||
icon={LogIn}
|
<EmptyState
|
||||||
title="Sign in to see your appointments"
|
icon={LogIn}
|
||||||
description="Create an account or sign in to view, reschedule, and cancel your bookings."
|
title="Sign in to see your appointments"
|
||||||
action={
|
description="Create an account or sign in to view, reschedule, and cancel your bookings."
|
||||||
<Button asChild>
|
action={
|
||||||
<Link href="/sign-in?callbackUrl=/appointments">Sign In</Link>
|
<Button asChild>
|
||||||
</Button>
|
<Link href="/sign-in?callbackUrl=/appointments">Sign In</Link>
|
||||||
}
|
</Button>
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const business = await getPrimaryBusiness();
|
const [business, unreadNotificationCount] = await Promise.all([
|
||||||
|
getPrimaryBusiness(),
|
||||||
|
getUnreadNotificationCount(prisma, session.user.id),
|
||||||
|
]);
|
||||||
const customerProfile = await prisma.customerProfile.findUnique({
|
const customerProfile = await prisma.customerProfile.findUnique({
|
||||||
where: { userId: session.user.id },
|
where: { userId: session.user.id },
|
||||||
});
|
});
|
||||||
|
const bell = <InlineNotificationBell href="/notifications" unreadCount={unreadNotificationCount} />;
|
||||||
|
|
||||||
if (!customerProfile) {
|
if (!customerProfile) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-2xl px-6 pt-12">
|
<div className="pb-12">
|
||||||
<h1 className="mb-6 text-2xl font-bold tracking-tight">Appointments</h1>
|
<TopNav title="Appointments" sticky className="bg-stone" action={bell} />
|
||||||
<EmptyState
|
<div className="mx-auto max-w-2xl px-6 pt-6">
|
||||||
icon={CalendarPlus}
|
<EmptyState
|
||||||
title="No appointments yet"
|
icon={CalendarPlus}
|
||||||
description="Book your first appointment to see it here."
|
title="No appointments yet"
|
||||||
action={
|
description="Book your first appointment to see it here."
|
||||||
<Button asChild>
|
action={
|
||||||
<Link href="/book">Book Now</Link>
|
<Button asChild>
|
||||||
</Button>
|
<Link href="/book">Book Now</Link>
|
||||||
}
|
</Button>
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -153,18 +164,20 @@ export default async function AppointmentsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-2xl px-6 pt-12">
|
<div className="pb-12">
|
||||||
<h1 className="mb-6 text-2xl font-bold tracking-tight">Appointments</h1>
|
<TopNav title="Appointments" sticky className="bg-stone" action={bell} />
|
||||||
<Tabs defaultValue="upcoming">
|
<div className="mx-auto max-w-2xl px-6 pt-6">
|
||||||
<TabsList>
|
<Tabs defaultValue="upcoming">
|
||||||
<TabsTrigger value="upcoming">Upcoming</TabsTrigger>
|
<TabsList>
|
||||||
<TabsTrigger value="past">Past</TabsTrigger>
|
<TabsTrigger value="upcoming">Upcoming</TabsTrigger>
|
||||||
<TabsTrigger value="cancelled">Cancelled</TabsTrigger>
|
<TabsTrigger value="past">Past</TabsTrigger>
|
||||||
</TabsList>
|
<TabsTrigger value="cancelled">Cancelled</TabsTrigger>
|
||||||
<TabsContent value="upcoming">{renderList(upcoming, "upcoming")}</TabsContent>
|
</TabsList>
|
||||||
<TabsContent value="past">{renderList(past, "past")}</TabsContent>
|
<TabsContent value="upcoming">{renderList(upcoming, "upcoming")}</TabsContent>
|
||||||
<TabsContent value="cancelled">{renderList(cancelled, "cancelled")}</TabsContent>
|
<TabsContent value="past">{renderList(past, "past")}</TabsContent>
|
||||||
</Tabs>
|
<TabsContent value="cancelled">{renderList(cancelled, "cancelled")}</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,9 @@ import { TopNav } from "@/components/navigation/top-nav";
|
|||||||
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
||||||
|
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
|
||||||
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
||||||
|
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Loyalty" };
|
export const metadata: Metadata = { title: "Loyalty" };
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -20,25 +22,29 @@ export default async function LoyaltyPage() {
|
|||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-2xl px-6 pt-12">
|
<div className="pb-12">
|
||||||
<h1 className="mb-6 text-2xl font-bold tracking-tight">Loyalty</h1>
|
<TopNav title="Loyalty" sticky className="bg-stone" />
|
||||||
<EmptyState
|
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||||
icon={LogIn}
|
<EmptyState
|
||||||
title="Sign in to see your loyalty card"
|
icon={LogIn}
|
||||||
description="Track your stamps and rewards across visits."
|
title="Sign in to see your loyalty card"
|
||||||
action={
|
description="Track your stamps and rewards across visits."
|
||||||
<Button asChild>
|
action={
|
||||||
<Link href="/sign-in?callbackUrl=/loyalty">Sign In</Link>
|
<Button asChild>
|
||||||
</Button>
|
<Link href="/sign-in?callbackUrl=/loyalty">Sign In</Link>
|
||||||
}
|
</Button>
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const customerProfile = await prisma.customerProfile.findUnique({
|
const [customerProfile, unreadNotificationCount] = await Promise.all([
|
||||||
where: { userId: session.user.id },
|
prisma.customerProfile.findUnique({ where: { userId: session.user.id } }),
|
||||||
});
|
getUnreadNotificationCount(prisma, session.user.id),
|
||||||
|
]);
|
||||||
|
const bell = <InlineNotificationBell href="/notifications" unreadCount={unreadNotificationCount} />;
|
||||||
|
|
||||||
const loyalty = customerProfile
|
const loyalty = customerProfile
|
||||||
? await getLoyaltySummary(prisma, { customerId: customerProfile.id, businessId: business.id })
|
? await getLoyaltySummary(prisma, { customerId: customerProfile.id, businessId: business.id })
|
||||||
@@ -47,7 +53,7 @@ export default async function LoyaltyPage() {
|
|||||||
if (!customerProfile || !loyalty?.isEnabled) {
|
if (!customerProfile || !loyalty?.isEnabled) {
|
||||||
return (
|
return (
|
||||||
<div className="pb-12">
|
<div className="pb-12">
|
||||||
<TopNav title="Loyalty" onBack sticky className="bg-stone" />
|
<TopNav title="Loyalty" sticky className="bg-stone" action={bell} />
|
||||||
<main className="mx-auto max-w-2xl px-6 py-6">
|
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Gift}
|
icon={Gift}
|
||||||
@@ -72,7 +78,7 @@ export default async function LoyaltyPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-12">
|
<div className="pb-12">
|
||||||
<TopNav title="Loyalty" onBack sticky className="bg-stone" />
|
<TopNav title="Loyalty" sticky className="bg-stone" action={bell} />
|
||||||
|
|
||||||
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
|
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
|
||||||
<LoyaltyCard
|
<LoyaltyCard
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import { auth } from "@/auth";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { EmptyState } from "@/components/feedback/empty-state";
|
import { EmptyState } from "@/components/feedback/empty-state";
|
||||||
import { ProfileCard } from "@/components/ui/profile-card";
|
import { ProfileCard } from "@/components/ui/profile-card";
|
||||||
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
import { getBranding } from "@/lib/business/branding";
|
import { getBranding } from "@/lib/business/branding";
|
||||||
|
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
|
||||||
import { SignOutButton } from "@/features/auth/components/sign-out-button";
|
import { SignOutButton } from "@/features/auth/components/sign-out-button";
|
||||||
import { InstallAppButton } from "@/features/pwa/components/install-app-button";
|
import { InstallAppButton } from "@/features/pwa/components/install-app-button";
|
||||||
|
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Settings" };
|
export const metadata: Metadata = { title: "Settings" };
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -24,57 +27,67 @@ export default async function CustomerSettingsPage() {
|
|||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-2xl px-6 pt-12">
|
<div className="pb-12">
|
||||||
<h1 className="mb-6 text-2xl font-bold tracking-tight">Settings</h1>
|
<TopNav title="Settings" sticky className="bg-stone" />
|
||||||
<EmptyState
|
<div className="mx-auto max-w-2xl px-6 pt-6">
|
||||||
icon={LogIn}
|
<EmptyState
|
||||||
title="Sign in to manage your account"
|
icon={LogIn}
|
||||||
description="Save your details, track loyalty rewards, and manage your bookings."
|
title="Sign in to manage your account"
|
||||||
action={
|
description="Save your details, track loyalty rewards, and manage your bookings."
|
||||||
<Button asChild>
|
action={
|
||||||
<Link href="/sign-in?callbackUrl=/settings">Sign In</Link>
|
<Button asChild>
|
||||||
</Button>
|
<Link href="/sign-in?callbackUrl=/settings">Sign In</Link>
|
||||||
}
|
</Button>
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [customerProfile, user, branding] = await Promise.all([
|
const [customerProfile, user, branding, unreadNotificationCount] = await Promise.all([
|
||||||
prisma.customerProfile.findUnique({ where: { userId: session.user.id } }),
|
prisma.customerProfile.findUnique({ where: { userId: session.user.id } }),
|
||||||
prisma.user.findUnique({ where: { id: session.user.id }, select: { image: true } }),
|
prisma.user.findUnique({ where: { id: session.user.id }, select: { image: true } }),
|
||||||
getBranding(),
|
getBranding(),
|
||||||
|
getUnreadNotificationCount(prisma, session.user.id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-2xl space-y-6 px-6 pt-12">
|
<div className="pb-12">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
|
<TopNav
|
||||||
<ProfileCard
|
title="Settings"
|
||||||
name={
|
sticky
|
||||||
customerProfile
|
className="bg-stone"
|
||||||
? `${customerProfile.firstName} ${customerProfile.lastName}`
|
action={<InlineNotificationBell href="/notifications" unreadCount={unreadNotificationCount} />}
|
||||||
: (session.user.name ?? "Signed-in customer")
|
|
||||||
}
|
|
||||||
meta={customerProfile?.email ?? session.user.email}
|
|
||||||
avatarUrl={user?.image ?? undefined}
|
|
||||||
editHref="/settings/profile"
|
|
||||||
/>
|
/>
|
||||||
<ul className="divide-line divide-y rounded-md border border-line bg-white">
|
<div className="mx-auto max-w-2xl space-y-6 px-6 pt-6">
|
||||||
{SETTINGS_LINKS.map(({ href, label, icon: Icon }) => (
|
<ProfileCard
|
||||||
<li key={href}>
|
name={
|
||||||
<Link href={href} className="hover:bg-stone flex items-center gap-3 px-5 py-4 text-sm font-bold">
|
customerProfile
|
||||||
<Icon className="text-muted h-5 w-5" aria-hidden="true" />
|
? `${customerProfile.firstName} ${customerProfile.lastName}`
|
||||||
<span className="flex-1">{label}</span>
|
: (session.user.name ?? "Signed-in customer")
|
||||||
<ChevronRight className="text-muted h-4 w-4" aria-hidden="true" />
|
}
|
||||||
</Link>
|
meta={customerProfile?.email ?? session.user.email}
|
||||||
</li>
|
avatarUrl={user?.image ?? undefined}
|
||||||
))}
|
editHref="/settings/profile"
|
||||||
</ul>
|
/>
|
||||||
<div className="border-line rounded-md border bg-white p-5">
|
<ul className="divide-line divide-y rounded-md border border-line bg-white">
|
||||||
<p className="mb-3 text-sm font-bold">Get the app</p>
|
{SETTINGS_LINKS.map(({ href, label, icon: Icon }) => (
|
||||||
<InstallAppButton businessName={branding.name} />
|
<li key={href}>
|
||||||
|
<Link href={href} className="hover:bg-stone flex items-center gap-3 px-5 py-4 text-sm font-bold">
|
||||||
|
<Icon className="text-muted h-5 w-5" aria-hidden="true" />
|
||||||
|
<span className="flex-1">{label}</span>
|
||||||
|
<ChevronRight className="text-muted h-4 w-4" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="border-line rounded-md border bg-white p-5">
|
||||||
|
<p className="mb-3 text-sm font-bold">Get the app</p>
|
||||||
|
<InstallAppButton businessName={branding.name} />
|
||||||
|
</div>
|
||||||
|
<SignOutButton />
|
||||||
</div>
|
</div>
|
||||||
<SignOutButton />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import { auth } from "@/auth";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { EmptyState } from "@/components/feedback/empty-state";
|
import { EmptyState } from "@/components/feedback/empty-state";
|
||||||
import { AppointmentCard } from "@/components/ui/appointment-card";
|
import { AppointmentCard } from "@/components/ui/appointment-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";
|
||||||
@@ -19,11 +20,15 @@ import {
|
|||||||
import type { CalendarDate } from "@/lib/time/business-time";
|
import type { CalendarDate } from "@/lib/time/business-time";
|
||||||
import { getAvailableRewardFlags } from "@/services/loyalty/loyalty.service";
|
import { getAvailableRewardFlags } from "@/services/loyalty/loyalty.service";
|
||||||
import { listAppointments } from "@/services/scheduling/appointment.service";
|
import { listAppointments } from "@/services/scheduling/appointment.service";
|
||||||
|
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
|
||||||
import { DateStrip } from "@/features/staff/components/date-strip";
|
import { DateStrip } from "@/features/staff/components/date-strip";
|
||||||
import { DayCalendar } from "@/features/staff/components/day-calendar";
|
import { DayCalendar } from "@/features/staff/components/day-calendar";
|
||||||
import type { CalendarAppointment, CalendarBlock, CalendarColumn } from "@/features/staff/components/day-calendar";
|
import type { CalendarAppointment, CalendarBlock, CalendarColumn } from "@/features/staff/components/day-calendar";
|
||||||
|
import { ViewSwitch } from "@/features/staff/components/view-switch";
|
||||||
import { OwnerBlockTimeButton } from "@/features/owner/components/owner-block-time-button";
|
import { OwnerBlockTimeButton } from "@/features/owner/components/owner-block-time-button";
|
||||||
import { cn } from "@/lib/utils/cn";
|
import { StaffFilterPills } from "@/features/owner/components/staff-filter-pills";
|
||||||
|
import type { StaffFilterOption } from "@/features/owner/components/staff-filter-pills";
|
||||||
|
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
|
||||||
import type { AppointmentStatus } from "@/types/domain";
|
import type { AppointmentStatus } from "@/types/domain";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "All-Staff Calendar" };
|
export const metadata: Metadata = { title: "All-Staff Calendar" };
|
||||||
@@ -64,6 +69,8 @@ export default async function OwnerCalendarPage({
|
|||||||
const context = await getActingBusinessContext();
|
const context = await getActingBusinessContext();
|
||||||
if (!context) redirect("/staff");
|
if (!context) redirect("/staff");
|
||||||
|
|
||||||
|
const unreadNotificationCount = await getUnreadNotificationCount(prisma, session.user.id);
|
||||||
|
|
||||||
const { date: dateParam, view: viewParam, staffId: staffIdParam } = await searchParams;
|
const { date: dateParam, view: viewParam, staffId: staffIdParam } = await searchParams;
|
||||||
const today = todayIn(context.businessTimezone);
|
const today = todayIn(context.businessTimezone);
|
||||||
const date: CalendarDate = dateParam ?? today;
|
const date: CalendarDate = dateParam ?? today;
|
||||||
@@ -173,23 +180,42 @@ export default async function OwnerCalendarPage({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Every active staff member gets a filter card (not just staffToRender,
|
||||||
|
// which narrows to the current selection) — the filter row needs to show
|
||||||
|
// everyone to filter *between*.
|
||||||
|
const staffFilterOptions: StaffFilterOption[] = allStaff.map((s) => {
|
||||||
|
const workingHours = workingHoursAll.find((h) => h.staffId === s.id);
|
||||||
|
const hoursLabel =
|
||||||
|
workingHours && !workingHours.isOff && workingHours.startMinutes !== null && workingHours.endMinutes !== null
|
||||||
|
? formatRange(workingHours.startMinutes, workingHours.endMinutes)
|
||||||
|
: null;
|
||||||
|
return { id: s.id, name: s.user.name ?? "Staff", avatarUrl: s.user.image, hoursLabel };
|
||||||
|
});
|
||||||
|
const staffFilterHrefFor = (staffId: string | null) =>
|
||||||
|
`/owner/calendar?date=${date}&view=${view}${staffId ? `&staffId=${staffId}` : ""}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-6">
|
<div className="pb-6">
|
||||||
<div className="flex items-center justify-between px-6 pt-12">
|
<TopNav
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Calendar</h1>
|
title="Calendar"
|
||||||
<div className="flex gap-2">
|
sticky
|
||||||
{selectedStaff && <OwnerBlockTimeButton staffId={selectedStaff.id} staffName={selectedStaff.user.name ?? "Staff"} date={date} />}
|
className="bg-stone"
|
||||||
<Button asChild size="sm">
|
leftAction={<InlineNotificationBell href="/owner/notifications" unreadCount={unreadNotificationCount} />}
|
||||||
{/* /staff/book already lets whoever is signed in book with any eligible barber — the
|
action={
|
||||||
owner is a staff member too, so this is the same "book for a customer" flow Phase 5
|
<div className="flex gap-2">
|
||||||
built, not a duplicate. See docs/phase-6-summary.md, "Owner appointment access". */}
|
{selectedStaff && <OwnerBlockTimeButton staffId={selectedStaff.id} staffName={selectedStaff.user.name ?? "Staff"} date={date} />}
|
||||||
<Link href="/staff/book">
|
<Button asChild size="sm">
|
||||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
{/* /staff/book already lets whoever is signed in book with any eligible barber — the
|
||||||
New
|
owner is a staff member too, so this is the same "book for a customer" flow Phase 5
|
||||||
</Link>
|
built, not a duplicate. See docs/phase-6-summary.md, "Owner appointment access". */}
|
||||||
</Button>
|
<Link href="/staff/book">
|
||||||
</div>
|
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||||
</div>
|
New
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<DateStrip
|
<DateStrip
|
||||||
selectedDate={date}
|
selectedDate={date}
|
||||||
@@ -199,49 +225,16 @@ export default async function OwnerCalendarPage({
|
|||||||
extraParams={selectedStaff ? { staffId: selectedStaff.id } : undefined}
|
extraParams={selectedStaff ? { staffId: selectedStaff.id } : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-2 px-6">
|
<div className="mt-4 px-6">
|
||||||
<Link
|
<ViewSwitch
|
||||||
href={`/owner/calendar?date=${date}&view=${view}`}
|
view={view}
|
||||||
className={cn(
|
dayHref={`/owner/calendar?date=${date}&view=day${selectedStaff ? `&staffId=${selectedStaff.id}` : ""}`}
|
||||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
agendaHref={`/owner/calendar?date=${date}&view=agenda${selectedStaff ? `&staffId=${selectedStaff.id}` : ""}`}
|
||||||
!selectedStaff ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
/>
|
||||||
)}
|
|
||||||
>
|
|
||||||
All Staff
|
|
||||||
</Link>
|
|
||||||
{allStaff.map((s) => (
|
|
||||||
<Link
|
|
||||||
key={s.id}
|
|
||||||
href={`/owner/calendar?date=${date}&view=${view}&staffId=${s.id}`}
|
|
||||||
className={cn(
|
|
||||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
|
||||||
selectedStaff?.id === s.id ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{s.user.name}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex gap-2 px-6">
|
<div className="mt-4">
|
||||||
<Link
|
<StaffFilterPills options={staffFilterOptions} selectedId={selectedStaff?.id} hrefFor={staffFilterHrefFor} />
|
||||||
href={`/owner/calendar?date=${date}&view=day${selectedStaff ? `&staffId=${selectedStaff.id}` : ""}`}
|
|
||||||
className={cn(
|
|
||||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
|
||||||
view === "day" ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Day
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={`/owner/calendar?date=${date}&view=agenda${selectedStaff ? `&staffId=${selectedStaff.id}` : ""}`}
|
|
||||||
className={cn(
|
|
||||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
|
||||||
view === "agenda" ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Agenda
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
|
|||||||
+129
-117
@@ -22,12 +22,15 @@ import {
|
|||||||
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";
|
||||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||||
import { todayIn } from "@/lib/time/business-time";
|
import { todayIn } from "@/lib/time/business-time";
|
||||||
import { getDashboardSummary } from "@/services/reporting/owner-reporting.service";
|
import { getDashboardSummary } from "@/services/reporting/owner-reporting.service";
|
||||||
|
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
|
||||||
|
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Business Dashboard" };
|
export const metadata: Metadata = { title: "Business Dashboard" };
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -105,130 +108,139 @@ export default async function OwnerDashboardPage() {
|
|||||||
if (!context) redirect("/staff");
|
if (!context) redirect("/staff");
|
||||||
|
|
||||||
const today = todayIn(context.businessTimezone);
|
const today = todayIn(context.businessTimezone);
|
||||||
const summary = await getDashboardSummary(prisma, {
|
const [summary, unreadNotificationCount] = await Promise.all([
|
||||||
businessId: context.businessId,
|
getDashboardSummary(prisma, {
|
||||||
businessTimezone: context.businessTimezone,
|
businessId: context.businessId,
|
||||||
date: today,
|
businessTimezone: context.businessTimezone,
|
||||||
});
|
date: today,
|
||||||
|
}),
|
||||||
|
getUnreadNotificationCount(prisma, session.user.id),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 px-6 pt-12 pb-12">
|
<div className="pb-12">
|
||||||
<div>
|
<TopNav
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Manage</h1>
|
title="Manage"
|
||||||
|
sticky
|
||||||
|
className="bg-stone"
|
||||||
|
leftAction={<InlineNotificationBell href="/owner/notifications" unreadCount={unreadNotificationCount} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-6 px-6 pt-6">
|
||||||
<p className="text-muted text-sm">{context.businessName} — today's overview</p>
|
<p className="text-muted text-sm">{context.businessName} — today's overview</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
{summary.warnings.length > 0 && (
|
{summary.warnings.length > 0 && (
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
{summary.warnings.map((w, i) => (
|
{summary.warnings.map((w, i) => (
|
||||||
<div
|
<div
|
||||||
key={`${w.code}-${i}`}
|
key={`${w.code}-${i}`}
|
||||||
role="alert"
|
role="alert"
|
||||||
className="bg-warning/10 text-warning-foreground border-warning/30 flex items-start gap-3 rounded-md border p-4 text-sm"
|
className="bg-warning/10 text-warning-foreground border-warning/30 flex items-start gap-3 rounded-md border p-4 text-sm"
|
||||||
>
|
>
|
||||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||||
<p>{w.message}</p>
|
<p>{w.message}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
|
||||||
<StatTile icon={Calendar} label="Today's appointments" value={summary.totals.total} />
|
|
||||||
<StatTile icon={CheckCheck} label="Completed" value={summary.totals.completed} />
|
|
||||||
<StatTile icon={CalendarX2} label="Cancelled" value={summary.totals.cancelled} />
|
|
||||||
<StatTile icon={UserX} label="No-shows" value={summary.totals.noShow} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
|
||||||
<StatTile icon={TrendingUp} label="In progress now" value={summary.totals.inProgress} />
|
|
||||||
<StatTile icon={Calendar} label="Still to come today" value={summary.totals.upcoming} />
|
|
||||||
<StatTile
|
|
||||||
icon={TrendingUp}
|
|
||||||
label="Estimated occupancy"
|
|
||||||
value={summary.occupancy ? `${summary.occupancy.percent}%` : "—"}
|
|
||||||
hint={summary.occupancy ? "Booked vs. working minutes today — an estimate, not a financial figure." : "No staff working today."}
|
|
||||||
/>
|
|
||||||
<StatTile icon={Gift} label="Customers with a reward" value={summary.customersWithAvailableRewards} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="grid gap-4 lg:grid-cols-2">
|
|
||||||
<Card className="p-5">
|
|
||||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
|
|
||||||
<Users className="text-accent h-4 w-4" aria-hidden="true" /> Staff Today
|
|
||||||
</h2>
|
|
||||||
{summary.staffWorkingToday.length === 0 ? (
|
|
||||||
<p className="text-muted text-sm">No one is scheduled to work today.</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{summary.staffWorkingToday.map((s) => (
|
|
||||||
<li key={s.staffId} className="flex items-center justify-between text-sm">
|
|
||||||
<span className="font-bold">{s.name}</span>
|
|
||||||
<span className="text-accent text-xs font-bold tracking-widest uppercase">Working</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
{summary.staffOnTimeOffToday.length > 0 && (
|
|
||||||
<ul className="border-line mt-3 space-y-2 border-t pt-3">
|
|
||||||
{summary.staffOnTimeOffToday.map((s) => (
|
|
||||||
<li key={s.staffId} className="flex items-center justify-between text-sm">
|
|
||||||
<span className="font-bold">{s.name}</span>
|
|
||||||
<span className="text-muted text-xs font-bold tracking-widest uppercase">
|
|
||||||
{s.reason ?? "Time off"}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-5">
|
|
||||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
|
|
||||||
<TrendingUp className="text-accent h-4 w-4" aria-hidden="true" /> Popular Services
|
|
||||||
</h2>
|
|
||||||
<p className="text-muted mb-3 text-xs">Completed visits, last 30 days.</p>
|
|
||||||
{summary.popularServices.length === 0 ? (
|
|
||||||
<p className="text-muted text-sm">No completed visits in the last 30 days yet.</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{summary.popularServices.map((s) => (
|
|
||||||
<li key={s.serviceId} className="flex items-center justify-between text-sm">
|
|
||||||
<span className="font-bold">{s.name}</span>
|
|
||||||
<span className="text-muted">{s.count}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<Card className="p-5">
|
|
||||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
|
|
||||||
<Stamp className="text-accent h-4 w-4" aria-hidden="true" /> Recent Loyalty Activity
|
|
||||||
</h2>
|
|
||||||
{summary.recentLoyaltyActivity.length === 0 ? (
|
|
||||||
<p className="text-muted text-sm">No loyalty activity yet.</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{summary.recentLoyaltyActivity.map((t) => (
|
|
||||||
<li key={t.id} className="text-muted text-xs">
|
|
||||||
<span className="text-ink font-bold">
|
|
||||||
{t.loyaltyAccount.customer.firstName} {t.loyaltyAccount.customer.lastName}
|
|
||||||
</span>{" "}
|
|
||||||
— {t.amount > 0 ? "+" : ""}
|
|
||||||
{t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"} ({t.type.replace(/_/g, " ").toLowerCase()})
|
|
||||||
{t.authoredByStaff?.user.name ? ` · ${t.authoredByStaff.user.name}` : ""}
|
|
||||||
</li>
|
|
||||||
))}
|
))}
|
||||||
</ul>
|
</section>
|
||||||
)}
|
)}
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="space-y-5">
|
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
{MANAGE_SECTIONS.map((section) => (
|
<StatTile icon={Calendar} label="Today's appointments" value={summary.totals.total} />
|
||||||
<ManageSection key={section.title} {...section} />
|
<StatTile icon={CheckCheck} label="Completed" value={summary.totals.completed} />
|
||||||
))}
|
<StatTile icon={CalendarX2} label="Cancelled" value={summary.totals.cancelled} />
|
||||||
|
<StatTile icon={UserX} label="No-shows" value={summary.totals.noShow} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
|
<StatTile icon={TrendingUp} label="In progress now" value={summary.totals.inProgress} />
|
||||||
|
<StatTile icon={Calendar} label="Still to come today" value={summary.totals.upcoming} />
|
||||||
|
<StatTile
|
||||||
|
icon={TrendingUp}
|
||||||
|
label="Estimated occupancy"
|
||||||
|
value={summary.occupancy ? `${summary.occupancy.percent}%` : "—"}
|
||||||
|
hint={summary.occupancy ? "Booked vs. working minutes today — an estimate, not a financial figure." : "No staff working today."}
|
||||||
|
/>
|
||||||
|
<StatTile icon={Gift} label="Customers with a reward" value={summary.customersWithAvailableRewards} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Card className="p-5">
|
||||||
|
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
|
||||||
|
<Users className="text-accent h-4 w-4" aria-hidden="true" /> Staff Today
|
||||||
|
</h2>
|
||||||
|
{summary.staffWorkingToday.length === 0 ? (
|
||||||
|
<p className="text-muted text-sm">No one is scheduled to work today.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{summary.staffWorkingToday.map((s) => (
|
||||||
|
<li key={s.staffId} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-bold">{s.name}</span>
|
||||||
|
<span className="text-accent text-xs font-bold tracking-widest uppercase">Working</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{summary.staffOnTimeOffToday.length > 0 && (
|
||||||
|
<ul className="border-line mt-3 space-y-2 border-t pt-3">
|
||||||
|
{summary.staffOnTimeOffToday.map((s) => (
|
||||||
|
<li key={s.staffId} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-bold">{s.name}</span>
|
||||||
|
<span className="text-muted text-xs font-bold tracking-widest uppercase">
|
||||||
|
{s.reason ?? "Time off"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-5">
|
||||||
|
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
|
||||||
|
<TrendingUp className="text-accent h-4 w-4" aria-hidden="true" /> Popular Services
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted mb-3 text-xs">Completed visits, last 30 days.</p>
|
||||||
|
{summary.popularServices.length === 0 ? (
|
||||||
|
<p className="text-muted text-sm">No completed visits in the last 30 days yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{summary.popularServices.map((s) => (
|
||||||
|
<li key={s.serviceId} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-bold">{s.name}</span>
|
||||||
|
<span className="text-muted">{s.count}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Card className="p-5">
|
||||||
|
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
|
||||||
|
<Stamp className="text-accent h-4 w-4" aria-hidden="true" /> Recent Loyalty Activity
|
||||||
|
</h2>
|
||||||
|
{summary.recentLoyaltyActivity.length === 0 ? (
|
||||||
|
<p className="text-muted text-sm">No loyalty activity yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{summary.recentLoyaltyActivity.map((t) => (
|
||||||
|
<li key={t.id} className="text-muted text-xs">
|
||||||
|
<span className="text-ink font-bold">
|
||||||
|
{t.loyaltyAccount.customer.firstName} {t.loyaltyAccount.customer.lastName}
|
||||||
|
</span>{" "}
|
||||||
|
— {t.amount > 0 ? "+" : ""}
|
||||||
|
{t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"} ({t.type.replace(/_/g, " ").toLowerCase()})
|
||||||
|
{t.authoredByStaff?.user.name ? ` · ${t.authoredByStaff.user.name}` : ""}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
{MANAGE_SECTIONS.map((section) => (
|
||||||
|
<ManageSection key={section.title} {...section} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -180,18 +180,18 @@ export default async function StaffCustomerDetailsPage({
|
|||||||
{customer.phone && (
|
{customer.phone && (
|
||||||
<a
|
<a
|
||||||
href={`tel:${customer.phone}`}
|
href={`tel:${customer.phone}`}
|
||||||
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
|
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
|
<Phone className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Call
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<a
|
<a
|
||||||
href={`mailto:${customer.email}`}
|
href={`mailto:${customer.email}`}
|
||||||
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
|
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
|
<Mail className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Email
|
||||||
</a>
|
</a>
|
||||||
<Button asChild size="sm">
|
<Button asChild size="md" className="min-w-[104px] flex-1">
|
||||||
<Link href={`/staff/book?customerId=${customer.id}`}>Book Appointment</Link>
|
<Link href={`/staff/book?customerId=${customer.id}`}>Book Appointment</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -208,8 +208,11 @@ export default async function StaffCustomerDetailsPage({
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div className="mb-3 flex flex-wrap gap-2">
|
<div className="mb-3 flex flex-wrap gap-2">
|
||||||
<AddStampButton customerId={id} />
|
<AddStampButton customerId={id} className="min-w-[140px] flex-1" />
|
||||||
<CorrectStampButton loyaltyAccountId={loyalty.accountId} />
|
<CorrectStampButton
|
||||||
|
loyaltyAccountId={loyalty.accountId}
|
||||||
|
className="min-w-[140px] flex-1"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{loyaltyTransactions.length > 0 && (
|
{loyaltyTransactions.length > 0 && (
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
@@ -246,6 +249,17 @@ export default async function StaffCustomerDetailsPage({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="mt-4 space-y-4 lg:mt-0">
|
<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">
|
<Tabs defaultValue="upcoming">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="upcoming">Upcoming</TabsTrigger>
|
<TabsTrigger value="upcoming">Upcoming</TabsTrigger>
|
||||||
@@ -260,17 +274,6 @@ export default async function StaffCustomerDetailsPage({
|
|||||||
{renderList(cancelled, "No cancelled appointments")}
|
{renderList(cancelled, "No cancelled appointments")}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import { Search, Users } from "lucide-react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { EmptyState } from "@/components/feedback/empty-state";
|
import { EmptyState } from "@/components/feedback/empty-state";
|
||||||
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||||
|
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
|
||||||
import { matchesCustomerSearch } from "@/services/customer/customer.service";
|
import { matchesCustomerSearch } from "@/services/customer/customer.service";
|
||||||
import { getLoyaltySummariesForCustomers } from "@/services/loyalty/loyalty.service";
|
import { getLoyaltySummariesForCustomers } from "@/services/loyalty/loyalty.service";
|
||||||
|
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
|
||||||
import { cn } from "@/lib/utils/cn";
|
import { cn } from "@/lib/utils/cn";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Clients" };
|
export const metadata: Metadata = { title: "Clients" };
|
||||||
@@ -24,6 +27,8 @@ export default async function StaffClientsPage({
|
|||||||
|
|
||||||
const { q = "", sort } = await searchParams;
|
const { q = "", sort } = await searchParams;
|
||||||
|
|
||||||
|
const unreadNotificationCount = await getUnreadNotificationCount(prisma, context.userId);
|
||||||
|
|
||||||
// Scoped via the appointment relation, not "every CustomerProfile row" —
|
// Scoped via the appointment relation, not "every CustomerProfile row" —
|
||||||
// a real business boundary even though this is a single-business v1 app
|
// 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
|
// (see docs/phase-5-summary.md). `select`-scoped rather than the full
|
||||||
@@ -67,106 +72,113 @@ export default async function StaffClientsPage({
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 pt-12 pb-8 lg:max-w-5xl">
|
<div className="pb-8 lg:max-w-5xl">
|
||||||
<h1 className="mb-6 text-2xl font-bold tracking-tight">Clients</h1>
|
<TopNav
|
||||||
|
title="Clients"
|
||||||
|
sticky
|
||||||
|
className="bg-stone"
|
||||||
|
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
|
||||||
|
/>
|
||||||
|
|
||||||
<form action="/staff/clients" method="GET" className="relative mb-4">
|
<div className="px-6 pt-6">
|
||||||
{sort && <input type="hidden" name="sort" value={sort} />}
|
<form action="/staff/clients" method="GET" className="relative mb-4">
|
||||||
<Search
|
{sort && <input type="hidden" name="sort" value={sort} />}
|
||||||
className="text-muted absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2"
|
<Search
|
||||||
aria-hidden="true"
|
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 htmlFor="client-search" className="sr-only">
|
||||||
</label>
|
Search clients by name, email, or phone
|
||||||
<input
|
</label>
|
||||||
id="client-search"
|
<input
|
||||||
type="search"
|
id="client-search"
|
||||||
name="q"
|
type="search"
|
||||||
defaultValue={q}
|
name="q"
|
||||||
placeholder="Search by name, email, or phone"
|
defaultValue={q}
|
||||||
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"
|
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>
|
/>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div className="mb-4 flex gap-2">
|
<div className="mb-4 flex gap-2">
|
||||||
<Link
|
<Link
|
||||||
href={`/staff/clients?q=${encodeURIComponent(q)}&sort=name`}
|
href={`/staff/clients?q=${encodeURIComponent(q)}&sort=name`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
"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",
|
sort !== "recent" ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Alphabetical
|
Alphabetical
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={`/staff/clients?q=${encodeURIComponent(q)}&sort=recent`}
|
href={`/staff/clients?q=${encodeURIComponent(q)}&sort=recent`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
"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",
|
sort === "recent" ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Recent
|
Recent
|
||||||
</Link>
|
</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>
|
||||||
)}
|
|
||||||
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,11 +5,13 @@ import { redirect } from "next/navigation";
|
|||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { StatusPill } from "@/components/ui/status-pill";
|
import { StatusPill } from "@/components/ui/status-pill";
|
||||||
import { EmptyState } from "@/components/feedback/empty-state";
|
import { EmptyState } from "@/components/feedback/empty-state";
|
||||||
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||||
import { todayIn } from "@/lib/time/business-time";
|
import { todayIn } from "@/lib/time/business-time";
|
||||||
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
||||||
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
||||||
|
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
|
||||||
import {
|
import {
|
||||||
listAppointments,
|
listAppointments,
|
||||||
selectCurrentAppointment,
|
selectCurrentAppointment,
|
||||||
@@ -17,6 +19,7 @@ import {
|
|||||||
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
|
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
|
||||||
import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button";
|
import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button";
|
||||||
import { AddStampButton } from "@/features/staff/components/add-stamp-button";
|
import { AddStampButton } from "@/features/staff/components/add-stamp-button";
|
||||||
|
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
|
||||||
import type { AppointmentStatus } from "@/types/domain";
|
import type { AppointmentStatus } from "@/types/domain";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Current Appointment" };
|
export const metadata: Metadata = { title: "Current Appointment" };
|
||||||
@@ -46,16 +49,24 @@ export default async function StaffCurrentPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const current = selectCurrentAppointment(todaysAppointments, new Date());
|
const current = selectCurrentAppointment(todaysAppointments, new Date());
|
||||||
|
const unreadNotificationCount = await getUnreadNotificationCount(prisma, staff.user.id);
|
||||||
|
|
||||||
if (!current) {
|
if (!current) {
|
||||||
return (
|
return (
|
||||||
<div className="px-6 pt-12">
|
<div className="pb-8">
|
||||||
<h1 className="mb-6 text-2xl font-bold tracking-tight">Current Appointment</h1>
|
<TopNav
|
||||||
<EmptyState
|
title="Current Appointment"
|
||||||
icon={Scissors}
|
sticky
|
||||||
title="Nothing scheduled right now"
|
className="bg-stone"
|
||||||
description="Your next appointment today will show up here."
|
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
|
||||||
/>
|
/>
|
||||||
|
<div className="px-6 pt-6">
|
||||||
|
<EmptyState
|
||||||
|
icon={Scissors}
|
||||||
|
title="Nothing scheduled right now"
|
||||||
|
description="Your next appointment today will show up here."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -80,147 +91,158 @@ export default async function StaffCurrentPage() {
|
|||||||
const tz = staff.business.timezone;
|
const tz = staff.business.timezone;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 px-6 pt-12 pb-8">
|
<div className="pb-8">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Current Appointment</h1>
|
<TopNav
|
||||||
|
title="Current Appointment"
|
||||||
<Card className="space-y-5 p-6">
|
sticky
|
||||||
<div className="flex items-start justify-between">
|
className="bg-stone"
|
||||||
<div>
|
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
|
||||||
<p className="text-lg font-bold">
|
/>
|
||||||
{current.customer.firstName} {current.customer.lastName}
|
<div className="space-y-4 px-6 pt-6">
|
||||||
</p>
|
<Card className="space-y-5 p-6">
|
||||||
<p className="text-muted text-xs">
|
<div className="flex items-start justify-between">
|
||||||
{current.customer.isGuest ? "Guest customer" : "Registered customer"}
|
<div>
|
||||||
</p>
|
<p className="text-lg font-bold">
|
||||||
|
{current.customer.firstName} {current.customer.lastName}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted text-xs">
|
||||||
|
{current.customer.isGuest ? "Guest customer" : "Registered customer"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<StatusPill status={STATUS_MAP[current.status] ?? "confirmed"} />
|
||||||
</div>
|
</div>
|
||||||
<StatusPill status={STATUS_MAP[current.status] ?? "confirmed"} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
{current.customer.phone && (
|
{current.customer.phone && (
|
||||||
|
<a
|
||||||
|
href={`tel:${current.customer.phone}`}
|
||||||
|
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
|
||||||
|
>
|
||||||
|
<Phone className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Call
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
<a
|
<a
|
||||||
href={`tel:${current.customer.phone}`}
|
href={`mailto:${current.customer.email}`}
|
||||||
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
|
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
|
||||||
>
|
>
|
||||||
<Phone className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Call
|
<Mail className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Email
|
||||||
</a>
|
</a>
|
||||||
)}
|
<Link
|
||||||
<a
|
href={`/staff/clients/${current.customerId}`}
|
||||||
href={`mailto:${current.customer.email}`}
|
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
|
||||||
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
|
>
|
||||||
>
|
View Customer
|
||||||
<Mail className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Email
|
</Link>
|
||||||
</a>
|
</div>
|
||||||
<Link
|
|
||||||
href={`/staff/clients/${current.customerId}`}
|
|
||||||
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
|
|
||||||
>
|
|
||||||
View Customer
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-line grid grid-cols-2 gap-4 border-t pt-4 text-sm">
|
<div className="border-line grid grid-cols-2 gap-4 border-t pt-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||||
Services
|
Services
|
||||||
</span>
|
</span>
|
||||||
<p className="font-bold">{current.services.map((s) => s.service.name).join(" + ")}</p>
|
<p className="font-bold">{current.services.map((s) => s.service.name).join(" + ")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||||
Time
|
Time
|
||||||
</span>
|
</span>
|
||||||
<p className="font-bold">
|
<p className="font-bold">
|
||||||
{current.startAt.toLocaleTimeString("en-GB", {
|
{current.startAt.toLocaleTimeString("en-GB", {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
timeZone: tz,
|
timeZone: tz,
|
||||||
})}{" "}
|
})}{" "}
|
||||||
–{" "}
|
–{" "}
|
||||||
{current.endAt.toLocaleTimeString("en-GB", {
|
{current.endAt.toLocaleTimeString("en-GB", {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
timeZone: tz,
|
timeZone: tz,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||||
Duration
|
Duration
|
||||||
</span>
|
</span>
|
||||||
<p className="font-bold">{totalDuration} min</p>
|
<p className="font-bold">{totalDuration} min</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||||
Price
|
Price
|
||||||
</span>
|
</span>
|
||||||
<p className="font-bold">£{totalPrice.toFixed(2)}</p>
|
<p className="font-bold">£{totalPrice.toFixed(2)}</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{current.notes && (
|
|
||||||
<div className="border-line border-t pt-4">
|
|
||||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
|
||||||
Booking Notes
|
|
||||||
</span>
|
|
||||||
<p className="text-muted mt-1 text-sm italic">{current.notes}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{notes.length > 0 && (
|
|
||||||
<div className="border-line border-t pt-4">
|
|
||||||
<span className="text-muted mb-2 flex items-center gap-1.5 text-[10px] font-bold tracking-widest uppercase">
|
|
||||||
<NotebookText className="h-3 w-3" aria-hidden="true" /> Customer Notes
|
|
||||||
</span>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{notes.map((n) => (
|
|
||||||
<p key={n.id} className="text-muted text-xs">
|
|
||||||
<span className="font-bold">{n.author.user.name ?? "Staff"}:</span> {n.body}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{loyalty?.isEnabled && (
|
{current.notes && (
|
||||||
<div className="border-line border-t pt-4">
|
<div className="border-line border-t pt-4">
|
||||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||||
Loyalty
|
Booking Notes
|
||||||
</span>
|
</span>
|
||||||
<p className="text-sm font-bold">
|
<p className="text-muted mt-1 text-sm italic">{current.notes}</p>
|
||||||
{loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps
|
</div>
|
||||||
{loyalty.availableReward && (
|
)}
|
||||||
<span className="text-accent-loyalty"> · Reward available</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{lastCompleted && (
|
{notes.length > 0 && (
|
||||||
<div className="border-line border-t pt-4">
|
<div className="border-line border-t pt-4">
|
||||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
<span className="text-muted mb-2 flex items-center gap-1.5 text-[10px] font-bold tracking-widest uppercase">
|
||||||
Last Visit
|
<NotebookText className="h-3 w-3" aria-hidden="true" /> Customer Notes
|
||||||
</span>
|
</span>
|
||||||
<p className="text-muted text-sm">
|
<div className="space-y-2">
|
||||||
{lastCompleted.services.map((s) => s.service.name).join(" + ")} ·{" "}
|
{notes.map((n) => (
|
||||||
{lastCompleted.startAt.toLocaleDateString("en-GB", {
|
<p key={n.id} className="text-muted text-xs">
|
||||||
day: "numeric",
|
<span className="font-bold">{n.author.user.name ?? "Staff"}:</span> {n.body}
|
||||||
month: "short",
|
</p>
|
||||||
year: "numeric",
|
))}
|
||||||
timeZone: tz,
|
</div>
|
||||||
})}
|
</div>
|
||||||
</p>
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<AppointmentStatusActions appointmentId={current.id} status={current.status} />
|
{loyalty?.isEnabled && (
|
||||||
|
<div className="border-line border-t pt-4">
|
||||||
|
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||||
|
Loyalty
|
||||||
|
</span>
|
||||||
|
<p className="text-sm font-bold">
|
||||||
|
{loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps
|
||||||
|
{loyalty.availableReward && (
|
||||||
|
<span className="text-accent-loyalty"> · Reward available</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
{lastCompleted && (
|
||||||
{loyalty?.isEnabled && (
|
<div className="border-line border-t pt-4">
|
||||||
<AddStampButton customerId={current.customerId} appointmentId={current.id} />
|
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||||
)}
|
Last Visit
|
||||||
<BookFollowUpButton appointmentId={current.id} />
|
</span>
|
||||||
|
<p className="text-muted text-sm">
|
||||||
|
{lastCompleted.services.map((s) => s.service.name).join(" + ")} ·{" "}
|
||||||
|
{lastCompleted.startAt.toLocaleDateString("en-GB", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
timeZone: tz,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<AppointmentStatusActions appointmentId={current.id} status={current.status} layout="stack" />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{loyalty?.isEnabled && (
|
||||||
|
<AddStampButton
|
||||||
|
customerId={current.customerId}
|
||||||
|
appointmentId={current.id}
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<BookFollowUpButton appointmentId={current.id} className="w-full" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { EmptyState } from "@/components/feedback/empty-state";
|
import { EmptyState } from "@/components/feedback/empty-state";
|
||||||
import { AppointmentCard } from "@/components/ui/appointment-card";
|
import { AppointmentCard } from "@/components/ui/appointment-card";
|
||||||
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||||
import {
|
import {
|
||||||
@@ -16,11 +17,13 @@ import {
|
|||||||
import type { CalendarDate } from "@/lib/time/business-time";
|
import type { CalendarDate } from "@/lib/time/business-time";
|
||||||
import { getAvailableRewardFlags } from "@/services/loyalty/loyalty.service";
|
import { getAvailableRewardFlags } from "@/services/loyalty/loyalty.service";
|
||||||
import { listAppointments, selectCurrentAppointment } from "@/services/scheduling/appointment.service";
|
import { listAppointments, selectCurrentAppointment } from "@/services/scheduling/appointment.service";
|
||||||
|
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
|
||||||
import { DateStrip } from "@/features/staff/components/date-strip";
|
import { DateStrip } from "@/features/staff/components/date-strip";
|
||||||
import { DayCalendar } from "@/features/staff/components/day-calendar";
|
import { DayCalendar } from "@/features/staff/components/day-calendar";
|
||||||
import type { CalendarAppointment, CalendarBlock, CalendarColumn } from "@/features/staff/components/day-calendar";
|
import type { CalendarAppointment, CalendarBlock, CalendarColumn } from "@/features/staff/components/day-calendar";
|
||||||
|
import { ViewSwitch } from "@/features/staff/components/view-switch";
|
||||||
import { BlockTimeButton } from "@/features/staff/components/block-time-button";
|
import { BlockTimeButton } from "@/features/staff/components/block-time-button";
|
||||||
import { cn } from "@/lib/utils/cn";
|
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
|
||||||
import type { AppointmentStatus } from "@/types/domain";
|
import type { AppointmentStatus } from "@/types/domain";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Calendar" };
|
export const metadata: Metadata = { title: "Calendar" };
|
||||||
@@ -142,20 +145,27 @@ export default async function StaffCalendarPage({
|
|||||||
// "Responsive Design"). Only meaningful when viewing today.
|
// "Responsive Design"). Only meaningful when viewing today.
|
||||||
const rightNow = date === today ? selectCurrentAppointment(appointments, new Date()) : null;
|
const rightNow = date === today ? selectCurrentAppointment(appointments, new Date()) : null;
|
||||||
|
|
||||||
|
const unreadNotificationCount = await getUnreadNotificationCount(prisma, staff.user.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-6">
|
<div className="pb-6">
|
||||||
<div className="flex items-center justify-between px-6 pt-12">
|
<TopNav
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Calendar</h1>
|
title="Calendar"
|
||||||
<div className="flex gap-2">
|
sticky
|
||||||
<BlockTimeButton date={date} />
|
className="bg-stone"
|
||||||
<Button asChild size="sm">
|
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
|
||||||
<Link href="/staff/book">
|
action={
|
||||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
<div className="flex gap-2">
|
||||||
New
|
<BlockTimeButton date={date} />
|
||||||
</Link>
|
<Button asChild size="sm">
|
||||||
</Button>
|
<Link href="/staff/book">
|
||||||
</div>
|
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||||
</div>
|
New
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<DateStrip
|
<DateStrip
|
||||||
selectedDate={date}
|
selectedDate={date}
|
||||||
@@ -164,25 +174,8 @@ export default async function StaffCalendarPage({
|
|||||||
view={view}
|
view={view}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-4 flex gap-2 px-6">
|
<div className="mt-4 px-6">
|
||||||
<Link
|
<ViewSwitch view={view} dayHref={`/staff?date=${date}&view=day`} agendaHref={`/staff?date=${date}&view=agenda`} />
|
||||||
href={`/staff?date=${date}&view=day`}
|
|
||||||
className={cn(
|
|
||||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
|
||||||
view === "day" ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Day
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={`/staff?date=${date}&view=agenda`}
|
|
||||||
className={cn(
|
|
||||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
|
||||||
view === "agenda" ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Agenda
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 lg:flex lg:items-start lg:gap-6 lg:px-6">
|
<div className="mt-4 lg:flex lg:items-start lg:gap-6 lg:px-6">
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Bell, CalendarClock, CalendarOff, CalendarSync, Clock, UserCircle } from "lucide-react";
|
import { Bell, CalendarClock, CalendarOff, CalendarSync, Clock, type LucideIcon } from "lucide-react";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { ProfileCard } from "@/components/ui/profile-card";
|
import { ProfileCard } from "@/components/ui/profile-card";
|
||||||
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
import { SignOutButton } from "@/features/auth/components/sign-out-button";
|
import { SignOutButton } from "@/features/auth/components/sign-out-button";
|
||||||
import { getNavRole } from "@/lib/auth/permissions";
|
import { getNavRole } from "@/lib/auth/permissions";
|
||||||
import { getBranding } from "@/lib/business/branding";
|
import { getBranding } from "@/lib/business/branding";
|
||||||
import { InstallAppButton } from "@/features/pwa/components/install-app-button";
|
import { InstallAppButton } from "@/features/pwa/components/install-app-button";
|
||||||
|
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
|
||||||
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
|
||||||
|
|
||||||
const ACCOUNT_LINKS = [
|
const ACCOUNT_LINKS = [{ href: "/staff/settings/notifications", label: "Notifications", icon: Bell }] as const;
|
||||||
{ href: "/staff/settings/profile", label: "Profile", icon: UserCircle },
|
|
||||||
{ href: "/staff/settings/notifications", label: "Notifications", icon: Bell },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const WORKING_PATTERN_LINKS = [
|
const WORKING_PATTERN_LINKS = [
|
||||||
{ href: "/staff/settings/working-hours", label: "Working Hours", icon: Clock },
|
{ href: "/staff/settings/working-hours", label: "Working Hours", icon: Clock },
|
||||||
@@ -26,7 +27,7 @@ function SettingsSection({
|
|||||||
links,
|
links,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
links: ReadonlyArray<{ href: string; label: string; icon: typeof UserCircle }>;
|
links: ReadonlyArray<{ href: string; label: string; icon: LucideIcon }>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -54,45 +55,60 @@ export const metadata: Metadata = { title: "Settings" };
|
|||||||
* lives behind the "Manage" nav item, see (owner)/owner/page.tsx). Working
|
* lives behind the "Manage" nav item, see (owner)/owner/page.tsx). Working
|
||||||
* Pattern and Integrations only apply to a real personal barber schedule,
|
* Pattern and Integrations only apply to a real personal barber schedule,
|
||||||
* so a Super Administrator (who deliberately has no StaffProfile — see
|
* so a Super Administrator (who deliberately has no StaffProfile — see
|
||||||
* isSuperAdmin) sees only Notifications and Sign Out; their identity is
|
* isSuperAdmin) sees only Notifications and Sign Out. Profile editing is
|
||||||
* shown via the ProfileCard above rather than a "Profile" link, since the
|
* the ProfileCard's own edit-pencil (matching the customer settings hub)
|
||||||
* profile-edit page below is staff-bio-specific and a Super Administrator
|
* rather than a menu link — withheld for a Super Administrator since the
|
||||||
* has no StaffProfile to edit.
|
* profile-edit page below is staff-bio-specific and they have no
|
||||||
|
* StaffProfile to edit.
|
||||||
*/
|
*/
|
||||||
export default async function StaffSettingsPage() {
|
export default async function StaffSettingsPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user) return null;
|
if (!session?.user) return null;
|
||||||
|
|
||||||
const [role, branding] = await Promise.all([getNavRole(session.user.id), getBranding()]);
|
const [role, branding, unreadNotificationCount] = await Promise.all([
|
||||||
|
getNavRole(session.user.id),
|
||||||
|
getBranding(),
|
||||||
|
getUnreadNotificationCount(prisma, session.user.id),
|
||||||
|
]);
|
||||||
const isSuperAdmin = role === "superadmin";
|
const isSuperAdmin = role === "superadmin";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 px-6 pt-12 pb-12">
|
<div className="pb-12">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
|
<TopNav
|
||||||
<ProfileCard name={session.user.name ?? "Account"} meta={session.user.email} avatarUrl={session.user.image ?? undefined} />
|
title="Settings"
|
||||||
|
sticky
|
||||||
|
className="bg-stone"
|
||||||
|
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="space-y-5">
|
<div className="space-y-6 px-6 pt-6">
|
||||||
<SettingsSection
|
<ProfileCard
|
||||||
title="Account"
|
name={session.user.name ?? "Account"}
|
||||||
links={isSuperAdmin ? ACCOUNT_LINKS.filter((l) => l.label === "Notifications") : ACCOUNT_LINKS}
|
meta={session.user.email}
|
||||||
|
avatarUrl={session.user.image ?? undefined}
|
||||||
|
editHref={isSuperAdmin ? undefined : "/staff/settings/profile"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!isSuperAdmin && (
|
<div className="space-y-5">
|
||||||
<>
|
<SettingsSection title="Account" links={ACCOUNT_LINKS} />
|
||||||
<SettingsSection title="Working Pattern" links={WORKING_PATTERN_LINKS} />
|
|
||||||
<SettingsSection title="Integrations" links={INTEGRATION_LINKS} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
{!isSuperAdmin && (
|
||||||
<p className="text-muted mb-2 px-1 text-[10px] font-bold tracking-widest uppercase">App</p>
|
<>
|
||||||
<div className="space-y-3">
|
<SettingsSection title="Working Pattern" links={WORKING_PATTERN_LINKS} />
|
||||||
<div className="rounded-md border border-line bg-white p-5">
|
<SettingsSection title="Integrations" links={INTEGRATION_LINKS} />
|
||||||
<p className="mb-3 text-sm font-bold">Get the app</p>
|
</>
|
||||||
<InstallAppButton businessName={branding.name} />
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-muted mb-2 px-1 text-[10px] font-bold tracking-widest uppercase">App</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-md border border-line bg-white p-5">
|
||||||
|
<p className="mb-3 text-sm font-bold">Get the app</p>
|
||||||
|
<InstallAppButton businessName={branding.name} />
|
||||||
|
</div>
|
||||||
|
<SignOutButton />
|
||||||
</div>
|
</div>
|
||||||
<SignOutButton />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export interface TopNavProps {
|
|||||||
title: string;
|
title: string;
|
||||||
/** Show a back button that calls router.back(), or a custom href/handler. */
|
/** Show a back button that calls router.back(), or a custom href/handler. */
|
||||||
onBack?: (() => void) | true;
|
onBack?: (() => void) | true;
|
||||||
|
/** Rendered in the back button's place when `onBack` isn't given — e.g. a notification bell on a top-level page that has no "back" to go to. Ignored if `onBack` is set. */
|
||||||
|
leftAction?: ReactNode;
|
||||||
action?: ReactNode;
|
action?: ReactNode;
|
||||||
sticky?: boolean;
|
sticky?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -19,7 +21,7 @@ export interface TopNavProps {
|
|||||||
* variations, across nearly every template screen — consolidated into one
|
* variations, across nearly every template screen — consolidated into one
|
||||||
* component (see docs/implementation-plan.md §7, "TopHeader").
|
* component (see docs/implementation-plan.md §7, "TopHeader").
|
||||||
*/
|
*/
|
||||||
export function TopNav({ title, onBack, action, sticky = true, className }: TopNavProps) {
|
export function TopNav({ title, onBack, leftAction, action, sticky = true, className }: TopNavProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -31,7 +33,7 @@ export function TopNav({ title, onBack, action, sticky = true, className }: TopN
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{onBack && (
|
{onBack ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onBack === true ? () => router.back() : onBack}
|
onClick={onBack === true ? () => router.back() : onBack}
|
||||||
@@ -40,6 +42,8 @@ export function TopNav({ title, onBack, action, sticky = true, className }: TopN
|
|||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
leftAction
|
||||||
)}
|
)}
|
||||||
<h1 className="text-xl font-bold tracking-tight">{title}</h1>
|
<h1 className="text-xl font-bold tracking-tight">{title}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const Tabs = TabsPrimitive.Root;
|
|||||||
export function TabsList({ className, ...props }: TabsPrimitive.TabsListProps) {
|
export function TabsList({ className, ...props }: TabsPrimitive.TabsListProps) {
|
||||||
return (
|
return (
|
||||||
<TabsPrimitive.List
|
<TabsPrimitive.List
|
||||||
className={cn("flex gap-1 rounded-full bg-white p-1", className)}
|
className={cn("flex gap-1 rounded-md bg-white p-1", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Bell } from "lucide-react";
|
import { Bell } from "lucide-react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
/** Pages that render their own inline bell (see InlineNotificationBell) as part of a proper header — the shell's own floating bell steps aside there instead of sitting on top of it. */
|
||||||
|
const PAGES_WITH_INLINE_BELL = [
|
||||||
|
"/staff",
|
||||||
|
"/owner/calendar",
|
||||||
|
"/staff/current",
|
||||||
|
"/staff/clients",
|
||||||
|
"/staff/settings",
|
||||||
|
"/appointments",
|
||||||
|
"/loyalty",
|
||||||
|
"/settings",
|
||||||
|
"/owner",
|
||||||
|
];
|
||||||
|
|
||||||
|
function badge(unreadCount: number) {
|
||||||
|
if (unreadCount === 0) return null;
|
||||||
|
return (
|
||||||
|
<span className="bg-danger absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-[10px] font-bold text-white">
|
||||||
|
{unreadCount > 9 ? "9+" : unreadCount}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fixed top-right on every screen inside the shell that renders it (see
|
* Fixed top-right on every screen inside the shell that renders it (see
|
||||||
@@ -8,8 +33,16 @@ import { Bell } from "lucide-react";
|
|||||||
* only ever reflects "as of this page load/navigation". Real-time badge
|
* only ever reflects "as of this page load/navigation". Real-time badge
|
||||||
* updates without a reload would need a persistent connection this phase
|
* updates without a reload would need a persistent connection this phase
|
||||||
* doesn't build (see docs/phase-7-summary.md, "Known limitations").
|
* doesn't build (see docs/phase-7-summary.md, "Known limitations").
|
||||||
|
*
|
||||||
|
* Steps aside (renders nothing) on pages that build their own proper
|
||||||
|
* header — a fixed top-right bell has nowhere sensible to sit once a page
|
||||||
|
* already has its own top-right control (e.g. Calendar's "New" button) —
|
||||||
|
* see InlineNotificationBell below, used in exactly those pages' headers.
|
||||||
*/
|
*/
|
||||||
export function NotificationBell({ href, unreadCount }: { href: string; unreadCount: number }) {
|
export function NotificationBell({ href, unreadCount }: { href: string; unreadCount: number }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
if (PAGES_WITH_INLINE_BELL.includes(pathname)) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
@@ -17,11 +50,21 @@ export function NotificationBell({ href, unreadCount }: { href: string; unreadCo
|
|||||||
className="fixed top-4 right-4 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-line bg-white shadow-sm"
|
className="fixed top-4 right-4 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-line bg-white shadow-sm"
|
||||||
>
|
>
|
||||||
<Bell className="h-4 w-4" aria-hidden="true" />
|
<Bell className="h-4 w-4" aria-hidden="true" />
|
||||||
{unreadCount > 0 && (
|
{badge(unreadCount)}
|
||||||
<span className="bg-danger absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-[10px] font-bold text-white">
|
</Link>
|
||||||
{unreadCount > 9 ? "9+" : unreadCount}
|
);
|
||||||
</span>
|
}
|
||||||
)}
|
|
||||||
|
/** Same look as the fixed bell above, but a normal in-flow element — for a page's own TopNav `leftAction`, not the shell's floating corner. */
|
||||||
|
export function InlineNotificationBell({ href, unreadCount }: { href: string; unreadCount: number }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : "Notifications"}
|
||||||
|
className="relative flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-line"
|
||||||
|
>
|
||||||
|
<Bell className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{badge(unreadCount)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -57,9 +57,9 @@ export function OwnerBlockTimeButton({ staffId, staffName, date }: { staffId: st
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
|
<Button variant="secondary" size="sm" aria-label="Block Time" onClick={() => setOpen(true)}>
|
||||||
<CalendarOff className="h-4 w-4" aria-hidden="true" />
|
<CalendarOff className="h-4 w-4" aria-hidden="true" />
|
||||||
Block Time
|
<span className="hidden sm:inline">Block Time</span>
|
||||||
</Button>
|
</Button>
|
||||||
<BottomSheet open={open} onClose={handleClose} title={`Block Time — ${staffName}`}>
|
<BottomSheet open={open} onClose={handleClose} title={`Block Time — ${staffName}`}>
|
||||||
<div className="space-y-4 pb-2">
|
<div className="space-y-4 pb-2">
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Users } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils/cn";
|
||||||
|
|
||||||
|
export interface StaffFilterOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
/** Working-hours range for the day (e.g. "10:00 AM – 7:00 PM"), or null if off entirely. */
|
||||||
|
hoursLabel: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CARD_CLASS = "flex shrink-0 items-center gap-2 rounded-2xl border bg-white px-3 py-2 text-left";
|
||||||
|
|
||||||
|
/** Avatar + name + hours cards for filtering the all-staff calendar by barber — an "All Staff" card plus one per active staff member, matching the day grid's own column-header style so the filter row and the grid read as one system. */
|
||||||
|
export function StaffFilterPills({
|
||||||
|
options,
|
||||||
|
selectedId,
|
||||||
|
hrefFor,
|
||||||
|
}: {
|
||||||
|
options: StaffFilterOption[];
|
||||||
|
/** undefined = "All Staff" selected. */
|
||||||
|
selectedId?: string;
|
||||||
|
hrefFor: (staffId: string | null) => string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 overflow-x-auto px-6 py-1">
|
||||||
|
<Link
|
||||||
|
href={hrefFor(null)}
|
||||||
|
aria-current={!selectedId ? "true" : undefined}
|
||||||
|
className={cn(CARD_CLASS, !selectedId ? "border-ink ring-ink ring-1" : "border-line")}
|
||||||
|
>
|
||||||
|
<span className="bg-stone text-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-line">
|
||||||
|
<Users className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<p className="text-sm font-bold">All Staff</p>
|
||||||
|
<p className="text-muted text-[10px]">Everyone</p>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{options.map((option) => (
|
||||||
|
<Link
|
||||||
|
key={option.id}
|
||||||
|
href={hrefFor(option.id)}
|
||||||
|
aria-current={selectedId === option.id ? "true" : undefined}
|
||||||
|
className={cn(CARD_CLASS, selectedId === option.id ? "border-ink ring-ink ring-1" : "border-line")}
|
||||||
|
>
|
||||||
|
{option.avatarUrl ? (
|
||||||
|
<Image
|
||||||
|
src={option.avatarUrl}
|
||||||
|
alt=""
|
||||||
|
width={36}
|
||||||
|
height={36}
|
||||||
|
className="h-9 w-9 shrink-0 rounded-full border border-line object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="bg-stone text-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-line text-sm font-bold">
|
||||||
|
{option.name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-bold">{option.name}</p>
|
||||||
|
<p className="text-muted truncate text-[10px]">{option.hoursLabel ?? "Off today"}</p>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,14 +6,19 @@ import { useState, useTransition } from "react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Modal } from "@/components/ui/modal";
|
import { Modal } from "@/components/ui/modal";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { cn } from "@/lib/utils/cn";
|
||||||
import { addLoyaltyStampAction } from "../actions/loyalty-actions";
|
import { addLoyaltyStampAction } from "../actions/loyalty-actions";
|
||||||
|
|
||||||
export function AddStampButton({
|
export function AddStampButton({
|
||||||
customerId,
|
customerId,
|
||||||
appointmentId,
|
appointmentId,
|
||||||
|
className,
|
||||||
|
size = "sm",
|
||||||
}: {
|
}: {
|
||||||
customerId: string;
|
customerId: string;
|
||||||
appointmentId?: string;
|
appointmentId?: string;
|
||||||
|
className?: string;
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -57,7 +62,12 @@ export function AddStampButton({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size={size}
|
||||||
|
className={cn("shrink-0", className)}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
<Stamp className="h-4 w-4" aria-hidden="true" />
|
<Stamp className="h-4 w-4" aria-hidden="true" />
|
||||||
Add Loyalty Stamp
|
Add Loyalty Stamp
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -54,10 +54,13 @@ export function AppointmentStatusActions({
|
|||||||
appointmentId,
|
appointmentId,
|
||||||
status,
|
status,
|
||||||
actions = DEFAULT_ACTIONS,
|
actions = DEFAULT_ACTIONS,
|
||||||
|
layout = "wrap",
|
||||||
}: {
|
}: {
|
||||||
appointmentId: string;
|
appointmentId: string;
|
||||||
status: RawStatus;
|
status: RawStatus;
|
||||||
actions?: AppointmentActionSet;
|
actions?: AppointmentActionSet;
|
||||||
|
/** "stack" renders one full-width button per row (Current Appointment); "wrap" (default, used by Appointment Details) keeps the original inline small buttons. */
|
||||||
|
layout?: "wrap" | "stack";
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
@@ -121,10 +124,11 @@ export function AppointmentStatusActions({
|
|||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className={layout === "stack" ? "flex flex-col gap-2" : "flex flex-wrap gap-2"}>
|
||||||
{status === "PENDING" && (
|
{status === "PENDING" && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size={layout === "stack" ? undefined : "sm"}
|
||||||
|
className={layout === "stack" ? "w-full" : undefined}
|
||||||
isLoading={isPending}
|
isLoading={isPending}
|
||||||
onClick={() => runQuiet(() => actions.confirm(appointmentId))}
|
onClick={() => runQuiet(() => actions.confirm(appointmentId))}
|
||||||
>
|
>
|
||||||
@@ -133,7 +137,8 @@ export function AppointmentStatusActions({
|
|||||||
)}
|
)}
|
||||||
{status === "CONFIRMED" && (
|
{status === "CONFIRMED" && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size={layout === "stack" ? undefined : "sm"}
|
||||||
|
className={layout === "stack" ? "w-full" : undefined}
|
||||||
isLoading={isPending}
|
isLoading={isPending}
|
||||||
onClick={() => runQuiet(() => actions.checkIn(appointmentId))}
|
onClick={() => runQuiet(() => actions.checkIn(appointmentId))}
|
||||||
>
|
>
|
||||||
@@ -142,7 +147,8 @@ export function AppointmentStatusActions({
|
|||||||
)}
|
)}
|
||||||
{status === "CHECKED_IN" && (
|
{status === "CHECKED_IN" && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size={layout === "stack" ? undefined : "sm"}
|
||||||
|
className={layout === "stack" ? "w-full" : undefined}
|
||||||
isLoading={isPending}
|
isLoading={isPending}
|
||||||
onClick={() => runQuiet(() => actions.start(appointmentId))}
|
onClick={() => runQuiet(() => actions.start(appointmentId))}
|
||||||
>
|
>
|
||||||
@@ -150,17 +156,31 @@ export function AppointmentStatusActions({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{status === "IN_PROGRESS" && (
|
{status === "IN_PROGRESS" && (
|
||||||
<Button size="sm" onClick={() => setConfirmKind("complete")}>
|
<Button
|
||||||
|
size={layout === "stack" ? undefined : "sm"}
|
||||||
|
className={layout === "stack" ? "w-full" : undefined}
|
||||||
|
onClick={() => setConfirmKind("complete")}
|
||||||
|
>
|
||||||
Complete Appointment
|
Complete Appointment
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{status === "CONFIRMED" && (
|
{status === "CONFIRMED" && (
|
||||||
<Button size="sm" variant="secondary" onClick={() => setConfirmKind("no_show")}>
|
<Button
|
||||||
|
size={layout === "stack" ? undefined : "sm"}
|
||||||
|
className={layout === "stack" ? "w-full" : undefined}
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setConfirmKind("no_show")}
|
||||||
|
>
|
||||||
Mark No-Show
|
Mark No-Show
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{(status === "PENDING" || status === "CONFIRMED" || status === "CHECKED_IN") && (
|
{(status === "PENDING" || status === "CONFIRMED" || status === "CHECKED_IN") && (
|
||||||
<Button size="sm" variant="destructive" onClick={() => setConfirmKind("cancel")}>
|
<Button
|
||||||
|
size={layout === "stack" ? undefined : "sm"}
|
||||||
|
className={layout === "stack" ? "w-full" : undefined}
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setConfirmKind("cancel")}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -62,9 +62,9 @@ export function BlockTimeButton({ date }: { date: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
|
<Button variant="secondary" size="sm" aria-label="Block Time" onClick={() => setOpen(true)}>
|
||||||
<CalendarOff className="h-4 w-4" aria-hidden="true" />
|
<CalendarOff className="h-4 w-4" aria-hidden="true" />
|
||||||
Block Time
|
<span className="hidden sm:inline">Block Time</span>
|
||||||
</Button>
|
</Button>
|
||||||
<BottomSheet open={open} onClose={handleClose} title="Block Time">
|
<BottomSheet open={open} onClose={handleClose} title="Block Time">
|
||||||
<div className="space-y-4 pb-2">
|
<div className="space-y-4 pb-2">
|
||||||
|
|||||||
@@ -2,15 +2,22 @@
|
|||||||
|
|
||||||
import { useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils/cn";
|
||||||
import { startStaffFollowUpAction } from "../actions/staff-booking";
|
import { startStaffFollowUpAction } from "../actions/staff-booking";
|
||||||
|
|
||||||
export function BookFollowUpButton({ appointmentId }: { appointmentId: string }) {
|
export function BookFollowUpButton({
|
||||||
|
appointmentId,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
appointmentId: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="rounded-md border-line border bg-white px-5 py-3 text-sm font-bold"
|
className={cn("rounded-md border-line border bg-white px-5 py-3 text-sm font-bold", className)}
|
||||||
isLoading={isPending}
|
isLoading={isPending}
|
||||||
onClick={() => startTransition(() => startStaffFollowUpAction(appointmentId))}
|
onClick={() => startTransition(() => startStaffFollowUpAction(appointmentId))}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,10 +7,17 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Modal } from "@/components/ui/modal";
|
import { Modal } from "@/components/ui/modal";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { cn } from "@/lib/utils/cn";
|
||||||
import { correctLoyaltyStampAction } from "../actions/loyalty-actions";
|
import { correctLoyaltyStampAction } from "../actions/loyalty-actions";
|
||||||
|
|
||||||
/** Removes stamps via a compensating ledger entry — never edits or deletes the original transaction. A reason is required (enforced both here and server-side in LoyaltyService.correctStamp). */
|
/** Removes stamps via a compensating ledger entry — never edits or deletes the original transaction. A reason is required (enforced both here and server-side in LoyaltyService.correctStamp). */
|
||||||
export function CorrectStampButton({ loyaltyAccountId }: { loyaltyAccountId: string }) {
|
export function CorrectStampButton({
|
||||||
|
loyaltyAccountId,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
loyaltyAccountId: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [amount, setAmount] = useState("1");
|
const [amount, setAmount] = useState("1");
|
||||||
@@ -52,7 +59,7 @@ export function CorrectStampButton({ loyaltyAccountId }: { loyaltyAccountId: str
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button variant="secondary" size="sm" onClick={() => setOpen(true)}>
|
<Button variant="secondary" size="sm" className={cn(className)} onClick={() => setOpen(true)}>
|
||||||
<Undo2 className="h-4 w-4" aria-hidden="true" />
|
<Undo2 className="h-4 w-4" aria-hidden="true" />
|
||||||
Correct Stamp
|
Correct Stamp
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -42,15 +42,15 @@ export function DateStrip({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 px-6 pt-4">
|
<div className="space-y-3 px-6 pt-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="border-line flex items-center overflow-hidden rounded-full border bg-white">
|
||||||
<Link
|
<Link
|
||||||
href={hrefFor(addDays(selectedDate, -1))}
|
href={hrefFor(addDays(selectedDate, -1))}
|
||||||
aria-label="Previous day"
|
aria-label="Previous day"
|
||||||
className="border-line flex h-9 w-9 items-center justify-center rounded-full border bg-white text-white"
|
className="hover:bg-stone flex h-11 w-12 shrink-0 items-center justify-center"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="text-ink h-4 w-4" aria-hidden="true" />
|
<ChevronLeft className="text-ink h-4 w-4" aria-hidden="true" />
|
||||||
</Link>
|
</Link>
|
||||||
<div className="text-center">
|
<div className="flex-1 py-1.5 text-center">
|
||||||
<p className="text-sm font-bold">{selectedDate === today ? "Today" : label}</p>
|
<p className="text-sm font-bold">{selectedDate === today ? "Today" : label}</p>
|
||||||
{selectedDate === today && <p className="text-muted text-xs">{label}</p>}
|
{selectedDate === today && <p className="text-muted text-xs">{label}</p>}
|
||||||
{selectedDate !== today && (
|
{selectedDate !== today && (
|
||||||
@@ -65,7 +65,7 @@ export function DateStrip({
|
|||||||
<Link
|
<Link
|
||||||
href={hrefFor(addDays(selectedDate, 1))}
|
href={hrefFor(addDays(selectedDate, 1))}
|
||||||
aria-label="Next day"
|
aria-label="Next day"
|
||||||
className="border-line flex h-9 w-9 items-center justify-center rounded-full border bg-white"
|
className="hover:bg-stone flex h-11 w-12 shrink-0 items-center justify-center"
|
||||||
>
|
>
|
||||||
<ChevronRight className="text-ink h-4 w-4" aria-hidden="true" />
|
<ChevronRight className="text-ink h-4 w-4" aria-hidden="true" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ function formatCompactTime(minutes: number): string {
|
|||||||
const displayHour = h % 12 === 0 ? 12 : h % 12;
|
const displayHour = h % 12 === 0 ? 12 : h % 12;
|
||||||
return `${displayHour}:${String(m).padStart(2, "0")}`;
|
return `${displayHour}:${String(m).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
/** "2:47 pm" — the current-time indicator's own label, read out next to its dot on the axis. */
|
||||||
|
function formatCurrentTimeLabel(minutes: number): string {
|
||||||
|
const h = Math.floor(minutes / MINUTES_PER_HOUR);
|
||||||
|
const m = minutes % MINUTES_PER_HOUR;
|
||||||
|
const period = h >= 12 ? "pm" : "am";
|
||||||
|
const displayHour = h % 12 === 0 ? 12 : h % 12;
|
||||||
|
return `${displayHour}:${String(m).padStart(2, "0")} ${period}`;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CalendarAppointment {
|
export interface CalendarAppointment {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -104,7 +112,7 @@ export function DayCalendar({
|
|||||||
currentTimeMinutes !== null && currentTimeMinutes >= RANGE_START_MINUTES && currentTimeMinutes <= RANGE_END_MINUTES;
|
currentTimeMinutes !== null && currentTimeMinutes >= RANGE_START_MINUTES && currentTimeMinutes <= RANGE_END_MINUTES;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2 px-6 pb-8">
|
<div className="flex gap-2 px-6 pb-8" data-testid="calendar-grid">
|
||||||
{/* Time axis — a plain sibling of the scrolling columns strip below, not part of its horizontal scroll at all, so it's immune to any scroll-snap/sticky edge cases: it simply never moves. */}
|
{/* Time axis — a plain sibling of the scrolling columns strip below, not part of its horizontal scroll at all, so it's immune to any scroll-snap/sticky edge cases: it simply never moves. */}
|
||||||
<div className="w-11 shrink-0" style={{ height: HEADER_HEIGHT + gridHeight }}>
|
<div className="w-11 shrink-0" style={{ height: HEADER_HEIGHT + gridHeight }}>
|
||||||
<div style={{ height: HEADER_HEIGHT }} aria-hidden="true" />
|
<div style={{ height: HEADER_HEIGHT }} aria-hidden="true" />
|
||||||
@@ -126,11 +134,16 @@ export function DayCalendar({
|
|||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
{showCurrentTime && (
|
{showCurrentTime && (
|
||||||
<span
|
<div
|
||||||
className="bg-danger absolute right-0 top-0 h-2 w-2 -translate-y-1/2 rounded-full"
|
className="absolute inset-x-0 z-40 flex items-center justify-end gap-1"
|
||||||
style={{ top: topFor(currentTimeMinutes!) }}
|
style={{ top: topFor(currentTimeMinutes!) }}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
>
|
||||||
|
<span className="bg-danger h-2 w-2 shrink-0 -translate-y-1/2 rounded-full" />
|
||||||
|
<span className="bg-danger absolute left-full -translate-y-1/2 rounded px-1.5 py-0.5 text-[9px] font-bold whitespace-nowrap text-white">
|
||||||
|
{formatCurrentTimeLabel(currentTimeMinutes!)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { cn } from "@/lib/utils/cn";
|
||||||
|
|
||||||
|
/** Day/Agenda as one segmented switch (a single bordered pill, active side filled) rather than two separate floating pills. Plain Links — same "no client JS needed just to change a view" reasoning as DateStrip. */
|
||||||
|
export function ViewSwitch({ dayHref, agendaHref, view }: { dayHref: string; agendaHref: string; view: "day" | "agenda" }) {
|
||||||
|
const segmentClass = (active: boolean) =>
|
||||||
|
cn(
|
||||||
|
"flex-1 rounded-full px-4 py-1.5 text-center text-xs font-bold tracking-widest uppercase transition-colors",
|
||||||
|
active ? "bg-ink text-white" : "text-muted",
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-line inline-flex rounded-full border bg-white p-1">
|
||||||
|
<Link href={dayHref} aria-current={view === "day" ? "true" : undefined} className={segmentClass(view === "day")}>
|
||||||
|
Day
|
||||||
|
</Link>
|
||||||
|
<Link href={agendaHref} aria-current={view === "agenda" ? "true" : undefined} className={segmentClass(view === "agenda")}>
|
||||||
|
Agenda
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user