Phase 6
Owner Administration is complete. Summary of this final stretch of work: Seed data extended with an inactive staff member, a pending invitation, a redeemed-reward fixture, a service with no eligible staff, and a future-appointment fixture for conflict testing — all idempotent, verified against the dev DB. Testing brought to 179 unit / 86 integration / 27 E2E tests, all passing together on a freshly seeded, disposable Postgres: Unit tests for every new pure validator (business hours, categories/services, booking rules, loyalty config, staff creation). Integration tests covering opening-hours conflicts, last-owner protection, staff deactivation/eligibility conflicts, loyalty redemption's atomic race-guard, and owner-vs-barber/business-isolation authorization — all against a real Postgres. 12 new Playwright owner journeys, which surfaced and fixed two genuine issues along the way: a cookie-clearing race condition in the sign-out-mid-test flow (a real, reproducible timing bug, not just a test artifact), and confirmation that seed fixtures upserted by deterministic ID go stale once "today" has moved on — now handled defensively. Desktop responsiveness: converted the single-column Business Details, Booking Rules, and Loyalty Configuration forms to a genuine two-column layout at the lg: breakpoint (verified via measured element positions, not just screenshots), confirmed mobile stacking is unaffected. Final verification: clean tsc, eslint, and next build, plus the full three-suite run against one fresh database as the authoritative pass.
This commit is contained in:
73 files changed
+8400
-53
No files matched your search
@@ -0,0 +1,268 @@
|
||||
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 { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
||||
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 owner = await prisma.staffProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
include: { business: { select: { id: true, timezone: true } } },
|
||||
});
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const { id } = await params;
|
||||
const appointment = await getAppointmentById(prisma, id);
|
||||
if (!appointment) notFound();
|
||||
|
||||
try {
|
||||
assertAppointmentInBusiness(appointment, { id: owner.id, businessId: owner.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: owner.businessId }),
|
||||
appointment.cancelledByUserId
|
||||
? prisma.user.findUnique({ where: { id: appointment.cancelledByUserId }, select: { name: true } })
|
||||
: null,
|
||||
]);
|
||||
|
||||
const tz = owner.business.timezone;
|
||||
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}/{loyalty.stampsRequired} 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-jumbo 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-jumbo 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,310 @@
|
||||
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 { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
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 { DateStrip } from "@/features/staff/components/date-strip";
|
||||
import { DayCalendar } from "@/features/staff/components/day-calendar";
|
||||
import type { CalendarAppointment, CalendarBlock } from "@/features/staff/components/day-calendar";
|
||||
import { OwnerBlockTimeButton } from "@/features/owner/components/owner-block-time-button";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
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 owner = await prisma.staffProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
include: { business: { select: { id: true, name: true, timezone: true } } },
|
||||
});
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const { date: dateParam, view: viewParam, staffId: staffIdParam } = await searchParams;
|
||||
const today = todayIn(owner.business.timezone);
|
||||
const date: CalendarDate = dateParam ?? today;
|
||||
const view = viewParam === "agenda" ? "agenda" : "day";
|
||||
const weekday = weekdayOf(date);
|
||||
const dateUtcMidnight = calendarDateToUtcMidnight(date);
|
||||
const businessTimezone = owner.business.timezone;
|
||||
|
||||
const allStaff = await prisma.staffProfile.findMany({
|
||||
where: { businessId: owner.businessId, status: "ACTIVE" },
|
||||
include: { user: { select: { id: true, name: 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: owner.businessId,
|
||||
staffId: selectedStaff?.id,
|
||||
fromDate: date,
|
||||
toDate: date,
|
||||
businessTimezone,
|
||||
}),
|
||||
prisma.workingHours.findMany({
|
||||
where: { staff: { businessId: owner.businessId }, dayOfWeek: weekday },
|
||||
}),
|
||||
prisma.breakDay.findMany({
|
||||
where: { dayOfWeek: weekday, break: { staff: { businessId: owner.businessId } } },
|
||||
include: { break: true },
|
||||
}),
|
||||
prisma.timeOff.findMany({
|
||||
where: {
|
||||
staff: { businessId: owner.businessId },
|
||||
status: "APPROVED",
|
||||
startDate: { lte: dateUtcMidnight },
|
||||
endDate: { gte: dateUtcMidnight },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const rewardFlags = await getAvailableRewardFlags(prisma, {
|
||||
businessId: owner.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 };
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pb-6">
|
||||
<div className="flex items-center justify-between px-6 pt-12">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Calendar</h1>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<DateStrip
|
||||
selectedDate={date}
|
||||
businessTimezone={businessTimezone}
|
||||
basePath="/owner/calendar"
|
||||
view={view}
|
||||
extraParams={selectedStaff ? { staffId: selectedStaff.id } : undefined}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2 px-6">
|
||||
<Link
|
||||
href={`/owner/calendar?date=${date}&view=${view}`}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
!selectedStaff ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
All Staff
|
||||
</Link>
|
||||
{allStaff.map((s) => (
|
||||
<Link
|
||||
key={s.id}
|
||||
href={`/owner/calendar?date=${date}&view=${view}&staffId=${s.id}`}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
selectedStaff?.id === s.id ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
{s.user.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-2 px-6">
|
||||
<Link
|
||||
href={`/owner/calendar?date=${date}&view=day${selectedStaff ? `&staffId=${selectedStaff.id}` : ""}`}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
view === "day" ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
Day
|
||||
</Link>
|
||||
<Link
|
||||
href={`/owner/calendar?date=${date}&view=agenda${selectedStaff ? `&staffId=${selectedStaff.id}` : ""}`}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
view === "agenda" ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
Agenda
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div 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>
|
||||
) : (
|
||||
<div className="flex gap-4 overflow-x-auto px-6 pb-4 lg:px-0">
|
||||
{staffToRender.map((s) => {
|
||||
const column = buildColumnFor(s.id);
|
||||
return (
|
||||
<div key={s.id} className="w-[320px] shrink-0 lg:w-auto lg:flex-1">
|
||||
<p className="text-muted mb-2 px-6 text-xs font-bold tracking-widest uppercase lg:px-0">
|
||||
{s.user.name}
|
||||
</p>
|
||||
{column.wholeDayOff ? (
|
||||
<div className="px-6 lg:px-0">
|
||||
<EmptyState icon={CalendarX2} title="Off this day" />
|
||||
</div>
|
||||
) : (
|
||||
<DayCalendar
|
||||
appointments={column.staffAppointments}
|
||||
blocks={column.blocks}
|
||||
workingWindow={column.workingWindow}
|
||||
currentTimeMinutes={currentTimeMinutes}
|
||||
appointmentHrefBase="/owner/appointments"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,189 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ChartLine } from "lucide-react";
|
||||
import { ComingSoon } from "@/components/feedback/coming-soon";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Calendar,
|
||||
CalendarX2,
|
||||
CheckCheck,
|
||||
Gift,
|
||||
Stamp,
|
||||
TrendingUp,
|
||||
UserX,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
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 { todayIn } from "@/lib/time/business-time";
|
||||
import { getDashboardSummary } from "@/services/reporting/owner-reporting.service";
|
||||
|
||||
export const metadata: Metadata = { title: "Business Dashboard" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function OwnerDashboardPage() {
|
||||
return <ComingSoon icon={ChartLine} title="Business Dashboard" phase="Phase 4: Owner Administration" />;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 staff = await prisma.staffProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
include: { business: { select: { id: true, name: true, timezone: true } } },
|
||||
});
|
||||
if (!staff) redirect("/staff");
|
||||
|
||||
const today = todayIn(staff.business.timezone);
|
||||
const summary = await getDashboardSummary(prisma, {
|
||||
businessId: staff.businessId,
|
||||
businessTimezone: staff.business.timezone,
|
||||
date: today,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-6 pt-12 pb-12">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
|
||||
<p className="text-muted text-sm">{staff.business.name} — today</p>
|
||||
</div>
|
||||
|
||||
{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-jumbo 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="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/owner/team"
|
||||
className="rounded-jumbo border-line flex-1 border bg-white px-5 py-4 text-center text-sm font-bold"
|
||||
>
|
||||
Manage Team
|
||||
</Link>
|
||||
<Link
|
||||
href="/owner/settings/booking-rules"
|
||||
className="rounded-jumbo bg-ink flex-1 px-5 py-4 text-center text-sm font-bold text-white"
|
||||
>
|
||||
Update Booking Rules
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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 { 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 owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const categories = await listCategories(prisma, owner.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 ?? "",
|
||||
durationMinutes: s.durationMinutes,
|
||||
price: s.price.toString(),
|
||||
bufferBeforeMinutes: s.bufferBeforeMinutes,
|
||||
bufferAfterMinutes: s.bufferAfterMinutes,
|
||||
isActive: s.isActive,
|
||||
allowOnlineBooking: s.allowOnlineBooking,
|
||||
})),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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 { 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 owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const rule = await getBookingRule(prisma, owner.businessId);
|
||||
if (!rule) redirect("/owner");
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Booking Rules" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl">
|
||||
<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,
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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 { 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 staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!staff) redirect("/staff");
|
||||
|
||||
const business = await getBusinessProfile(prisma, staff.businessId);
|
||||
if (!business) redirect("/owner");
|
||||
|
||||
const location = business.locations[0];
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Business Details" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl">
|
||||
<BusinessDetailsForm
|
||||
initial={{
|
||||
name: business.name,
|
||||
description: business.description ?? "",
|
||||
logoUrl: business.logoUrl ?? "",
|
||||
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 ?? "",
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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 { 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 staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!staff) redirect("/staff");
|
||||
|
||||
const hours = await getOpeningHours(prisma, staff.businessId);
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Opening Hours" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||
<OpeningHoursForm initialRows={hours} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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 { 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 owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const [programme, services, staff] = await Promise.all([
|
||||
getProgrammeConfig(prisma, owner.businessId),
|
||||
prisma.service.findMany({ where: { businessId: owner.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
|
||||
prisma.staffProfile.findMany({
|
||||
where: { businessId: owner.businessId, status: "ACTIVE" },
|
||||
include: { user: { select: { name: true } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const initialConfig = programme
|
||||
? {
|
||||
isEnabled: programme.isEnabled,
|
||||
stampsRequired: programme.stampsRequired,
|
||||
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,
|
||||
}
|
||||
: {
|
||||
isEnabled: false,
|
||||
stampsRequired: 10,
|
||||
rewardType: "PERCENTAGE" as const,
|
||||
autoApplyReward: true,
|
||||
requiresStaffApproval: false,
|
||||
earnOnCancelled: false,
|
||||
earnOnNoShow: false,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Loyalty Programme" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl">
|
||||
<LoyaltyConfigForm
|
||||
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" }))}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Bell, Building2, CalendarClock, ClipboardList, Crown, ScissorsSquare } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
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";
|
||||
|
||||
export const metadata: Metadata = { title: "Owner Settings" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const SETTINGS_LINKS = [
|
||||
{ href: "/owner/settings/business", label: "Business Details", icon: Building2 },
|
||||
{ href: "/owner/settings/hours", label: "Opening Hours", icon: CalendarClock },
|
||||
{ href: "/owner/settings/booking-rules", label: "Booking Rules", icon: ClipboardList },
|
||||
{ href: "/owner/settings/loyalty", label: "Loyalty Programme", icon: Crown },
|
||||
{ href: "/staff/settings/notifications", label: "Notifications", icon: Bell },
|
||||
] as const;
|
||||
|
||||
export default async function OwnerSettingsPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-6 pt-12 pb-12">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Owner Settings</h1>
|
||||
<ProfileCard name={session.user.name ?? "Owner"} meta={session.user.email} avatarUrl={session.user.image ?? undefined} />
|
||||
|
||||
<Link
|
||||
href="/staff/settings"
|
||||
className="rounded-jumbo border-line flex items-center gap-3 border bg-white p-5 text-sm font-bold hover:bg-stone"
|
||||
>
|
||||
<ScissorsSquare className="text-accent h-5 w-5" aria-hidden="true" />
|
||||
Switch to Barber Tools
|
||||
</Link>
|
||||
|
||||
<ul className="divide-line rounded-jumbo border-line divide-y border bg-white">
|
||||
{SETTINGS_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>
|
||||
|
||||
<SignOutButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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 { 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 owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const { id } = await params;
|
||||
const target = await prisma.staffProfile.findFirst({
|
||||
where: { id, businessId: owner.businessId },
|
||||
include: { user: { select: { name: true } } },
|
||||
});
|
||||
if (!target) notFound();
|
||||
|
||||
const [workingHours, businessHours] = await Promise.all([
|
||||
getWorkingHours(prisma, id),
|
||||
prisma.businessHours.findMany({ where: { businessId: owner.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">
|
||||
<WorkingHoursForm
|
||||
initialRows={workingHours}
|
||||
businessHoursLabel={businessHoursLabel}
|
||||
onSave={(days) => updateStaffWorkingHoursAction(id, days)}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Mail, Phone } from "lucide-react";
|
||||
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 { 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 owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const { id } = await params;
|
||||
const [target, roles, allServices] = await Promise.all([
|
||||
getStaffDetailById(prisma, { staffId: id, businessId: owner.businessId }),
|
||||
listRolesForBusiness(prisma, owner.businessId),
|
||||
prisma.service.findMany({ where: { businessId: owner.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
|
||||
]);
|
||||
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 ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- external avatar URL, no next/image domain config for it
|
||||
<img src={target.user.image} alt="" 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>
|
||||
</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,37 @@
|
||||
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 { 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 owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const [roles, services] = await Promise.all([
|
||||
listRolesForBusiness(prisma, owner.businessId),
|
||||
prisma.service.findMany({ where: { businessId: owner.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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,95 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Users } from "lucide-react";
|
||||
import { ComingSoon } from "@/components/feedback/coming-soon";
|
||||
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 { listStaffForBusiness } from "@/services/staff/staff.service";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
export const metadata: Metadata = { title: "Team Management" };
|
||||
export const metadata: Metadata = { title: "Team" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function OwnerTeamPage() {
|
||||
return <ComingSoon icon={Users} title="Team Management" phase="Phase 4: Owner Administration" />;
|
||||
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 owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const { q = "" } = await searchParams;
|
||||
const staff = await listStaffForBusiness(prisma, { businessId: owner.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-jumbo 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-jumbo 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