Phase 7
This commit is contained in:
189 files changed
+9873
-655
No files matched your search
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { ErrorState } from "@/components/feedback/error-state";
|
||||
|
||||
/** Keeps (staff)/layout.tsx's sidebar/bottom nav visible on-screen even when a page below it throws — see src/app/error.tsx for the app-wide fallback this backs up. */
|
||||
export default function StaffError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
return <ErrorState onRetry={reset} />;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { StaffShell } from "@/components/navigation/staff-shell";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
|
||||
|
||||
export default async function StaffLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await auth();
|
||||
@@ -18,5 +19,11 @@ export default async function StaffLayout({ children }: { children: React.ReactN
|
||||
redirect("/home");
|
||||
}
|
||||
|
||||
return <StaffShell>{children}</StaffShell>;
|
||||
const unreadNotificationCount = await getUnreadNotificationCount(prisma, session.user.id);
|
||||
|
||||
return (
|
||||
<StaffShell notificationsHref="/staff/notifications" unreadNotificationCount={unreadNotificationCount}>
|
||||
{children}
|
||||
</StaffShell>
|
||||
);
|
||||
}
|
||||
@@ -163,14 +163,26 @@ export default async function StaffAppointmentDetailsPage({
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Duration / Price
|
||||
Duration
|
||||
</span>
|
||||
<p className="font-bold">
|
||||
{totalDuration} min · £{totalPrice.toFixed(2)}
|
||||
{discount > 0 && (
|
||||
<span className="text-accent-loyalty"> (-£{discount.toFixed(2)} reward)</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="font-bold">{totalDuration} min</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-line border-t pt-4 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted">Subtotal</span>
|
||||
<span className="font-bold">£{totalPrice.toFixed(2)}</span>
|
||||
</div>
|
||||
{discount > 0 && (
|
||||
<div className="text-accent-loyalty flex items-center justify-between">
|
||||
<span>Loyalty reward</span>
|
||||
<span className="font-bold">-£{discount.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-line mt-1 flex items-center justify-between border-t pt-1">
|
||||
<span className="font-bold">Total</span>
|
||||
<span className="font-bold">£{(totalPrice - discount).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@ export default async function StaffCustomerDetailsPage({
|
||||
<li key={t.id} className="text-muted text-xs">
|
||||
{t.createdAt.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}{" "}
|
||||
— {t.amount > 0 ? "+" : ""}
|
||||
{t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"}
|
||||
{t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"} — {t.type.replace(/_/g, " ").toLowerCase()}
|
||||
{t.reason ? ` (${t.reason})` : ""}
|
||||
{t.authoredByStaff?.user.name ? ` · ${t.authoredByStaff.user.name}` : ""}
|
||||
</li>
|
||||
|
||||
@@ -26,11 +26,25 @@ export default async function StaffClientsPage({
|
||||
|
||||
// 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).
|
||||
// (see docs/phase-5-summary.md). `select`-scoped rather than the full
|
||||
// row + full appointment `include` this used to fetch — the card only
|
||||
// ever reads status/startAt off each appointment, never the service
|
||||
// lines/notes/discount that a full include would also pull down for
|
||||
// every appointment of every client on every page view (see the Phase 7
|
||||
// performance pass).
|
||||
const customers = await prisma.customerProfile.findMany({
|
||||
where: { appointments: { some: { businessId: staff.businessId } } },
|
||||
include: {
|
||||
appointments: { where: { businessId: staff.businessId }, orderBy: { startAt: "desc" } },
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
appointments: {
|
||||
where: { businessId: staff.businessId },
|
||||
orderBy: { startAt: "desc" },
|
||||
select: { status: true, startAt: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { listInAppNotifications } from "@/services/notifications/notification.service";
|
||||
import { NotificationList } from "@/features/notifications/components/notification-list";
|
||||
|
||||
export const metadata: Metadata = { title: "Notifications" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function StaffNotificationsPage() {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) redirect("/home");
|
||||
|
||||
const notifications = await listInAppNotifications(prisma, { userId: staff.userId });
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Notifications" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||
<NotificationList notifications={notifications} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,41 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { NotificationChannel, NotificationType } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { getNotificationPreferences } from "@/services/staff/staff.service";
|
||||
import { getNotificationPreferences } from "@/services/notifications/notification.service";
|
||||
import { NotificationPreferencesForm } from "@/features/staff/components/notification-preferences-form";
|
||||
import type { PreferenceCellState } from "@/features/staff/components/notification-preferences-form";
|
||||
import { PushSubscribeToggle } from "@/features/notifications/components/push-subscribe-toggle";
|
||||
import { saveNotificationPreferencesAction } from "@/features/staff/actions/profile";
|
||||
|
||||
export const metadata: Metadata = { title: "Notifications" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const REMINDER_CHANNELS: NotificationChannel[] = [NotificationChannel.EMAIL, NotificationChannel.PUSH];
|
||||
|
||||
export default async function StaffNotificationsPage() {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) redirect("/home");
|
||||
|
||||
const cells = await getNotificationPreferences(prisma, staff.userId);
|
||||
const rows = await getNotificationPreferences(prisma, staff.userId);
|
||||
const cells: PreferenceCellState[] = REMINDER_CHANNELS.map((channel) => ({
|
||||
channel,
|
||||
isEnabled:
|
||||
rows.find((r) => r.type === NotificationType.STAFF_APPOINTMENT_REMINDER && r.channel === channel)
|
||||
?.isEnabled ?? true,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Notifications" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||
<NotificationPreferencesForm initial={cells} />
|
||||
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6">
|
||||
<NotificationPreferencesForm initial={cells} action={saveNotificationPreferencesAction} />
|
||||
<div className="rounded-jumbo border-line border bg-white p-5">
|
||||
<h2 className="mb-3 text-sm font-bold">Push Notifications</h2>
|
||||
<PushSubscribeToggle />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ProfileCard } from "@/components/ui/profile-card";
|
||||
import { SignOutButton } from "@/features/auth/components/sign-out-button";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { InstallAppButton } from "@/features/pwa/components/install-app-button";
|
||||
|
||||
const SETTINGS_LINKS = [
|
||||
{ href: "/staff/settings/working-hours", label: "Working Hours", icon: Clock },
|
||||
@@ -52,6 +53,11 @@ export default async function StaffSettingsPage() {
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="rounded-jumbo border border-line bg-white p-5">
|
||||
<p className="mb-3 text-sm font-bold">Get the app</p>
|
||||
<InstallAppButton />
|
||||
</div>
|
||||
|
||||
<SignOutButton />
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user