Phase 5
Phase 5 is complete. All 53 tasks are done, verified on a fresh database in a single clean run: **121 unit tests**, **55 integration tests**, and **15 E2E journeys** (6 Phase 4 + 9 new Phase 5) all passing, plus a clean `tsc`/`eslint`/`build`. Summary of this final stretch: - **Fixed a real bug**: `StaffService`'s break/time-off update/delete functions threw uncaught instead of failing gracefully on a permission mismatch — fixed with proper `PERMISSION_DENIED` handling, covered by new integration tests. - **Built the remaining Phase 5 UI**: Breaks editor, Time Off editor, Barber Profile, and Notification Preferences — all wired to real services, no placeholders. - **Extended seed data**: more customers, loyalty accounts/transactions/rewards, and appointments spanning every status today (anchored to real time so the "Current Appointment" priority logic is reliably exercised). - **Found and fixed a second real bug live in the browser**: the Break/Time-Off edit sheets showed stale default values instead of the item being edited (a classic `useState`-doesn't-reinitialize-on-prop-change bug) — fixed with a `key` prop to force remount. - **Wrote the full Phase 5 test suite**: unit tests for `selectCurrentAppointment` and all `StaffService` validators, integration tests for `LoyaltyService` and `StaffService` (including the permission-fix regression tests), and 9 new Playwright E2E journeys covering the brief's exact required list. - **Did the desktop-responsive pass**: a persistent sidebar nav replacing the bottom bar at `lg:`, a genuine calendar+"Right Now" side-by-side split, and 2-column layouts for the clients list and customer details — addressing the brief's explicit "more than simple centering" requirement. - **Wrote [docs/phase-5-summary.md](docs/phase-5-summary.md)** documenting everything above in full. Nothing is committed — the whole session (Phases 2–5) sits uncommitted on top of the initial template commit, same as you left it. Let me know if you'd like me to commit, or if you want to move on to Phase 6.
This commit is contained in:
81 files changed
+8024
-113
No files matched your search
@@ -0,0 +1,169 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { cancelBooking, rescheduleBooking } from "@/services/scheduling/booking.service";
|
||||
import {
|
||||
checkIn,
|
||||
complete,
|
||||
confirm,
|
||||
getAppointmentById,
|
||||
markNoShow,
|
||||
start,
|
||||
} from "@/services/scheduling/appointment.service";
|
||||
import { awardStampForCompletedAppointment } from "@/services/loyalty/loyalty.service";
|
||||
import { assertOwnAppointment, StaffPermissionDeniedError } from "../authorization";
|
||||
|
||||
/**
|
||||
* Every status transition here does exactly three things: load the
|
||||
* appointment, assert it's this staff member's own (in their own
|
||||
* business), and delegate to AppointmentService/BookingService for the
|
||||
* actual transition — the transition rules themselves (which statuses can
|
||||
* move to which) live entirely in AppointmentService's own state machine,
|
||||
* never re-implemented here. Completing an appointment additionally calls
|
||||
* LoyaltyService, since "complete this visit" and "award a stamp for it"
|
||||
* are two different domain responsibilities this layer is allowed to
|
||||
* orchestrate together — see docs/phase-5-summary.md.
|
||||
*/
|
||||
|
||||
export interface StaffAppointmentActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
loyaltyStampAwarded?: boolean;
|
||||
}
|
||||
|
||||
async function loadOwnAppointment(appointmentId: string) {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff)
|
||||
return { staff: null, appointment: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
const appointment = await getAppointmentById(prisma, appointmentId);
|
||||
if (!appointment) return { staff, appointment: null, error: "Appointment not found." } as const;
|
||||
|
||||
try {
|
||||
assertOwnAppointment(appointment, staff);
|
||||
} catch (error) {
|
||||
if (error instanceof StaffPermissionDeniedError)
|
||||
return { staff, appointment: null, error: error.message } as const;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { staff, appointment, error: null } as const;
|
||||
}
|
||||
|
||||
export async function confirmAppointmentAction(
|
||||
appointmentId: string,
|
||||
): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnAppointment(appointmentId);
|
||||
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) };
|
||||
}
|
||||
|
||||
export async function checkInAppointmentAction(
|
||||
appointmentId: string,
|
||||
): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnAppointment(appointmentId);
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await checkIn(prisma, appointmentId);
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
|
||||
export async function startAppointmentAction(
|
||||
appointmentId: string,
|
||||
): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnAppointment(appointmentId);
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await start(prisma, appointmentId);
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
|
||||
export async function completeAppointmentAction(
|
||||
appointmentId: string,
|
||||
): Promise<StaffAppointmentActionResult> {
|
||||
const { staff, appointment, error } = await loadOwnAppointment(appointmentId);
|
||||
if (!appointment || !staff) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await complete(prisma, appointmentId);
|
||||
if (!result.success) {
|
||||
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
|
||||
const loyaltyResult = await awardStampForCompletedAppointment(prisma, {
|
||||
appointmentId,
|
||||
staffId: staff.id,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
loyaltyStampAwarded: loyaltyResult.success && !loyaltyResult.alreadyAwarded,
|
||||
};
|
||||
}
|
||||
|
||||
export async function markNoShowAction(
|
||||
appointmentId: string,
|
||||
): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnAppointment(appointmentId);
|
||||
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) };
|
||||
}
|
||||
|
||||
export async function cancelStaffAppointmentAction(params: {
|
||||
appointmentId: string;
|
||||
reason?: string;
|
||||
}): Promise<StaffAppointmentActionResult> {
|
||||
const { staff, appointment, error } = await loadOwnAppointment(params.appointmentId);
|
||||
if (!appointment || !staff) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await cancelBooking(prisma, {
|
||||
appointmentId: params.appointmentId,
|
||||
cancelledByUserId: staff.user.id,
|
||||
reason: params.reason,
|
||||
});
|
||||
return result.success
|
||||
? { success: true }
|
||||
: {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Couldn't cancel this appointment.",
|
||||
};
|
||||
}
|
||||
|
||||
export async function rescheduleStaffAppointmentAction(params: {
|
||||
appointmentId: string;
|
||||
staffId?: string;
|
||||
startAt: string;
|
||||
}): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnAppointment(params.appointmentId);
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await rescheduleBooking(prisma, {
|
||||
appointmentId: params.appointmentId,
|
||||
staffId: params.staffId,
|
||||
startAt: new Date(params.startAt),
|
||||
});
|
||||
return result.success
|
||||
? { success: true }
|
||||
: {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Couldn't reschedule this appointment.",
|
||||
};
|
||||
}
|
||||
|
||||
function friendlyTransitionMessage(code?: string): string {
|
||||
if (code === "INVALID_STATUS_TRANSITION")
|
||||
return "This appointment can't move to that status right now — someone may have already updated it.";
|
||||
if (code === "APPOINTMENT_NOT_FOUND") return "We couldn't find that appointment.";
|
||||
return "Something went wrong. Please try again.";
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { createTimeOff } from "@/services/staff/staff.service";
|
||||
|
||||
export interface BlockTimeConflict {
|
||||
id: string;
|
||||
customerName: string;
|
||||
serviceNames: string;
|
||||
startAt: string;
|
||||
}
|
||||
|
||||
export interface BlockTimeActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: BlockTimeConflict[];
|
||||
}
|
||||
|
||||
/** One-off "Block Time" from the calendar — represented as a partial-day TimeOff row, see schema.prisma and docs/phase-5-summary.md. */
|
||||
export async function blockTimeAction(input: {
|
||||
date: string;
|
||||
startMinutes: number;
|
||||
endMinutes: number;
|
||||
reason: string;
|
||||
acknowledgeConflicts?: boolean;
|
||||
}): Promise<BlockTimeActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff to do that." };
|
||||
|
||||
const result = await createTimeOff(prisma, {
|
||||
staffId: staff.id,
|
||||
businessTimezone: staff.business.timezone,
|
||||
input: {
|
||||
startDate: input.date,
|
||||
endDate: input.date,
|
||||
reason: input.reason.trim() || "Blocked",
|
||||
startMinutes: input.startMinutes,
|
||||
endMinutes: input.endMinutes,
|
||||
},
|
||||
acknowledgeConflicts: input.acknowledgeConflicts,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Something went wrong. Please try again.",
|
||||
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(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { createBreak, deleteBreak, updateBreak } from "@/services/staff/staff.service";
|
||||
import type { BreakInput } from "@/services/staff/staff.service";
|
||||
|
||||
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(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createBreakAction(input: BreakInput, acknowledgeConflicts?: boolean): Promise<BreakActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await createBreak(prisma, { staffId: staff.id, businessTimezone: staff.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) };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateBreakAction(breakId: string, input: BreakInput, acknowledgeConflicts?: boolean): Promise<BreakActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await updateBreak(prisma, { breakId, staffId: staff.id, businessTimezone: staff.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) };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteBreakAction(breakId: string): Promise<BreakActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await deleteBreak(prisma, { breakId, staffId: staff.id });
|
||||
return result.success ? { success: true } : { success: false, error: "Couldn't delete this break." };
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { assertOwnNote, StaffPermissionDeniedError } from "../authorization";
|
||||
|
||||
/**
|
||||
* Note permission model (documented per the Phase 5 brief's "choose a
|
||||
* sensible permission model and document it"):
|
||||
* - Any staff member in the business may ADD a note — notes are shared
|
||||
* context for whoever's working with this customer next, not private.
|
||||
* - Only the AUTHOR may edit or delete their own note — prevents one
|
||||
* staff member silently rewriting or erasing another's observation.
|
||||
* - Edits and deletes are recorded to the existing AuditLog table (see
|
||||
* schema.prisma — built in Phase 2, unused until now) rather than a new
|
||||
* edit-history table; CustomerNote.updatedAt alone tells a reader "this
|
||||
* was edited", AuditLog keeps the discarded content in case it matters
|
||||
* later. Notes are never silently overwritten with no trace.
|
||||
*/
|
||||
|
||||
export interface CustomerNoteActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function addCustomerNoteAction(params: {
|
||||
customerId: string;
|
||||
body: string;
|
||||
}): Promise<CustomerNoteActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
if (!params.body.trim()) return { success: false, error: "Note can't be empty." };
|
||||
|
||||
await prisma.customerNote.create({
|
||||
data: { customerId: params.customerId, authorStaffId: staff.id, body: params.body.trim() },
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateCustomerNoteAction(params: {
|
||||
noteId: string;
|
||||
body: string;
|
||||
}): Promise<CustomerNoteActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
if (!params.body.trim()) return { success: false, error: "Note can't be empty." };
|
||||
|
||||
const existing = await prisma.customerNote.findUnique({ where: { id: params.noteId } });
|
||||
if (!existing) return { success: false, error: "Note not found." };
|
||||
|
||||
try {
|
||||
assertOwnNote(existing, staff);
|
||||
} catch (error) {
|
||||
if (error instanceof StaffPermissionDeniedError)
|
||||
return { success: false, error: error.message };
|
||||
throw error;
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.customerNote.update({
|
||||
where: { id: params.noteId },
|
||||
data: { body: params.body.trim() },
|
||||
}),
|
||||
prisma.auditLog.create({
|
||||
data: {
|
||||
actorUserId: staff.user.id,
|
||||
action: "UPDATE",
|
||||
entityType: "CustomerNote",
|
||||
entityId: params.noteId,
|
||||
metadata: { previousBody: existing.body },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteCustomerNoteAction(params: {
|
||||
noteId: string;
|
||||
}): Promise<CustomerNoteActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const existing = await prisma.customerNote.findUnique({ where: { id: params.noteId } });
|
||||
if (!existing) return { success: false, error: "Note not found." };
|
||||
|
||||
try {
|
||||
assertOwnNote(existing, staff);
|
||||
} catch (error) {
|
||||
if (error instanceof StaffPermissionDeniedError)
|
||||
return { success: false, error: error.message };
|
||||
throw error;
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.auditLog.create({
|
||||
data: {
|
||||
actorUserId: staff.user.id,
|
||||
action: "DELETE",
|
||||
entityType: "CustomerNote",
|
||||
entityId: params.noteId,
|
||||
metadata: { body: existing.body, customerId: existing.customerId },
|
||||
},
|
||||
}),
|
||||
prisma.customerNote.delete({ where: { id: params.noteId } }),
|
||||
]);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { addStamp, correctStamp } from "@/services/loyalty/loyalty.service";
|
||||
|
||||
export interface LoyaltyActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
rewardCreated?: boolean;
|
||||
}
|
||||
|
||||
export async function addLoyaltyStampAction(params: {
|
||||
customerId: string;
|
||||
amount: number;
|
||||
appointmentId?: string;
|
||||
reason?: string;
|
||||
}): Promise<LoyaltyActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await addStamp(prisma, {
|
||||
customerId: params.customerId,
|
||||
businessId: staff.businessId,
|
||||
amount: params.amount,
|
||||
staffId: staff.id,
|
||||
appointmentId: params.appointmentId,
|
||||
reason: params.reason,
|
||||
});
|
||||
|
||||
return result.success
|
||||
? { success: true, rewardCreated: result.rewardCreated }
|
||||
: { success: false, error: result.validation.issues[0]?.message ?? "Couldn't add a stamp." };
|
||||
}
|
||||
|
||||
export async function correctLoyaltyStampAction(params: {
|
||||
loyaltyAccountId: string;
|
||||
amount: number;
|
||||
reason: string;
|
||||
}): Promise<LoyaltyActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await correctStamp(prisma, {
|
||||
loyaltyAccountId: params.loyaltyAccountId,
|
||||
amount: params.amount,
|
||||
staffId: staff.id,
|
||||
reason: params.reason,
|
||||
});
|
||||
|
||||
return result.success
|
||||
? { success: true }
|
||||
: {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Couldn't correct that stamp.",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { setNotificationPreferences, updateStaffProfile } from "@/services/staff/staff.service";
|
||||
import type { NotificationPreferenceCell } from "@/services/staff/staff.service";
|
||||
|
||||
export interface ProfileActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function updateProfileAction(input: {
|
||||
name: string;
|
||||
phone: string;
|
||||
image: string;
|
||||
bio: string;
|
||||
isBookableOnline: boolean;
|
||||
}): Promise<ProfileActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
if (!input.name.trim()) return { success: false, error: "Name can't be empty." };
|
||||
|
||||
const result = await updateStaffProfile(prisma, {
|
||||
staffId: staff.id,
|
||||
name: input.name.trim(),
|
||||
phone: input.phone.trim(),
|
||||
image: input.image.trim(),
|
||||
bio: input.bio.trim(),
|
||||
isBookableOnline: input.isBookableOnline,
|
||||
});
|
||||
|
||||
return result.success ? { success: true } : { success: false, error: "Couldn't save your profile." };
|
||||
}
|
||||
|
||||
export async function saveNotificationPreferencesAction(
|
||||
cells: NotificationPreferenceCell[],
|
||||
): Promise<ProfileActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
await setNotificationPreferences(prisma, staff.userId, cells);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import {
|
||||
clearStaffDraft,
|
||||
readStaffDraft,
|
||||
writeLastBookingReference,
|
||||
writeStaffDraft,
|
||||
} from "@/lib/booking-draft/cookie";
|
||||
import { guestDetailsSchema } from "@/lib/booking-draft/schema";
|
||||
import { getFirstFriendlyMessage } from "@/features/booking/error-messages";
|
||||
import { resolveCustomerForBooking } from "@/services/customer/customer.service";
|
||||
import { createBooking } from "@/services/scheduling/booking.service";
|
||||
import { validateBookingSlot } from "@/services/scheduling/availability.service";
|
||||
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
||||
import { assertOwnAppointment, StaffPermissionDeniedError } from "../authorization";
|
||||
|
||||
/**
|
||||
* The staff "book for customer" flow — collects input across a few steps
|
||||
* (same signed-draft-cookie pattern as the customer flow, see
|
||||
* src/lib/booking-draft), then hands off entirely to BookingService at
|
||||
* confirm time. No availability or validation logic lives here; every
|
||||
* check either already happened in AvailabilityService (via
|
||||
* validateBookingSlot, for a better error message earlier) or happens
|
||||
* again, authoritatively, inside BookingService.createBooking itself.
|
||||
*/
|
||||
|
||||
export interface StaffBookingStepResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string[] | undefined>;
|
||||
}
|
||||
|
||||
export async function selectExistingCustomerAction(customerId: string): Promise<void> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) redirect("/home");
|
||||
|
||||
const draft = await readStaffDraft(staff.businessId);
|
||||
await writeStaffDraft({ ...draft, businessId: staff.businessId, customerId, guest: undefined });
|
||||
redirect("/staff/book/services");
|
||||
}
|
||||
|
||||
export async function selectNewGuestCustomerAction(
|
||||
input: unknown,
|
||||
): Promise<StaffBookingStepResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const parsed = guestDetailsSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Please check the highlighted fields.",
|
||||
fieldErrors: parsed.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
const draft = await readStaffDraft(staff.businessId);
|
||||
await writeStaffDraft({
|
||||
...draft,
|
||||
businessId: staff.businessId,
|
||||
guest: parsed.data,
|
||||
customerId: undefined,
|
||||
});
|
||||
redirect("/staff/book/services");
|
||||
}
|
||||
|
||||
export async function saveStaffServicesStepAction(input: {
|
||||
serviceIds: string[];
|
||||
}): Promise<StaffBookingStepResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
if (input.serviceIds.length === 0)
|
||||
return { success: false, error: "Please select at least one service." };
|
||||
|
||||
const draft = await readStaffDraft(staff.businessId);
|
||||
if (!draft.customerId && !draft.guest) redirect("/staff/book");
|
||||
|
||||
const previousServiceIds = [...draft.serviceIds].sort().join(",");
|
||||
const nextServiceIds = [...input.serviceIds].sort().join(",");
|
||||
const servicesChanged = previousServiceIds !== nextServiceIds;
|
||||
|
||||
await writeStaffDraft({
|
||||
...draft,
|
||||
businessId: staff.businessId,
|
||||
serviceIds: input.serviceIds,
|
||||
...(servicesChanged ? { staffId: undefined, date: undefined, startAt: undefined } : {}),
|
||||
});
|
||||
redirect("/staff/book/barber-time");
|
||||
}
|
||||
|
||||
export async function saveStaffBarberTimeStepAction(input: {
|
||||
staffId?: string;
|
||||
date: string;
|
||||
startAt: string;
|
||||
}): Promise<StaffBookingStepResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const draft = await readStaffDraft(staff.businessId);
|
||||
if (draft.serviceIds.length === 0)
|
||||
return { success: false, error: "Please select a service first." };
|
||||
|
||||
// Only pre-validate a specific staff pick — "any available barber" is
|
||||
// resolved by BookingService itself at confirm time, same reasoning as
|
||||
// the customer flow's equivalent step (see draft.ts).
|
||||
if (input.staffId) {
|
||||
const { validation } = await validateBookingSlot(prisma, {
|
||||
businessId: staff.businessId,
|
||||
serviceIds: draft.serviceIds,
|
||||
staffId: input.staffId,
|
||||
startAt: new Date(input.startAt),
|
||||
});
|
||||
if (!validation.valid) return { success: false, error: getFirstFriendlyMessage(validation) };
|
||||
}
|
||||
|
||||
await writeStaffDraft({
|
||||
...draft,
|
||||
staffId: input.staffId,
|
||||
date: input.date,
|
||||
startAt: input.startAt,
|
||||
});
|
||||
redirect("/staff/book/review");
|
||||
}
|
||||
|
||||
export async function saveStaffNotesAction(notes: string): Promise<StaffBookingStepResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
const draft = await readStaffDraft(staff.businessId);
|
||||
await writeStaffDraft({ ...draft, notes: notes || undefined });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function confirmStaffBookingAction(notes?: string): Promise<StaffBookingStepResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const draft = await readStaffDraft(staff.businessId);
|
||||
if (draft.serviceIds.length === 0 || !draft.startAt) {
|
||||
return { success: false, error: "Booking details are incomplete — please start again." };
|
||||
}
|
||||
if (!draft.customerId && !draft.guest) {
|
||||
return { success: false, error: "Please select or add a customer first." };
|
||||
}
|
||||
|
||||
let customerId: string;
|
||||
try {
|
||||
if (draft.customerId) {
|
||||
customerId = draft.customerId;
|
||||
} else {
|
||||
const resolved = await resolveCustomerForBooking(prisma, {
|
||||
businessId: staff.businessId,
|
||||
guest: draft.guest,
|
||||
});
|
||||
customerId = resolved.customerId;
|
||||
}
|
||||
} catch {
|
||||
return { success: false, error: "We couldn't resolve the customer. Please try again." };
|
||||
}
|
||||
|
||||
const result = await createBooking(prisma, {
|
||||
businessId: staff.businessId,
|
||||
customerId,
|
||||
serviceIds: draft.serviceIds,
|
||||
staffId: draft.staffId,
|
||||
startAt: new Date(draft.startAt),
|
||||
notes: notes || draft.notes,
|
||||
createdByUserId: staff.user.id,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: getFirstFriendlyMessage(result.validation) };
|
||||
}
|
||||
|
||||
await writeLastBookingReference(result.appointment.id);
|
||||
await clearStaffDraft();
|
||||
redirect(`/staff/appointments/${result.appointment.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Book Follow-Up" — preselects customer, services, and barber from a
|
||||
* past appointment, then sends the barber straight to the services step
|
||||
* (skipping customer selection, since it's already known). Prices and
|
||||
* availability are recalculated fresh at every later step; nothing here
|
||||
* copies the old appointment's snapshot values.
|
||||
*/
|
||||
export async function startStaffFollowUpAction(appointmentId: string): Promise<void> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) redirect("/home");
|
||||
|
||||
const appointment = await getAppointmentById(prisma, appointmentId);
|
||||
if (!appointment) redirect("/staff/current");
|
||||
|
||||
try {
|
||||
assertOwnAppointment(appointment, staff);
|
||||
} catch (error) {
|
||||
if (error instanceof StaffPermissionDeniedError) redirect("/staff/current");
|
||||
throw error;
|
||||
}
|
||||
|
||||
const activeServiceIds = appointment.services
|
||||
.filter((line) => line.service.isActive)
|
||||
.map((line) => line.serviceId);
|
||||
const staffId =
|
||||
appointment.staff.status === "ACTIVE" && appointment.staff.isBookableOnline
|
||||
? appointment.staffId
|
||||
: undefined;
|
||||
|
||||
await clearStaffDraft();
|
||||
await writeStaffDraft({
|
||||
businessId: appointment.businessId,
|
||||
serviceIds: activeServiceIds,
|
||||
staffId,
|
||||
customerId: appointment.customerId,
|
||||
followUpFromAppointmentId: appointment.id,
|
||||
});
|
||||
|
||||
redirect("/staff/book/services");
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { createTimeOff, deleteTimeOff, updateTimeOff } from "@/services/staff/staff.service";
|
||||
import type { TimeOffInput } from "@/services/staff/staff.service";
|
||||
|
||||
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(),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Whole-day time off only — the settings editor doesn't handle partial-day blocks; that's the calendar's "Block Time" action (see block-time.ts). */
|
||||
export async function createTimeOffAction(
|
||||
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
|
||||
acknowledgeConflicts?: boolean,
|
||||
): Promise<TimeOffActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await createTimeOff(prisma, {
|
||||
staffId: staff.id,
|
||||
businessTimezone: staff.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) };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateTimeOffAction(
|
||||
timeOffId: string,
|
||||
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
|
||||
acknowledgeConflicts?: boolean,
|
||||
): Promise<TimeOffActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await updateTimeOff(prisma, {
|
||||
timeOffId,
|
||||
staffId: staff.id,
|
||||
businessTimezone: staff.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) };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteTimeOffAction(timeOffId: string): Promise<TimeOffActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await deleteTimeOff(prisma, { timeOffId, staffId: staff.id });
|
||||
return result.success ? { success: true } : { success: false, error: "Couldn't remove this time off." };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { setWorkingHours } from "@/services/staff/staff.service";
|
||||
import type { WorkingHoursDayInput } from "@/services/staff/staff.service";
|
||||
|
||||
export interface WorkingHoursActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function saveWorkingHoursAction(days: WorkingHoursDayInput[]): Promise<WorkingHoursActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await setWorkingHours(prisma, { staffId: staff.id, days });
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't save working hours." };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
Reference in new issue
Block a user