690 files changed
+12045
-33943
No files matched your search
@@ -0,0 +1,270 @@
|
||||
import type { Metadata } from "next";
|
||||
import { CalendarPlus, Mail, Phone } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { StatusPill } from "@/components/ui/status-pill";
|
||||
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 { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
||||
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
||||
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
||||
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
|
||||
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
|
||||
import { AddStampButton } from "@/features/staff/components/add-stamp-button";
|
||||
import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button";
|
||||
import { ownerAppointmentActions } from "@/features/owner/actions/appointment-action-set";
|
||||
import { RedeemRewardButton } from "@/features/owner/components/redeem-reward-button";
|
||||
import type { AppointmentStatus } from "@/types/domain";
|
||||
|
||||
export const metadata: Metadata = { title: "Appointment Details" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const STATUS_MAP: Record<string, AppointmentStatus> = {
|
||||
PENDING: "pending_approval",
|
||||
CONFIRMED: "confirmed",
|
||||
CHECKED_IN: "arrived",
|
||||
IN_PROGRESS: "in_progress",
|
||||
COMPLETED: "completed",
|
||||
CANCELLED: "cancelled",
|
||||
NO_SHOW: "no_show",
|
||||
};
|
||||
const RESCHEDULABLE = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
|
||||
|
||||
export default async function OwnerAppointmentDetailsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canView = await hasPermission(session.user.id, Permission.VIEW_ALL_STAFF_APPOINTMENTS);
|
||||
if (!canView) redirect("/staff");
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const { id } = await params;
|
||||
const appointment = await getAppointmentById(prisma, id);
|
||||
if (!appointment) notFound();
|
||||
|
||||
try {
|
||||
// `id` is unused by assertAppointmentInBusiness (only checks businessId)
|
||||
// — see src/features/staff/authorization.ts. context.userId stands in
|
||||
// for it since a Super Administrator has no StaffProfile id to offer.
|
||||
assertAppointmentInBusiness(appointment, { id: context.userId, businessId: context.businessId });
|
||||
} catch (error) {
|
||||
if (error instanceof StaffPermissionDeniedError) notFound();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const [notes, loyaltyTransactions, loyalty, cancelledByUser] = await Promise.all([
|
||||
prisma.customerNote.findMany({
|
||||
where: { customerId: appointment.customerId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 5,
|
||||
include: { author: { include: { user: { select: { name: true } } } } },
|
||||
}),
|
||||
prisma.loyaltyTransaction.findMany({
|
||||
where: { appointmentId: appointment.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
getLoyaltySummary(prisma, { customerId: appointment.customerId, businessId: context.businessId }),
|
||||
appointment.cancelledByUserId
|
||||
? prisma.user.findUnique({ where: { id: appointment.cancelledByUserId }, select: { name: true } })
|
||||
: null,
|
||||
]);
|
||||
|
||||
const tz = context.businessTimezone;
|
||||
const totalDuration = appointment.services.reduce((sum, s) => sum + s.durationAtBookingMinutes, 0);
|
||||
const subtotal = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
|
||||
const discount = appointment.discountAmount ? Number(appointment.discountAmount) : 0;
|
||||
const total = subtotal - discount;
|
||||
|
||||
const canRedeemReward =
|
||||
loyalty?.availableReward &&
|
||||
appointment.discountAmount === null &&
|
||||
appointment.status !== "CANCELLED";
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Appointment Details" onBack sticky className="bg-stone" />
|
||||
|
||||
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
|
||||
<Card className="space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<StatusPill status={STATUS_MAP[appointment.status] ?? "confirmed"} />
|
||||
<span className="text-muted text-[10px] font-bold tracking-widest uppercase">
|
||||
Ref: {appointment.id.slice(-8)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="border-line space-y-1 border-t pt-4">
|
||||
<Link href={`/owner/team/${appointment.staffId}`} className="text-muted text-xs font-bold tracking-widest uppercase underline">
|
||||
{appointment.staff.user.name ?? "Staff"}
|
||||
</Link>
|
||||
<Link href={`/staff/clients/${appointment.customerId}`} className="block text-lg font-bold underline">
|
||||
{appointment.customer.firstName} {appointment.customer.lastName}
|
||||
</Link>
|
||||
<p className="text-muted text-sm">
|
||||
{appointment.customer.isGuest ? "Guest customer" : "Registered customer"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{appointment.customer.phone && (
|
||||
<a
|
||||
href={`tel:${appointment.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
|
||||
href={`mailto:${appointment.customer.email}`}
|
||||
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
|
||||
>
|
||||
<Mail className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Email
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="border-line grid grid-cols-2 gap-4 border-t pt-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Services</span>
|
||||
<p className="font-bold">{appointment.services.map((s) => s.service.name).join(" + ")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Date</span>
|
||||
<p className="font-bold">
|
||||
{appointment.startAt.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: tz })}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Time</span>
|
||||
<p className="font-bold">
|
||||
{appointment.startAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: tz })} –{" "}
|
||||
{appointment.endAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: tz })}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Duration</span>
|
||||
<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">£{subtotal.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">£{total.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{appointment.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">{appointment.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notes.length > 0 && (
|
||||
<div className="border-line border-t pt-4">
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Customer Notes</span>
|
||||
<div className="mt-2 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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
{loyaltyTransactions.length > 0 && (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{loyaltyTransactions.map((t) => (
|
||||
<li key={t.id} className="text-muted text-xs">
|
||||
{t.amount > 0 ? "+" : ""}
|
||||
{t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"} — {t.type.replace(/_/g, " ").toLowerCase()}
|
||||
{t.reason ? ` (${t.reason})` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{appointment.status === "CANCELLED" && (
|
||||
<div className="border-line border-t pt-4 text-sm">
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Cancellation</span>
|
||||
<p className="text-muted mt-1">
|
||||
Cancelled {appointment.cancelledAt?.toLocaleString("en-GB", { timeZone: tz })}
|
||||
{cancelledByUser ? ` by ${cancelledByUser.name}` : ""}
|
||||
</p>
|
||||
{appointment.cancellationReason && (
|
||||
<p className="text-muted mt-1 italic">“{appointment.cancellationReason}”</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<a
|
||||
href={`/api/appointments/${appointment.id}/calendar.ics`}
|
||||
className="rounded-md border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
|
||||
>
|
||||
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
|
||||
</a>
|
||||
{RESCHEDULABLE.has(appointment.status) && (
|
||||
<Link
|
||||
href={`/owner/appointments/${appointment.id}/reschedule`}
|
||||
className="rounded-md border-line border bg-white px-5 py-3 text-sm font-bold"
|
||||
>
|
||||
Reschedule / Reassign
|
||||
</Link>
|
||||
)}
|
||||
{appointment.status === "COMPLETED" && <BookFollowUpButton appointmentId={appointment.id} />}
|
||||
</div>
|
||||
|
||||
<AppointmentStatusActions
|
||||
appointmentId={appointment.id}
|
||||
status={appointment.status}
|
||||
actions={ownerAppointmentActions}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{loyalty?.isEnabled && <AddStampButton customerId={appointment.customerId} appointmentId={appointment.id} />}
|
||||
{canRedeemReward && loyalty.availableReward && (
|
||||
<RedeemRewardButton
|
||||
appointmentId={appointment.id}
|
||||
rewardId={loyalty.availableReward.id}
|
||||
rewardLabel={loyalty.availableReward.label}
|
||||
subtotal={subtotal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import type { Metadata } from "next";
|
||||
import { CalendarX2, Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
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";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import {
|
||||
calendarDateToUtcMidnight,
|
||||
minutesFromMidnight,
|
||||
todayIn,
|
||||
weekdayOf,
|
||||
} from "@/lib/time/business-time";
|
||||
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 { 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" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const STATUS_MAP: Record<string, AppointmentStatus> = {
|
||||
PENDING: "pending_approval",
|
||||
CONFIRMED: "confirmed",
|
||||
CHECKED_IN: "arrived",
|
||||
IN_PROGRESS: "in_progress",
|
||||
COMPLETED: "completed",
|
||||
CANCELLED: "cancelled",
|
||||
NO_SHOW: "no_show",
|
||||
};
|
||||
|
||||
function formatRange(startMinutes: number, endMinutes: number): string {
|
||||
const fmt = (m: number) => {
|
||||
const h = Math.floor(m / 60);
|
||||
const min = m % 60;
|
||||
const period = h >= 12 ? "PM" : "AM";
|
||||
const displayHour = h % 12 === 0 ? 12 : h % 12;
|
||||
return `${displayHour}:${String(min).padStart(2, "0")} ${period}`;
|
||||
};
|
||||
return `${fmt(startMinutes)} – ${fmt(endMinutes)}`;
|
||||
}
|
||||
|
||||
export default async function OwnerCalendarPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ date?: string; view?: string; staffId?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in?callbackUrl=/owner/calendar");
|
||||
|
||||
const canView = await hasPermission(session.user.id, Permission.VIEW_ALL_STAFF_APPOINTMENTS);
|
||||
if (!canView) redirect("/staff");
|
||||
|
||||
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;
|
||||
const view = viewParam === "agenda" ? "agenda" : "day";
|
||||
const weekday = weekdayOf(date);
|
||||
const dateUtcMidnight = calendarDateToUtcMidnight(date);
|
||||
const businessTimezone = context.businessTimezone;
|
||||
|
||||
const allStaff = await prisma.staffProfile.findMany({
|
||||
where: { businessId: context.businessId, status: "ACTIVE" },
|
||||
include: { user: { select: { id: true, name: true, image: true } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
const selectedStaff = staffIdParam ? allStaff.find((s) => s.id === staffIdParam) : undefined;
|
||||
|
||||
const [appointments, workingHoursAll, breakDaysAll, timeOffAll] = await Promise.all([
|
||||
listAppointments(prisma, {
|
||||
businessId: context.businessId,
|
||||
staffId: selectedStaff?.id,
|
||||
fromDate: date,
|
||||
toDate: date,
|
||||
businessTimezone,
|
||||
}),
|
||||
prisma.workingHours.findMany({
|
||||
where: { staff: { businessId: context.businessId }, dayOfWeek: weekday },
|
||||
}),
|
||||
prisma.breakDay.findMany({
|
||||
where: { dayOfWeek: weekday, break: { staff: { businessId: context.businessId } } },
|
||||
include: { break: true },
|
||||
}),
|
||||
prisma.timeOff.findMany({
|
||||
where: {
|
||||
staff: { businessId: context.businessId },
|
||||
status: "APPROVED",
|
||||
startDate: { lte: dateUtcMidnight },
|
||||
endDate: { gte: dateUtcMidnight },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const rewardFlags = await getAvailableRewardFlags(prisma, {
|
||||
businessId: context.businessId,
|
||||
customerIds: [...new Set(appointments.map((a) => a.customerId))],
|
||||
});
|
||||
|
||||
const currentTimeMinutes = date === today ? minutesFromMidnight(new Date(), businessTimezone) : null;
|
||||
|
||||
const staffToRender = selectedStaff ? [selectedStaff] : allStaff;
|
||||
|
||||
function buildColumnFor(staffId: string) {
|
||||
const workingHours = workingHoursAll.find((h) => h.staffId === staffId);
|
||||
const workingWindow =
|
||||
workingHours && !workingHours.isOff && workingHours.startMinutes !== null && workingHours.endMinutes !== null
|
||||
? { startMinutes: workingHours.startMinutes, endMinutes: workingHours.endMinutes }
|
||||
: null;
|
||||
|
||||
const blocks: CalendarBlock[] = [
|
||||
...breakDaysAll
|
||||
.filter((bd) => bd.break.staffId === staffId)
|
||||
.map((bd) => ({
|
||||
id: `break-${bd.id}`,
|
||||
startMinutes: bd.break.startMinutes,
|
||||
endMinutes: bd.break.endMinutes,
|
||||
label: bd.break.label,
|
||||
kind: "break" as const,
|
||||
})),
|
||||
...timeOffAll
|
||||
.filter((t) => t.staffId === staffId && t.startMinutes !== null && t.endMinutes !== null)
|
||||
.map((t) => ({
|
||||
id: `timeoff-${t.id}`,
|
||||
startMinutes: t.startMinutes!,
|
||||
endMinutes: t.endMinutes!,
|
||||
label: t.reason ?? "Blocked",
|
||||
kind: "timeOff" as const,
|
||||
})),
|
||||
];
|
||||
|
||||
const wholeDayOff = timeOffAll.some((t) => t.staffId === staffId && t.startMinutes === null);
|
||||
|
||||
const staffAppointments: CalendarAppointment[] = appointments
|
||||
.filter((a) => a.staffId === staffId)
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
startMinutes: minutesFromMidnight(a.startAt, businessTimezone),
|
||||
endMinutes: minutesFromMidnight(a.endAt, businessTimezone),
|
||||
customerName: `${a.customer.firstName} ${a.customer.lastName}`,
|
||||
serviceNames: a.services.map((s) => s.service.name).join(" + "),
|
||||
status: STATUS_MAP[a.status] ?? "confirmed",
|
||||
hasNotes: !!a.notes,
|
||||
hasAvailableReward: rewardFlags.get(a.customerId) ?? false,
|
||||
}));
|
||||
|
||||
return { workingWindow, blocks, wholeDayOff, staffAppointments };
|
||||
}
|
||||
|
||||
const columns: CalendarColumn[] = staffToRender.map((s) => {
|
||||
const column = buildColumnFor(s.id);
|
||||
return {
|
||||
id: s.id,
|
||||
name: s.user.name ?? "Staff",
|
||||
avatarUrl: s.user.image,
|
||||
hoursLabel: column.workingWindow ? formatRange(column.workingWindow.startMinutes, column.workingWindow.endMinutes) : null,
|
||||
wholeDayOff: column.wholeDayOff,
|
||||
appointments: column.staffAppointments,
|
||||
blocks: column.blocks,
|
||||
workingWindow: column.workingWindow,
|
||||
};
|
||||
});
|
||||
|
||||
// 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 (
|
||||
<div className="pb-6">
|
||||
<TopNav
|
||||
title="Calendar"
|
||||
sticky
|
||||
className="bg-stone"
|
||||
leftAction={<InlineNotificationBell href="/owner/notifications" unreadCount={unreadNotificationCount} />}
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
{selectedStaff && <OwnerBlockTimeButton staffId={selectedStaff.id} staffName={selectedStaff.user.name ?? "Staff"} date={date} />}
|
||||
<Button asChild size="sm">
|
||||
{/* /staff/book already lets whoever is signed in book with any eligible barber — the
|
||||
owner is a staff member too, so this is the same "book for a customer" flow Phase 5
|
||||
built, not a duplicate. See docs/phase-6-summary.md, "Owner appointment access". */}
|
||||
<Link href="/staff/book">
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
New
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<DateStrip
|
||||
selectedDate={date}
|
||||
businessTimezone={businessTimezone}
|
||||
basePath="/owner/calendar"
|
||||
view={view}
|
||||
extraParams={selectedStaff ? { staffId: selectedStaff.id } : undefined}
|
||||
/>
|
||||
|
||||
<div className="mt-4 px-6">
|
||||
<ViewSwitch
|
||||
view={view}
|
||||
dayHref={`/owner/calendar?date=${date}&view=day${selectedStaff ? `&staffId=${selectedStaff.id}` : ""}`}
|
||||
agendaHref={`/owner/calendar?date=${date}&view=agenda${selectedStaff ? `&staffId=${selectedStaff.id}` : ""}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<StaffFilterPills options={staffFilterOptions} selectedId={selectedStaff?.id} hrefFor={staffFilterHrefFor} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{view === "day" ? (
|
||||
staffToRender.length === 0 ? (
|
||||
<div className="px-6">
|
||||
<EmptyState icon={CalendarX2} title="No active staff" description="Add a staff member from Team." />
|
||||
</div>
|
||||
) : (
|
||||
<DayCalendar columns={columns} currentTimeMinutes={currentTimeMinutes} appointmentHrefBase="/owner/appointments" />
|
||||
)
|
||||
) : appointments.length === 0 ? (
|
||||
<div className="px-6">
|
||||
<EmptyState icon={CalendarX2} title="No appointments" description="Nothing scheduled for this day." />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 px-6 lg:mx-auto lg:max-w-3xl">
|
||||
{[...appointments]
|
||||
.sort((a, b) => a.startAt.getTime() - b.startAt.getTime())
|
||||
.map((a) => (
|
||||
<div key={a.id}>
|
||||
{!selectedStaff && (
|
||||
<p className="text-muted mb-1 text-[10px] font-bold tracking-widest uppercase">
|
||||
{a.staff.user.name}
|
||||
</p>
|
||||
)}
|
||||
<AppointmentCard
|
||||
status={STATUS_MAP[a.status] ?? "confirmed"}
|
||||
serviceName={a.services.map((s) => s.service.name).join(" + ")}
|
||||
person={{ id: a.customerId, name: `${a.customer.firstName} ${a.customer.lastName}` }}
|
||||
personLabel="for"
|
||||
formattedTime={formatRange(
|
||||
minutesFromMidnight(a.startAt, businessTimezone),
|
||||
minutesFromMidnight(a.endAt, businessTimezone),
|
||||
)}
|
||||
hasNotes={!!a.notes}
|
||||
hasAvailableReward={rewardFlags.get(a.customerId) ?? false}
|
||||
actions={
|
||||
<Link
|
||||
href={`/owner/appointments/${a.id}`}
|
||||
className="border-line rounded-lg border px-4 py-2 text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
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";
|
||||
|
||||
/** Access to this route is already gated by (owner)/layout.tsx's MANAGE_STAFF check — this page only fetches this owner's own notifications, same as the staff/customer variants. */
|
||||
export default async function OwnerNotificationsPage() {
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/home");
|
||||
|
||||
const notifications = await listInAppNotifications(prisma, { userId: context.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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
AlertTriangle,
|
||||
BarChart3,
|
||||
Building2,
|
||||
Calendar,
|
||||
CalendarClock,
|
||||
CalendarX2,
|
||||
CheckCheck,
|
||||
ClipboardList,
|
||||
Crown,
|
||||
Gift,
|
||||
Images,
|
||||
ScissorsSquare,
|
||||
Smartphone,
|
||||
Stamp,
|
||||
Star,
|
||||
TrendingUp,
|
||||
UserX,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
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, isSuperAdmin } 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";
|
||||
|
||||
function StatTile({ icon: Icon, label, value, hint }: { icon: typeof Calendar; label: string; value: string | number; hint?: string }) {
|
||||
return (
|
||||
<Card className="p-5">
|
||||
<div className="text-muted mb-2 flex items-center gap-2">
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="text-[10px] font-bold tracking-widest uppercase">{label}</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{value}</p>
|
||||
{hint && <p className="text-muted mt-1 text-xs">{hint}</p>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** App Icon & Home Screen is Super Administrator only (see /owner/settings/app) — everything else here is any Owner. */
|
||||
function getManageSections(isSuper: boolean) {
|
||||
return [
|
||||
{
|
||||
title: "Business",
|
||||
links: [
|
||||
{ href: "/owner/settings/business", label: "Business Details", icon: Building2 },
|
||||
{ href: "/owner/settings/hours", label: "Opening Hours", icon: CalendarClock },
|
||||
{ href: "/owner/settings/gallery", label: "Gallery", icon: Images },
|
||||
...(isSuper
|
||||
? [{ href: "/owner/settings/app", label: "App Icon & Home Screen", icon: Smartphone }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Booking",
|
||||
links: [
|
||||
{ href: "/owner/services", label: "Services", icon: ScissorsSquare },
|
||||
{ href: "/owner/settings/booking-rules", label: "Booking Rules", icon: ClipboardList },
|
||||
{ href: "/owner/settings/loyalty", label: "Loyalty Programme", icon: Crown },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Customers",
|
||||
links: [{ href: "/owner/settings/reviews", label: "Reviews", icon: Star }],
|
||||
},
|
||||
{
|
||||
title: "Team",
|
||||
links: [{ href: "/owner/team", label: "Team Management", icon: Users }],
|
||||
},
|
||||
{
|
||||
title: "Reports",
|
||||
links: [{ href: "/owner/reports", label: "Reports", icon: BarChart3 }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function ManageSection({ title, links }: ReturnType<typeof getManageSections>[number]) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-muted mb-2 px-1 text-[10px] font-bold tracking-widest uppercase">{title}</p>
|
||||
<ul className="divide-line rounded-md border-line divide-y border bg-white">
|
||||
{links.map(({ href, label, icon: Icon }) => (
|
||||
<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" />
|
||||
{label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function OwnerDashboardPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in?callbackUrl=/owner");
|
||||
|
||||
const canView = await hasPermission(session.user.id, Permission.VIEW_REPORTING);
|
||||
if (!canView) redirect("/staff");
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const today = todayIn(context.businessTimezone);
|
||||
const [summary, unreadNotificationCount, isSuper] = await Promise.all([
|
||||
getDashboardSummary(prisma, {
|
||||
businessId: context.businessId,
|
||||
businessTimezone: context.businessTimezone,
|
||||
date: today,
|
||||
}),
|
||||
getUnreadNotificationCount(prisma, session.user.id),
|
||||
isSuperAdmin(session.user.id),
|
||||
]);
|
||||
const manageSections = getManageSections(isSuper);
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav
|
||||
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>
|
||||
|
||||
{summary.warnings.length > 0 && (
|
||||
<section className="space-y-2">
|
||||
{summary.warnings.map((w, i) => (
|
||||
<div
|
||||
key={`${w.code}-${i}`}
|
||||
role="alert"
|
||||
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" />
|
||||
<p>{w.message}</p>
|
||||
</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>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="space-y-5">
|
||||
{manageSections.map((section) => (
|
||||
<ManageSection key={section.title} {...section} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { Card } from "@/components/ui/card";
|
||||
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 { addDays, endOfBusinessDay, startOfBusinessDay, todayIn } from "@/lib/time/business-time";
|
||||
import type { CalendarDate } from "@/lib/time/business-time";
|
||||
import {
|
||||
getAppointmentReport,
|
||||
getCustomerReport,
|
||||
getLoyaltyReport,
|
||||
getNotificationReport,
|
||||
getServiceReport,
|
||||
getStaffReport,
|
||||
} from "@/services/reporting/reports.service";
|
||||
|
||||
export const metadata: Metadata = { title: "Reports" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PRESETS = [
|
||||
{ label: "7 days", days: 7 },
|
||||
{ label: "30 days", days: 30 },
|
||||
{ label: "90 days", days: 90 },
|
||||
] as const;
|
||||
|
||||
function StatTile({ label, value, hint }: { label: string; value: string | number; hint?: string }) {
|
||||
return (
|
||||
<Card className="p-5">
|
||||
<p className="text-muted mb-2 text-[10px] font-bold tracking-widest uppercase">{label}</p>
|
||||
<p className="text-2xl font-bold">{value}</p>
|
||||
{hint && <p className="text-muted mt-1 text-xs">{hint}</p>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function OwnerReportsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ from?: string; to?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in?callbackUrl=/owner/reports");
|
||||
|
||||
const canView = await hasPermission(session.user.id, Permission.VIEW_REPORTING);
|
||||
if (!canView) redirect("/staff");
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const tz = context.businessTimezone;
|
||||
const today = todayIn(tz);
|
||||
const { from: fromParam, to: toParam } = await searchParams;
|
||||
const toDate: CalendarDate = toParam ?? today;
|
||||
const fromDate: CalendarDate = fromParam ?? addDays(toDate, -29);
|
||||
|
||||
const range = { from: startOfBusinessDay(fromDate, tz), to: endOfBusinessDay(toDate, tz) };
|
||||
|
||||
const [appointmentReport, serviceReport, staffReport, customerReport, loyaltyReport, notificationReport] =
|
||||
await Promise.all([
|
||||
getAppointmentReport(prisma, { businessId: context.businessId, range }),
|
||||
getServiceReport(prisma, { businessId: context.businessId, range }),
|
||||
getStaffReport(prisma, { businessId: context.businessId, range }),
|
||||
getCustomerReport(prisma, { businessId: context.businessId, range }),
|
||||
getLoyaltyReport(prisma, { businessId: context.businessId, range }),
|
||||
getNotificationReport(prisma, { businessId: context.businessId, range }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-6 pt-12 pb-12">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Reports</h1>
|
||||
<p className="text-muted text-sm">
|
||||
{fromDate} to {toDate}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PRESETS.map((preset) => (
|
||||
<Link
|
||||
key={preset.days}
|
||||
href={`/owner/reports?from=${addDays(today, -(preset.days - 1))}&to=${today}`}
|
||||
className="border-line rounded-full border bg-white px-4 py-2 text-xs font-bold"
|
||||
>
|
||||
Last {preset.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Appointments</h2>
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<StatTile label="Total" value={appointmentReport.totalAppointments} />
|
||||
<StatTile label="Completed" value={appointmentReport.completed} hint={`${appointmentReport.completionRate}% of total`} />
|
||||
<StatTile label="Cancelled" value={appointmentReport.cancelled} hint={`${appointmentReport.cancellationRate}% of total`} />
|
||||
<StatTile label="No-shows" value={appointmentReport.noShow} hint={`${appointmentReport.noShowRate}% of total`} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<StatTile label="Gross revenue" value={`£${appointmentReport.grossRevenue}`} />
|
||||
<StatTile label="Discounts given" value={`£${appointmentReport.discountsGiven}`} />
|
||||
<StatTile label="Net revenue" value={`£${appointmentReport.netRevenue}`} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Services</h2>
|
||||
<Card className="divide-line divide-y px-5">
|
||||
{serviceReport.length === 0 ? (
|
||||
<p className="text-muted py-4 text-sm">No completed appointments in this range.</p>
|
||||
) : (
|
||||
serviceReport.map((s) => (
|
||||
<div key={s.serviceId} className="flex items-center justify-between py-3 text-sm">
|
||||
<div>
|
||||
<p className="font-bold">{s.name}</p>
|
||||
<p className="text-muted text-xs">{s.timesCompleted} completed</p>
|
||||
</div>
|
||||
<span className="font-bold">£{s.revenue}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Staff</h2>
|
||||
<Card className="divide-line divide-y px-5">
|
||||
{staffReport.length === 0 ? (
|
||||
<p className="text-muted py-4 text-sm">No appointments in this range.</p>
|
||||
) : (
|
||||
staffReport.map((s) => (
|
||||
<div key={s.staffId} className="flex items-center justify-between py-3 text-sm">
|
||||
<div>
|
||||
<p className="font-bold">{s.name}</p>
|
||||
<p className="text-muted text-xs">
|
||||
{s.completed} completed · {s.cancelled} cancelled · {s.noShow} no-show
|
||||
</p>
|
||||
</div>
|
||||
<span className="font-bold">£{s.revenue}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Customers</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<StatTile label="New customers" value={customerReport.newCustomers} />
|
||||
<StatTile label="Returning customers" value={customerReport.returningCustomers} />
|
||||
</div>
|
||||
{customerReport.topCustomers.length > 0 && (
|
||||
<Card className="divide-line divide-y px-5">
|
||||
{customerReport.topCustomers.map((c) => (
|
||||
<div key={c.customerId} className="flex items-center justify-between py-3 text-sm">
|
||||
<div>
|
||||
<p className="font-bold">{c.name}</p>
|
||||
<p className="text-muted text-xs">{c.visits} visit{c.visits === 1 ? "" : "s"}</p>
|
||||
</div>
|
||||
<span className="font-bold">£{c.spend}</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Loyalty</h2>
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<StatTile label="Stamps issued" value={loyaltyReport.stampsIssued} />
|
||||
<StatTile label="Stamps removed" value={loyaltyReport.stampsRemoved} />
|
||||
<StatTile label="Rewards earned" value={loyaltyReport.rewardsEarned} />
|
||||
<StatTile label="Rewards redeemed" value={loyaltyReport.rewardsRedeemed} />
|
||||
</div>
|
||||
{loyaltyReport.rewardsExpired > 0 && <StatTile label="Rewards expired" value={loyaltyReport.rewardsExpired} />}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Notifications</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<StatTile label="Sent" value={notificationReport.totalSent} />
|
||||
<StatTile label="Failed" value={notificationReport.totalFailed} />
|
||||
</div>
|
||||
{notificationReport.byChannel.length > 0 && (
|
||||
<Card className="divide-line divide-y px-5">
|
||||
{notificationReport.byChannel.map((c) => (
|
||||
<div key={c.channel} className="flex items-center justify-between py-3 text-sm">
|
||||
<span className="font-bold">{c.channel}</span>
|
||||
<span className="text-muted">
|
||||
{c.sent} sent{c.failed > 0 ? ` · ${c.failed} failed` : ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
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 { listCategories } from "@/services/catalogue/service-catalogue.service";
|
||||
import { CatalogueManager } from "@/features/owner/components/catalogue-manager";
|
||||
|
||||
export const metadata: Metadata = { title: "Services" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OwnerServicesPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_SERVICES);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const categories = await listCategories(prisma, context.businessId);
|
||||
|
||||
return (
|
||||
<div className="px-6 pt-12 pb-12 lg:max-w-3xl">
|
||||
<h1 className="mb-6 text-2xl font-bold tracking-tight">Services</h1>
|
||||
<CatalogueManager
|
||||
categories={categories.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
isActive: c.isActive,
|
||||
services: c.services.map((s) => ({
|
||||
id: s.id,
|
||||
categoryId: s.categoryId,
|
||||
name: s.name,
|
||||
description: s.description ?? "",
|
||||
imageUrl: s.imageUrl,
|
||||
durationMinutes: s.durationMinutes,
|
||||
price: s.price.toString(),
|
||||
bufferBeforeMinutes: s.bufferBeforeMinutes,
|
||||
bufferAfterMinutes: s.bufferAfterMinutes,
|
||||
isActive: s.isActive,
|
||||
allowOnlineBooking: s.allowOnlineBooking,
|
||||
})),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { isSuperAdmin } from "@/lib/auth/permissions";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { parseSplashScreens } from "@/lib/business/branding";
|
||||
import { getBusinessProfile } from "@/services/business/business.service";
|
||||
import { AppIconSettingsForm } from "@/features/owner/components/app-icon-settings-form";
|
||||
|
||||
export const metadata: Metadata = { title: "App Icon & Home Screen" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Super Administrator only — not Owner, unlike every other /owner/settings/* page. See requireSuperAdminBusinessManager in features/owner/actions/business.ts for the matching action-layer check. */
|
||||
export default async function OwnerAppIconSettingsPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
if (!(await isSuperAdmin(session.user.id))) redirect("/owner");
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const business = await getBusinessProfile(prisma, context.businessId);
|
||||
if (!business) redirect("/owner");
|
||||
|
||||
return (
|
||||
<SettingsPageShell title="App Icon & Home Screen">
|
||||
<AppIconSettingsForm
|
||||
initial={{
|
||||
shortName: business.shortName?.trim() || business.name,
|
||||
appIconUrl: business.appIconUrl,
|
||||
themeColor: business.themeColor,
|
||||
backgroundColor: business.backgroundColor,
|
||||
icon192Url: business.icon192Url,
|
||||
icon512Url: business.icon512Url,
|
||||
maskableIcon192Url: business.maskableIcon192Url,
|
||||
maskableIcon512Url: business.maskableIcon512Url,
|
||||
splashScreens: parseSplashScreens(business.splashScreens),
|
||||
bottomNavShowLabels: business.bottomNavShowLabels,
|
||||
}}
|
||||
/>
|
||||
</SettingsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
|
||||
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 { getBookingRule } from "@/services/scheduling/booking-rule.service";
|
||||
import { BookingRulesForm } from "@/features/owner/components/booking-rules-form";
|
||||
|
||||
export const metadata: Metadata = { title: "Booking Rules" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OwnerBookingRulesPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BOOKING_RULES);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const rule = await getBookingRule(prisma, context.businessId);
|
||||
if (!rule) redirect("/owner");
|
||||
|
||||
return (
|
||||
<SettingsPageShell title="Booking Rules">
|
||||
<BookingRulesForm
|
||||
initial={{
|
||||
minNoticeMinutes: rule.minNoticeMinutes,
|
||||
maxAdvanceDays: rule.maxAdvanceDays,
|
||||
cancellationWindowHours: rule.cancellationWindowHours,
|
||||
rescheduleWindowHours: rule.rescheduleWindowHours,
|
||||
slotIntervalMinutes: rule.slotIntervalMinutes,
|
||||
bufferBeforeMinutes: rule.bufferBeforeMinutes,
|
||||
bufferAfterMinutes: rule.bufferAfterMinutes,
|
||||
maxAppointmentsPerDay: rule.maxAppointmentsPerDay,
|
||||
maxAppointmentsPerStaffPerDay: rule.maxAppointmentsPerStaffPerDay,
|
||||
customerCanChooseStaff: rule.customerCanChooseStaff,
|
||||
allowGuestBooking: rule.allowGuestBooking,
|
||||
requireApproval: rule.requireApproval,
|
||||
}}
|
||||
/>
|
||||
</SettingsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
|
||||
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 { getBusinessProfile } from "@/services/business/business.service";
|
||||
import { BusinessDetailsForm } from "@/features/owner/components/business-details-form";
|
||||
|
||||
export const metadata: Metadata = { title: "Business Details" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OwnerBusinessDetailsPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const business = await getBusinessProfile(prisma, context.businessId);
|
||||
if (!business) redirect("/owner");
|
||||
|
||||
const location = business.locations[0];
|
||||
|
||||
return (
|
||||
<SettingsPageShell title="Business Details">
|
||||
<BusinessDetailsForm
|
||||
initial={{
|
||||
name: business.name,
|
||||
description: business.description ?? "",
|
||||
logoUrl: business.logoUrl,
|
||||
coverImageUrl: business.coverImageUrl,
|
||||
welcomeBackgroundDesktopUrl: business.welcomeBackgroundDesktopUrl,
|
||||
welcomeBackgroundMobileUrl: business.welcomeBackgroundMobileUrl,
|
||||
faviconUrl: business.faviconUrl,
|
||||
phone: business.phone ?? "",
|
||||
email: business.email ?? "",
|
||||
website: business.website ?? "",
|
||||
instagramUrl: business.instagramUrl ?? "",
|
||||
facebookUrl: business.facebookUrl ?? "",
|
||||
timezone: business.timezone,
|
||||
currency: business.currency,
|
||||
locale: business.locale,
|
||||
addressLine1: location?.addressLine1 ?? "",
|
||||
addressLine2: location?.addressLine2 ?? "",
|
||||
city: location?.city ?? "",
|
||||
region: location?.region ?? "",
|
||||
postalCode: location?.postalCode ?? "",
|
||||
country: location?.country ?? "GB",
|
||||
cancellationPolicy: business.settings?.cancellationPolicy ?? "",
|
||||
lateArrivalPolicy: business.settings?.lateArrivalPolicy ?? "",
|
||||
bookingTerms: business.settings?.bookingTerms ?? "",
|
||||
privacyPolicyUrl: business.settings?.privacyPolicyUrl ?? "",
|
||||
}}
|
||||
/>
|
||||
</SettingsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { GalleryPlacement } from "@/generated/prisma/enums";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { listGalleryImages } from "@/services/gallery/gallery.service";
|
||||
import { GalleryManager } from "@/features/owner/components/gallery-manager";
|
||||
|
||||
export const metadata: Metadata = { title: "Gallery" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OwnerGalleryPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const [heroImages, portfolioImages] = await Promise.all([
|
||||
listGalleryImages(prisma, { businessId: context.businessId, placement: GalleryPlacement.HERO }),
|
||||
listGalleryImages(prisma, { businessId: context.businessId, placement: GalleryPlacement.PORTFOLIO }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<SettingsPageShell title="Gallery" mainClassName="space-y-8">
|
||||
<GalleryManager
|
||||
placement={GalleryPlacement.HERO}
|
||||
title="Home Hero Gallery"
|
||||
description="Shown at the top of the customer Home page. Falls back to your cover image, then your logo, when empty."
|
||||
showCategory={false}
|
||||
initialImages={heroImages}
|
||||
/>
|
||||
<GalleryManager
|
||||
placement={GalleryPlacement.PORTFOLIO}
|
||||
title="Portfolio"
|
||||
description="Shown in the Portfolio tab on Home. Add a category to each image to power the filter pills there."
|
||||
showCategory
|
||||
initialImages={portfolioImages}
|
||||
/>
|
||||
</SettingsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
|
||||
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 { getOpeningHours } from "@/services/business/business.service";
|
||||
import { OpeningHoursForm } from "@/features/owner/components/opening-hours-form";
|
||||
|
||||
export const metadata: Metadata = { title: "Opening Hours" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OwnerOpeningHoursPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const hours = await getOpeningHours(prisma, context.businessId);
|
||||
|
||||
return (
|
||||
<SettingsPageShell title="Opening Hours" mainClassName="lg:max-w-2xl">
|
||||
<OpeningHoursForm initialRows={hours} />
|
||||
</SettingsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { LoyaltyCardFont, LoyaltyCardTextSize, Permission, RewardPositionMode } from "@/generated/prisma/enums";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { getProgrammeConfig } from "@/services/loyalty/loyalty.service";
|
||||
import { LoyaltyConfigForm } from "@/features/owner/components/loyalty-config-form";
|
||||
|
||||
export const metadata: Metadata = { title: "Loyalty Programme" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OwnerLoyaltyConfigPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_LOYALTY_PROGRAMME);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const [programme, services, staff] = await Promise.all([
|
||||
getProgrammeConfig(prisma, context.businessId),
|
||||
prisma.service.findMany({ where: { businessId: context.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
|
||||
prisma.staffProfile.findMany({
|
||||
where: { businessId: context.businessId, status: "ACTIVE" },
|
||||
include: { user: { select: { name: true } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const initialConfig = programme
|
||||
? {
|
||||
isEnabled: programme.isEnabled,
|
||||
stampsRequired: programme.stampsRequired,
|
||||
rewardPosition: programme.rewardPosition,
|
||||
rewardType: programme.rewardType,
|
||||
rewardValue: programme.rewardValue?.toString(),
|
||||
rewardLabel: programme.rewardLabel ?? undefined,
|
||||
maxRewardValue: programme.maxRewardValue?.toString(),
|
||||
freeServiceId: programme.freeServiceId ?? undefined,
|
||||
autoApplyReward: programme.autoApplyReward,
|
||||
requiresStaffApproval: programme.requiresStaffApproval,
|
||||
stampsExpireAfterDays: programme.stampsExpireAfterDays ?? undefined,
|
||||
rewardExpiresAfterDays: programme.rewardExpiresAfterDays ?? undefined,
|
||||
earnOnCancelled: programme.earnOnCancelled,
|
||||
earnOnNoShow: programme.earnOnNoShow,
|
||||
cardBackgroundColor: programme.cardBackgroundColor ?? undefined,
|
||||
cardBackgroundImageUrl: programme.cardBackgroundImageUrl ?? undefined,
|
||||
stampIcon: programme.stampIcon ?? undefined,
|
||||
termsLine: programme.termsLine ?? undefined,
|
||||
cardTitleText: programme.cardTitleText ?? undefined,
|
||||
cardFont: programme.cardFont,
|
||||
cardTitleTextSize: programme.cardTitleTextSize,
|
||||
cardTitleTextColor: programme.cardTitleTextColor ?? undefined,
|
||||
cardBorderColor: programme.cardBorderColor ?? undefined,
|
||||
cardMemberNumberColor: programme.cardMemberNumberColor ?? undefined,
|
||||
}
|
||||
: {
|
||||
isEnabled: false,
|
||||
stampsRequired: 10,
|
||||
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
|
||||
rewardType: "PERCENTAGE" as const,
|
||||
autoApplyReward: true,
|
||||
requiresStaffApproval: false,
|
||||
earnOnCancelled: false,
|
||||
earnOnNoShow: false,
|
||||
cardFont: LoyaltyCardFont.SANS,
|
||||
cardTitleTextSize: LoyaltyCardTextSize.MD,
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsPageShell title="Loyalty Programme">
|
||||
<LoyaltyConfigForm
|
||||
businessName={context.businessName}
|
||||
initialConfig={initialConfig}
|
||||
initialEligibleServiceIds={programme?.eligibleServices.map((s) => s.serviceId) ?? []}
|
||||
initialEligibleStaffIds={programme?.eligibleStaff.map((s) => s.staffId) ?? []}
|
||||
services={services.map((s) => ({ id: s.id, name: s.name }))}
|
||||
staff={staff.map((s) => ({ id: s.id, name: s.user.name ?? "Staff" }))}
|
||||
/>
|
||||
</SettingsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/**
|
||||
* Kept as a redirect rather than deleted so any existing bookmark to
|
||||
* `/owner/settings` still works (see the Phase 11 merged-nav brief: "avoid
|
||||
* unnecessary route changes... add redirects only where old nav entry
|
||||
* routes become obsolete"). The page it used to render — a mix of personal
|
||||
* settings (Notifications) and business settings (Business Details,
|
||||
* Gallery, Hours, Booking Rules, Loyalty, Reviews) plus a "Switch to
|
||||
* Barber Tools" link — is now split cleanly: personal settings live at the
|
||||
* one shared `/staff/settings` hub, and business settings are reachable
|
||||
* from the "Manage" nav item's grouped links (see (owner)/owner/page.tsx).
|
||||
*/
|
||||
export default function OwnerSettingsRedirectPage() {
|
||||
redirect("/staff/settings");
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
|
||||
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 { getBusinessProfile } from "@/services/business/business.service";
|
||||
import { ReviewsManager } from "@/features/owner/components/reviews-manager";
|
||||
|
||||
export const metadata: Metadata = { title: "Reviews" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OwnerReviewsPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_REVIEWS);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const business = await getBusinessProfile(prisma, context.businessId);
|
||||
if (!business) redirect("/owner");
|
||||
|
||||
return (
|
||||
<SettingsPageShell title="Reviews">
|
||||
<ReviewsManager
|
||||
initialGooglePlaceId={business.googlePlaceId ?? ""}
|
||||
googleRating={business.googleRating ? Number(business.googleRating) : null}
|
||||
googleRatingCount={business.googleRatingCount}
|
||||
/>
|
||||
</SettingsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
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 { listBreaks } from "@/services/staff/staff.service";
|
||||
import { BreaksList } from "@/features/staff/components/breaks-list";
|
||||
import { createOwnerBreakActionSet } from "@/features/owner/actions/break-action-set";
|
||||
|
||||
export const metadata: Metadata = { title: "Staff Breaks" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OwnerStaffBreaksPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const { id } = await params;
|
||||
const target = await prisma.staffProfile.findFirst({
|
||||
where: { id, businessId: context.businessId },
|
||||
include: { user: { select: { name: true } } },
|
||||
});
|
||||
if (!target) notFound();
|
||||
|
||||
const breaks = await listBreaks(prisma, id);
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title={`${target.user.name ?? "Staff"} — Breaks`} onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||
<BreaksList
|
||||
breaks={breaks.map((b) => ({
|
||||
id: b.id,
|
||||
label: b.label,
|
||||
startMinutes: b.startMinutes,
|
||||
endMinutes: b.endMinutes,
|
||||
days: b.days.map((d) => d.dayOfWeek),
|
||||
}))}
|
||||
actions={createOwnerBreakActionSet(id)}
|
||||
appointmentHrefBase="/owner/appointments"
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission, Weekday } from "@/generated/prisma/enums";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { getWorkingHours } from "@/services/staff/staff.service";
|
||||
import { WorkingHoursForm } from "@/features/staff/components/working-hours-form";
|
||||
import { updateStaffWorkingHoursAction } from "@/features/owner/actions/staff";
|
||||
|
||||
export const metadata: Metadata = { title: "Staff Working Hours" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const DAY_ORDER: Weekday[] = [
|
||||
Weekday.MONDAY,
|
||||
Weekday.TUESDAY,
|
||||
Weekday.WEDNESDAY,
|
||||
Weekday.THURSDAY,
|
||||
Weekday.FRIDAY,
|
||||
Weekday.SATURDAY,
|
||||
Weekday.SUNDAY,
|
||||
];
|
||||
|
||||
function formatMinutes(m: number): string {
|
||||
const h = Math.floor(m / 60);
|
||||
const min = m % 60;
|
||||
return `${String(h).padStart(2, "0")}:${String(min).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default async function OwnerStaffWorkingHoursPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const { id } = await params;
|
||||
const target = await prisma.staffProfile.findFirst({
|
||||
where: { id, businessId: context.businessId },
|
||||
include: { user: { select: { name: true } } },
|
||||
});
|
||||
if (!target) notFound();
|
||||
|
||||
const [workingHours, businessHours] = await Promise.all([
|
||||
getWorkingHours(prisma, id),
|
||||
prisma.businessHours.findMany({ where: { businessId: context.businessId } }),
|
||||
]);
|
||||
|
||||
const businessHoursLabel = Object.fromEntries(
|
||||
DAY_ORDER.map((day) => {
|
||||
const hours = businessHours.find((h) => h.dayOfWeek === day);
|
||||
const label =
|
||||
!hours || hours.isClosed || hours.opensAtMinutes === null || hours.closesAtMinutes === null
|
||||
? "Closed"
|
||||
: `${formatMinutes(hours.opensAtMinutes)}–${formatMinutes(hours.closesAtMinutes)}`;
|
||||
return [day, label];
|
||||
}),
|
||||
) as Record<Weekday, string>;
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title={`${target.user.name ?? "Staff"} — Working Hours`} onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||
{/* .bind(null, id), not an arrow-function wrapper — a Client Component can only
|
||||
receive an actual Server Action reference as a prop. An inline arrow closure
|
||||
here throws "Event handlers cannot be passed to Client Component props" at
|
||||
render time (found empirically in Phase 7 — this page never actually worked
|
||||
until this fix; see docs/phase-7-summary.md and
|
||||
src/features/owner/actions/break-action-set.ts for the same issue). */}
|
||||
<WorkingHoursForm
|
||||
initialRows={workingHours}
|
||||
businessHoursLabel={businessHoursLabel}
|
||||
onSave={updateStaffWorkingHoursAction.bind(null, id)}
|
||||
appointmentHrefBase="/owner/appointments"
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Mail, Phone } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { Card } from "@/components/ui/card";
|
||||
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 { getStaffDetailById, listRolesForBusiness } from "@/services/staff/staff.service";
|
||||
import { StaffDetailPanel } from "@/features/owner/components/staff-detail-panel";
|
||||
|
||||
export const metadata: Metadata = { title: "Staff Details" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OwnerStaffDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const { id } = await params;
|
||||
const [target, roles, allServices, calendarConnection] = await Promise.all([
|
||||
getStaffDetailById(prisma, { staffId: id, businessId: context.businessId }),
|
||||
listRolesForBusiness(prisma, context.businessId),
|
||||
prisma.service.findMany({ where: { businessId: context.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
|
||||
// Status only — never tokens (see the brief's "Owners must not see
|
||||
// OAuth tokens, refresh tokens, or unrelated private calendar
|
||||
// events"). Owners can see whether a barber has connected, not
|
||||
// connect on their behalf.
|
||||
prisma.staffCalendarConnection.findUnique({ where: { staffId: id }, select: { revokedAt: true, lastSyncError: true } }),
|
||||
]);
|
||||
if (!target) notFound();
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Staff Details" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6 lg:max-w-4xl lg:grid lg:max-w-none lg:grid-cols-[320px_1fr] lg:items-start lg:gap-6 lg:space-y-0">
|
||||
<div className="space-y-4">
|
||||
<Card className="flex flex-col items-center p-6 text-center">
|
||||
{target.user.image ? (
|
||||
<Image
|
||||
src={target.user.image}
|
||||
alt=""
|
||||
width={96}
|
||||
height={96}
|
||||
className="border-stone mb-4 h-24 w-24 rounded-full border-4 object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-stone text-muted mb-4 flex h-24 w-24 items-center justify-center rounded-full text-2xl font-bold">
|
||||
{(target.user.name ?? "?").charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-xl font-bold">{target.user.name}</h2>
|
||||
<p className="text-accent mt-1 text-xs font-bold tracking-widest uppercase">
|
||||
{target.role.name} · {target.status === "ACTIVE" ? "Active" : target.status === "ON_BREAK" ? "On Break" : "Inactive"}
|
||||
</p>
|
||||
<p className="text-muted mt-2 text-xs">Joined {target.createdAt.toLocaleDateString("en-GB", { month: "short", year: "numeric" })}</p>
|
||||
{target.bio && <p className="text-muted mt-3 text-sm">{target.bio}</p>}
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{target.user.phone && (
|
||||
<a href={`tel:${target.user.phone}`} className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold">
|
||||
<Phone className="text-accent h-4 w-4" aria-hidden="true" /> Call
|
||||
</a>
|
||||
)}
|
||||
{target.user.email && (
|
||||
<a href={`mailto:${target.user.email}`} className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold">
|
||||
<Mail className="text-accent h-4 w-4" aria-hidden="true" /> Email
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card className="p-4">
|
||||
<p className="text-muted mb-1 text-[10px] font-bold tracking-widest uppercase">Google Calendar</p>
|
||||
<p className="text-sm font-bold">
|
||||
{!calendarConnection || calendarConnection.revokedAt
|
||||
? "Not connected"
|
||||
: calendarConnection.lastSyncError
|
||||
? "Sync error"
|
||||
: "Connected"}
|
||||
</p>
|
||||
{calendarConnection?.lastSyncError && !calendarConnection.revokedAt && (
|
||||
<p className="text-danger mt-1 text-xs">{calendarConnection.lastSyncError}</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<StaffDetailPanel
|
||||
staffId={target.id}
|
||||
currentRoleId={target.roleId}
|
||||
roles={roles.map((r) => ({ id: r.id, name: r.name }))}
|
||||
allServices={allServices.map((s) => ({ id: s.id, name: s.name }))}
|
||||
initialServiceIds={target.services.map((s) => s.serviceId)}
|
||||
status={target.status}
|
||||
hasPendingInvitation={!!target.invitation && !target.invitation.acceptedAt}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
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 { listTimeOff } from "@/services/staff/staff.service";
|
||||
import { TimeOffList } from "@/features/staff/components/time-off-list";
|
||||
import { createOwnerTimeOffActionSet } from "@/features/owner/actions/time-off-action-set";
|
||||
|
||||
export const metadata: Metadata = { title: "Staff Time Off" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function toCalendarDate(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export default async function OwnerStaffTimeOffPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const { id } = await params;
|
||||
const target = await prisma.staffProfile.findFirst({
|
||||
where: { id, businessId: context.businessId },
|
||||
include: { user: { select: { name: true } } },
|
||||
});
|
||||
if (!target) notFound();
|
||||
|
||||
const { upcoming, past } = await listTimeOff(prisma, id);
|
||||
// Whole-day rows only — partial-day "Block Time" entries are managed from the owner calendar (see owner-block-time-button.tsx).
|
||||
const wholeDay = (rows: typeof upcoming) => rows.filter((r) => r.startMinutes === null && r.endMinutes === null);
|
||||
const toEditable = (rows: typeof upcoming) =>
|
||||
wholeDay(rows).map((t) => ({
|
||||
id: t.id,
|
||||
startDate: toCalendarDate(t.startDate),
|
||||
endDate: toCalendarDate(t.endDate),
|
||||
reason: t.reason,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title={`${target.user.name ?? "Staff"} — Time Off`} onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||
<TimeOffList
|
||||
upcoming={toEditable(upcoming)}
|
||||
past={toEditable(past)}
|
||||
actions={createOwnerTimeOffActionSet(id)}
|
||||
appointmentHrefBase="/owner/appointments"
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
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 { listRolesForBusiness } from "@/services/staff/staff.service";
|
||||
import { AddStaffForm } from "@/features/owner/components/add-staff-form";
|
||||
|
||||
export const metadata: Metadata = { title: "Add Staff" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OwnerAddStaffPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const [roles, services] = await Promise.all([
|
||||
listRolesForBusiness(prisma, context.businessId),
|
||||
prisma.service.findMany({ where: { businessId: context.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Add Staff" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||
<AddStaffForm roles={roles.map((r) => ({ id: r.id, name: r.name }))} services={services.map((s) => ({ id: s.id, name: s.name }))} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
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 { listStaffForBusiness } from "@/services/staff/staff.service";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
export const metadata: Metadata = { title: "Team" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OwnerTeamPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const { q = "" } = await searchParams;
|
||||
const staff = await listStaffForBusiness(prisma, { businessId: context.businessId, q, includeInactive: true });
|
||||
|
||||
return (
|
||||
<div className="px-6 pt-12 pb-12 lg:max-w-4xl">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Team</h1>
|
||||
<Link
|
||||
href="/owner/team/add"
|
||||
className="bg-ink flex h-10 w-10 items-center justify-center rounded-full text-white"
|
||||
aria-label="Add staff member"
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<form action="/owner/team" method="GET" className="relative mb-4">
|
||||
<Search className="text-muted absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2" aria-hidden="true" />
|
||||
<label htmlFor="team-search" className="sr-only">
|
||||
Search staff by name or email
|
||||
</label>
|
||||
<input
|
||||
id="team-search"
|
||||
type="search"
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="Search by name or email"
|
||||
className="border-line focus:ring-ink w-full rounded-xl border bg-white py-3 pr-4 pl-11 text-sm outline-none focus:ring-2"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{staff.length === 0 ? (
|
||||
<p className="text-muted col-span-full rounded-md border border-line bg-white p-5 text-center text-sm">
|
||||
No staff match “{q}”.
|
||||
</p>
|
||||
) : (
|
||||
staff.map((s) => (
|
||||
<Link
|
||||
key={s.id}
|
||||
href={`/owner/team/${s.id}`}
|
||||
className={cn(
|
||||
"rounded-md border-line flex items-center gap-4 border bg-white p-5",
|
||||
s.status === "INACTIVE" && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className="bg-stone text-muted flex h-12 w-12 shrink-0 items-center justify-center rounded-full text-sm font-bold">
|
||||
{(s.user.name ?? "?").charAt(0)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-bold">{s.user.name}</p>
|
||||
<p className="text-muted truncate text-xs">{s.user.email}</p>
|
||||
<p className="text-accent mt-0.5 text-[10px] font-bold tracking-widest uppercase">
|
||||
{s.role.name}
|
||||
{s.status === "INACTIVE" && <span className="text-muted"> · Inactive</span>}
|
||||
{s.status === "ACTIVE" && s.invitation && !s.invitation.acceptedAt && (
|
||||
<span className="text-warning-foreground"> · Invite Pending</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user