PHP Rebuild
CI / ci (push) Canceled after 0s

Initial PHP rebuild of the app
This commit is contained in:
Stefan Lazic committed 2026-09-05 08:21:33 +01:00
1 parent 8a2b211069
commit 2ef3654841
690 files changed
+33944 -12046

No files matched your search

@@ -1,56 +0,0 @@
import { describe, expect, it } from "vitest";
import {
StaffPermissionDeniedError,
assertAppointmentInBusiness,
assertOwnAppointment,
assertOwnNote,
} from "../authorization";
const staff = { id: "staff_1", businessId: "biz_1" };
const otherStaff = { id: "staff_2", businessId: "biz_1" };
describe("assertAppointmentInBusiness", () => {
it("allows an appointment in the staff member's own business", () => {
expect(() =>
assertAppointmentInBusiness({ businessId: "biz_1", staffId: "staff_9" }, staff),
).not.toThrow();
});
it("denies an appointment from a different business, even if the staffId happened to match", () => {
expect(() =>
assertAppointmentInBusiness({ businessId: "biz_2", staffId: "staff_1" }, staff),
).toThrow(StaffPermissionDeniedError);
});
});
describe("assertOwnAppointment", () => {
it("allows the assigned staff member to manage their own appointment", () => {
expect(() =>
assertOwnAppointment({ businessId: "biz_1", staffId: "staff_1" }, staff),
).not.toThrow();
});
it("denies another staff member in the same business from managing it", () => {
expect(() =>
assertOwnAppointment({ businessId: "biz_1", staffId: "staff_1" }, otherStaff),
).toThrow(StaffPermissionDeniedError);
});
it("denies a cross-business appointment even for the matching staffId", () => {
expect(() =>
assertOwnAppointment({ businessId: "biz_2", staffId: "staff_1" }, staff),
).toThrow(StaffPermissionDeniedError);
});
});
describe("assertOwnNote", () => {
it("allows the author to act on their own note", () => {
expect(() => assertOwnNote({ authorStaffId: "staff_1" }, staff)).not.toThrow();
});
it("denies a staff member editing another staff member's note", () => {
expect(() => assertOwnNote({ authorStaffId: "staff_1" }, otherStaff)).toThrow(
StaffPermissionDeniedError,
);
});
});
@@ -1,182 +0,0 @@
"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 { notifyAppointmentNoShow, notifyBookingApproved, notifyBookingCompleted } from "@/services/scheduling/booking-notifications";
import { syncAppointmentSafely } from "@/services/calendar/calendar-sync.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);
if (!result.success) {
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
await notifyBookingApproved(prisma, result.appointment);
// Approval is the first point a PENDING appointment becomes syncable —
// see calendar-sync.service.ts's own policy comment.
await syncAppointmentSafely(prisma, appointmentId);
return { success: true };
}
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) };
}
await notifyBookingCompleted(prisma, result.appointment);
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);
if (!result.success) {
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
await notifyAppointmentNoShow(prisma, result.appointment);
await syncAppointmentSafely(prisma, appointmentId);
return { success: true };
}
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,
});
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't cancel this appointment." };
}
await syncAppointmentSafely(prisma, params.appointmentId);
return { success: true };
}
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),
});
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't reschedule this appointment." };
}
// Covers both a plain reschedule and a barber reassignment (staffId
// change) — syncAppointmentSafely itself detects and cancels a stale
// event under the previous staff member's connection when the staffId
// changed (see that function's own comment).
await syncAppointmentSafely(prisma, params.appointmentId);
return { success: true };
}
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.";
}
-58
View File
@@ -1,58 +0,0 @@
"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 };
}
-58
View File
@@ -1,58 +0,0 @@
"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." };
}
@@ -1,107 +0,0 @@
"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 };
}
@@ -1,57 +0,0 @@
"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.",
};
}
-61
View File
@@ -1,61 +0,0 @@
"use server";
import { auth } from "@/auth";
import { NotificationType } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { setNotificationPreference } from "@/services/notifications/notification.service";
import type { PreferenceCellState } from "@/features/staff/components/notification-preferences-form";
import { updateStaffProfile } 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." };
}
/**
* Keyed purely by userId, not a StaffProfile — a Super Administrator (who
* has no StaffProfile, see isSuperAdmin) still has their own notification
* preferences, so this checks session auth directly rather than requiring
* getActingStaff() the way the staff-specific actions above do.
*/
export async function saveNotificationPreferencesAction(
cells: PreferenceCellState[],
): Promise<ProfileActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "You must be signed in." };
for (const cell of cells) {
await setNotificationPreference(prisma, {
userId: session.user.id,
type: NotificationType.STAFF_APPOINTMENT_REMINDER,
channel: cell.channel,
isEnabled: cell.isEnabled,
});
}
return { success: true };
}
-221
View File
@@ -1,221 +0,0 @@
"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({ appointmentId: 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");
}
-77
View File
@@ -1,77 +0,0 @@
"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." };
}
@@ -1,47 +0,0 @@
"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 WorkingHoursConflictItem {
id: string;
customerName: string;
serviceNames: string;
startAt: string;
}
export interface WorkingHoursActionResult {
success: boolean;
error?: string;
conflicts?: WorkingHoursConflictItem[];
}
export async function saveWorkingHoursAction(
days: WorkingHoursDayInput[],
acknowledgeConflicts = false,
): 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,
businessTimezone: staff.business.timezone,
days,
acknowledgeConflicts,
});
if (!result.success) {
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(),
})),
};
}
return { success: true };
}
-64
View File
@@ -1,64 +0,0 @@
/**
* Staff-facing ownership checks — the mirror of
* src/features/booking/authorization.ts for the barber section. Kept in
* the feature layer, not a domain service, for the same reason as the
* customer version: AppointmentService/CustomerNote etc. are reused by
* multiple future permission contexts (a future owner all-staff view will
* need different rules), so "can THIS staff member touch THIS resource"
* lives next to the staff actions that need it, not inside the services.
*
* Business scoping note: CustomerProfile has no businessId column (see
* schema.prisma) — this is the same single-business v1 assumption Phase 4
* documented for the customer app. Every appointment IS businessId-scoped,
* so appointment checks below are real; a customer-record check would be a
* no-op given the current schema and isn't included for that reason.
*/
export class StaffPermissionDeniedError extends Error {
constructor(message = "You don't have permission to do that.") {
super(message);
this.name = "StaffPermissionDeniedError";
}
}
export interface StaffOwnableAppointment {
businessId: string;
staffId: string;
}
export interface ActingStaff {
id: string;
businessId: string;
}
/** Viewing (details, calendar, customer history) only requires the appointment to belong to the staff member's own business — see the class doc for why "own business" is the only real scoping this phase (no all-staff owner calendar built yet). */
export function assertAppointmentInBusiness(
appointment: StaffOwnableAppointment,
staff: ActingStaff,
): void {
if (appointment.businessId !== staff.businessId) {
throw new StaffPermissionDeniedError("This appointment doesn't belong to your business.");
}
}
/** Mutating an appointment (status transitions, reschedule, cancel) additionally requires it to be assigned to THIS staff member — Phase 5 scope is "manage appointments assigned to them", not any staff member's. */
export function assertOwnAppointment(
appointment: StaffOwnableAppointment,
staff: ActingStaff,
): void {
assertAppointmentInBusiness(appointment, staff);
if (appointment.staffId !== staff.id) {
throw new StaffPermissionDeniedError("You can only manage appointments assigned to you.");
}
}
export interface OwnableNote {
authorStaffId: string;
}
/** Any staff member may add a note; only its author may edit or delete it — see docs/phase-5-summary.md for the full note permission model and why. */
export function assertOwnNote(note: OwnableNote, staff: ActingStaff): void {
if (note.authorStaffId !== staff.id) {
throw new StaffPermissionDeniedError("You can only edit or delete your own notes.");
}
}
@@ -1,117 +0,0 @@
"use client";
import { Stamp } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Modal } from "@/components/ui/modal";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils/cn";
import { addLoyaltyStampAction } from "../actions/loyalty-actions";
export function AddStampButton({
customerId,
appointmentId,
className,
size = "sm",
}: {
customerId: string;
appointmentId?: string;
className?: string;
size?: "sm" | "md" | "lg";
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [amount, setAmount] = useState("1");
const [reason, setReason] = useState("");
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<{ rewardCreated?: boolean } | null>(null);
const [isPending, startTransition] = useTransition();
const close = () => {
const shouldRefresh = !!result;
setOpen(false);
setResult(null);
setError(null);
setAmount("1");
setReason("");
if (shouldRefresh) router.refresh();
};
const submit = () => {
const parsed = Number(amount);
if (!Number.isInteger(parsed) || parsed <= 0) {
setError("Enter a positive number of stamps.");
return;
}
setError(null);
startTransition(async () => {
const res = await addLoyaltyStampAction({
customerId,
amount: parsed,
appointmentId,
reason: reason || undefined,
});
if (!res.success) {
setError(res.error ?? "Couldn't add a stamp.");
return;
}
setResult({ rewardCreated: res.rewardCreated });
});
};
return (
<>
<Button
variant="secondary"
size={size}
className={cn("shrink-0", className)}
onClick={() => setOpen(true)}
>
<Stamp className="h-4 w-4" aria-hidden="true" />
Add Loyalty Stamp
</Button>
<Modal open={open} onClose={close} title={result ? "Stamp Added" : "Add Loyalty Stamp"}>
{result ? (
<div className="space-y-4">
<p role="status" className="text-muted text-sm">
The stamp has been recorded on this customer&apos;s loyalty account.
</p>
{result.rewardCreated && (
<p className="border-accent-loyalty/30 bg-accent-loyalty/10 rounded-lg border p-3 text-xs font-medium">
They&apos;ve now reached the stamp threshold — a reward is available.
</p>
)}
<Button className="w-full" onClick={close}>
Done
</Button>
</div>
) : (
<div className="space-y-4">
<Input
label="Stamps to add"
type="number"
min={1}
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
<Input
label="Reason (optional)"
placeholder="e.g. Referral bonus"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={submit}>
Add Stamp
</Button>
</div>
)}
</Modal>
</>
);
}
@@ -1,251 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Modal } from "@/components/ui/modal";
import { Textarea } from "@/components/ui/textarea";
import {
cancelStaffAppointmentAction,
checkInAppointmentAction,
completeAppointmentAction,
confirmAppointmentAction,
markNoShowAction,
startAppointmentAction,
} from "../actions/appointment-actions";
import type { StaffAppointmentActionResult } from "../actions/appointment-actions";
type RawStatus =
"PENDING" | "CONFIRMED" | "CHECKED_IN" | "IN_PROGRESS" | "COMPLETED" | "CANCELLED" | "NO_SHOW";
type ConfirmKind = "complete" | "no_show" | "cancel";
/** Same shape the owner variant (src/features/owner/actions/appointments.ts) implements — see AppointmentStatusActions' `actions` prop. */
export interface AppointmentActionSet {
confirm: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
checkIn: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
start: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
complete: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
markNoShow: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
cancel: (params: { appointmentId: string; reason?: string }) => Promise<StaffAppointmentActionResult>;
}
const DEFAULT_ACTIONS: AppointmentActionSet = {
confirm: confirmAppointmentAction,
checkIn: checkInAppointmentAction,
start: startAppointmentAction,
complete: completeAppointmentAction,
markNoShow: markNoShowAction,
cancel: cancelStaffAppointmentAction,
};
/**
* The status-appropriate action set — shared by Current Appointment and
* Appointment Details, since both need exactly the same buttons for the
* same status. Every button calls a Server Action that delegates to
* AppointmentService's own state machine; this component never decides
* whether a transition is legal, only which buttons to *offer* for a given
* status (offering an illegal one would just get rejected server-side —
* see appointment-actions.ts). `actions` defaults to the staff (own-only)
* Server Actions; the owner appointment details page passes the owner
* variant instead, so this one component serves both contexts without
* forking — see docs/phase-6-summary.md, "Owner appointment access".
*/
export function AppointmentStatusActions({
appointmentId,
status,
actions = DEFAULT_ACTIONS,
layout = "wrap",
}: {
appointmentId: string;
status: RawStatus;
actions?: AppointmentActionSet;
/** "stack" renders one full-width button per row (Current Appointment); "wrap" (default, used by Appointment Details) keeps the original inline small buttons. */
layout?: "wrap" | "stack";
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [confirmKind, setConfirmKind] = useState<ConfirmKind | null>(null);
const [reason, setReason] = useState("");
const [done, setDone] = useState<{ loyaltyStampAwarded?: boolean } | null>(null);
const runQuiet = (action: () => Promise<{ success: boolean; error?: string }>) => {
setError(null);
startTransition(async () => {
const result = await action();
if (!result.success) {
setError(result.error ?? "Something went wrong.");
return;
}
router.refresh();
});
};
const runConfirmed = () => {
if (!confirmKind) return;
setError(null);
startTransition(async () => {
const result =
confirmKind === "complete"
? await actions.complete(appointmentId)
: confirmKind === "no_show"
? await actions.markNoShow(appointmentId)
: await actions.cancel({ appointmentId, reason: reason || undefined });
if (!result.success) {
setError(result.error ?? "Something went wrong.");
return;
}
setDone(
confirmKind === "complete" ? { loyaltyStampAwarded: result.loyaltyStampAwarded } : {},
);
});
};
const closeConfirm = () => {
const shouldRefresh = !!done;
setConfirmKind(null);
setDone(null);
setReason("");
setError(null);
if (shouldRefresh) router.refresh();
};
const modalTitle =
confirmKind === "complete"
? "Complete Appointment?"
: confirmKind === "no_show"
? "Mark as No-Show?"
: "Cancel Appointment?";
return (
<div className="space-y-3">
{error && !confirmKind && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<div className={layout === "stack" ? "flex flex-col gap-2" : "flex flex-wrap gap-2"}>
{status === "PENDING" && (
<Button
size={layout === "stack" ? undefined : "sm"}
className={layout === "stack" ? "w-full" : undefined}
isLoading={isPending}
onClick={() => runQuiet(() => actions.confirm(appointmentId))}
>
Confirm Booking
</Button>
)}
{status === "CONFIRMED" && (
<Button
size={layout === "stack" ? undefined : "sm"}
className={layout === "stack" ? "w-full" : undefined}
isLoading={isPending}
onClick={() => runQuiet(() => actions.checkIn(appointmentId))}
>
Check In
</Button>
)}
{status === "CHECKED_IN" && (
<Button
size={layout === "stack" ? undefined : "sm"}
className={layout === "stack" ? "w-full" : undefined}
isLoading={isPending}
onClick={() => runQuiet(() => actions.start(appointmentId))}
>
Start Appointment
</Button>
)}
{status === "IN_PROGRESS" && (
<Button
size={layout === "stack" ? undefined : "sm"}
className={layout === "stack" ? "w-full" : undefined}
onClick={() => setConfirmKind("complete")}
>
Complete Appointment
</Button>
)}
{status === "CONFIRMED" && (
<Button
size={layout === "stack" ? undefined : "sm"}
className={layout === "stack" ? "w-full" : undefined}
variant="secondary"
onClick={() => setConfirmKind("no_show")}
>
Mark No-Show
</Button>
)}
{(status === "PENDING" || status === "CONFIRMED" || status === "CHECKED_IN") && (
<Button
size={layout === "stack" ? undefined : "sm"}
className={layout === "stack" ? "w-full" : undefined}
variant="destructive"
onClick={() => setConfirmKind("cancel")}
>
Cancel
</Button>
)}
</div>
<Modal open={!!confirmKind} onClose={closeConfirm} title={done ? "Done" : modalTitle}>
{done ? (
<div className="space-y-4">
<p role="status" className="text-muted text-sm">
{confirmKind === "complete" && "This appointment is marked complete."}
{confirmKind === "no_show" && "This appointment is marked as a no-show."}
{confirmKind === "cancel" &&
"This appointment has been cancelled. It won't be deleted from history."}
</p>
{done.loyaltyStampAwarded && (
<p className="border-accent-loyalty/30 bg-accent-loyalty/10 rounded-lg border p-3 text-xs font-medium">
A loyalty stamp was added for this visit.
</p>
)}
<Button className="w-full" onClick={closeConfirm}>
Done
</Button>
</div>
) : (
<div className="space-y-4">
<p className="text-muted text-sm">
{confirmKind === "complete" &&
"This marks the visit as finished and (if eligible) adds a loyalty stamp."}
{confirmKind === "no_show" &&
"The customer didn't arrive for this appointment. This can't be undone."}
{confirmKind === "cancel" &&
"This can't be undone. You can add an optional reason below."}
</p>
{confirmKind === "cancel" && (
<Textarea
placeholder="Reason (optional)"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
)}
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={closeConfirm}>
Keep As Is
</Button>
<Button
variant={confirmKind === "complete" ? "primary" : "destructive"}
className="flex-1"
isLoading={isPending}
onClick={runConfirmed}
>
{confirmKind === "complete"
? "Complete"
: confirmKind === "no_show"
? "Mark No-Show"
: "Cancel It"}
</Button>
</div>
</div>
)}
</Modal>
</div>
);
}
@@ -1,147 +0,0 @@
"use client";
import { CalendarOff } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { BottomSheet } from "@/components/ui/bottom-sheet";
import { Input } from "@/components/ui/input";
import type { BlockTimeConflict } from "../actions/block-time";
import { blockTimeAction } from "../actions/block-time";
function toMinutes(hhmm: string): number | null {
const match = /^(\d{2}):(\d{2})$/.exec(hhmm);
if (!match) return null;
return Number(match[1]) * 60 + Number(match[2]);
}
export function BlockTimeButton({ date }: { date: string }) {
const [open, setOpen] = useState(false);
const [startTime, setStartTime] = useState("12:00");
const [endTime, setEndTime] = useState("13:00");
const [reason, setReason] = useState("");
const [error, setError] = useState<string | null>(null);
const [conflicts, setConflicts] = useState<BlockTimeConflict[] | null>(null);
const [isPending, startTransition] = useTransition();
const router = useRouter();
const handleClose = () => {
setOpen(false);
setError(null);
setConflicts(null);
setReason("");
};
const submit = (acknowledgeConflicts: boolean) => {
const startMinutes = toMinutes(startTime);
const endMinutes = toMinutes(endTime);
if (startMinutes === null || endMinutes === null || startMinutes >= endMinutes) {
setError("Start time must be before end time.");
return;
}
setError(null);
startTransition(async () => {
const result = await blockTimeAction({
date,
startMinutes,
endMinutes,
reason,
acknowledgeConflicts,
});
if (!result.success) {
setError(result.error ?? "Something went wrong.");
setConflicts(result.conflicts ?? null);
return;
}
setOpen(false);
setConflicts(null);
router.refresh();
});
};
return (
<>
<Button variant="secondary" size="sm" aria-label="Block Time" onClick={() => setOpen(true)}>
<CalendarOff className="h-4 w-4" aria-hidden="true" />
<span className="hidden sm:inline">Block Time</span>
</Button>
<BottomSheet open={open} onClose={handleClose} title="Block Time">
<div className="space-y-4 pb-2">
<p className="text-muted text-sm">
Reserve part of{" "}
{new Date(`${date}T00:00:00`).toLocaleDateString("en-GB", {
weekday: "long",
day: "numeric",
month: "short",
})}{" "}
for something other than appointments — a meeting, admin, a personal break.
</p>
{conflicts && conflicts.length > 0 ? (
<div className="space-y-3">
<p role="alert" className="text-danger text-sm font-medium">
This overlaps {conflicts.length} existing appointment
{conflicts.length > 1 ? "s" : ""}:
</p>
<ul className="space-y-2">
{conflicts.map((c) => (
<li key={c.id} className="border-line rounded-lg border bg-white p-3 text-xs">
<Link href={`/staff/appointments/${c.id}`} className="font-bold underline">
{c.customerName}
</Link>
<p className="text-muted">{c.serviceNames}</p>
</li>
))}
</ul>
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={handleClose}>
Cancel
</Button>
<Button
variant="destructive"
className="flex-1"
isLoading={isPending}
onClick={() => submit(true)}
>
Block Anyway
</Button>
</div>
</div>
) : (
<>
<div className="grid grid-cols-2 gap-4">
<Input
label="Start"
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
/>
<Input
label="End"
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
/>
</div>
<Input
label="Reason (optional)"
placeholder="e.g. Team meeting"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={() => submit(false)}>
Block This Time
</Button>
</>
)}
</div>
</BottomSheet>
</>
);
}
@@ -1,27 +0,0 @@
"use client";
import { useTransition } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils/cn";
import { startStaffFollowUpAction } from "../actions/staff-booking";
export function BookFollowUpButton({
appointmentId,
className,
}: {
appointmentId: string;
className?: string;
}) {
const [isPending, startTransition] = useTransition();
return (
<Button
variant="secondary"
className={cn("rounded-md border-line border bg-white px-5 py-3 text-sm font-bold", className)}
isLoading={isPending}
onClick={() => startTransition(() => startStaffFollowUpAction(appointmentId))}
>
Book Follow-Up
</Button>
);
}
@@ -1,230 +0,0 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { BottomSheet } from "@/components/ui/bottom-sheet";
import { Input } from "@/components/ui/input";
import { Weekday } from "@/generated/prisma/enums";
import type { BreakActionResult, BreakConflictItem } from "../actions/breaks";
import { createBreakAction, deleteBreakAction, updateBreakAction } from "../actions/breaks";
import type { BreakInput } from "@/services/staff/staff.service";
/** Same DI shape as AppointmentStatusActions' `actions` prop — the owner-on-behalf-of-staff editor injects its own Server Actions bound to a chosen staffId instead of forking this component. */
export interface BreakActionSet {
create: (input: BreakInput, acknowledgeConflicts?: boolean) => Promise<BreakActionResult>;
update: (breakId: string, input: BreakInput, acknowledgeConflicts?: boolean) => Promise<BreakActionResult>;
remove: (breakId: string) => Promise<BreakActionResult>;
}
const DEFAULT_ACTIONS: BreakActionSet = {
create: createBreakAction,
update: updateBreakAction,
remove: deleteBreakAction,
};
const DAY_ORDER: Weekday[] = [
Weekday.MONDAY,
Weekday.TUESDAY,
Weekday.WEDNESDAY,
Weekday.THURSDAY,
Weekday.FRIDAY,
Weekday.SATURDAY,
Weekday.SUNDAY,
];
const DAY_SHORT: Record<Weekday, string> = {
MONDAY: "Mon",
TUESDAY: "Tue",
WEDNESDAY: "Wed",
THURSDAY: "Thu",
FRIDAY: "Fri",
SATURDAY: "Sat",
SUNDAY: "Sun",
};
function toMinutes(hhmm: string): number | null {
const match = /^(\d{2}):(\d{2})$/.exec(hhmm);
if (!match) return null;
return Number(match[1]) * 60 + Number(match[2]);
}
function toTimeString(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
export interface EditableBreak {
id: string;
label: string;
startMinutes: number;
endMinutes: number;
days: Weekday[];
}
export function BreakFormSheet({
open,
onClose,
existing,
actions = DEFAULT_ACTIONS,
appointmentHrefBase = "/staff/appointments",
}: {
open: boolean;
onClose: () => void;
existing?: EditableBreak | null;
actions?: BreakActionSet;
appointmentHrefBase?: string;
}) {
const router = useRouter();
const [label, setLabel] = useState(existing?.label ?? "Break");
const [startTime, setStartTime] = useState(existing ? toTimeString(existing.startMinutes) : "12:00");
const [endTime, setEndTime] = useState(existing ? toTimeString(existing.endMinutes) : "13:00");
const [days, setDays] = useState<Weekday[]>(existing?.days ?? []);
const [error, setError] = useState<string | null>(null);
const [conflicts, setConflicts] = useState<BreakConflictItem[] | null>(null);
const [isPending, startTransition] = useTransition();
const [isDeleting, startDeleteTransition] = useTransition();
const reset = () => {
setLabel(existing?.label ?? "Break");
setStartTime(existing ? toTimeString(existing.startMinutes) : "12:00");
setEndTime(existing ? toTimeString(existing.endMinutes) : "13:00");
setDays(existing?.days ?? []);
setError(null);
setConflicts(null);
};
const handleClose = () => {
onClose();
reset();
};
const toggleDay = (day: Weekday) => {
setDays((prev) => (prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day]));
};
const submit = (acknowledgeConflicts: boolean) => {
const startMinutes = toMinutes(startTime);
const endMinutes = toMinutes(endTime);
if (startMinutes === null || endMinutes === null || startMinutes >= endMinutes) {
setError("Start time must be before end time.");
return;
}
if (days.length === 0) {
setError("Select at least one day.");
return;
}
if (!label.trim()) {
setError("Give this break a label.");
return;
}
setError(null);
startTransition(async () => {
const input = { label, startMinutes, endMinutes, days };
const result: BreakActionResult = existing
? await actions.update(existing.id, input, acknowledgeConflicts)
: await actions.create(input, acknowledgeConflicts);
if (!result.success) {
setError(result.error ?? "Something went wrong.");
setConflicts(result.conflicts ?? null);
return;
}
handleClose();
router.refresh();
});
};
const handleDelete = () => {
if (!existing) return;
startDeleteTransition(async () => {
const result = await actions.remove(existing.id);
if (!result.success) {
setError(result.error ?? "Couldn't delete this break.");
return;
}
handleClose();
router.refresh();
});
};
return (
<BottomSheet open={open} onClose={handleClose} title={existing ? "Edit Break" : "New Break"}>
<div className="space-y-4 pb-2">
{conflicts && conflicts.length > 0 ? (
<div className="space-y-3">
<p role="alert" className="text-danger text-sm font-medium">
This overlaps {conflicts.length} upcoming appointment
{conflicts.length > 1 ? "s" : ""}:
</p>
<ul className="space-y-2">
{conflicts.map((c) => (
<li key={c.id} className="border-line rounded-lg border bg-white p-3 text-xs">
<Link href={`${appointmentHrefBase}/${c.id}`} className="font-bold underline">
{c.customerName}
</Link>
<p className="text-muted">{c.serviceNames}</p>
</li>
))}
</ul>
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={handleClose}>
Cancel
</Button>
<Button
variant="destructive"
className="flex-1"
isLoading={isPending}
onClick={() => submit(true)}
>
Save Anyway
</Button>
</div>
</div>
) : (
<>
<Input label="Label" placeholder="e.g. Lunch" value={label} onChange={(e) => setLabel(e.target.value)} />
<div className="grid grid-cols-2 gap-4">
<Input label="Start" type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} />
<Input label="End" type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} />
</div>
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-widest text-muted">Repeats on</p>
<div className="flex flex-wrap gap-2">
{DAY_ORDER.map((day) => {
const active = days.includes(day);
return (
<button
key={day}
type="button"
onClick={() => toggleDay(day)}
aria-pressed={active}
className={`rounded-full border px-3 py-1.5 text-xs font-bold transition-colors ${
active ? "border-accent bg-accent text-accent-foreground" : "border-line bg-white text-ink"
}`}
>
{DAY_SHORT[day]}
</button>
);
})}
</div>
</div>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={() => submit(false)}>
{existing ? "Save Changes" : "Add Break"}
</Button>
{existing && (
<Button variant="ghost" className="w-full text-danger" isLoading={isDeleting} onClick={handleDelete}>
Delete Break
</Button>
)}
</>
)}
</div>
</BottomSheet>
);
}
@@ -1,106 +0,0 @@
"use client";
import { Plus } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Weekday } from "@/generated/prisma/enums";
import { BreakFormSheet } from "./break-form-sheet";
import type { BreakActionSet, EditableBreak } from "./break-form-sheet";
const DAY_SHORT: Record<Weekday, string> = {
MONDAY: "Mon",
TUESDAY: "Tue",
WEDNESDAY: "Wed",
THURSDAY: "Thu",
FRIDAY: "Fri",
SATURDAY: "Sat",
SUNDAY: "Sun",
};
const DAY_ORDER: Weekday[] = [
Weekday.MONDAY,
Weekday.TUESDAY,
Weekday.WEDNESDAY,
Weekday.THURSDAY,
Weekday.FRIDAY,
Weekday.SATURDAY,
Weekday.SUNDAY,
];
function formatDays(days: Weekday[]): string {
return DAY_ORDER.filter((d) => days.includes(d))
.map((d) => DAY_SHORT[d])
.join(", ");
}
function toTimeString(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
const period = h >= 12 ? "PM" : "AM";
const h12 = h % 12 === 0 ? 12 : h % 12;
return `${h12}:${String(m).padStart(2, "0")} ${period}`;
}
export function BreaksList({
breaks,
actions,
appointmentHrefBase,
}: {
breaks: EditableBreak[];
actions?: BreakActionSet;
appointmentHrefBase?: string;
}) {
const [sheetOpen, setSheetOpen] = useState(false);
const [editing, setEditing] = useState<EditableBreak | null>(null);
const openNew = () => {
setEditing(null);
setSheetOpen(true);
};
const openEdit = (b: EditableBreak) => {
setEditing(b);
setSheetOpen(true);
};
return (
<div className="space-y-4">
<Button variant="secondary" className="w-full" onClick={openNew}>
<Plus className="h-4 w-4" aria-hidden="true" />
Add Break
</Button>
{breaks.length === 0 ? (
<p className="text-muted rounded-md border border-line bg-white p-5 text-center text-sm">
No recurring breaks yet.
</p>
) : (
<ul className="divide-y divide-line rounded-md border border-line bg-white">
{breaks.map((b) => (
<li key={b.id}>
<button
type="button"
onClick={() => openEdit(b)}
className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left hover:bg-stone"
>
<div>
<p className="text-sm font-bold">{b.label}</p>
<p className="text-xs text-muted">
{toTimeString(b.startMinutes)}–{toTimeString(b.endMinutes)} · {formatDays(b.days)}
</p>
</div>
</button>
</li>
))}
</ul>
)}
<BreakFormSheet
key={editing?.id ?? "new"}
open={sheetOpen}
onClose={() => setSheetOpen(false)}
existing={editing}
actions={actions}
appointmentHrefBase={appointmentHrefBase}
/>
</div>
);
}
@@ -1,39 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { confirmStaffBookingAction } from "../actions/staff-booking";
export function ConfirmStaffBooking() {
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const handleConfirm = () => {
setError(null);
startTransition(async () => {
const result = await confirmStaffBookingAction(notes || undefined);
if (!result.success) setError(result.error ?? "Something went wrong. Please try again.");
});
};
return (
<div className="space-y-4">
<Textarea
label="Booking Notes (optional)"
placeholder="e.g. requested style, allergies…"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<Button className="w-full" onClick={handleConfirm} isLoading={isPending}>
Confirm Booking
</Button>
</div>
);
}
@@ -1,108 +0,0 @@
"use client";
import { Undo2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Modal } from "@/components/ui/modal";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils/cn";
import { correctLoyaltyStampAction } from "../actions/loyalty-actions";
/** Removes stamps via a compensating ledger entry — never edits or deletes the original transaction. A reason is required (enforced both here and server-side in LoyaltyService.correctStamp). */
export function CorrectStampButton({
loyaltyAccountId,
className,
}: {
loyaltyAccountId: string;
className?: string;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [amount, setAmount] = useState("1");
const [reason, setReason] = useState("");
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const [isPending, startTransition] = useTransition();
const close = () => {
const shouldRefresh = done;
setOpen(false);
setDone(false);
setError(null);
setAmount("1");
setReason("");
if (shouldRefresh) router.refresh();
};
const submit = () => {
const parsed = Number(amount);
if (!Number.isInteger(parsed) || parsed <= 0) {
setError("Enter a positive number of stamps to remove.");
return;
}
if (!reason.trim()) {
setError("A reason is required to correct a stamp.");
return;
}
setError(null);
startTransition(async () => {
const res = await correctLoyaltyStampAction({ loyaltyAccountId, amount: parsed, reason });
if (!res.success) {
setError(res.error ?? "Couldn't correct that stamp.");
return;
}
setDone(true);
});
};
return (
<>
<Button variant="secondary" size="sm" className={cn(className)} onClick={() => setOpen(true)}>
<Undo2 className="h-4 w-4" aria-hidden="true" />
Correct Stamp
</Button>
<Modal open={open} onClose={close} title={done ? "Stamp Corrected" : "Correct a Stamp"}>
{done ? (
<div className="space-y-4">
<p role="status" className="text-muted text-sm">
A compensating entry has been recorded — the original stamp stays in the
customer&apos;s history.
</p>
<Button className="w-full" onClick={close}>
Done
</Button>
</div>
) : (
<div className="space-y-4">
<p className="text-muted text-sm">
This removes stamps by recording a correction — it never deletes the original entry.
</p>
<Input
label="Stamps to remove"
type="number"
min={1}
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
<Textarea
label="Reason (required)"
placeholder="e.g. Added by mistake"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<Button className="w-full" variant="destructive" isLoading={isPending} onClick={submit}>
Correct Stamp
</Button>
</div>
)}
</Modal>
</>
);
}
@@ -1,187 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
addCustomerNoteAction,
deleteCustomerNoteAction,
updateCustomerNoteAction,
} from "../actions/customer-notes";
export interface CustomerNoteItem {
id: string;
body: string;
createdAt: string;
updatedAt: string;
authorStaffId: string;
authorName: string;
}
export function CustomerNotes({
customerId,
notes,
actingStaffId,
}: {
customerId: string;
notes: CustomerNoteItem[];
actingStaffId: string;
}) {
const router = useRouter();
const [newBody, setNewBody] = useState("");
const [error, setError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editBody, setEditBody] = useState("");
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const submitNew = () => {
if (!newBody.trim()) return;
setError(null);
startTransition(async () => {
const result = await addCustomerNoteAction({ customerId, body: newBody });
if (!result.success) {
setError(result.error ?? "Couldn't add note.");
return;
}
setNewBody("");
router.refresh();
});
};
const startEdit = (note: CustomerNoteItem) => {
setEditingId(note.id);
setEditBody(note.body);
};
const saveEdit = (noteId: string) => {
setError(null);
startTransition(async () => {
const result = await updateCustomerNoteAction({ noteId, body: editBody });
if (!result.success) {
setError(result.error ?? "Couldn't save note.");
return;
}
setEditingId(null);
router.refresh();
});
};
const confirmDelete = (noteId: string) => {
setError(null);
startTransition(async () => {
const result = await deleteCustomerNoteAction({ noteId });
if (!result.success) {
setError(result.error ?? "Couldn't delete note.");
return;
}
setConfirmDeleteId(null);
router.refresh();
});
};
return (
<div className="space-y-4">
<div className="space-y-2">
<Textarea
placeholder="Add a note about this customer…"
value={newBody}
onChange={(e) => setNewBody(e.target.value)}
/>
<Button size="sm" isLoading={isPending} disabled={!newBody.trim()} onClick={submitNew}>
Add Note
</Button>
</div>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
{notes.length === 0 ? (
<p className="text-muted text-sm">No notes yet.</p>
) : (
<ul className="space-y-3">
{notes.map((note) => {
const isOwn = note.authorStaffId === actingStaffId;
const isEditing = editingId === note.id;
const edited = note.updatedAt !== note.createdAt;
return (
<li key={note.id} className="border-line rounded-lg border bg-white p-3">
<div className="mb-1 flex items-center justify-between">
<span className="text-muted text-[10px] font-bold tracking-widest uppercase">
{note.authorName} ·{" "}
{new Date(note.createdAt).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
})}
{edited && " (edited)"}
</span>
</div>
{isEditing ? (
<div className="space-y-2">
<Textarea value={editBody} onChange={(e) => setEditBody(e.target.value)} />
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => setEditingId(null)}>
Cancel
</Button>
<Button size="sm" isLoading={isPending} onClick={() => saveEdit(note.id)}>
Save
</Button>
</div>
</div>
) : (
<>
<p className="text-muted text-sm">{note.body}</p>
{isOwn && (
<div className="mt-2 flex gap-3">
<button
type="button"
onClick={() => startEdit(note)}
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
>
Edit
</button>
{confirmDeleteId === note.id ? (
<>
<span className="text-muted text-[10px]">Delete this note?</span>
<button
type="button"
onClick={() => confirmDelete(note.id)}
className="text-danger text-[10px] font-bold tracking-widest uppercase underline"
>
Confirm
</button>
<button
type="button"
onClick={() => setConfirmDeleteId(null)}
className="text-muted text-[10px] font-bold tracking-widest uppercase underline"
>
Cancel
</button>
</>
) : (
<button
type="button"
onClick={() => setConfirmDeleteId(note.id)}
className="text-danger text-[10px] font-bold tracking-widest uppercase underline"
>
Delete
</button>
)}
</div>
)}
</>
)}
</li>
);
})}
</ul>
)}
</div>
);
}
@@ -1,103 +0,0 @@
import Link from "next/link";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { addDays, todayIn } from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { cn } from "@/lib/utils/cn";
/**
* Today/previous/next/date-selection, all as plain navigation — every
* control here is a Link that changes the `?date=` search param, so the
* whole calendar page stays a Server Component with no client JS needed
* just to pick a day.
*/
export function DateStrip({
selectedDate,
businessTimezone,
basePath,
view,
extraParams,
}: {
selectedDate: CalendarDate;
businessTimezone: string;
basePath: string;
view?: string;
/** Preserved across date navigation — e.g. the owner calendar's `?staffId=` filter. */
extraParams?: Record<string, string>;
}) {
const today = todayIn(businessTimezone);
const viewParam = view ? `&view=${view}` : "";
const extraParamsString = extraParams
? Object.entries(extraParams)
.map(([key, value]) => `&${key}=${encodeURIComponent(value)}`)
.join("")
: "";
const hrefFor = (date: CalendarDate) => `${basePath}?date=${date}${viewParam}${extraParamsString}`;
const days = Array.from({ length: 7 }, (_, i) => addDays(selectedDate, i - 3));
const label = new Date(`${selectedDate}T00:00:00`).toLocaleDateString("en-GB", {
weekday: "long",
day: "numeric",
month: "long",
});
return (
<div className="space-y-3 px-6 pt-4">
<div className="border-line flex items-center overflow-hidden rounded-full border bg-white">
<Link
href={hrefFor(addDays(selectedDate, -1))}
aria-label="Previous day"
className="hover:bg-stone flex h-11 w-12 shrink-0 items-center justify-center"
>
<ChevronLeft className="text-ink h-4 w-4" aria-hidden="true" />
</Link>
<div className="flex-1 py-1.5 text-center">
<p className="text-sm font-bold">{selectedDate === today ? "Today" : label}</p>
{selectedDate === today && <p className="text-muted text-xs">{label}</p>}
{selectedDate !== today && (
<Link
href={hrefFor(today)}
className="text-accent text-[10px] font-bold tracking-widest uppercase underline"
>
Jump to today
</Link>
)}
</div>
<Link
href={hrefFor(addDays(selectedDate, 1))}
aria-label="Next day"
className="hover:bg-stone flex h-11 w-12 shrink-0 items-center justify-center"
>
<ChevronRight className="text-ink h-4 w-4" aria-hidden="true" />
</Link>
</div>
<div className="flex justify-between gap-1">
{days.map((date) => {
const isSelected = date === selectedDate;
const isToday = date === today;
const d = new Date(`${date}T00:00:00`);
return (
<Link
key={date}
href={hrefFor(date)}
aria-current={isSelected ? "date" : undefined}
className="flex flex-1 flex-col items-center gap-1 py-2 text-xs"
>
<span className="text-muted text-[9px] font-bold tracking-widest uppercase">
{d.toLocaleDateString("en-GB", { weekday: "short" })}
</span>
<span
className={cn(
"flex h-7 w-7 items-center justify-center rounded-full font-bold",
isSelected ? "bg-ink text-white" : isToday ? "text-accent" : "text-ink",
)}
>
{d.getDate()}
</span>
</Link>
);
})}
</div>
</div>
);
}
@@ -1,354 +0,0 @@
import Image from "next/image";
import Link from "next/link";
import { Ban, CheckCheck, CircleDot, Clock, Gift, LogIn, NotebookText, UserX } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils/cn";
import type { AppointmentStatus } from "@/types/domain";
const START_HOUR = 7;
const END_HOUR = 21;
const PX_PER_HOUR = 112;
const MINUTES_PER_HOUR = 60;
const RANGE_START_MINUTES = START_HOUR * MINUTES_PER_HOUR;
const RANGE_END_MINUTES = END_HOUR * MINUTES_PER_HOUR;
const HEADER_HEIGHT = 56;
function topFor(minutes: number): number {
return ((clampToRange(minutes) - RANGE_START_MINUTES) / MINUTES_PER_HOUR) * PX_PER_HOUR;
}
function clampToRange(minutes: number): number {
return Math.min(RANGE_END_MINUTES, Math.max(RANGE_START_MINUTES, minutes));
}
function heightFor(startMinutes: number, endMinutes: number): number {
return Math.max(24, topFor(endMinutes) - topFor(startMinutes));
}
/** Compact "9:30" / "1:15" clock text — no AM/PM, matching the tight in-block time-range label. */
function formatCompactTime(minutes: number): string {
const h = Math.floor(minutes / MINUTES_PER_HOUR);
const m = minutes % MINUTES_PER_HOUR;
const displayHour = h % 12 === 0 ? 12 : h % 12;
return `${displayHour}:${String(m).padStart(2, "0")}`;
}
/** "2:47 pm" — the current-time indicator's own label, read out next to its dot on the axis. */
function formatCurrentTimeLabel(minutes: number): string {
const h = Math.floor(minutes / MINUTES_PER_HOUR);
const m = minutes % MINUTES_PER_HOUR;
const period = h >= 12 ? "pm" : "am";
const displayHour = h % 12 === 0 ? 12 : h % 12;
return `${displayHour}:${String(m).padStart(2, "0")} ${period}`;
}
export interface CalendarAppointment {
id: string;
startMinutes: number;
endMinutes: number;
customerName: string;
serviceNames: string;
status: AppointmentStatus;
hasNotes: boolean;
hasAvailableReward: boolean;
}
export interface CalendarBlock {
id: string;
startMinutes: number;
endMinutes: number;
label: string;
kind: "break" | "timeOff";
}
export interface CalendarColumn {
id: string;
name: string;
avatarUrl?: string | null;
/** Formatted working-hours range for the day (e.g. "10:00 AM – 7:00 PM"), or null if off entirely. */
hoursLabel: string | null;
wholeDayOff: boolean;
appointments: CalendarAppointment[];
blocks: CalendarBlock[];
/** The column's working window that day, for shading outside-hours space — null if off entirely. */
workingWindow: { startMinutes: number; endMinutes: number } | null;
}
const STATUS_BADGE_ICON: Record<AppointmentStatus, LucideIcon> = {
pending_approval: Clock,
confirmed: CheckCheck,
arrived: LogIn,
in_progress: CircleDot,
completed: CheckCheck,
cancelled: Ban,
no_show: UserX,
};
/** Column width on small screens — under 100% so the next column peeks in at the edge, inviting a swipe (per the reference design). Desktop ignores this and flexes to fill instead. */
const COLUMN_WIDTH_CLASS = "w-[86%] shrink-0 snap-start sm:w-[300px] lg:w-auto lg:flex-1";
/**
* A shared-axis, multi-column, swipeable day grid — one time axis pinned to
* the left edge (via `sticky`, so it never scrolls out of view), one column
* per staff member snapping into place as the row is swiped horizontally
* (CSS scroll-snap — no touch/gesture JS needed, matching this app's
* "reliable tap/scroll over fragile custom gestures" preference). A single
* staff member's own view (staff/page.tsx) is just the one-column case of
* the same component. Deliberately no drag-and-drop — every interactive
* element is a plain Link (open details / start a reschedule from there).
* Pure presentation: every minute value here is already computed by the
* page from real WorkingHours/Break/TimeOff/Appointment rows.
*/
export function DayCalendar({
columns,
currentTimeMinutes,
appointmentHrefBase = "/staff/appointments",
}: {
columns: CalendarColumn[];
/** Minutes-from-midnight right now, only passed when the viewed day is today. */
currentTimeMinutes: number | null;
/** Owner's all-staff calendar reuses this component unchanged but links into /owner/appointments/[id] instead — see docs/phase-6-summary.md, "All-staff calendar approach". */
appointmentHrefBase?: string;
}) {
const hourMarks = Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, i) => START_HOUR + i);
const gridHeight = (END_HOUR - START_HOUR) * PX_PER_HOUR;
const showCurrentTime =
currentTimeMinutes !== null && currentTimeMinutes >= RANGE_START_MINUTES && currentTimeMinutes <= RANGE_END_MINUTES;
return (
<div className="flex gap-2 px-6 pb-8" data-testid="calendar-grid">
{/* Time axis — a plain sibling of the scrolling columns strip below, not part of its horizontal scroll at all, so it's immune to any scroll-snap/sticky edge cases: it simply never moves. */}
<div className="w-11 shrink-0" style={{ height: HEADER_HEIGHT + gridHeight }}>
<div style={{ height: HEADER_HEIGHT }} aria-hidden="true" />
<div className="relative" style={{ height: gridHeight }}>
{hourMarks.map((hour) =>
hour === END_HOUR ? null : (
<div key={hour} className="absolute inset-x-0" style={{ top: (hour - START_HOUR) * PX_PER_HOUR }}>
<span className="text-muted absolute -top-2 right-0 text-[10px] font-bold">{formatHour(hour)}</span>
{[15, 30, 45].map((minute) => (
<span
key={minute}
className="text-muted/70 absolute right-0 text-[9px]"
style={{ top: (minute / MINUTES_PER_HOUR) * PX_PER_HOUR - 6 }}
>
{minute}
</span>
))}
</div>
),
)}
{showCurrentTime && (
<div
className="absolute inset-x-0 z-40 flex items-center justify-end gap-1"
style={{ top: topFor(currentTimeMinutes!) }}
aria-hidden="true"
>
<span className="bg-danger h-2 w-2 shrink-0 -translate-y-1/2 rounded-full" />
<span className="bg-danger absolute left-full -translate-y-1/2 rounded px-1.5 py-0.5 text-[9px] font-bold whitespace-nowrap text-white">
{formatCurrentTimeLabel(currentTimeMinutes!)}
</span>
</div>
)}
</div>
</div>
{/* Staff columns — a separately-scrolling strip, so swiping through staff never drags the axis along with it. Each column is a full swipeable "page": its own header, then its own grid body. */}
<div className="snap-x snap-mandatory flex flex-1 gap-3 overflow-x-auto">
{columns.map((column) => (
<div key={column.id} className={COLUMN_WIDTH_CLASS}>
<div className="flex items-center gap-2 pb-3" style={{ height: HEADER_HEIGHT }}>
{column.avatarUrl ? (
<Image
src={column.avatarUrl}
alt=""
width={36}
height={36}
className="h-9 w-9 shrink-0 rounded-full border border-line object-cover"
/>
) : (
<div className="bg-white text-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-line text-sm font-bold">
{column.name.charAt(0)}
</div>
)}
<div className="min-w-0">
<p className="truncate text-sm font-bold">{column.name}</p>
<p className="text-muted truncate text-[10px]">{column.hoursLabel ?? "Off today"}</p>
</div>
</div>
<div className="border-line relative border-l" style={{ height: gridHeight }}>
{/* Hour gridlines */}
{hourMarks.map((hour) => (
<div
key={hour}
className="border-line absolute inset-x-0 border-t"
style={{ top: (hour - START_HOUR) * PX_PER_HOUR }}
/>
))}
{column.wholeDayOff ? (
<div className="bg-line/10 absolute inset-0 flex items-center justify-center">
<span className="text-muted text-xs font-bold tracking-widest uppercase">Off today</span>
</div>
) : (
<>
{/* Outside-working-hours shading — diagonal hatch, same texture as the break/time-off blocks below. */}
{column.workingWindow ? (
<>
{column.workingWindow.startMinutes > RANGE_START_MINUTES && (
<div
className={HATCH_CLASS}
style={{ top: 0, height: topFor(column.workingWindow.startMinutes) }}
aria-hidden="true"
/>
)}
{column.workingWindow.endMinutes < RANGE_END_MINUTES && (
<div
className={HATCH_CLASS}
style={{ top: topFor(column.workingWindow.endMinutes), bottom: 0 }}
aria-hidden="true"
/>
)}
</>
) : (
<div className={cn(HATCH_CLASS, "inset-0")} aria-hidden="true" />
)}
{/* Breaks / time off */}
{column.blocks.map((block) => (
<div
key={block.id}
className="border-warning/40 bg-warning/10 absolute inset-x-1 z-10 overflow-hidden rounded-lg border bg-[repeating-linear-gradient(135deg,transparent,transparent_6px,rgba(0,0,0,0.04)_6px,rgba(0,0,0,0.04)_12px)] px-2 py-1"
style={{
top: topFor(block.startMinutes),
height: heightFor(block.startMinutes, block.endMinutes),
}}
>
<p className="text-warning-foreground truncate text-[10px] font-bold tracking-widest uppercase">
{block.label}
</p>
</div>
))}
{/* Appointments */}
{column.appointments.map((appt) => {
const blockHeight = heightFor(appt.startMinutes, appt.endMinutes);
const BadgeIcon = STATUS_BADGE_ICON[appt.status];
return (
<Link
key={appt.id}
href={`${appointmentHrefBase}/${appt.id}`}
className={cn(
"absolute inset-x-1 z-20 overflow-hidden rounded-lg border-l-4 p-2 shadow-sm transition-shadow hover:shadow-md",
"focus-visible:ring-ink focus-visible:ring-2 focus-visible:ring-offset-1",
statusBlockClass(appt.status),
)}
style={{
top: topFor(appt.startMinutes),
height: blockHeight,
}}
>
<span
className={cn(
"absolute top-1 right-1 flex h-4 w-4 items-center justify-center rounded-full",
statusBadgeClass(appt.status),
)}
>
<BadgeIcon className="h-2.5 w-2.5" aria-hidden="true" />
<span className="sr-only">{statusLabel(appt.status)}</span>
</span>
<div className="flex items-center gap-1 pr-4">
<p className="truncate text-[11px] font-bold">
{formatCompactTime(appt.startMinutes)} – {formatCompactTime(appt.endMinutes)}
</p>
{appt.hasNotes && <NotebookText className="text-muted h-2.5 w-2.5 shrink-0" aria-label="Has notes" />}
{appt.hasAvailableReward && (
<Gift className="text-accent-loyalty h-2.5 w-2.5 shrink-0" aria-label="Reward available" />
)}
</div>
{blockHeight >= 44 && (
<p className="truncate text-[11px] opacity-80">
{appt.customerName} • {appt.serviceNames}
</p>
)}
</Link>
);
})}
{/* Current-time indicator */}
{showCurrentTime && (
<div
className="border-danger pointer-events-none absolute inset-x-0 z-30 border-t-2"
style={{ top: topFor(currentTimeMinutes!) }}
aria-hidden="true"
/>
)}
</>
)}
</div>
</div>
))}
</div>
</div>
);
}
const HATCH_CLASS =
"bg-line/15 absolute inset-x-0 bg-[repeating-linear-gradient(135deg,transparent,transparent_6px,rgba(0,0,0,0.05)_6px,rgba(0,0,0,0.05)_12px)]";
function formatHour(hour: number): string {
if (hour === 0 || hour === 24) return "12:00";
if (hour === 12) return "12:00";
return `${hour > 12 ? hour - 12 : hour}:00`;
}
function statusLabel(status: AppointmentStatus): string {
switch (status) {
case "pending_approval":
return "Pending approval";
case "confirmed":
return "Confirmed";
case "arrived":
return "Arrived";
case "in_progress":
return "In progress";
case "completed":
return "Completed";
case "cancelled":
return "Cancelled";
case "no_show":
return "No-show";
}
}
function statusBlockClass(status: AppointmentStatus): string {
switch (status) {
case "cancelled":
case "no_show":
return "bg-status-cancelled/10 border-status-cancelled text-status-cancelled-foreground opacity-70";
case "completed":
return "bg-line/40 border-line text-muted";
case "in_progress":
return "bg-status-progress/15 border-status-progress text-ink";
case "arrived":
return "bg-status-confirmed/15 border-status-confirmed text-ink";
case "pending_approval":
return "bg-status-pending/15 border-status-pending text-ink";
default:
return "bg-status-confirmed/15 border-status-confirmed text-ink";
}
}
function statusBadgeClass(status: AppointmentStatus): string {
switch (status) {
case "cancelled":
case "no_show":
return "bg-status-cancelled text-white";
case "completed":
return "bg-muted text-white";
case "in_progress":
return "bg-status-progress text-white";
case "arrived":
return "bg-status-confirmed text-white";
case "pending_approval":
return "bg-status-pending text-white";
default:
return "bg-status-confirmed text-white";
}
}
@@ -1,61 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState, useTransition } from "react";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { guestDetailsSchema } from "@/lib/booking-draft/schema";
import type { GuestDetails } from "@/lib/booking-draft/schema";
import { selectNewGuestCustomerAction } from "../actions/staff-booking";
/** Same guestDetailsSchema the customer app's own guest checkout uses — a new customer entered by staff is resolved through the exact same CustomerService matching rules, see staff-booking.ts. */
export function NewGuestCustomerForm() {
const [serverError, setServerError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const {
register,
handleSubmit,
setError,
formState: { errors },
} = useForm<GuestDetails>({ resolver: zodResolver(guestDetailsSchema) });
const onSubmit = (data: GuestDetails) => {
setServerError(null);
startTransition(async () => {
const result = await selectNewGuestCustomerAction(data);
if (!result.success) {
setServerError(result.error ?? "Please check the highlighted fields.");
if (result.fieldErrors) {
for (const [field, messages] of Object.entries(result.fieldErrors)) {
if (messages?.[0]) setError(field as keyof GuestDetails, { message: messages[0] });
}
}
}
});
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Input label="First Name" error={errors.firstName?.message} {...register("firstName")} />
<Input label="Last Name" error={errors.lastName?.message} {...register("lastName")} />
</div>
<Input
label="Email Address"
type="email"
error={errors.email?.message}
{...register("email")}
/>
<Input label="Phone Number" type="tel" error={errors.phone?.message} {...register("phone")} />
{serverError && (
<p role="alert" className="text-danger text-sm font-medium">
{serverError}
</p>
)}
<Button type="submit" className="w-full" isLoading={isPending}>
Continue With This Customer
</Button>
</form>
);
}
@@ -1,88 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { NotificationChannel } from "@/generated/prisma/enums";
const CHANNEL_ORDER: NotificationChannel[] = [NotificationChannel.EMAIL, NotificationChannel.PUSH];
const CHANNEL_LABEL: Record<NotificationChannel, string> = { EMAIL: "Email", PUSH: "Push", IN_APP: "In-app" };
export interface PreferenceCellState {
channel: NotificationChannel;
isEnabled: boolean;
}
/**
* Every other notification a barber gets (new appointment, reschedule,
* cancellation, schedule change) is required — always sent, no toggle —
* so this form only ever shows the one type that's genuinely optional:
* appointment reminders. See NotificationService/types.ts,
* PREFERENCE_CONTROLLED_TYPES, and docs/phase-7-summary.md — showing a
* toggle for a required type would just be a no-op switch that does
* nothing when flipped, which the Phase 7 brief explicitly calls out to
* avoid.
*/
export interface NotificationPreferencesFormProps {
initial: PreferenceCellState[];
/** Recipient-specific — see features/staff/actions/profile.ts (staff) and features/notifications/actions/preferences.ts (customer). */
action: (cells: PreferenceCellState[]) => Promise<{ success: boolean; error?: string }>;
}
export function NotificationPreferencesForm({ initial, action }: NotificationPreferencesFormProps) {
const router = useRouter();
const [cells, setCells] = useState(initial);
const [saved, setSaved] = useState(false);
const [isPending, startTransition] = useTransition();
const isEnabled = (channel: NotificationChannel) => cells.find((c) => c.channel === channel)?.isEnabled ?? true;
const toggle = (channel: NotificationChannel) => {
setSaved(false);
setCells((prev) => prev.map((c) => (c.channel === channel ? { ...c, isEnabled: !c.isEnabled } : c)));
};
const handleSave = () => {
startTransition(async () => {
await action(cells);
setSaved(true);
router.refresh();
});
};
return (
<div className="space-y-4">
<p className="text-muted text-sm">
Booking confirmations, changes, and cancellations always notify you — those aren&apos;t
optional. Choose how you&apos;d like to hear about upcoming appointment reminders.
</p>
<div className="rounded-md border-line divide-line divide-y overflow-hidden border bg-white">
{CHANNEL_ORDER.map((channel) => (
<div key={channel} className="flex items-center justify-between px-4 py-3">
<span className="text-sm font-medium">Reminders via {CHANNEL_LABEL[channel]}</span>
<button
type="button"
role="switch"
aria-checked={isEnabled(channel)}
aria-label={`Appointment reminders via ${CHANNEL_LABEL[channel]}`}
onClick={() => toggle(channel)}
className={`h-5 w-5 rounded-md border transition-colors ${
isEnabled(channel) ? "border-accent bg-accent" : "border-line bg-white"
}`}
/>
</div>
))}
</div>
{saved && (
<p role="status" className="text-accent-foreground text-sm font-medium">
Saved.
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
Save
</Button>
</div>
);
}
@@ -1,87 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { MediaPicker } from "@/features/media/components/media-picker";
import { updateProfileAction } from "../actions/profile";
export interface ProfileFormInitial {
name: string;
phone: string;
image: string;
bio: string;
isBookableOnline: boolean;
}
export function ProfileForm({ initial }: { initial: ProfileFormInitial }) {
const router = useRouter();
const [name, setName] = useState(initial.name);
const [phone, setPhone] = useState(initial.phone);
const [image, setImage] = useState(initial.image);
const [bio, setBio] = useState(initial.bio);
const [isBookableOnline, setIsBookableOnline] = useState(initial.isBookableOnline);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [isPending, startTransition] = useTransition();
const handleSave = () => {
setError(null);
setSaved(false);
startTransition(async () => {
const result = await updateProfileAction({ name, phone, image, bio, isBookableOnline });
if (!result.success) {
setError(result.error ?? "Couldn't save your profile.");
return;
}
setSaved(true);
router.refresh();
});
};
return (
<div className="space-y-4">
<Input label="Display name" value={name} onChange={(e) => setName(e.target.value)} />
<Input
label="Phone"
type="tel"
placeholder="+1 555 123 4567"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
<MediaPicker label="Photo" value={image} onChange={(url) => setImage(url ?? "")} category="staff" />
<Textarea
label="Bio"
placeholder="A short introduction customers will see on your profile"
value={bio}
onChange={(e) => setBio(e.target.value)}
/>
<div className="rounded-md border border-line bg-white p-5">
<Switch
label="Visible for online booking"
description="Customers can find and book you when this is on."
checked={isBookableOnline}
onCheckedChange={setIsBookableOnline}
/>
</div>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
{saved && !error && (
<p role="status" className="text-accent-foreground text-sm font-medium">
Saved.
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
Save
</Button>
</div>
);
}
@@ -1,177 +0,0 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { BottomSheet } from "@/components/ui/bottom-sheet";
import { Input } from "@/components/ui/input";
import type { TimeOffActionResult, TimeOffConflictItem } from "../actions/time-off";
import { createTimeOffAction, deleteTimeOffAction, updateTimeOffAction } from "../actions/time-off";
import type { TimeOffInput } from "@/services/staff/staff.service";
export interface EditableTimeOff {
id: string;
startDate: string;
endDate: string;
reason: string | null;
}
/** Same DI shape as AppointmentStatusActions' `actions` prop — the owner-on-behalf-of-staff editor injects its own Server Actions bound to a chosen staffId instead of forking this component. */
export interface TimeOffActionSet {
create: (
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
acknowledgeConflicts?: boolean,
) => Promise<TimeOffActionResult>;
update: (
timeOffId: string,
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
acknowledgeConflicts?: boolean,
) => Promise<TimeOffActionResult>;
remove: (timeOffId: string) => Promise<TimeOffActionResult>;
}
const DEFAULT_ACTIONS: TimeOffActionSet = {
create: createTimeOffAction,
update: updateTimeOffAction,
remove: deleteTimeOffAction,
};
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
export function TimeOffFormSheet({
open,
onClose,
existing,
actions = DEFAULT_ACTIONS,
appointmentHrefBase = "/staff/appointments",
}: {
open: boolean;
onClose: () => void;
existing?: EditableTimeOff | null;
actions?: TimeOffActionSet;
appointmentHrefBase?: string;
}) {
const router = useRouter();
const [startDate, setStartDate] = useState(existing?.startDate ?? todayIso());
const [endDate, setEndDate] = useState(existing?.endDate ?? existing?.startDate ?? todayIso());
const [reason, setReason] = useState(existing?.reason ?? "");
const [error, setError] = useState<string | null>(null);
const [conflicts, setConflicts] = useState<TimeOffConflictItem[] | null>(null);
const [isPending, startTransition] = useTransition();
const [isDeleting, startDeleteTransition] = useTransition();
const reset = () => {
setStartDate(existing?.startDate ?? todayIso());
setEndDate(existing?.endDate ?? existing?.startDate ?? todayIso());
setReason(existing?.reason ?? "");
setError(null);
setConflicts(null);
};
const handleClose = () => {
onClose();
reset();
};
const submit = (acknowledgeConflicts: boolean) => {
if (startDate > endDate) {
setError("Start date must be on or before the end date.");
return;
}
setError(null);
startTransition(async () => {
const input = { startDate, endDate, reason: reason.trim() || undefined };
const result: TimeOffActionResult = existing
? await actions.update(existing.id, input, acknowledgeConflicts)
: await actions.create(input, acknowledgeConflicts);
if (!result.success) {
setError(result.error ?? "Something went wrong.");
setConflicts(result.conflicts ?? null);
return;
}
handleClose();
router.refresh();
});
};
const handleDelete = () => {
if (!existing) return;
startDeleteTransition(async () => {
const result = await actions.remove(existing.id);
if (!result.success) {
setError(result.error ?? "Couldn't remove this time off.");
return;
}
handleClose();
router.refresh();
});
};
return (
<BottomSheet open={open} onClose={handleClose} title={existing ? "Edit Time Off" : "New Time Off"}>
<div className="space-y-4 pb-2">
{conflicts && conflicts.length > 0 ? (
<div className="space-y-3">
<p role="alert" className="text-danger text-sm font-medium">
This overlaps {conflicts.length} existing appointment
{conflicts.length > 1 ? "s" : ""}. These won&apos;t be cancelled automatically —
you&apos;ll need to reschedule or cancel them yourself.
</p>
<ul className="space-y-2">
{conflicts.map((c) => (
<li key={c.id} className="border-line rounded-lg border bg-white p-3 text-xs">
<Link href={`${appointmentHrefBase}/${c.id}`} className="font-bold underline">
{c.customerName}
</Link>
<p className="text-muted">{c.serviceNames}</p>
</li>
))}
</ul>
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={handleClose}>
Cancel
</Button>
<Button
variant="destructive"
className="flex-1"
isLoading={isPending}
onClick={() => submit(true)}
>
Save Anyway
</Button>
</div>
</div>
) : (
<>
<div className="grid grid-cols-2 gap-4">
<Input label="Start date" type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
<Input label="End date" type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
</div>
<Input
label="Reason (optional)"
placeholder="e.g. Vacation"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={() => submit(false)}>
{existing ? "Save Changes" : "Add Time Off"}
</Button>
{existing && (
<Button variant="ghost" className="w-full text-danger" isLoading={isDeleting} onClick={handleDelete}>
Remove Time Off
</Button>
)}
</>
)}
</div>
</BottomSheet>
);
}
@@ -1,98 +0,0 @@
"use client";
import { Plus } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import type { EditableTimeOff, TimeOffActionSet } from "./time-off-form-sheet";
import { TimeOffFormSheet } from "./time-off-form-sheet";
function formatRange(startDate: string, endDate: string): string {
const start = new Date(`${startDate}T00:00:00`);
const end = new Date(`${endDate}T00:00:00`);
const startLabel = start.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" });
if (startDate === endDate) return startLabel;
const endLabel = end.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" });
return `${startLabel} – ${endLabel}`;
}
export function TimeOffList({
upcoming,
past,
actions,
appointmentHrefBase,
}: {
upcoming: EditableTimeOff[];
past: EditableTimeOff[];
actions?: TimeOffActionSet;
appointmentHrefBase?: string;
}) {
const [sheetOpen, setSheetOpen] = useState(false);
const [editing, setEditing] = useState<EditableTimeOff | null>(null);
const openNew = () => {
setEditing(null);
setSheetOpen(true);
};
const openEdit = (t: EditableTimeOff) => {
setEditing(t);
setSheetOpen(true);
};
return (
<div className="space-y-6">
<Button variant="secondary" className="w-full" onClick={openNew}>
<Plus className="h-4 w-4" aria-hidden="true" />
Add Time Off
</Button>
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-widest text-muted">Upcoming</p>
{upcoming.length === 0 ? (
<p className="text-muted rounded-md border border-line bg-white p-5 text-center text-sm">
No upcoming time off.
</p>
) : (
<ul className="divide-y divide-line rounded-md border border-line bg-white">
{upcoming.map((t) => (
<li key={t.id}>
<button
type="button"
onClick={() => openEdit(t)}
className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left hover:bg-stone"
>
<div>
<p className="text-sm font-bold">{formatRange(t.startDate, t.endDate)}</p>
{t.reason && <p className="text-xs text-muted">{t.reason}</p>}
</div>
</button>
</li>
))}
</ul>
)}
</div>
{past.length > 0 && (
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-widest text-muted">Past</p>
<ul className="divide-y divide-line rounded-md border border-line bg-stone/50">
{past.map((t) => (
<li key={t.id} className="px-5 py-4">
<p className="text-sm font-bold text-muted">{formatRange(t.startDate, t.endDate)}</p>
{t.reason && <p className="text-xs text-muted">{t.reason}</p>}
</li>
))}
</ul>
</div>
)}
<TimeOffFormSheet
key={editing?.id ?? "new"}
open={sheetOpen}
onClose={() => setSheetOpen(false)}
existing={editing}
actions={actions}
appointmentHrefBase={appointmentHrefBase}
/>
</div>
);
}
@@ -1,22 +0,0 @@
import Link from "next/link";
import { cn } from "@/lib/utils/cn";
/** Day/Agenda as one segmented switch (a single bordered pill, active side filled) rather than two separate floating pills. Plain Links — same "no client JS needed just to change a view" reasoning as DateStrip. */
export function ViewSwitch({ dayHref, agendaHref, view }: { dayHref: string; agendaHref: string; view: "day" | "agenda" }) {
const segmentClass = (active: boolean) =>
cn(
"flex-1 rounded-full px-4 py-1.5 text-center text-xs font-bold tracking-widest uppercase transition-colors",
active ? "bg-ink text-white" : "text-muted",
);
return (
<div className="border-line inline-flex rounded-full border bg-white p-1">
<Link href={dayHref} aria-current={view === "day" ? "true" : undefined} className={segmentClass(view === "day")}>
Day
</Link>
<Link href={agendaHref} aria-current={view === "agenda" ? "true" : undefined} className={segmentClass(view === "agenda")}>
Agenda
</Link>
</div>
);
}
@@ -1,213 +0,0 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Weekday } from "@/generated/prisma/enums";
import { saveWorkingHoursAction } from "../actions/working-hours";
import type { WorkingHoursActionResult, WorkingHoursConflictItem } from "../actions/working-hours";
import type { WorkingHoursDayInput } from "@/services/staff/staff.service";
const DAY_ORDER: Weekday[] = [
Weekday.MONDAY,
Weekday.TUESDAY,
Weekday.WEDNESDAY,
Weekday.THURSDAY,
Weekday.FRIDAY,
Weekday.SATURDAY,
Weekday.SUNDAY,
];
const DAY_LABEL: Record<Weekday, string> = {
MONDAY: "Monday",
TUESDAY: "Tuesday",
WEDNESDAY: "Wednesday",
THURSDAY: "Thursday",
FRIDAY: "Friday",
SATURDAY: "Saturday",
SUNDAY: "Sunday",
};
interface DayState {
dayOfWeek: Weekday;
isOff: boolean;
startTime: string; // "HH:MM"
endTime: string;
}
function toTimeString(minutes: number | null): string {
if (minutes === null) return "09:00";
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
function toMinutes(time: string): number {
const [h, m] = time.split(":").map(Number);
return (h ?? 0) * 60 + (m ?? 0);
}
export interface InitialWorkingHoursRow {
dayOfWeek: Weekday;
isOff: boolean;
startMinutes: number | null;
endMinutes: number | null;
}
export function WorkingHoursForm({
initialRows,
businessHoursLabel,
onSave = saveWorkingHoursAction,
appointmentHrefBase = "/staff/appointments",
}: {
initialRows: InitialWorkingHoursRow[];
businessHoursLabel: Record<Weekday, string>;
/** Owner-on-behalf-of-staff editing (src/app/(owner)/owner/team/[id]/hours) injects its own Server Action here instead of the self-service default — same pattern as AppointmentStatusActions' `actions` prop. */
onSave?: (
days: WorkingHoursDayInput[],
acknowledgeConflicts?: boolean,
) => Promise<WorkingHoursActionResult>;
/** Conflict links point at /owner/appointments/[id] when the owner is editing on a staff member's behalf. */
appointmentHrefBase?: string;
}) {
const router = useRouter();
const [days, setDays] = useState<DayState[]>(() =>
DAY_ORDER.map((dayOfWeek) => {
const existing = initialRows.find((r) => r.dayOfWeek === dayOfWeek);
return {
dayOfWeek,
isOff: existing?.isOff ?? true,
startTime: toTimeString(existing?.startMinutes ?? null),
endTime: toTimeString(existing?.endMinutes ?? 19 * 60),
};
}),
);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [conflicts, setConflicts] = useState<WorkingHoursConflictItem[]>([]);
const [isPending, startTransition] = useTransition();
const updateDay = (dayOfWeek: Weekday, patch: Partial<DayState>) => {
setSaved(false);
setConflicts([]);
setDays((prev) => prev.map((d) => (d.dayOfWeek === dayOfWeek ? { ...d, ...patch } : d)));
};
const copyMondayToAll = () => {
const monday = days.find((d) => d.dayOfWeek === Weekday.MONDAY);
if (!monday) return;
setSaved(false);
setConflicts([]);
setDays((prev) => prev.map((d) => (d.dayOfWeek === Weekday.MONDAY ? d : { ...d, isOff: monday.isOff, startTime: monday.startTime, endTime: monday.endTime })));
};
const submit = (acknowledgeConflicts: boolean) => {
setError(null);
startTransition(async () => {
const result = await onSave(
days.map((d) => ({
dayOfWeek: d.dayOfWeek,
isOff: d.isOff,
...(d.isOff ? {} : { startMinutes: toMinutes(d.startTime), endMinutes: toMinutes(d.endTime) }),
})),
acknowledgeConflicts,
);
if (!result.success) {
setError(result.error ?? "Couldn't save working hours.");
setConflicts(result.conflicts ?? []);
return;
}
setSaved(true);
setConflicts([]);
router.refresh();
});
};
if (conflicts.length > 0) {
return (
<div className="space-y-4">
<p role="alert" className="text-danger text-sm font-medium">
These hours would leave {conflicts.length} upcoming appointment{conflicts.length > 1 ? "s" : ""} outside
working hours. They won&apos;t be cancelled automatically — review them, then confirm if you still want to
save.
</p>
<ul className="space-y-2">
{conflicts.map((c) => (
<li key={c.id} className="border-line rounded-lg border bg-white p-3 text-xs">
<Link href={`${appointmentHrefBase}/${c.id}`} className="font-bold underline">
{c.customerName}
</Link>
<p className="text-muted">{c.serviceNames}</p>
</li>
))}
</ul>
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={() => setConflicts([])}>
Go Back
</Button>
<Button variant="destructive" className="flex-1" isLoading={isPending} onClick={() => submit(true)}>
Save Anyway
</Button>
</div>
</div>
);
}
return (
<div className="space-y-4">
<button type="button" onClick={copyMondayToAll} className="text-accent text-xs font-bold tracking-widest uppercase underline">
Copy Monday to all days
</button>
<div className="divide-line rounded-md border-line divide-y border bg-white">
{days.map((day) => (
<div key={day.dayOfWeek} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="flex items-center gap-3">
<Switch checked={!day.isOff} onCheckedChange={(checked) => updateDay(day.dayOfWeek, { isOff: !checked })} aria-label={`${DAY_LABEL[day.dayOfWeek]} working`} />
<div>
<p className="text-sm font-bold">{DAY_LABEL[day.dayOfWeek]}</p>
<p className="text-muted text-[10px]">Business hours: {businessHoursLabel[day.dayOfWeek]}</p>
</div>
</div>
{day.isOff ? (
<span className="text-muted text-xs">Off</span>
) : (
<div className="flex items-center gap-2">
<input
type="time"
value={day.startTime}
onChange={(e) => updateDay(day.dayOfWeek, { startTime: e.target.value })}
className="border-line rounded-lg border px-2 py-1.5 text-xs"
aria-label={`${DAY_LABEL[day.dayOfWeek]} start time`}
/>
<span className="text-muted text-xs">to</span>
<input
type="time"
value={day.endTime}
onChange={(e) => updateDay(day.dayOfWeek, { endTime: e.target.value })}
className="border-line rounded-lg border px-2 py-1.5 text-xs"
aria-label={`${DAY_LABEL[day.dayOfWeek]} end time`}
/>
</div>
)}
</div>
))}
</div>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
{saved && !error && (
<p role="status" className="text-accent-foreground text-sm font-medium">
Saved.
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={() => submit(false)}>
Save
</Button>
</div>
);
}