Phase 5
Phase 5 is complete. All 53 tasks are done, verified on a fresh database in a single clean run: **121 unit tests**, **55 integration tests**, and **15 E2E journeys** (6 Phase 4 + 9 new Phase 5) all passing, plus a clean `tsc`/`eslint`/`build`. Summary of this final stretch: - **Fixed a real bug**: `StaffService`'s break/time-off update/delete functions threw uncaught instead of failing gracefully on a permission mismatch — fixed with proper `PERMISSION_DENIED` handling, covered by new integration tests. - **Built the remaining Phase 5 UI**: Breaks editor, Time Off editor, Barber Profile, and Notification Preferences — all wired to real services, no placeholders. - **Extended seed data**: more customers, loyalty accounts/transactions/rewards, and appointments spanning every status today (anchored to real time so the "Current Appointment" priority logic is reliably exercised). - **Found and fixed a second real bug live in the browser**: the Break/Time-Off edit sheets showed stale default values instead of the item being edited (a classic `useState`-doesn't-reinitialize-on-prop-change bug) — fixed with a `key` prop to force remount. - **Wrote the full Phase 5 test suite**: unit tests for `selectCurrentAppointment` and all `StaffService` validators, integration tests for `LoyaltyService` and `StaffService` (including the permission-fix regression tests), and 9 new Playwright E2E journeys covering the brief's exact required list. - **Did the desktop-responsive pass**: a persistent sidebar nav replacing the bottom bar at `lg:`, a genuine calendar+"Right Now" side-by-side split, and 2-column layouts for the clients list and customer details — addressing the brief's explicit "more than simple centering" requirement. - **Wrote [docs/phase-5-summary.md](docs/phase-5-summary.md)** documenting everything above in full. Nothing is committed — the whole session (Phases 2–5) sits uncommitted on top of the initial template commit, same as you left it. Let me know if you'd like me to commit, or if you want to move on to Phase 6.
This commit is contained in:
81 files changed
+8024
-113
No files matched your search
@@ -0,0 +1,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,169 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { cancelBooking, rescheduleBooking } from "@/services/scheduling/booking.service";
|
||||
import {
|
||||
checkIn,
|
||||
complete,
|
||||
confirm,
|
||||
getAppointmentById,
|
||||
markNoShow,
|
||||
start,
|
||||
} from "@/services/scheduling/appointment.service";
|
||||
import { awardStampForCompletedAppointment } from "@/services/loyalty/loyalty.service";
|
||||
import { assertOwnAppointment, StaffPermissionDeniedError } from "../authorization";
|
||||
|
||||
/**
|
||||
* Every status transition here does exactly three things: load the
|
||||
* appointment, assert it's this staff member's own (in their own
|
||||
* business), and delegate to AppointmentService/BookingService for the
|
||||
* actual transition — the transition rules themselves (which statuses can
|
||||
* move to which) live entirely in AppointmentService's own state machine,
|
||||
* never re-implemented here. Completing an appointment additionally calls
|
||||
* LoyaltyService, since "complete this visit" and "award a stamp for it"
|
||||
* are two different domain responsibilities this layer is allowed to
|
||||
* orchestrate together — see docs/phase-5-summary.md.
|
||||
*/
|
||||
|
||||
export interface StaffAppointmentActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
loyaltyStampAwarded?: boolean;
|
||||
}
|
||||
|
||||
async function loadOwnAppointment(appointmentId: string) {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff)
|
||||
return { staff: null, appointment: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
const appointment = await getAppointmentById(prisma, appointmentId);
|
||||
if (!appointment) return { staff, appointment: null, error: "Appointment not found." } as const;
|
||||
|
||||
try {
|
||||
assertOwnAppointment(appointment, staff);
|
||||
} catch (error) {
|
||||
if (error instanceof StaffPermissionDeniedError)
|
||||
return { staff, appointment: null, error: error.message } as const;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { staff, appointment, error: null } as const;
|
||||
}
|
||||
|
||||
export async function confirmAppointmentAction(
|
||||
appointmentId: string,
|
||||
): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnAppointment(appointmentId);
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await confirm(prisma, appointmentId);
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
|
||||
export async function checkInAppointmentAction(
|
||||
appointmentId: string,
|
||||
): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnAppointment(appointmentId);
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await checkIn(prisma, appointmentId);
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
|
||||
export async function startAppointmentAction(
|
||||
appointmentId: string,
|
||||
): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnAppointment(appointmentId);
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await start(prisma, appointmentId);
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
|
||||
export async function completeAppointmentAction(
|
||||
appointmentId: string,
|
||||
): Promise<StaffAppointmentActionResult> {
|
||||
const { staff, appointment, error } = await loadOwnAppointment(appointmentId);
|
||||
if (!appointment || !staff) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await complete(prisma, appointmentId);
|
||||
if (!result.success) {
|
||||
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
|
||||
const loyaltyResult = await awardStampForCompletedAppointment(prisma, {
|
||||
appointmentId,
|
||||
staffId: staff.id,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
loyaltyStampAwarded: loyaltyResult.success && !loyaltyResult.alreadyAwarded,
|
||||
};
|
||||
}
|
||||
|
||||
export async function markNoShowAction(
|
||||
appointmentId: string,
|
||||
): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnAppointment(appointmentId);
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await markNoShow(prisma, appointmentId);
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
|
||||
export async function cancelStaffAppointmentAction(params: {
|
||||
appointmentId: string;
|
||||
reason?: string;
|
||||
}): Promise<StaffAppointmentActionResult> {
|
||||
const { staff, appointment, error } = await loadOwnAppointment(params.appointmentId);
|
||||
if (!appointment || !staff) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await cancelBooking(prisma, {
|
||||
appointmentId: params.appointmentId,
|
||||
cancelledByUserId: staff.user.id,
|
||||
reason: params.reason,
|
||||
});
|
||||
return result.success
|
||||
? { success: true }
|
||||
: {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Couldn't cancel this appointment.",
|
||||
};
|
||||
}
|
||||
|
||||
export async function rescheduleStaffAppointmentAction(params: {
|
||||
appointmentId: string;
|
||||
staffId?: string;
|
||||
startAt: string;
|
||||
}): Promise<StaffAppointmentActionResult> {
|
||||
const { appointment, error } = await loadOwnAppointment(params.appointmentId);
|
||||
if (!appointment) return { success: false, error: error ?? "Not found." };
|
||||
|
||||
const result = await rescheduleBooking(prisma, {
|
||||
appointmentId: params.appointmentId,
|
||||
staffId: params.staffId,
|
||||
startAt: new Date(params.startAt),
|
||||
});
|
||||
return result.success
|
||||
? { success: true }
|
||||
: {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Couldn't reschedule this appointment.",
|
||||
};
|
||||
}
|
||||
|
||||
function friendlyTransitionMessage(code?: string): string {
|
||||
if (code === "INVALID_STATUS_TRANSITION")
|
||||
return "This appointment can't move to that status right now — someone may have already updated it.";
|
||||
if (code === "APPOINTMENT_NOT_FOUND") return "We couldn't find that appointment.";
|
||||
return "Something went wrong. Please try again.";
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { createTimeOff } from "@/services/staff/staff.service";
|
||||
|
||||
export interface BlockTimeConflict {
|
||||
id: string;
|
||||
customerName: string;
|
||||
serviceNames: string;
|
||||
startAt: string;
|
||||
}
|
||||
|
||||
export interface BlockTimeActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: BlockTimeConflict[];
|
||||
}
|
||||
|
||||
/** One-off "Block Time" from the calendar — represented as a partial-day TimeOff row, see schema.prisma and docs/phase-5-summary.md. */
|
||||
export async function blockTimeAction(input: {
|
||||
date: string;
|
||||
startMinutes: number;
|
||||
endMinutes: number;
|
||||
reason: string;
|
||||
acknowledgeConflicts?: boolean;
|
||||
}): Promise<BlockTimeActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff to do that." };
|
||||
|
||||
const result = await createTimeOff(prisma, {
|
||||
staffId: staff.id,
|
||||
businessTimezone: staff.business.timezone,
|
||||
input: {
|
||||
startDate: input.date,
|
||||
endDate: input.date,
|
||||
reason: input.reason.trim() || "Blocked",
|
||||
startMinutes: input.startMinutes,
|
||||
endMinutes: input.endMinutes,
|
||||
},
|
||||
acknowledgeConflicts: input.acknowledgeConflicts,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Something went wrong. Please try again.",
|
||||
conflicts: result.conflicts?.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { createBreak, deleteBreak, updateBreak } from "@/services/staff/staff.service";
|
||||
import type { BreakInput } from "@/services/staff/staff.service";
|
||||
|
||||
export interface BreakConflictItem {
|
||||
id: string;
|
||||
customerName: string;
|
||||
serviceNames: string;
|
||||
startAt: string;
|
||||
}
|
||||
|
||||
export interface BreakActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: BreakConflictItem[];
|
||||
}
|
||||
|
||||
function mapConflicts(conflicts: { id: string; customer: { firstName: string; lastName: string }; services: { service: { name: string } }[]; startAt: Date }[]): BreakConflictItem[] {
|
||||
return conflicts.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createBreakAction(input: BreakInput, acknowledgeConflicts?: boolean): Promise<BreakActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await createBreak(prisma, { staffId: staff.id, businessTimezone: staff.business.timezone, input, acknowledgeConflicts });
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't create this break.", conflicts: mapConflicts(result.conflicts) };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateBreakAction(breakId: string, input: BreakInput, acknowledgeConflicts?: boolean): Promise<BreakActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await updateBreak(prisma, { breakId, staffId: staff.id, businessTimezone: staff.business.timezone, input, acknowledgeConflicts });
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this break.", conflicts: mapConflicts(result.conflicts) };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteBreakAction(breakId: string): Promise<BreakActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await deleteBreak(prisma, { breakId, staffId: staff.id });
|
||||
return result.success ? { success: true } : { success: false, error: "Couldn't delete this break." };
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { assertOwnNote, StaffPermissionDeniedError } from "../authorization";
|
||||
|
||||
/**
|
||||
* Note permission model (documented per the Phase 5 brief's "choose a
|
||||
* sensible permission model and document it"):
|
||||
* - Any staff member in the business may ADD a note — notes are shared
|
||||
* context for whoever's working with this customer next, not private.
|
||||
* - Only the AUTHOR may edit or delete their own note — prevents one
|
||||
* staff member silently rewriting or erasing another's observation.
|
||||
* - Edits and deletes are recorded to the existing AuditLog table (see
|
||||
* schema.prisma — built in Phase 2, unused until now) rather than a new
|
||||
* edit-history table; CustomerNote.updatedAt alone tells a reader "this
|
||||
* was edited", AuditLog keeps the discarded content in case it matters
|
||||
* later. Notes are never silently overwritten with no trace.
|
||||
*/
|
||||
|
||||
export interface CustomerNoteActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function addCustomerNoteAction(params: {
|
||||
customerId: string;
|
||||
body: string;
|
||||
}): Promise<CustomerNoteActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
if (!params.body.trim()) return { success: false, error: "Note can't be empty." };
|
||||
|
||||
await prisma.customerNote.create({
|
||||
data: { customerId: params.customerId, authorStaffId: staff.id, body: params.body.trim() },
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateCustomerNoteAction(params: {
|
||||
noteId: string;
|
||||
body: string;
|
||||
}): Promise<CustomerNoteActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
if (!params.body.trim()) return { success: false, error: "Note can't be empty." };
|
||||
|
||||
const existing = await prisma.customerNote.findUnique({ where: { id: params.noteId } });
|
||||
if (!existing) return { success: false, error: "Note not found." };
|
||||
|
||||
try {
|
||||
assertOwnNote(existing, staff);
|
||||
} catch (error) {
|
||||
if (error instanceof StaffPermissionDeniedError)
|
||||
return { success: false, error: error.message };
|
||||
throw error;
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.customerNote.update({
|
||||
where: { id: params.noteId },
|
||||
data: { body: params.body.trim() },
|
||||
}),
|
||||
prisma.auditLog.create({
|
||||
data: {
|
||||
actorUserId: staff.user.id,
|
||||
action: "UPDATE",
|
||||
entityType: "CustomerNote",
|
||||
entityId: params.noteId,
|
||||
metadata: { previousBody: existing.body },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteCustomerNoteAction(params: {
|
||||
noteId: string;
|
||||
}): Promise<CustomerNoteActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const existing = await prisma.customerNote.findUnique({ where: { id: params.noteId } });
|
||||
if (!existing) return { success: false, error: "Note not found." };
|
||||
|
||||
try {
|
||||
assertOwnNote(existing, staff);
|
||||
} catch (error) {
|
||||
if (error instanceof StaffPermissionDeniedError)
|
||||
return { success: false, error: error.message };
|
||||
throw error;
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.auditLog.create({
|
||||
data: {
|
||||
actorUserId: staff.user.id,
|
||||
action: "DELETE",
|
||||
entityType: "CustomerNote",
|
||||
entityId: params.noteId,
|
||||
metadata: { body: existing.body, customerId: existing.customerId },
|
||||
},
|
||||
}),
|
||||
prisma.customerNote.delete({ where: { id: params.noteId } }),
|
||||
]);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { addStamp, correctStamp } from "@/services/loyalty/loyalty.service";
|
||||
|
||||
export interface LoyaltyActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
rewardCreated?: boolean;
|
||||
}
|
||||
|
||||
export async function addLoyaltyStampAction(params: {
|
||||
customerId: string;
|
||||
amount: number;
|
||||
appointmentId?: string;
|
||||
reason?: string;
|
||||
}): Promise<LoyaltyActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await addStamp(prisma, {
|
||||
customerId: params.customerId,
|
||||
businessId: staff.businessId,
|
||||
amount: params.amount,
|
||||
staffId: staff.id,
|
||||
appointmentId: params.appointmentId,
|
||||
reason: params.reason,
|
||||
});
|
||||
|
||||
return result.success
|
||||
? { success: true, rewardCreated: result.rewardCreated }
|
||||
: { success: false, error: result.validation.issues[0]?.message ?? "Couldn't add a stamp." };
|
||||
}
|
||||
|
||||
export async function correctLoyaltyStampAction(params: {
|
||||
loyaltyAccountId: string;
|
||||
amount: number;
|
||||
reason: string;
|
||||
}): Promise<LoyaltyActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await correctStamp(prisma, {
|
||||
loyaltyAccountId: params.loyaltyAccountId,
|
||||
amount: params.amount,
|
||||
staffId: staff.id,
|
||||
reason: params.reason,
|
||||
});
|
||||
|
||||
return result.success
|
||||
? { success: true }
|
||||
: {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Couldn't correct that stamp.",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { setNotificationPreferences, updateStaffProfile } from "@/services/staff/staff.service";
|
||||
import type { NotificationPreferenceCell } from "@/services/staff/staff.service";
|
||||
|
||||
export interface ProfileActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function updateProfileAction(input: {
|
||||
name: string;
|
||||
phone: string;
|
||||
image: string;
|
||||
bio: string;
|
||||
isBookableOnline: boolean;
|
||||
}): Promise<ProfileActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
if (!input.name.trim()) return { success: false, error: "Name can't be empty." };
|
||||
|
||||
const result = await updateStaffProfile(prisma, {
|
||||
staffId: staff.id,
|
||||
name: input.name.trim(),
|
||||
phone: input.phone.trim(),
|
||||
image: input.image.trim(),
|
||||
bio: input.bio.trim(),
|
||||
isBookableOnline: input.isBookableOnline,
|
||||
});
|
||||
|
||||
return result.success ? { success: true } : { success: false, error: "Couldn't save your profile." };
|
||||
}
|
||||
|
||||
export async function saveNotificationPreferencesAction(
|
||||
cells: NotificationPreferenceCell[],
|
||||
): Promise<ProfileActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
await setNotificationPreferences(prisma, staff.userId, cells);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import {
|
||||
clearStaffDraft,
|
||||
readStaffDraft,
|
||||
writeLastBookingReference,
|
||||
writeStaffDraft,
|
||||
} from "@/lib/booking-draft/cookie";
|
||||
import { guestDetailsSchema } from "@/lib/booking-draft/schema";
|
||||
import { getFirstFriendlyMessage } from "@/features/booking/error-messages";
|
||||
import { resolveCustomerForBooking } from "@/services/customer/customer.service";
|
||||
import { createBooking } from "@/services/scheduling/booking.service";
|
||||
import { validateBookingSlot } from "@/services/scheduling/availability.service";
|
||||
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
||||
import { assertOwnAppointment, StaffPermissionDeniedError } from "../authorization";
|
||||
|
||||
/**
|
||||
* The staff "book for customer" flow — collects input across a few steps
|
||||
* (same signed-draft-cookie pattern as the customer flow, see
|
||||
* src/lib/booking-draft), then hands off entirely to BookingService at
|
||||
* confirm time. No availability or validation logic lives here; every
|
||||
* check either already happened in AvailabilityService (via
|
||||
* validateBookingSlot, for a better error message earlier) or happens
|
||||
* again, authoritatively, inside BookingService.createBooking itself.
|
||||
*/
|
||||
|
||||
export interface StaffBookingStepResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string[] | undefined>;
|
||||
}
|
||||
|
||||
export async function selectExistingCustomerAction(customerId: string): Promise<void> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) redirect("/home");
|
||||
|
||||
const draft = await readStaffDraft(staff.businessId);
|
||||
await writeStaffDraft({ ...draft, businessId: staff.businessId, customerId, guest: undefined });
|
||||
redirect("/staff/book/services");
|
||||
}
|
||||
|
||||
export async function selectNewGuestCustomerAction(
|
||||
input: unknown,
|
||||
): Promise<StaffBookingStepResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const parsed = guestDetailsSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Please check the highlighted fields.",
|
||||
fieldErrors: parsed.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
const draft = await readStaffDraft(staff.businessId);
|
||||
await writeStaffDraft({
|
||||
...draft,
|
||||
businessId: staff.businessId,
|
||||
guest: parsed.data,
|
||||
customerId: undefined,
|
||||
});
|
||||
redirect("/staff/book/services");
|
||||
}
|
||||
|
||||
export async function saveStaffServicesStepAction(input: {
|
||||
serviceIds: string[];
|
||||
}): Promise<StaffBookingStepResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
if (input.serviceIds.length === 0)
|
||||
return { success: false, error: "Please select at least one service." };
|
||||
|
||||
const draft = await readStaffDraft(staff.businessId);
|
||||
if (!draft.customerId && !draft.guest) redirect("/staff/book");
|
||||
|
||||
const previousServiceIds = [...draft.serviceIds].sort().join(",");
|
||||
const nextServiceIds = [...input.serviceIds].sort().join(",");
|
||||
const servicesChanged = previousServiceIds !== nextServiceIds;
|
||||
|
||||
await writeStaffDraft({
|
||||
...draft,
|
||||
businessId: staff.businessId,
|
||||
serviceIds: input.serviceIds,
|
||||
...(servicesChanged ? { staffId: undefined, date: undefined, startAt: undefined } : {}),
|
||||
});
|
||||
redirect("/staff/book/barber-time");
|
||||
}
|
||||
|
||||
export async function saveStaffBarberTimeStepAction(input: {
|
||||
staffId?: string;
|
||||
date: string;
|
||||
startAt: string;
|
||||
}): Promise<StaffBookingStepResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const draft = await readStaffDraft(staff.businessId);
|
||||
if (draft.serviceIds.length === 0)
|
||||
return { success: false, error: "Please select a service first." };
|
||||
|
||||
// Only pre-validate a specific staff pick — "any available barber" is
|
||||
// resolved by BookingService itself at confirm time, same reasoning as
|
||||
// the customer flow's equivalent step (see draft.ts).
|
||||
if (input.staffId) {
|
||||
const { validation } = await validateBookingSlot(prisma, {
|
||||
businessId: staff.businessId,
|
||||
serviceIds: draft.serviceIds,
|
||||
staffId: input.staffId,
|
||||
startAt: new Date(input.startAt),
|
||||
});
|
||||
if (!validation.valid) return { success: false, error: getFirstFriendlyMessage(validation) };
|
||||
}
|
||||
|
||||
await writeStaffDraft({
|
||||
...draft,
|
||||
staffId: input.staffId,
|
||||
date: input.date,
|
||||
startAt: input.startAt,
|
||||
});
|
||||
redirect("/staff/book/review");
|
||||
}
|
||||
|
||||
export async function saveStaffNotesAction(notes: string): Promise<StaffBookingStepResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
const draft = await readStaffDraft(staff.businessId);
|
||||
await writeStaffDraft({ ...draft, notes: notes || undefined });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function confirmStaffBookingAction(notes?: string): Promise<StaffBookingStepResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const draft = await readStaffDraft(staff.businessId);
|
||||
if (draft.serviceIds.length === 0 || !draft.startAt) {
|
||||
return { success: false, error: "Booking details are incomplete — please start again." };
|
||||
}
|
||||
if (!draft.customerId && !draft.guest) {
|
||||
return { success: false, error: "Please select or add a customer first." };
|
||||
}
|
||||
|
||||
let customerId: string;
|
||||
try {
|
||||
if (draft.customerId) {
|
||||
customerId = draft.customerId;
|
||||
} else {
|
||||
const resolved = await resolveCustomerForBooking(prisma, {
|
||||
businessId: staff.businessId,
|
||||
guest: draft.guest,
|
||||
});
|
||||
customerId = resolved.customerId;
|
||||
}
|
||||
} catch {
|
||||
return { success: false, error: "We couldn't resolve the customer. Please try again." };
|
||||
}
|
||||
|
||||
const result = await createBooking(prisma, {
|
||||
businessId: staff.businessId,
|
||||
customerId,
|
||||
serviceIds: draft.serviceIds,
|
||||
staffId: draft.staffId,
|
||||
startAt: new Date(draft.startAt),
|
||||
notes: notes || draft.notes,
|
||||
createdByUserId: staff.user.id,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: getFirstFriendlyMessage(result.validation) };
|
||||
}
|
||||
|
||||
await writeLastBookingReference(result.appointment.id);
|
||||
await clearStaffDraft();
|
||||
redirect(`/staff/appointments/${result.appointment.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Book Follow-Up" — preselects customer, services, and barber from a
|
||||
* past appointment, then sends the barber straight to the services step
|
||||
* (skipping customer selection, since it's already known). Prices and
|
||||
* availability are recalculated fresh at every later step; nothing here
|
||||
* copies the old appointment's snapshot values.
|
||||
*/
|
||||
export async function startStaffFollowUpAction(appointmentId: string): Promise<void> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) redirect("/home");
|
||||
|
||||
const appointment = await getAppointmentById(prisma, appointmentId);
|
||||
if (!appointment) redirect("/staff/current");
|
||||
|
||||
try {
|
||||
assertOwnAppointment(appointment, staff);
|
||||
} catch (error) {
|
||||
if (error instanceof StaffPermissionDeniedError) redirect("/staff/current");
|
||||
throw error;
|
||||
}
|
||||
|
||||
const activeServiceIds = appointment.services
|
||||
.filter((line) => line.service.isActive)
|
||||
.map((line) => line.serviceId);
|
||||
const staffId =
|
||||
appointment.staff.status === "ACTIVE" && appointment.staff.isBookableOnline
|
||||
? appointment.staffId
|
||||
: undefined;
|
||||
|
||||
await clearStaffDraft();
|
||||
await writeStaffDraft({
|
||||
businessId: appointment.businessId,
|
||||
serviceIds: activeServiceIds,
|
||||
staffId,
|
||||
customerId: appointment.customerId,
|
||||
followUpFromAppointmentId: appointment.id,
|
||||
});
|
||||
|
||||
redirect("/staff/book/services");
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { createTimeOff, deleteTimeOff, updateTimeOff } from "@/services/staff/staff.service";
|
||||
import type { TimeOffInput } from "@/services/staff/staff.service";
|
||||
|
||||
export interface TimeOffConflictItem {
|
||||
id: string;
|
||||
customerName: string;
|
||||
serviceNames: string;
|
||||
startAt: string;
|
||||
}
|
||||
|
||||
export interface TimeOffActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: TimeOffConflictItem[];
|
||||
}
|
||||
|
||||
function mapConflicts(conflicts: { id: string; customer: { firstName: string; lastName: string }; services: { service: { name: string } }[]; startAt: Date }[]): TimeOffConflictItem[] {
|
||||
return conflicts.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Whole-day time off only — the settings editor doesn't handle partial-day blocks; that's the calendar's "Block Time" action (see block-time.ts). */
|
||||
export async function createTimeOffAction(
|
||||
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
|
||||
acknowledgeConflicts?: boolean,
|
||||
): Promise<TimeOffActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await createTimeOff(prisma, {
|
||||
staffId: staff.id,
|
||||
businessTimezone: staff.business.timezone,
|
||||
input,
|
||||
acknowledgeConflicts,
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't add this time off.", conflicts: mapConflicts(result.conflicts) };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateTimeOffAction(
|
||||
timeOffId: string,
|
||||
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
|
||||
acknowledgeConflicts?: boolean,
|
||||
): Promise<TimeOffActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await updateTimeOff(prisma, {
|
||||
timeOffId,
|
||||
staffId: staff.id,
|
||||
businessTimezone: staff.business.timezone,
|
||||
input,
|
||||
acknowledgeConflicts,
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this time off.", conflicts: mapConflicts(result.conflicts) };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteTimeOffAction(timeOffId: string): Promise<TimeOffActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await deleteTimeOff(prisma, { timeOffId, staffId: staff.id });
|
||||
return result.success ? { success: true } : { success: false, error: "Couldn't remove this time off." };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { setWorkingHours } from "@/services/staff/staff.service";
|
||||
import type { WorkingHoursDayInput } from "@/services/staff/staff.service";
|
||||
|
||||
export interface WorkingHoursActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function saveWorkingHoursAction(days: WorkingHoursDayInput[]): Promise<WorkingHoursActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await setWorkingHours(prisma, { staffId: staff.id, days });
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't save working hours." };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
@@ -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,107 @@
|
||||
"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 { addLoyaltyStampAction } from "../actions/loyalty-actions";
|
||||
|
||||
export function AddStampButton({
|
||||
customerId,
|
||||
appointmentId,
|
||||
}: {
|
||||
customerId: string;
|
||||
appointmentId?: 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 [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="sm" 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,206 @@
|
||||
"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";
|
||||
|
||||
type RawStatus =
|
||||
"PENDING" | "CONFIRMED" | "CHECKED_IN" | "IN_PROGRESS" | "COMPLETED" | "CANCELLED" | "NO_SHOW";
|
||||
type ConfirmKind = "complete" | "no_show" | "cancel";
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export function AppointmentStatusActions({
|
||||
appointmentId,
|
||||
status,
|
||||
}: {
|
||||
appointmentId: string;
|
||||
status: RawStatus;
|
||||
}) {
|
||||
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 completeAppointmentAction(appointmentId)
|
||||
: confirmKind === "no_show"
|
||||
? await markNoShowAction(appointmentId)
|
||||
: await cancelStaffAppointmentAction({ 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="flex flex-wrap gap-2">
|
||||
{status === "PENDING" && (
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={isPending}
|
||||
onClick={() => runQuiet(() => confirmAppointmentAction(appointmentId))}
|
||||
>
|
||||
Confirm Booking
|
||||
</Button>
|
||||
)}
|
||||
{status === "CONFIRMED" && (
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={isPending}
|
||||
onClick={() => runQuiet(() => checkInAppointmentAction(appointmentId))}
|
||||
>
|
||||
Check In
|
||||
</Button>
|
||||
)}
|
||||
{status === "CHECKED_IN" && (
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={isPending}
|
||||
onClick={() => runQuiet(() => startAppointmentAction(appointmentId))}
|
||||
>
|
||||
Start Appointment
|
||||
</Button>
|
||||
)}
|
||||
{status === "IN_PROGRESS" && (
|
||||
<Button size="sm" onClick={() => setConfirmKind("complete")}>
|
||||
Complete Appointment
|
||||
</Button>
|
||||
)}
|
||||
{status === "CONFIRMED" && (
|
||||
<Button size="sm" variant="secondary" onClick={() => setConfirmKind("no_show")}>
|
||||
Mark No-Show
|
||||
</Button>
|
||||
)}
|
||||
{(status === "PENDING" || status === "CONFIRMED" || status === "CHECKED_IN") && (
|
||||
<Button size="sm" 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" onClick={() => setOpen(true)}>
|
||||
<CalendarOff className="h-4 w-4" aria-hidden="true" />
|
||||
Block Time
|
||||
</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,20 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { startStaffFollowUpAction } from "../actions/staff-booking";
|
||||
|
||||
export function BookFollowUpButton({ appointmentId }: { appointmentId: string }) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="rounded-jumbo border-line border bg-white px-5 py-3 text-sm font-bold"
|
||||
isLoading={isPending}
|
||||
onClick={() => startTransition(() => startStaffFollowUpAction(appointmentId))}
|
||||
>
|
||||
Book Follow-Up
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
"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";
|
||||
|
||||
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,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
existing?: EditableBreak | null;
|
||||
}) {
|
||||
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 updateBreakAction(existing.id, input, acknowledgeConflicts)
|
||||
: await createBreakAction(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 deleteBreakAction(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={`/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)}
|
||||
>
|
||||
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,96 @@
|
||||
"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 { 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 }: { breaks: EditableBreak[] }) {
|
||||
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-jumbo border border-line bg-white p-5 text-center text-sm">
|
||||
No recurring breaks yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-line rounded-jumbo 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}
|
||||
/>
|
||||
</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,101 @@
|
||||
"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 { 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 }: { loyaltyAccountId: 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" 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,100 @@
|
||||
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,
|
||||
}: {
|
||||
selectedDate: CalendarDate;
|
||||
businessTimezone: string;
|
||||
basePath: string;
|
||||
view?: string;
|
||||
}) {
|
||||
const today = todayIn(businessTimezone);
|
||||
const viewParam = view ? `&view=${view}` : "";
|
||||
const hrefFor = (date: CalendarDate) => `${basePath}?date=${date}${viewParam}`;
|
||||
|
||||
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="flex items-center justify-between">
|
||||
<Link
|
||||
href={hrefFor(addDays(selectedDate, -1))}
|
||||
aria-label="Previous day"
|
||||
className="border-line flex h-9 w-9 items-center justify-center rounded-full border bg-white text-white"
|
||||
>
|
||||
<ChevronLeft className="text-ink h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
<div className="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="border-line flex h-9 w-9 items-center justify-center rounded-full border bg-white"
|
||||
>
|
||||
<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={cn(
|
||||
"flex flex-1 flex-col items-center gap-1 rounded-xl border py-2 text-xs",
|
||||
isSelected
|
||||
? "border-ink bg-ink text-white"
|
||||
: "border-line text-ink hover:bg-stone bg-white",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[9px] font-bold tracking-widest uppercase",
|
||||
isSelected ? "text-white/70" : "text-muted",
|
||||
)}
|
||||
>
|
||||
{d.toLocaleDateString("en-GB", { weekday: "short" })}
|
||||
</span>
|
||||
<span className={cn("font-bold", isToday && !isSelected && "text-accent")}>
|
||||
{d.getDate()}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import Link from "next/link";
|
||||
import { Gift, NotebookText } from "lucide-react";
|
||||
import { StatusPill } from "@/components/ui/status-pill";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import type { AppointmentStatus } from "@/types/domain";
|
||||
|
||||
const START_HOUR = 7;
|
||||
const END_HOUR = 21;
|
||||
const PX_PER_HOUR = 64;
|
||||
const RANGE_START_MINUTES = START_HOUR * 60;
|
||||
const RANGE_END_MINUTES = END_HOUR * 60;
|
||||
|
||||
function topFor(minutes: number): number {
|
||||
return ((clampToRange(minutes) - RANGE_START_MINUTES) / 60) * 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(20, topFor(endMinutes) - topFor(startMinutes));
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
/**
|
||||
* A single day's grid for one staff member. Deliberately no drag-and-drop —
|
||||
* every interactive element is a plain Link (open details / start a
|
||||
* reschedule from there), per the brief's preference for a reliable
|
||||
* tap-to-act flow over an unreliable drag interaction. Pure presentation:
|
||||
* every minute value here is already computed by the page from real
|
||||
* WorkingHours/Break/TimeOff/Appointment rows, nothing is derived here.
|
||||
*/
|
||||
export function DayCalendar({
|
||||
appointments,
|
||||
blocks,
|
||||
workingWindow,
|
||||
currentTimeMinutes,
|
||||
}: {
|
||||
appointments: CalendarAppointment[];
|
||||
blocks: CalendarBlock[];
|
||||
/** The staff member's working window that day, for shading outside-hours space — null if they're off entirely. */
|
||||
workingWindow: { startMinutes: number; endMinutes: number } | null;
|
||||
/** Minutes-from-midnight right now, only passed when the viewed day is today. */
|
||||
currentTimeMinutes: number | null;
|
||||
}) {
|
||||
const hourMarks = Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, i) => START_HOUR + i);
|
||||
const gridHeight = (END_HOUR - START_HOUR) * PX_PER_HOUR;
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto px-6 pb-8">
|
||||
<div className="relative ml-14" style={{ height: gridHeight }}>
|
||||
{/* Hour gridlines + labels */}
|
||||
{hourMarks.map((hour) => (
|
||||
<div
|
||||
key={hour}
|
||||
className="border-line absolute inset-x-0 border-t"
|
||||
style={{ top: (hour - START_HOUR) * PX_PER_HOUR }}
|
||||
>
|
||||
<span className="text-muted absolute -top-2.5 -left-14 w-12 text-right text-[10px] font-medium">
|
||||
{hour === 12 ? "12pm" : hour > 12 ? `${hour - 12}pm` : `${hour}am`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Outside-working-hours shading */}
|
||||
{workingWindow ? (
|
||||
<>
|
||||
{workingWindow.startMinutes > RANGE_START_MINUTES && (
|
||||
<div
|
||||
className="bg-line/20 absolute inset-x-0"
|
||||
style={{ top: 0, height: topFor(workingWindow.startMinutes) }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{workingWindow.endMinutes < RANGE_END_MINUTES && (
|
||||
<div
|
||||
className="bg-line/20 absolute inset-x-0"
|
||||
style={{ top: topFor(workingWindow.endMinutes), bottom: 0 }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="bg-line/20 absolute inset-0" aria-hidden="true" />
|
||||
)}
|
||||
|
||||
{/* Breaks / time off */}
|
||||
{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 */}
|
||||
{appointments.map((appt) => (
|
||||
<Link
|
||||
key={appt.id}
|
||||
href={`/staff/appointments/${appt.id}`}
|
||||
className={cn(
|
||||
"absolute inset-x-1 z-20 overflow-hidden rounded-lg border-l-4 bg-white p-2 shadow-sm transition-shadow hover:shadow-md",
|
||||
"focus-visible:ring-ink focus-visible:ring-2 focus-visible:ring-offset-1",
|
||||
statusBorderClass(appt.status),
|
||||
)}
|
||||
style={{
|
||||
top: topFor(appt.startMinutes),
|
||||
height: heightFor(appt.startMinutes, appt.endMinutes),
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<StatusPill status={appt.status} iconOnly className="px-1.5 py-0.5" />
|
||||
{appt.hasNotes && (
|
||||
<NotebookText className="text-muted h-3 w-3 shrink-0" aria-label="Has notes" />
|
||||
)}
|
||||
{appt.hasAvailableReward && (
|
||||
<Gift
|
||||
className="text-accent-loyalty h-3 w-3 shrink-0"
|
||||
aria-label="Reward available"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-xs font-bold">{appt.customerName}</p>
|
||||
<p className="text-muted truncate text-[10px]">{appt.serviceNames}</p>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Current-time indicator */}
|
||||
{currentTimeMinutes !== null &&
|
||||
currentTimeMinutes >= RANGE_START_MINUTES &&
|
||||
currentTimeMinutes <= RANGE_END_MINUTES && (
|
||||
<div
|
||||
className="border-danger absolute inset-x-0 z-30 border-t-2"
|
||||
style={{ top: topFor(currentTimeMinutes) }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="bg-danger absolute -top-1.5 -left-1.5 h-3 w-3 rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusBorderClass(status: AppointmentStatus): string {
|
||||
switch (status) {
|
||||
case "cancelled":
|
||||
case "no_show":
|
||||
return "border-status-cancelled opacity-60";
|
||||
case "completed":
|
||||
return "border-line opacity-70";
|
||||
case "in_progress":
|
||||
return "border-status-progress";
|
||||
case "arrived":
|
||||
return "border-status-confirmed";
|
||||
case "pending_approval":
|
||||
return "border-status-pending";
|
||||
default:
|
||||
return "border-status-confirmed";
|
||||
}
|
||||
}
|
||||
@@ -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,121 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NotificationCategory, NotificationChannel } from "@/generated/prisma/enums";
|
||||
import { saveNotificationPreferencesAction } from "../actions/profile";
|
||||
|
||||
const CATEGORY_LABEL: Record<NotificationCategory, string> = {
|
||||
BOOKING_CONFIRMATION: "New booking confirmed",
|
||||
BOOKING_REMINDER: "Upcoming appointment reminders",
|
||||
BOOKING_CHANGED: "Appointment rescheduled",
|
||||
BOOKING_CANCELLED: "Appointment cancelled",
|
||||
LOYALTY_REWARD_EARNED: "Customer earns a loyalty stamp",
|
||||
LOYALTY_REWARD_AVAILABLE: "Customer reward becomes available",
|
||||
MARKETING: "Product news and tips",
|
||||
};
|
||||
const CATEGORY_ORDER: NotificationCategory[] = [
|
||||
NotificationCategory.BOOKING_CONFIRMATION,
|
||||
NotificationCategory.BOOKING_REMINDER,
|
||||
NotificationCategory.BOOKING_CHANGED,
|
||||
NotificationCategory.BOOKING_CANCELLED,
|
||||
NotificationCategory.LOYALTY_REWARD_EARNED,
|
||||
NotificationCategory.LOYALTY_REWARD_AVAILABLE,
|
||||
NotificationCategory.MARKETING,
|
||||
];
|
||||
const CHANNEL_ORDER: NotificationChannel[] = [
|
||||
NotificationChannel.EMAIL,
|
||||
NotificationChannel.SMS,
|
||||
NotificationChannel.PUSH,
|
||||
];
|
||||
const CHANNEL_LABEL: Record<NotificationChannel, string> = { EMAIL: "Email", SMS: "SMS", PUSH: "Push" };
|
||||
|
||||
export interface PreferenceCellState {
|
||||
category: NotificationCategory;
|
||||
channel: NotificationChannel;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
export function NotificationPreferencesForm({ initial }: { initial: PreferenceCellState[] }) {
|
||||
const router = useRouter();
|
||||
const [cells, setCells] = useState(initial);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const isEnabled = (category: NotificationCategory, channel: NotificationChannel) =>
|
||||
cells.find((c) => c.category === category && c.channel === channel)?.isEnabled ?? true;
|
||||
|
||||
const toggle = (category: NotificationCategory, channel: NotificationChannel) => {
|
||||
setSaved(false);
|
||||
setCells((prev) =>
|
||||
prev.map((c) => (c.category === category && c.channel === channel ? { ...c, isEnabled: !c.isEnabled } : c)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
startTransition(async () => {
|
||||
await saveNotificationPreferencesAction(cells);
|
||||
setSaved(true);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted text-sm">
|
||||
Choose which of these you want to hear about, and how. This controls what you receive —
|
||||
delivery isn't wired up yet.
|
||||
</p>
|
||||
|
||||
<div className="overflow-hidden rounded-jumbo border border-line bg-white">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line">
|
||||
<th scope="col" className="px-4 py-3 font-bold">
|
||||
Notification
|
||||
</th>
|
||||
{CHANNEL_ORDER.map((channel) => (
|
||||
<th key={channel} scope="col" className="px-2 py-3 text-center text-xs font-bold">
|
||||
{CHANNEL_LABEL[channel]}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{CATEGORY_ORDER.map((category) => (
|
||||
<tr key={category} className="border-b border-line last:border-b-0">
|
||||
<th scope="row" className="px-4 py-3 text-xs font-medium">
|
||||
{CATEGORY_LABEL[category]}
|
||||
</th>
|
||||
{CHANNEL_ORDER.map((channel) => (
|
||||
<td key={channel} className="px-2 py-3 text-center">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isEnabled(category, channel)}
|
||||
aria-label={`${CATEGORY_LABEL[category]} via ${CHANNEL_LABEL[channel]}`}
|
||||
onClick={() => toggle(category, channel)}
|
||||
className={`h-5 w-5 rounded-md border transition-colors ${
|
||||
isEnabled(category, channel) ? "border-accent bg-accent" : "border-line bg-white"
|
||||
}`}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{saved && (
|
||||
<p role="status" className="text-accent text-sm font-medium">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"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 { 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)}
|
||||
/>
|
||||
<Input
|
||||
label="Photo URL"
|
||||
placeholder="https://…"
|
||||
value={image}
|
||||
onChange={(e) => setImage(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
label="Bio"
|
||||
placeholder="A short introduction customers will see on your profile"
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="rounded-jumbo 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 text-sm font-medium">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"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";
|
||||
|
||||
export interface EditableTimeOff {
|
||||
id: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function TimeOffFormSheet({
|
||||
open,
|
||||
onClose,
|
||||
existing,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
existing?: EditableTimeOff | null;
|
||||
}) {
|
||||
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 updateTimeOffAction(existing.id, input, acknowledgeConflicts)
|
||||
: await createTimeOffAction(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 deleteTimeOffAction(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={`/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)}
|
||||
>
|
||||
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,92 @@
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { EditableTimeOff } 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,
|
||||
}: {
|
||||
upcoming: EditableTimeOff[];
|
||||
past: EditableTimeOff[];
|
||||
}) {
|
||||
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-jumbo border border-line bg-white p-5 text-center text-sm">
|
||||
No upcoming time off.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-line rounded-jumbo 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-jumbo 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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
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 }: { initialRows: InitialWorkingHoursRow[]; businessHoursLabel: Record<Weekday, 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 [isPending, startTransition] = useTransition();
|
||||
|
||||
const updateDay = (dayOfWeek: Weekday, patch: Partial<DayState>) => {
|
||||
setSaved(false);
|
||||
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);
|
||||
setDays((prev) => prev.map((d) => (d.dayOfWeek === Weekday.MONDAY ? d : { ...d, isOff: monday.isOff, startTime: monday.startTime, endTime: monday.endTime })));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await saveWorkingHoursAction(
|
||||
days.map((d) => ({
|
||||
dayOfWeek: d.dayOfWeek,
|
||||
isOff: d.isOff,
|
||||
...(d.isOff ? {} : { startMinutes: toMinutes(d.startTime), endMinutes: toMinutes(d.endTime) }),
|
||||
})),
|
||||
);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't save working hours.");
|
||||
return;
|
||||
}
|
||||
setSaved(true);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button type="button" onClick={copyMondayToAll} className="text-xs font-bold uppercase tracking-widest text-accent underline">
|
||||
Copy Monday to all days
|
||||
</button>
|
||||
|
||||
<div className="divide-y divide-line rounded-jumbo border border-line 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-[10px] text-muted">Business hours: {businessHoursLabel[day.dayOfWeek]}</p>
|
||||
</div>
|
||||
</div>
|
||||
{day.isOff ? (
|
||||
<span className="text-xs text-muted">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="rounded-lg border border-line px-2 py-1.5 text-xs"
|
||||
aria-label={`${DAY_LABEL[day.dayOfWeek]} start time`}
|
||||
/>
|
||||
<span className="text-xs text-muted">to</span>
|
||||
<input
|
||||
type="time"
|
||||
value={day.endTime}
|
||||
onChange={(e) => updateDay(day.dayOfWeek, { endTime: e.target.value })}
|
||||
className="rounded-lg border border-line px-2 py-1.5 text-xs"
|
||||
aria-label={`${DAY_LABEL[day.dayOfWeek]} end time`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm font-medium text-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{saved && !error && (
|
||||
<p role="status" className="text-sm font-medium text-accent">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user