690 files changed
+12045
-33943
No files matched your search
@@ -0,0 +1,56 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
"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.";
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { createTimeOff } from "@/services/staff/staff.service";
|
||||
|
||||
export interface BlockTimeConflict {
|
||||
id: string;
|
||||
customerName: string;
|
||||
serviceNames: string;
|
||||
startAt: string;
|
||||
}
|
||||
|
||||
export interface BlockTimeActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: BlockTimeConflict[];
|
||||
}
|
||||
|
||||
/** One-off "Block Time" from the calendar — represented as a partial-day TimeOff row, see schema.prisma and docs/phase-5-summary.md. */
|
||||
export async function blockTimeAction(input: {
|
||||
date: string;
|
||||
startMinutes: number;
|
||||
endMinutes: number;
|
||||
reason: string;
|
||||
acknowledgeConflicts?: boolean;
|
||||
}): Promise<BlockTimeActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff to do that." };
|
||||
|
||||
const result = await createTimeOff(prisma, {
|
||||
staffId: staff.id,
|
||||
businessTimezone: staff.business.timezone,
|
||||
input: {
|
||||
startDate: input.date,
|
||||
endDate: input.date,
|
||||
reason: input.reason.trim() || "Blocked",
|
||||
startMinutes: input.startMinutes,
|
||||
endMinutes: input.endMinutes,
|
||||
},
|
||||
acknowledgeConflicts: input.acknowledgeConflicts,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Something went wrong. Please try again.",
|
||||
conflicts: result.conflicts?.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { createBreak, deleteBreak, updateBreak } from "@/services/staff/staff.service";
|
||||
import type { BreakInput } from "@/services/staff/staff.service";
|
||||
|
||||
export interface BreakConflictItem {
|
||||
id: string;
|
||||
customerName: string;
|
||||
serviceNames: string;
|
||||
startAt: string;
|
||||
}
|
||||
|
||||
export interface BreakActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: BreakConflictItem[];
|
||||
}
|
||||
|
||||
function mapConflicts(conflicts: { id: string; customer: { firstName: string; lastName: string }; services: { service: { name: string } }[]; startAt: Date }[]): BreakConflictItem[] {
|
||||
return conflicts.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createBreakAction(input: BreakInput, acknowledgeConflicts?: boolean): Promise<BreakActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await createBreak(prisma, { staffId: staff.id, businessTimezone: staff.business.timezone, input, acknowledgeConflicts });
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't create this break.", conflicts: mapConflicts(result.conflicts) };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateBreakAction(breakId: string, input: BreakInput, acknowledgeConflicts?: boolean): Promise<BreakActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await updateBreak(prisma, { breakId, staffId: staff.id, businessTimezone: staff.business.timezone, input, acknowledgeConflicts });
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this break.", conflicts: mapConflicts(result.conflicts) };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteBreakAction(breakId: string): Promise<BreakActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await deleteBreak(prisma, { breakId, staffId: staff.id });
|
||||
return result.success ? { success: true } : { success: false, error: "Couldn't delete this break." };
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { assertOwnNote, StaffPermissionDeniedError } from "../authorization";
|
||||
|
||||
/**
|
||||
* Note permission model (documented per the Phase 5 brief's "choose a
|
||||
* sensible permission model and document it"):
|
||||
* - Any staff member in the business may ADD a note — notes are shared
|
||||
* context for whoever's working with this customer next, not private.
|
||||
* - Only the AUTHOR may edit or delete their own note — prevents one
|
||||
* staff member silently rewriting or erasing another's observation.
|
||||
* - Edits and deletes are recorded to the existing AuditLog table (see
|
||||
* schema.prisma — built in Phase 2, unused until now) rather than a new
|
||||
* edit-history table; CustomerNote.updatedAt alone tells a reader "this
|
||||
* was edited", AuditLog keeps the discarded content in case it matters
|
||||
* later. Notes are never silently overwritten with no trace.
|
||||
*/
|
||||
|
||||
export interface CustomerNoteActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function addCustomerNoteAction(params: {
|
||||
customerId: string;
|
||||
body: string;
|
||||
}): Promise<CustomerNoteActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
if (!params.body.trim()) return { success: false, error: "Note can't be empty." };
|
||||
|
||||
await prisma.customerNote.create({
|
||||
data: { customerId: params.customerId, authorStaffId: staff.id, body: params.body.trim() },
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateCustomerNoteAction(params: {
|
||||
noteId: string;
|
||||
body: string;
|
||||
}): Promise<CustomerNoteActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
if (!params.body.trim()) return { success: false, error: "Note can't be empty." };
|
||||
|
||||
const existing = await prisma.customerNote.findUnique({ where: { id: params.noteId } });
|
||||
if (!existing) return { success: false, error: "Note not found." };
|
||||
|
||||
try {
|
||||
assertOwnNote(existing, staff);
|
||||
} catch (error) {
|
||||
if (error instanceof StaffPermissionDeniedError)
|
||||
return { success: false, error: error.message };
|
||||
throw error;
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.customerNote.update({
|
||||
where: { id: params.noteId },
|
||||
data: { body: params.body.trim() },
|
||||
}),
|
||||
prisma.auditLog.create({
|
||||
data: {
|
||||
actorUserId: staff.user.id,
|
||||
action: "UPDATE",
|
||||
entityType: "CustomerNote",
|
||||
entityId: params.noteId,
|
||||
metadata: { previousBody: existing.body },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteCustomerNoteAction(params: {
|
||||
noteId: string;
|
||||
}): Promise<CustomerNoteActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const existing = await prisma.customerNote.findUnique({ where: { id: params.noteId } });
|
||||
if (!existing) return { success: false, error: "Note not found." };
|
||||
|
||||
try {
|
||||
assertOwnNote(existing, staff);
|
||||
} catch (error) {
|
||||
if (error instanceof StaffPermissionDeniedError)
|
||||
return { success: false, error: error.message };
|
||||
throw error;
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.auditLog.create({
|
||||
data: {
|
||||
actorUserId: staff.user.id,
|
||||
action: "DELETE",
|
||||
entityType: "CustomerNote",
|
||||
entityId: params.noteId,
|
||||
metadata: { body: existing.body, customerId: existing.customerId },
|
||||
},
|
||||
}),
|
||||
prisma.customerNote.delete({ where: { id: params.noteId } }),
|
||||
]);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { addStamp, correctStamp } from "@/services/loyalty/loyalty.service";
|
||||
|
||||
export interface LoyaltyActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
rewardCreated?: boolean;
|
||||
}
|
||||
|
||||
export async function addLoyaltyStampAction(params: {
|
||||
customerId: string;
|
||||
amount: number;
|
||||
appointmentId?: string;
|
||||
reason?: string;
|
||||
}): Promise<LoyaltyActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await addStamp(prisma, {
|
||||
customerId: params.customerId,
|
||||
businessId: staff.businessId,
|
||||
amount: params.amount,
|
||||
staffId: staff.id,
|
||||
appointmentId: params.appointmentId,
|
||||
reason: params.reason,
|
||||
});
|
||||
|
||||
return result.success
|
||||
? { success: true, rewardCreated: result.rewardCreated }
|
||||
: { success: false, error: result.validation.issues[0]?.message ?? "Couldn't add a stamp." };
|
||||
}
|
||||
|
||||
export async function correctLoyaltyStampAction(params: {
|
||||
loyaltyAccountId: string;
|
||||
amount: number;
|
||||
reason: string;
|
||||
}): Promise<LoyaltyActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await correctStamp(prisma, {
|
||||
loyaltyAccountId: params.loyaltyAccountId,
|
||||
amount: params.amount,
|
||||
staffId: staff.id,
|
||||
reason: params.reason,
|
||||
});
|
||||
|
||||
return result.success
|
||||
? { success: true }
|
||||
: {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Couldn't correct that stamp.",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"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 };
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import {
|
||||
clearStaffDraft,
|
||||
readStaffDraft,
|
||||
writeLastBookingReference,
|
||||
writeStaffDraft,
|
||||
} from "@/lib/booking-draft/cookie";
|
||||
import { guestDetailsSchema } from "@/lib/booking-draft/schema";
|
||||
import { getFirstFriendlyMessage } from "@/features/booking/error-messages";
|
||||
import { resolveCustomerForBooking } from "@/services/customer/customer.service";
|
||||
import { createBooking } from "@/services/scheduling/booking.service";
|
||||
import { validateBookingSlot } from "@/services/scheduling/availability.service";
|
||||
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
||||
import { assertOwnAppointment, StaffPermissionDeniedError } from "../authorization";
|
||||
|
||||
/**
|
||||
* The staff "book for customer" flow — collects input across a few steps
|
||||
* (same signed-draft-cookie pattern as the customer flow, see
|
||||
* src/lib/booking-draft), then hands off entirely to BookingService at
|
||||
* confirm time. No availability or validation logic lives here; every
|
||||
* check either already happened in AvailabilityService (via
|
||||
* validateBookingSlot, for a better error message earlier) or happens
|
||||
* again, authoritatively, inside BookingService.createBooking itself.
|
||||
*/
|
||||
|
||||
export interface StaffBookingStepResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string[] | undefined>;
|
||||
}
|
||||
|
||||
export async function selectExistingCustomerAction(customerId: string): Promise<void> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) redirect("/home");
|
||||
|
||||
const draft = await readStaffDraft(staff.businessId);
|
||||
await writeStaffDraft({ ...draft, businessId: staff.businessId, customerId, guest: undefined });
|
||||
redirect("/staff/book/services");
|
||||
}
|
||||
|
||||
export async function selectNewGuestCustomerAction(
|
||||
input: unknown,
|
||||
): Promise<StaffBookingStepResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const parsed = guestDetailsSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Please check the highlighted fields.",
|
||||
fieldErrors: parsed.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
const draft = await readStaffDraft(staff.businessId);
|
||||
await writeStaffDraft({
|
||||
...draft,
|
||||
businessId: staff.businessId,
|
||||
guest: parsed.data,
|
||||
customerId: undefined,
|
||||
});
|
||||
redirect("/staff/book/services");
|
||||
}
|
||||
|
||||
export async function saveStaffServicesStepAction(input: {
|
||||
serviceIds: string[];
|
||||
}): Promise<StaffBookingStepResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
if (input.serviceIds.length === 0)
|
||||
return { success: false, error: "Please select at least one service." };
|
||||
|
||||
const draft = await readStaffDraft(staff.businessId);
|
||||
if (!draft.customerId && !draft.guest) redirect("/staff/book");
|
||||
|
||||
const previousServiceIds = [...draft.serviceIds].sort().join(",");
|
||||
const nextServiceIds = [...input.serviceIds].sort().join(",");
|
||||
const servicesChanged = previousServiceIds !== nextServiceIds;
|
||||
|
||||
await writeStaffDraft({
|
||||
...draft,
|
||||
businessId: staff.businessId,
|
||||
serviceIds: input.serviceIds,
|
||||
...(servicesChanged ? { staffId: undefined, date: undefined, startAt: undefined } : {}),
|
||||
});
|
||||
redirect("/staff/book/barber-time");
|
||||
}
|
||||
|
||||
export async function saveStaffBarberTimeStepAction(input: {
|
||||
staffId?: string;
|
||||
date: string;
|
||||
startAt: string;
|
||||
}): Promise<StaffBookingStepResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const draft = await readStaffDraft(staff.businessId);
|
||||
if (draft.serviceIds.length === 0)
|
||||
return { success: false, error: "Please select a service first." };
|
||||
|
||||
// Only pre-validate a specific staff pick — "any available barber" is
|
||||
// resolved by BookingService itself at confirm time, same reasoning as
|
||||
// the customer flow's equivalent step (see draft.ts).
|
||||
if (input.staffId) {
|
||||
const { validation } = await validateBookingSlot(prisma, {
|
||||
businessId: staff.businessId,
|
||||
serviceIds: draft.serviceIds,
|
||||
staffId: input.staffId,
|
||||
startAt: new Date(input.startAt),
|
||||
});
|
||||
if (!validation.valid) return { success: false, error: getFirstFriendlyMessage(validation) };
|
||||
}
|
||||
|
||||
await writeStaffDraft({
|
||||
...draft,
|
||||
staffId: input.staffId,
|
||||
date: input.date,
|
||||
startAt: input.startAt,
|
||||
});
|
||||
redirect("/staff/book/review");
|
||||
}
|
||||
|
||||
export async function saveStaffNotesAction(notes: string): Promise<StaffBookingStepResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
const draft = await readStaffDraft(staff.businessId);
|
||||
await writeStaffDraft({ ...draft, notes: notes || undefined });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function confirmStaffBookingAction(notes?: string): Promise<StaffBookingStepResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const draft = await readStaffDraft(staff.businessId);
|
||||
if (draft.serviceIds.length === 0 || !draft.startAt) {
|
||||
return { success: false, error: "Booking details are incomplete — please start again." };
|
||||
}
|
||||
if (!draft.customerId && !draft.guest) {
|
||||
return { success: false, error: "Please select or add a customer first." };
|
||||
}
|
||||
|
||||
let customerId: string;
|
||||
try {
|
||||
if (draft.customerId) {
|
||||
customerId = draft.customerId;
|
||||
} else {
|
||||
const resolved = await resolveCustomerForBooking(prisma, {
|
||||
businessId: staff.businessId,
|
||||
guest: draft.guest,
|
||||
});
|
||||
customerId = resolved.customerId;
|
||||
}
|
||||
} catch {
|
||||
return { success: false, error: "We couldn't resolve the customer. Please try again." };
|
||||
}
|
||||
|
||||
const result = await createBooking(prisma, {
|
||||
businessId: staff.businessId,
|
||||
customerId,
|
||||
serviceIds: draft.serviceIds,
|
||||
staffId: draft.staffId,
|
||||
startAt: new Date(draft.startAt),
|
||||
notes: notes || draft.notes,
|
||||
createdByUserId: staff.user.id,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: getFirstFriendlyMessage(result.validation) };
|
||||
}
|
||||
|
||||
await writeLastBookingReference({ 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");
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { createTimeOff, deleteTimeOff, updateTimeOff } from "@/services/staff/staff.service";
|
||||
import type { TimeOffInput } from "@/services/staff/staff.service";
|
||||
|
||||
export interface TimeOffConflictItem {
|
||||
id: string;
|
||||
customerName: string;
|
||||
serviceNames: string;
|
||||
startAt: string;
|
||||
}
|
||||
|
||||
export interface TimeOffActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: TimeOffConflictItem[];
|
||||
}
|
||||
|
||||
function mapConflicts(conflicts: { id: string; customer: { firstName: string; lastName: string }; services: { service: { name: string } }[]; startAt: Date }[]): TimeOffConflictItem[] {
|
||||
return conflicts.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Whole-day time off only — the settings editor doesn't handle partial-day blocks; that's the calendar's "Block Time" action (see block-time.ts). */
|
||||
export async function createTimeOffAction(
|
||||
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
|
||||
acknowledgeConflicts?: boolean,
|
||||
): Promise<TimeOffActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await createTimeOff(prisma, {
|
||||
staffId: staff.id,
|
||||
businessTimezone: staff.business.timezone,
|
||||
input,
|
||||
acknowledgeConflicts,
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't add this time off.", conflicts: mapConflicts(result.conflicts) };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateTimeOffAction(
|
||||
timeOffId: string,
|
||||
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
|
||||
acknowledgeConflicts?: boolean,
|
||||
): Promise<TimeOffActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await updateTimeOff(prisma, {
|
||||
timeOffId,
|
||||
staffId: staff.id,
|
||||
businessTimezone: staff.business.timezone,
|
||||
input,
|
||||
acknowledgeConflicts,
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this time off.", conflicts: mapConflicts(result.conflicts) };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteTimeOffAction(timeOffId: string): Promise<TimeOffActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await deleteTimeOff(prisma, { timeOffId, staffId: staff.id });
|
||||
return result.success ? { success: true } : { success: false, error: "Couldn't remove this time off." };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"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 };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"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'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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"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't
|
||||
optional. Choose how you'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
"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't be cancelled automatically —
|
||||
you'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"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'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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user