This commit is contained in:
Stefan Lazic committed 2026-08-03 21:15:24 +01:00
1 parent ec5583f972
commit 546613e677
189 files changed
+9873 -655

No files matched your search

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