This commit is contained in:
Stefan Lazic committed 2026-08-03 21:15:24 +01:00
1 parent ec5583f972
commit 546613e677
189 files changed
+9873 -655

No files matched your search

+8
View File
@@ -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} />;
}
+8 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
+17 -3
View File
@@ -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
View File
@@ -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>
);