Phase 7
This commit is contained in:
189 files changed
+9873
-655
No files matched your search
@@ -0,0 +1,94 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import { createBreak, createTimeOff, deleteBreak, deleteTimeOff } from "@/services/staff/staff.service";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestBusiness,
|
||||
createTestStaff,
|
||||
prisma,
|
||||
} from "@/services/scheduling/__tests__/fixtures";
|
||||
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||
|
||||
/**
|
||||
* Owner-on-behalf-of-staff breaks and time off (Phase 7, closing the Phase
|
||||
* 6-documented gap — see docs/phase-7-summary.md, "Owner-managed
|
||||
* availability"). The owner Server Actions in
|
||||
* src/features/owner/actions/breaks.ts and time-off.ts are thin wrappers:
|
||||
* `assertOwnerPermission` (already covered by
|
||||
* src/features/owner/__tests__/authorization.integration.test.ts) plus a
|
||||
* business-scoped staffId lookup, then these exact StaffService calls with
|
||||
* an explicit target staffId instead of the caller's own. Importing the
|
||||
* "use server" action files directly here isn't possible — they import
|
||||
* `@/auth`, which pulls in next-auth's NextAuth() factory and fails
|
||||
* outside Next's own bundler/runtime (`Cannot find module
|
||||
* "next/server"`), so what these tests verify is the StaffService plumbing
|
||||
* an owner action delegates to, exactly as every other StaffService
|
||||
* integration test in this suite does.
|
||||
*/
|
||||
describe("Owner-managed breaks and time off (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
|
||||
beforeEach(async () => {
|
||||
business = await createTestBusiness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestBusiness(business);
|
||||
});
|
||||
|
||||
it("creates and deletes a break against an explicit target staffId, not the caller's own", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
|
||||
const created = await createBreak(prisma, {
|
||||
staffId,
|
||||
businessTimezone: business.timezone,
|
||||
input: { label: "Owner-added break", startMinutes: 12 * 60, endMinutes: 12 * 60 + 30, days: [Weekday.MONDAY] },
|
||||
});
|
||||
expect(created.success).toBe(true);
|
||||
if (!created.success) return;
|
||||
|
||||
const stored = await prisma.break.findUnique({ where: { id: created.break.id } });
|
||||
expect(stored?.staffId).toBe(staffId);
|
||||
expect(stored?.label).toBe("Owner-added break");
|
||||
|
||||
const removed = await deleteBreak(prisma, { breakId: created.break.id, staffId });
|
||||
expect(removed.success).toBe(true);
|
||||
const gone = await prisma.break.findUnique({ where: { id: created.break.id } });
|
||||
expect(gone).toBeNull();
|
||||
});
|
||||
|
||||
it("creates and deletes whole-day time off against an explicit target staffId", async () => {
|
||||
const { staffId } = await createTestStaff(business);
|
||||
|
||||
const created = await createTimeOff(prisma, {
|
||||
staffId,
|
||||
businessTimezone: business.timezone,
|
||||
input: { startDate: "2027-02-01", endDate: "2027-02-03", reason: "Owner-added leave" },
|
||||
});
|
||||
expect(created.success).toBe(true);
|
||||
if (!created.success) return;
|
||||
|
||||
const stored = await prisma.timeOff.findUnique({ where: { id: created.timeOff.id } });
|
||||
expect(stored?.staffId).toBe(staffId);
|
||||
expect(stored?.reason).toBe("Owner-added leave");
|
||||
|
||||
const removed = await deleteTimeOff(prisma, { timeOffId: created.timeOff.id, staffId });
|
||||
expect(removed.success).toBe(true);
|
||||
const gone = await prisma.timeOff.findUnique({ where: { id: created.timeOff.id } });
|
||||
expect(gone).toBeNull();
|
||||
});
|
||||
|
||||
it("a break created for staff A is never visible when querying by staff B's id (no accidental cross-staff writes)", async () => {
|
||||
const { staffId: staffA } = await createTestStaff(business);
|
||||
const { staffId: staffB } = await createTestStaff(business);
|
||||
|
||||
await createBreak(prisma, {
|
||||
staffId: staffA,
|
||||
businessTimezone: business.timezone,
|
||||
input: { label: "A's break", startMinutes: 9 * 60, endMinutes: 9 * 60 + 15, days: [Weekday.TUESDAY] },
|
||||
});
|
||||
|
||||
const staffBBreaks = await prisma.break.findMany({ where: { staffId: staffB } });
|
||||
expect(staffBBreaks).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
start,
|
||||
} from "@/services/scheduling/appointment.service";
|
||||
import { awardStampForCompletedAppointment } from "@/services/loyalty/loyalty.service";
|
||||
import { notifyAppointmentNoShow, notifyBookingApproved, notifyBookingBarberChanged, notifyBookingCompleted } from "@/services/scheduling/booking-notifications";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import type { StaffAppointmentActionResult } from "@/features/staff/actions/appointment-actions";
|
||||
|
||||
@@ -74,9 +75,11 @@ export async function ownerConfirmAppointmentAction(appointmentId: string): Prom
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await confirm(prisma, appointmentId);
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
if (!result.success) {
|
||||
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
await notifyBookingApproved(prisma, result.appointment);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function ownerCheckInAppointmentAction(appointmentId: string): Promise<StaffAppointmentActionResult> {
|
||||
@@ -107,6 +110,7 @@ export async function ownerCompleteAppointmentAction(appointmentId: string): Pro
|
||||
if (!result.success) {
|
||||
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
await notifyBookingCompleted(prisma, result.appointment);
|
||||
|
||||
const loyaltyResult = await awardStampForCompletedAppointment(prisma, { appointmentId, staffId: appointment.staffId });
|
||||
return { success: true, loyaltyStampAwarded: loyaltyResult.success && !loyaltyResult.alreadyAwarded };
|
||||
@@ -117,9 +121,11 @@ export async function ownerMarkNoShowAction(appointmentId: string): Promise<Staf
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await markNoShow(prisma, appointmentId);
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
if (!result.success) {
|
||||
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
await notifyAppointmentNoShow(prisma, result.appointment);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function ownerCancelAppointmentAction(params: {
|
||||
@@ -172,6 +178,7 @@ export async function ownerRescheduleAppointmentAction(params: {
|
||||
entityId: params.appointmentId,
|
||||
metadata: { fromStaffId: previousStaffId, toStaffId: params.staffId },
|
||||
});
|
||||
await notifyBookingBarberChanged(prisma, result.appointment);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ownerCreateBreakAction, ownerDeleteBreakAction, ownerUpdateBreakAction } from "./breaks";
|
||||
import type { BreakActionSet } from "@/features/staff/components/break-form-sheet";
|
||||
|
||||
/**
|
||||
* Plain module (no "use server") assembling the owner break Server Actions
|
||||
* into the `BreakActionSet` shape BreaksList/BreakFormSheet expect, bound
|
||||
* to one target staff member.
|
||||
*
|
||||
* Uses `.bind(null, staffId)`, not an arrow-function wrapper — a Client
|
||||
* Component can only receive an actual Server Action reference as a prop;
|
||||
* React's Flight serializer specifically recognizes `.bind()` on a Server
|
||||
* Action (Next's own documented pattern for passing extra arguments) and
|
||||
* reconstructs the call server-side, but a plain closure like `(input) =>
|
||||
* ownerCreateBreakAction(staffId, input)` is just an ordinary function with
|
||||
* no such registration and throws "Functions cannot be passed directly to
|
||||
* Client Components" at render time — found empirically in Phase 7 (see
|
||||
* docs/phase-7-summary.md) when this file's first version used arrow
|
||||
* wrappers instead.
|
||||
*/
|
||||
export function createOwnerBreakActionSet(staffId: string): BreakActionSet {
|
||||
return {
|
||||
create: ownerCreateBreakAction.bind(null, staffId),
|
||||
update: ownerUpdateBreakAction.bind(null, staffId),
|
||||
remove: ownerDeleteBreakAction.bind(null, staffId),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import { createBreak, deleteBreak, updateBreak } from "@/services/staff/staff.service";
|
||||
import type { BreakInput } from "@/services/staff/staff.service";
|
||||
import { notifyStaffScheduleChanged } from "@/services/staff/staff-notifications";
|
||||
|
||||
export interface BreakConflictItem {
|
||||
id: string;
|
||||
customerName: string;
|
||||
serviceNames: string;
|
||||
startAt: string;
|
||||
}
|
||||
|
||||
export interface BreakActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: BreakConflictItem[];
|
||||
}
|
||||
|
||||
function mapConflicts(conflicts: { id: string; customer: { firstName: string; lastName: string }; services: { service: { name: string } }[]; startAt: Date }[]): BreakConflictItem[] {
|
||||
return conflicts.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async function requireStaffManagerFor(staffId: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { owner: null, error: "You must be signed in." } as const;
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { owner: null, error: error.message } as const;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const owner = await prisma.staffProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
include: { business: { select: { name: true, timezone: true } }, user: { select: { name: true } } },
|
||||
});
|
||||
if (!owner) return { owner: null, target: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
const target = await prisma.staffProfile.findFirst({ where: { id: staffId, businessId: owner.businessId } });
|
||||
if (!target) return { owner: null, target: null, error: "Staff member not found." } as const;
|
||||
|
||||
return { owner, target, error: null } as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-on-behalf-of-staff break management — same StaffService calls the
|
||||
* barber's own editor uses, targeting an explicit `staffId` argument
|
||||
* instead of the caller's own. Assembled into BreaksList/BreakFormSheet's
|
||||
* `actions` prop shape by the plain (non-"use server") factory in
|
||||
* break-action-set.ts — a "use server" file may only export async
|
||||
* functions, never a plain object or a function that returns one
|
||||
* synchronously, so the per-staffId binding has to live outside this file
|
||||
* (see docs/phase-6-summary.md for where this constraint was first found).
|
||||
*/
|
||||
export async function ownerCreateBreakAction(
|
||||
staffId: string,
|
||||
input: BreakInput,
|
||||
acknowledgeConflicts?: boolean,
|
||||
): Promise<BreakActionResult> {
|
||||
const { owner, target, error } = await requireStaffManagerFor(staffId);
|
||||
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await createBreak(prisma, {
|
||||
staffId,
|
||||
businessTimezone: owner.business.timezone,
|
||||
input,
|
||||
acknowledgeConflicts,
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't create this break.", conflicts: mapConflicts(result.conflicts) };
|
||||
}
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_BREAK_CHANGED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
});
|
||||
await notifyStaffScheduleChanged(prisma, {
|
||||
businessId: owner.businessId,
|
||||
staffUserId: target.userId,
|
||||
changedByName: owner.user.name ?? "The business owner",
|
||||
whatChanged: "your breaks",
|
||||
viewSchedulePath: "/staff/settings/breaks",
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function ownerUpdateBreakAction(
|
||||
staffId: string,
|
||||
breakId: string,
|
||||
input: BreakInput,
|
||||
acknowledgeConflicts?: boolean,
|
||||
): Promise<BreakActionResult> {
|
||||
const { owner, target, error } = await requireStaffManagerFor(staffId);
|
||||
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await updateBreak(prisma, {
|
||||
breakId,
|
||||
staffId,
|
||||
businessTimezone: owner.business.timezone,
|
||||
input,
|
||||
acknowledgeConflicts,
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this break.", conflicts: mapConflicts(result.conflicts) };
|
||||
}
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_BREAK_CHANGED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
});
|
||||
await notifyStaffScheduleChanged(prisma, {
|
||||
businessId: owner.businessId,
|
||||
staffUserId: target.userId,
|
||||
changedByName: owner.user.name ?? "The business owner",
|
||||
whatChanged: "your breaks",
|
||||
viewSchedulePath: "/staff/settings/breaks",
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function ownerDeleteBreakAction(staffId: string, breakId: string): Promise<BreakActionResult> {
|
||||
const { owner, target, error } = await requireStaffManagerFor(staffId);
|
||||
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await deleteBreak(prisma, { breakId, staffId });
|
||||
if (!result.success) return { success: false, error: "Couldn't delete this break." };
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_BREAK_CHANGED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
});
|
||||
await notifyStaffScheduleChanged(prisma, {
|
||||
businessId: owner.businessId,
|
||||
staffUserId: target.userId,
|
||||
changedByName: owner.user.name ?? "The business owner",
|
||||
whatChanged: "your breaks",
|
||||
viewSchedulePath: "/staff/settings/breaks",
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@/services/staff/staff.service";
|
||||
import type { CreateStaffInput, UpdateStaffAsOwnerInput, WorkingHoursDayInput } from "@/services/staff/staff.service";
|
||||
import { createInvitation, regenerateInvitation } from "@/services/staff/staff-invitation.service";
|
||||
import { notifyStaffScheduleChanged } from "@/services/staff/staff-notifications";
|
||||
|
||||
async function requireStaffManager() {
|
||||
const session = await auth();
|
||||
@@ -27,7 +28,10 @@ async function requireStaffManager() {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
const staff = await prisma.staffProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
include: { business: { select: { timezone: true } }, user: { select: { name: true } } },
|
||||
});
|
||||
if (!staff) return { staff: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
return { staff, error: null } as const;
|
||||
@@ -182,15 +186,31 @@ export async function setStaffServiceEligibilityAction(
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export interface WorkingHoursConflictItem {
|
||||
id: string;
|
||||
customerName: string;
|
||||
serviceNames: string;
|
||||
startAt: string;
|
||||
}
|
||||
|
||||
export interface WorkingHoursActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: WorkingHoursConflictItem[];
|
||||
}
|
||||
|
||||
/** Owner-on-behalf-of-staff working hours — same StaffService.setWorkingHours call the barber's own Phase 5 editor uses, just targeting a chosen staffId instead of the caller. No conflict detection here, matching the existing self-service editor's behaviour (a documented gap — see docs/phase-6-summary.md). */
|
||||
/**
|
||||
* Owner-on-behalf-of-staff working hours — same StaffService.setWorkingHours
|
||||
* call the barber's own Phase 5 editor uses, just targeting a chosen
|
||||
* staffId instead of the caller. Now conflict-reviewed like every other
|
||||
* owner mutation that can strand an appointment (see
|
||||
* docs/phase-7-summary.md, "Working-hours conflict review") — this closes
|
||||
* the gap Phase 6 documented and deliberately left open.
|
||||
*/
|
||||
export async function updateStaffWorkingHoursAction(
|
||||
staffId: string,
|
||||
days: WorkingHoursDayInput[],
|
||||
acknowledgeConflicts = false,
|
||||
): Promise<WorkingHoursActionResult> {
|
||||
const { staff: owner, error } = await requireStaffManager();
|
||||
if (!owner) return { success: false, error: error ?? "Not authorized." };
|
||||
@@ -198,9 +218,23 @@ export async function updateStaffWorkingHoursAction(
|
||||
const target = await prisma.staffProfile.findFirst({ where: { id: staffId, businessId: owner.businessId } });
|
||||
if (!target) return { success: false, error: "Staff member not found." };
|
||||
|
||||
const result = await setWorkingHours(prisma, { staffId, days });
|
||||
const result = await setWorkingHours(prisma, {
|
||||
staffId,
|
||||
businessTimezone: owner.business.timezone,
|
||||
days,
|
||||
acknowledgeConflicts,
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't save working hours." };
|
||||
return {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Couldn't save working hours.",
|
||||
conflicts: result.conflicts.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
await writeAuditLog(prisma, {
|
||||
@@ -209,6 +243,13 @@ export async function updateStaffWorkingHoursAction(
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
});
|
||||
await notifyStaffScheduleChanged(prisma, {
|
||||
businessId: owner.businessId,
|
||||
staffUserId: target.userId,
|
||||
changedByName: owner.user.name ?? "The business owner",
|
||||
whatChanged: "your working hours",
|
||||
viewSchedulePath: "/staff/settings/working-hours",
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ownerCreateTimeOffAction, ownerDeleteTimeOffAction, ownerUpdateTimeOffAction } from "./time-off";
|
||||
import type { TimeOffActionSet } from "@/features/staff/components/time-off-form-sheet";
|
||||
|
||||
/**
|
||||
* Plain module (no "use server") assembling the owner time-off Server
|
||||
* Actions into the `TimeOffActionSet` shape TimeOffList/TimeOffFormSheet
|
||||
* expect, bound to one target staff member — see break-action-set.ts for
|
||||
* why `.bind(null, staffId)` is required here instead of an arrow-function
|
||||
* wrapper.
|
||||
*/
|
||||
export function createOwnerTimeOffActionSet(staffId: string): TimeOffActionSet {
|
||||
return {
|
||||
create: ownerCreateTimeOffAction.bind(null, staffId),
|
||||
update: ownerUpdateTimeOffAction.bind(null, staffId),
|
||||
remove: ownerDeleteTimeOffAction.bind(null, staffId),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import { createTimeOff, deleteTimeOff, updateTimeOff } from "@/services/staff/staff.service";
|
||||
import type { TimeOffInput } from "@/services/staff/staff.service";
|
||||
import { notifyStaffScheduleChanged } from "@/services/staff/staff-notifications";
|
||||
|
||||
export interface TimeOffConflictItem {
|
||||
id: string;
|
||||
customerName: string;
|
||||
serviceNames: string;
|
||||
startAt: string;
|
||||
}
|
||||
|
||||
export interface TimeOffActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: TimeOffConflictItem[];
|
||||
}
|
||||
|
||||
function mapConflicts(conflicts: { id: string; customer: { firstName: string; lastName: string }; services: { service: { name: string } }[]; startAt: Date }[]): TimeOffConflictItem[] {
|
||||
return conflicts.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async function requireStaffManagerFor(staffId: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { owner: null, target: null, error: "You must be signed in." } as const;
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { owner: null, target: null, error: error.message } as const;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const owner = await prisma.staffProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
include: { business: { select: { name: true, timezone: true } }, user: { select: { name: true } } },
|
||||
});
|
||||
if (!owner) return { owner: null, target: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
const target = await prisma.staffProfile.findFirst({ where: { id: staffId, businessId: owner.businessId } });
|
||||
if (!target) return { owner: null, target: null, error: "Staff member not found." } as const;
|
||||
|
||||
return { owner, target, error: null } as const;
|
||||
}
|
||||
|
||||
/** Owner-on-behalf-of-staff time off — mirrors breaks.ts exactly, including why the per-staffId binding lives in the separate time-off-action-set.ts factory rather than here. */
|
||||
export async function ownerCreateTimeOffAction(
|
||||
staffId: string,
|
||||
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
|
||||
acknowledgeConflicts?: boolean,
|
||||
): Promise<TimeOffActionResult> {
|
||||
const { owner, target, error } = await requireStaffManagerFor(staffId);
|
||||
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await createTimeOff(prisma, {
|
||||
staffId,
|
||||
businessTimezone: owner.business.timezone,
|
||||
input,
|
||||
acknowledgeConflicts,
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't add this time off.", conflicts: mapConflicts(result.conflicts) };
|
||||
}
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_TIME_OFF_CHANGED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
});
|
||||
await notifyStaffScheduleChanged(prisma, {
|
||||
businessId: owner.businessId,
|
||||
staffUserId: target.userId,
|
||||
changedByName: owner.user.name ?? "The business owner",
|
||||
whatChanged: "your time off",
|
||||
viewSchedulePath: "/staff/settings/time-off",
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function ownerUpdateTimeOffAction(
|
||||
staffId: string,
|
||||
timeOffId: string,
|
||||
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
|
||||
acknowledgeConflicts?: boolean,
|
||||
): Promise<TimeOffActionResult> {
|
||||
const { owner, target, error } = await requireStaffManagerFor(staffId);
|
||||
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await updateTimeOff(prisma, {
|
||||
timeOffId,
|
||||
staffId,
|
||||
businessTimezone: owner.business.timezone,
|
||||
input,
|
||||
acknowledgeConflicts,
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this time off.", conflicts: mapConflicts(result.conflicts) };
|
||||
}
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_TIME_OFF_CHANGED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
});
|
||||
await notifyStaffScheduleChanged(prisma, {
|
||||
businessId: owner.businessId,
|
||||
staffUserId: target.userId,
|
||||
changedByName: owner.user.name ?? "The business owner",
|
||||
whatChanged: "your time off",
|
||||
viewSchedulePath: "/staff/settings/time-off",
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function ownerDeleteTimeOffAction(staffId: string, timeOffId: string): Promise<TimeOffActionResult> {
|
||||
const { owner, target, error } = await requireStaffManagerFor(staffId);
|
||||
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await deleteTimeOff(prisma, { timeOffId, staffId });
|
||||
if (!result.success) return { success: false, error: "Couldn't remove this time off." };
|
||||
await writeAuditLog(prisma, {
|
||||
actorUserId: owner.userId,
|
||||
action: "STAFF_TIME_OFF_CHANGED",
|
||||
entityType: "StaffProfile",
|
||||
entityId: staffId,
|
||||
});
|
||||
await notifyStaffScheduleChanged(prisma, {
|
||||
businessId: owner.businessId,
|
||||
staffUserId: target.userId,
|
||||
changedByName: owner.user.name ?? "The business owner",
|
||||
whatChanged: "your time off",
|
||||
viewSchedulePath: "/staff/settings/time-off",
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
@@ -158,7 +158,7 @@ export function BookingRulesForm({ initial }: { initial: BookingRuleInput }) {
|
||||
</p>
|
||||
)}
|
||||
{saved && !error && (
|
||||
<p role="status" className="text-accent text-sm font-medium">
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved — the customer booking flow uses these rules immediately. Existing appointments are unaffected.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -193,7 +193,7 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
|
||||
</p>
|
||||
)}
|
||||
{saved && !error && (
|
||||
<p role="status" className="text-accent text-sm font-medium">
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved — changes are live for customers immediately.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -158,7 +158,12 @@ export function LoyaltyConfigForm({
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Rules & Expiry</h3>
|
||||
<div className="rounded-jumbo border-line space-y-5 border bg-white p-6">
|
||||
<Switch label="Apply reward automatically" checked={config.autoApplyReward} onCheckedChange={(v) => set("autoApplyReward", v)} />
|
||||
<Switch
|
||||
label="Apply reward automatically"
|
||||
description="Redemption is always staff-initiated this phase — this setting is saved but has no automatic effect yet."
|
||||
checked={config.autoApplyReward}
|
||||
onCheckedChange={(v) => set("autoApplyReward", v)}
|
||||
/>
|
||||
<Switch
|
||||
label="Require staff approval to redeem"
|
||||
description="Redemption already requires a permitted staff member — this is informational only this phase."
|
||||
@@ -203,7 +208,7 @@ export function LoyaltyConfigForm({
|
||||
</p>
|
||||
)}
|
||||
{saved && !error && (
|
||||
<p role="status" className="text-accent text-sm font-medium">
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -189,7 +189,7 @@ export function OpeningHoursForm({ initialRows }: { initialRows: InitialOpeningH
|
||||
</p>
|
||||
)}
|
||||
{saved && !error && (
|
||||
<p role="status" className="text-accent text-sm font-medium">
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved. The booking engine reflects these hours immediately.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -162,7 +162,7 @@ export function StaffDetailPanel({
|
||||
</p>
|
||||
)}
|
||||
{roleSaved && !roleError && (
|
||||
<p role="status" className="text-accent text-sm font-medium">
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
@@ -206,7 +206,7 @@ export function StaffDetailPanel({
|
||||
</p>
|
||||
)}
|
||||
{servicesSaved && !servicesError && (
|
||||
<p role="status" className="text-accent text-sm font-medium">
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
@@ -220,11 +220,19 @@ export function StaffDetailPanel({
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Availability</h3>
|
||||
<div className="rounded-jumbo border-line overflow-hidden border bg-white">
|
||||
<div className="rounded-jumbo border-line divide-line divide-y overflow-hidden border bg-white">
|
||||
<Link href={`/owner/team/${staffId}/hours`} className="hover:bg-stone flex items-center justify-between px-6 py-4">
|
||||
<span className="text-sm font-bold">Working Hours</span>
|
||||
<span className="text-muted text-xs">Edit</span>
|
||||
</Link>
|
||||
<Link href={`/owner/team/${staffId}/breaks`} className="hover:bg-stone flex items-center justify-between px-6 py-4">
|
||||
<span className="text-sm font-bold">Breaks</span>
|
||||
<span className="text-muted text-xs">Edit</span>
|
||||
</Link>
|
||||
<Link href={`/owner/team/${staffId}/time-off`} className="hover:bg-stone flex items-center justify-between px-6 py-4">
|
||||
<span className="text-sm font-bold">Time Off</span>
|
||||
<span className="text-muted text-xs">Edit</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in new issue
Block a user