Phase 7
This commit is contained in:
189 files changed
+9873
-655
No files matched your search
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { ErrorState } from "@/components/feedback/error-state";
|
||||
|
||||
/** Keeps (owner)/layout.tsx's sidebar/bottom nav visible on-screen even when a page below it throws — see src/app/error.tsx for the app-wide fallback this backs up. */
|
||||
export default function OwnerError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
return <ErrorState onRetry={reset} />;
|
||||
}
|
||||
@@ -3,7 +3,9 @@ import { auth } from "@/auth";
|
||||
import { StaffShell } from "@/components/navigation/staff-shell";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { OWNER_NAV_ITEMS } from "@/lib/navigation/staff-nav-items";
|
||||
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
|
||||
|
||||
/**
|
||||
* Owner is a permission on a staff account, not a separate account type
|
||||
@@ -21,8 +23,15 @@ export default async function OwnerLayout({ children }: { children: React.ReactN
|
||||
redirect("/staff");
|
||||
}
|
||||
|
||||
const unreadNotificationCount = await getUnreadNotificationCount(prisma, session.user.id);
|
||||
|
||||
return (
|
||||
<StaffShell items={OWNER_NAV_ITEMS} footerLink={{ href: "/staff", label: "Barber Tools" }}>
|
||||
<StaffShell
|
||||
items={OWNER_NAV_ITEMS}
|
||||
footerLink={{ href: "/staff", label: "Barber Tools" }}
|
||||
notificationsHref="/owner/notifications"
|
||||
unreadNotificationCount={unreadNotificationCount}
|
||||
>
|
||||
{children}
|
||||
</StaffShell>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
||||
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
||||
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
|
||||
@@ -44,10 +45,7 @@ export default async function OwnerAppointmentDetailsPage({
|
||||
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 } } },
|
||||
});
|
||||
const owner = await getActingStaff();
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import {
|
||||
calendarDateToUtcMidnight,
|
||||
minutesFromMidnight,
|
||||
@@ -60,10 +61,7 @@ export default async function OwnerCalendarPage({
|
||||
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 } } },
|
||||
});
|
||||
const owner = await getActingStaff();
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const { date: dateParam, view: viewParam, staffId: staffIdParam } = await searchParams;
|
||||
|
||||
@@ -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 { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { listInAppNotifications } from "@/services/notifications/notification.service";
|
||||
import { NotificationList } from "@/features/notifications/components/notification-list";
|
||||
|
||||
export const metadata: Metadata = { title: "Notifications" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** 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 staff = await getActingStaff();
|
||||
if (!staff) redirect("/home");
|
||||
|
||||
const notifications = await listInAppNotifications(prisma, { userId: staff.userId });
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Notifications" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||
<NotificationList notifications={notifications} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ 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 { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { todayIn } from "@/lib/time/business-time";
|
||||
import { getDashboardSummary } from "@/services/reporting/owner-reporting.service";
|
||||
|
||||
@@ -43,10 +44,7 @@ export default async function OwnerDashboardPage() {
|
||||
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 } } },
|
||||
});
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) redirect("/staff");
|
||||
|
||||
const today = todayIn(staff.business.timezone);
|
||||
@@ -171,6 +169,12 @@ export default async function OwnerDashboardPage() {
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/owner/reports"
|
||||
className="rounded-jumbo border-line flex-1 border bg-white px-5 py-4 text-center text-sm font-bold"
|
||||
>
|
||||
View Reports
|
||||
</Link>
|
||||
<Link
|
||||
href="/owner/team"
|
||||
className="rounded-jumbo border-line flex-1 border bg-white px-5 py-4 text-center text-sm font-bold"
|
||||
|
||||
@@ -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 { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
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 staff = await getActingStaff();
|
||||
if (!staff) redirect("/staff");
|
||||
|
||||
const tz = staff.business.timezone;
|
||||
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: staff.businessId, range }),
|
||||
getServiceReport(prisma, { businessId: staff.businessId, range }),
|
||||
getStaffReport(prisma, { businessId: staff.businessId, range }),
|
||||
getCustomerReport(prisma, { businessId: staff.businessId, range }),
|
||||
getLoyaltyReport(prisma, { businessId: staff.businessId, range }),
|
||||
getNotificationReport(prisma, { businessId: staff.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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { listCategories } from "@/services/catalogue/service-catalogue.service";
|
||||
import { CatalogueManager } from "@/features/owner/components/catalogue-manager";
|
||||
|
||||
@@ -17,7 +18,7 @@ export default async function OwnerServicesPage() {
|
||||
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 } });
|
||||
const owner = await getActingStaff();
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const categories = await listCategories(prisma, owner.businessId);
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { getBookingRule } from "@/services/scheduling/booking-rule.service";
|
||||
import { BookingRulesForm } from "@/features/owner/components/booking-rules-form";
|
||||
|
||||
@@ -18,7 +19,7 @@ export default async function OwnerBookingRulesPage() {
|
||||
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 } });
|
||||
const owner = await getActingStaff();
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const rule = await getBookingRule(prisma, owner.businessId);
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { getBusinessProfile } from "@/services/business/business.service";
|
||||
import { BusinessDetailsForm } from "@/features/owner/components/business-details-form";
|
||||
|
||||
@@ -18,7 +19,7 @@ export default async function OwnerBusinessDetailsPage() {
|
||||
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 } });
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) redirect("/staff");
|
||||
|
||||
const business = await getBusinessProfile(prisma, staff.businessId);
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { getOpeningHours } from "@/services/business/business.service";
|
||||
import { OpeningHoursForm } from "@/features/owner/components/opening-hours-form";
|
||||
|
||||
@@ -18,7 +19,7 @@ export default async function OwnerOpeningHoursPage() {
|
||||
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 } });
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) redirect("/staff");
|
||||
|
||||
const hours = await getOpeningHours(prisma, staff.businessId);
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { getProgrammeConfig } from "@/services/loyalty/loyalty.service";
|
||||
import { LoyaltyConfigForm } from "@/features/owner/components/loyalty-config-form";
|
||||
|
||||
@@ -18,7 +19,7 @@ export default async function OwnerLoyaltyConfigPage() {
|
||||
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 } });
|
||||
const owner = await getActingStaff();
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const [programme, services, staff] = await Promise.all([
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ProfileCard } from "@/components/ui/profile-card";
|
||||
import { SignOutButton } from "@/features/auth/components/sign-out-button";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { InstallAppButton } from "@/features/pwa/components/install-app-button";
|
||||
|
||||
export const metadata: Metadata = { title: "Owner Settings" };
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -50,6 +51,11 @@ export default async function OwnerSettingsPage() {
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="rounded-jumbo border-line border bg-white p-5">
|
||||
<p className="mb-3 text-sm font-bold">Get the app</p>
|
||||
<InstallAppButton />
|
||||
</div>
|
||||
|
||||
<SignOutButton />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
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 owner = await getActingStaff();
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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 { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { getWorkingHours } from "@/services/staff/staff.service";
|
||||
import { WorkingHoursForm } from "@/features/staff/components/working-hours-form";
|
||||
import { updateStaffWorkingHoursAction } from "@/features/owner/actions/staff";
|
||||
@@ -35,7 +36,7 @@ export default async function OwnerStaffWorkingHoursPage({ params }: { params: P
|
||||
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 } });
|
||||
const owner = await getActingStaff();
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const { id } = await params;
|
||||
@@ -65,10 +66,17 @@ export default async function OwnerStaffWorkingHoursPage({ params }: { params: P
|
||||
<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={(days) => updateStaffWorkingHoursAction(id, days)}
|
||||
onSave={updateStaffWorkingHoursAction.bind(null, id)}
|
||||
appointmentHrefBase="/owner/appointments"
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
@@ -7,6 +8,7 @@ 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 { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { getStaffDetailById, listRolesForBusiness } from "@/services/staff/staff.service";
|
||||
import { StaffDetailPanel } from "@/features/owner/components/staff-detail-panel";
|
||||
|
||||
@@ -20,7 +22,7 @@ export default async function OwnerStaffDetailPage({ params }: { params: Promise
|
||||
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 } });
|
||||
const owner = await getActingStaff();
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const { id } = await params;
|
||||
@@ -38,8 +40,13 @@ export default async function OwnerStaffDetailPage({ params }: { params: Promise
|
||||
<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" />
|
||||
<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)}
|
||||
|
||||
@@ -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 { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
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 owner = await getActingStaff();
|
||||
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 { 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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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 { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { listRolesForBusiness } from "@/services/staff/staff.service";
|
||||
import { AddStaffForm } from "@/features/owner/components/add-staff-form";
|
||||
|
||||
@@ -18,7 +19,7 @@ export default async function OwnerAddStaffPage() {
|
||||
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 } });
|
||||
const owner = await getActingStaff();
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const [roles, services] = await Promise.all([
|
||||
|
||||
@@ -6,6 +6,7 @@ import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { listStaffForBusiness } from "@/services/staff/staff.service";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
@@ -23,7 +24,7 @@ export default async function OwnerTeamPage({
|
||||
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 } });
|
||||
const owner = await getActingStaff();
|
||||
if (!owner) redirect("/staff");
|
||||
|
||||
const { q = "" } = await searchParams;
|
||||
|
||||
Reference in new issue
Block a user