diff --git a/e2e/owner-journeys.spec.ts b/e2e/owner-journeys.spec.ts index 0fae67e..4b86fc5 100644 --- a/e2e/owner-journeys.spec.ts +++ b/e2e/owner-journeys.spec.ts @@ -55,21 +55,26 @@ test.describe.serial("owner administration journeys", () => { await page.goto("/owner/calendar"); await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible(); - await expect(page.getByRole("link", { name: "Marcus Reed", exact: true })).toBeVisible(); - await expect(page.getByRole("link", { name: "Leo Vance", exact: true })).toBeVisible(); + // The staff filter pills are avatar+name+hours cards now, so their + // 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. - await page.getByRole("link", { name: "Leo Vance", exact: true }).click(); + await page.getByRole("link", { name: "Leo Vance" }).click(); await expect(page).toHaveURL(/staffId=/); // The single remaining day-view column is headed by a

with the staff member's exact name - // (the filter pill link has the same text, so scope to the paragraph specifically). - await expect(page.locator("p").getByText("Leo Vance", { exact: true })).toBeVisible(); + // — the staff filter pill above it is *also* an avatar+name+hours card with its own

of the + // 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 expect(page).toHaveURL(/view=agenda/); 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=/); }); diff --git a/src/app/(customer)/appointments/page.tsx b/src/app/(customer)/appointments/page.tsx index a6bcd3d..eb50b4c 100644 --- a/src/app/(customer)/appointments/page.tsx +++ b/src/app/(customer)/appointments/page.tsx @@ -6,11 +6,14 @@ import { AppointmentCard } from "@/components/ui/appointment-card"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { EmptyState } from "@/components/feedback/empty-state"; +import { TopNav } from "@/components/navigation/top-nav"; import { getPrimaryBusiness } from "@/lib/business/get-primary-business"; import { prisma } from "@/lib/db/prisma"; import { listAppointments } from "@/services/scheduling/appointment.service"; +import { getUnreadNotificationCount } from "@/services/notifications/notification.service"; import { BookAgainButton } from "@/features/booking/components/book-again-button"; import { CancelAppointmentDialog } from "@/features/booking/components/cancel-appointment-dialog"; +import { InlineNotificationBell } from "@/features/notifications/components/notification-bell"; import type { AppointmentStatus } from "@/types/domain"; export const metadata: Metadata = { title: "Appointments" }; @@ -36,41 +39,49 @@ export default async function AppointmentsPage() { if (!session?.user) { return ( -

-

Appointments

- - Sign In - - } - /> +
+ +
+ + Sign In + + } + /> +
); } - const business = await getPrimaryBusiness(); + const [business, unreadNotificationCount] = await Promise.all([ + getPrimaryBusiness(), + getUnreadNotificationCount(prisma, session.user.id), + ]); const customerProfile = await prisma.customerProfile.findUnique({ where: { userId: session.user.id }, }); + const bell = ; if (!customerProfile) { return ( -
-

Appointments

- - Book Now - - } - /> +
+ +
+ + Book Now + + } + /> +
); } @@ -153,18 +164,20 @@ export default async function AppointmentsPage() { }; return ( -
-

Appointments

- - - Upcoming - Past - Cancelled - - {renderList(upcoming, "upcoming")} - {renderList(past, "past")} - {renderList(cancelled, "cancelled")} - +
+ +
+ + + Upcoming + Past + Cancelled + + {renderList(upcoming, "upcoming")} + {renderList(past, "past")} + {renderList(cancelled, "cancelled")} + +
); } diff --git a/src/app/(customer)/loyalty/page.tsx b/src/app/(customer)/loyalty/page.tsx index 2343f0f..3f40df5 100644 --- a/src/app/(customer)/loyalty/page.tsx +++ b/src/app/(customer)/loyalty/page.tsx @@ -10,7 +10,9 @@ import { TopNav } from "@/components/navigation/top-nav"; import { getPrimaryBusiness } from "@/lib/business/get-primary-business"; import { prisma } from "@/lib/db/prisma"; import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service"; +import { getUnreadNotificationCount } from "@/services/notifications/notification.service"; import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold"; +import { InlineNotificationBell } from "@/features/notifications/components/notification-bell"; export const metadata: Metadata = { title: "Loyalty" }; export const dynamic = "force-dynamic"; @@ -20,25 +22,29 @@ export default async function LoyaltyPage() { if (!session?.user) { return ( -
-

Loyalty

- - Sign In - - } - /> +
+ +
+ + Sign In + + } + /> +
); } - const customerProfile = await prisma.customerProfile.findUnique({ - where: { userId: session.user.id }, - }); + const [customerProfile, unreadNotificationCount] = await Promise.all([ + prisma.customerProfile.findUnique({ where: { userId: session.user.id } }), + getUnreadNotificationCount(prisma, session.user.id), + ]); + const bell = ; const loyalty = customerProfile ? await getLoyaltySummary(prisma, { customerId: customerProfile.id, businessId: business.id }) @@ -47,7 +53,7 @@ export default async function LoyaltyPage() { if (!customerProfile || !loyalty?.isEnabled) { return (
- +
- +
-

Settings

- - Sign In - - } - /> +
+ +
+ + Sign In + + } + /> +
); } - const [customerProfile, user, branding] = await Promise.all([ + const [customerProfile, user, branding, unreadNotificationCount] = await Promise.all([ prisma.customerProfile.findUnique({ where: { userId: session.user.id } }), prisma.user.findUnique({ where: { id: session.user.id }, select: { image: true } }), getBranding(), + getUnreadNotificationCount(prisma, session.user.id), ]); return ( -
-

Settings

- + } /> -
    - {SETTINGS_LINKS.map(({ href, label, icon: Icon }) => ( -
  • - -
  • - ))} -
-
-

Get the app

- +
+ +
    + {SETTINGS_LINKS.map(({ href, label, icon: Icon }) => ( +
  • + +
  • + ))} +
+
+

Get the app

+ +
+
-
); } diff --git a/src/app/(owner)/owner/calendar/page.tsx b/src/app/(owner)/owner/calendar/page.tsx index b31223b..157d37c 100644 --- a/src/app/(owner)/owner/calendar/page.tsx +++ b/src/app/(owner)/owner/calendar/page.tsx @@ -6,6 +6,7 @@ import { auth } from "@/auth"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/feedback/empty-state"; import { AppointmentCard } from "@/components/ui/appointment-card"; +import { TopNav } from "@/components/navigation/top-nav"; import { prisma } from "@/lib/db/prisma"; import { hasPermission } from "@/lib/auth/permissions"; import { Permission } from "@/generated/prisma/enums"; @@ -19,11 +20,15 @@ import { import type { CalendarDate } from "@/lib/time/business-time"; import { getAvailableRewardFlags } from "@/services/loyalty/loyalty.service"; import { listAppointments } from "@/services/scheduling/appointment.service"; +import { getUnreadNotificationCount } from "@/services/notifications/notification.service"; import { DateStrip } from "@/features/staff/components/date-strip"; import { DayCalendar } 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 { 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"; export const metadata: Metadata = { title: "All-Staff Calendar" }; @@ -64,6 +69,8 @@ export default async function OwnerCalendarPage({ const context = await getActingBusinessContext(); if (!context) redirect("/staff"); + const unreadNotificationCount = await getUnreadNotificationCount(prisma, session.user.id); + const { date: dateParam, view: viewParam, staffId: staffIdParam } = await searchParams; const today = todayIn(context.businessTimezone); 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 (
-
-

Calendar

-
- {selectedStaff && } - -
-
+ } + action={ +
+ {selectedStaff && } + +
+ } + /> -
- - All Staff - - {allStaff.map((s) => ( - - {s.user.name} - - ))} +
+
-
- - Day - - - Agenda - +
+
diff --git a/src/app/(owner)/owner/page.tsx b/src/app/(owner)/owner/page.tsx index 6a21f5f..095b8f5 100644 --- a/src/app/(owner)/owner/page.tsx +++ b/src/app/(owner)/owner/page.tsx @@ -22,12 +22,15 @@ import { import { redirect } from "next/navigation"; import { auth } from "@/auth"; import { Card } from "@/components/ui/card"; +import { TopNav } from "@/components/navigation/top-nav"; import { prisma } from "@/lib/db/prisma"; import { hasPermission } from "@/lib/auth/permissions"; import { Permission } from "@/generated/prisma/enums"; import { getActingBusinessContext } from "@/lib/business/get-acting-business-context"; import { todayIn } from "@/lib/time/business-time"; 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 dynamic = "force-dynamic"; @@ -105,130 +108,139 @@ export default async function OwnerDashboardPage() { if (!context) redirect("/staff"); const today = todayIn(context.businessTimezone); - const summary = await getDashboardSummary(prisma, { - businessId: context.businessId, - businessTimezone: context.businessTimezone, - date: today, - }); + const [summary, unreadNotificationCount] = await Promise.all([ + getDashboardSummary(prisma, { + businessId: context.businessId, + businessTimezone: context.businessTimezone, + date: today, + }), + getUnreadNotificationCount(prisma, session.user.id), + ]); return ( -
-
-

Manage

+
+ } + /> + +

{context.businessName} — today's overview

-
- {summary.warnings.length > 0 && ( -
- {summary.warnings.map((w, i) => ( -
-
- ))} -
- )} - -
- - - - -
- -
- - - - -
- -
- -

-

- {summary.staffWorkingToday.length === 0 ? ( -

No one is scheduled to work today.

- ) : ( -
    - {summary.staffWorkingToday.map((s) => ( -
  • - {s.name} - Working -
  • - ))} -
- )} - {summary.staffOnTimeOffToday.length > 0 && ( -
    - {summary.staffOnTimeOffToday.map((s) => ( -
  • - {s.name} - - {s.reason ?? "Time off"} - -
  • - ))} -
- )} -
- - -

-

-

Completed visits, last 30 days.

- {summary.popularServices.length === 0 ? ( -

No completed visits in the last 30 days yet.

- ) : ( -
    - {summary.popularServices.map((s) => ( -
  • - {s.name} - {s.count} -
  • - ))} -
- )} -
-
- - -

-

- {summary.recentLoyaltyActivity.length === 0 ? ( -

No loyalty activity yet.

- ) : ( -
    - {summary.recentLoyaltyActivity.map((t) => ( -
  • - - {t.loyaltyAccount.customer.firstName} {t.loyaltyAccount.customer.lastName} - {" "} - — {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}` : ""} -
  • + {summary.warnings.length > 0 && ( +
    + {summary.warnings.map((w, i) => ( +
    +
    ))} -
+ )} -
-
- {MANAGE_SECTIONS.map((section) => ( - - ))} +
+ + + + +
+ +
+ + + + +
+ +
+ +

+

+ {summary.staffWorkingToday.length === 0 ? ( +

No one is scheduled to work today.

+ ) : ( +
    + {summary.staffWorkingToday.map((s) => ( +
  • + {s.name} + Working +
  • + ))} +
+ )} + {summary.staffOnTimeOffToday.length > 0 && ( +
    + {summary.staffOnTimeOffToday.map((s) => ( +
  • + {s.name} + + {s.reason ?? "Time off"} + +
  • + ))} +
+ )} +
+ + +

+

+

Completed visits, last 30 days.

+ {summary.popularServices.length === 0 ? ( +

No completed visits in the last 30 days yet.

+ ) : ( +
    + {summary.popularServices.map((s) => ( +
  • + {s.name} + {s.count} +
  • + ))} +
+ )} +
+
+ + +

+

+ {summary.recentLoyaltyActivity.length === 0 ? ( +

No loyalty activity yet.

+ ) : ( +
    + {summary.recentLoyaltyActivity.map((t) => ( +
  • + + {t.loyaltyAccount.customer.firstName} {t.loyaltyAccount.customer.lastName} + {" "} + — {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}` : ""} +
  • + ))} +
+ )} +
+ +
+ {MANAGE_SECTIONS.map((section) => ( + + ))} +
); diff --git a/src/app/(staff)/staff/clients/[id]/page.tsx b/src/app/(staff)/staff/clients/[id]/page.tsx index 0938d75..2aff4d0 100644 --- a/src/app/(staff)/staff/clients/[id]/page.tsx +++ b/src/app/(staff)/staff/clients/[id]/page.tsx @@ -180,18 +180,18 @@ export default async function StaffCustomerDetailsPage({ {customer.phone && ( )} -
@@ -208,8 +208,11 @@ export default async function StaffCustomerDetailsPage({ )}

- - + +
{loyaltyTransactions.length > 0 && (
    @@ -246,6 +249,17 @@ export default async function StaffCustomerDetailsPage({
    + +

    Notes

    + {/* 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. */} + +
    + Upcoming @@ -260,17 +274,6 @@ export default async function StaffCustomerDetailsPage({ {renderList(cancelled, "No cancelled appointments")} - - -

    Notes

    - {/* 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. */} - -
diff --git a/src/app/(staff)/staff/clients/page.tsx b/src/app/(staff)/staff/clients/page.tsx index 09a36e1..8a2005d 100644 --- a/src/app/(staff)/staff/clients/page.tsx +++ b/src/app/(staff)/staff/clients/page.tsx @@ -3,10 +3,13 @@ import { Search, Users } from "lucide-react"; import Link from "next/link"; import { redirect } from "next/navigation"; import { EmptyState } from "@/components/feedback/empty-state"; +import { TopNav } from "@/components/navigation/top-nav"; import { prisma } from "@/lib/db/prisma"; import { getActingBusinessContext } from "@/lib/business/get-acting-business-context"; +import { getUnreadNotificationCount } from "@/services/notifications/notification.service"; import { matchesCustomerSearch } from "@/services/customer/customer.service"; import { getLoyaltySummariesForCustomers } from "@/services/loyalty/loyalty.service"; +import { InlineNotificationBell } from "@/features/notifications/components/notification-bell"; import { cn } from "@/lib/utils/cn"; export const metadata: Metadata = { title: "Clients" }; @@ -24,6 +27,8 @@ export default async function StaffClientsPage({ const { q = "", sort } = await searchParams; + const unreadNotificationCount = await getUnreadNotificationCount(prisma, context.userId); + // Scoped via the appointment relation, not "every CustomerProfile row" — // a real business boundary even though this is a single-business v1 app // (see docs/phase-5-summary.md). `select`-scoped rather than the full @@ -67,106 +72,113 @@ export default async function StaffClientsPage({ const now = new Date(); return ( -
-

Clients

+
+ } + /> -
- {sort && } -