Phase 7
This commit is contained in:
189 files changed
+9873
-655
No files matched your search
@@ -12,6 +12,7 @@ import {
|
||||
start,
|
||||
} from "@/services/scheduling/appointment.service";
|
||||
import { awardStampForCompletedAppointment } from "@/services/loyalty/loyalty.service";
|
||||
import { notifyAppointmentNoShow, notifyBookingApproved, notifyBookingCompleted } from "@/services/scheduling/booking-notifications";
|
||||
import { assertOwnAppointment, StaffPermissionDeniedError } from "../authorization";
|
||||
|
||||
/**
|
||||
@@ -58,9 +59,11 @@ export async function confirmAppointmentAction(
|
||||
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 checkInAppointmentAction(
|
||||
@@ -97,6 +100,7 @@ export async function completeAppointmentAction(
|
||||
if (!result.success) {
|
||||
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
|
||||
}
|
||||
await notifyBookingCompleted(prisma, result.appointment);
|
||||
|
||||
const loyaltyResult = await awardStampForCompletedAppointment(prisma, {
|
||||
appointmentId,
|
||||
@@ -115,9 +119,11 @@ export async function markNoShowAction(
|
||||
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 cancelStaffAppointmentAction(params: {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { NotificationType } from "@/generated/prisma/enums";
|
||||
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";
|
||||
import { setNotificationPreference } from "@/services/notifications/notification.service";
|
||||
import type { PreferenceCellState } from "@/features/staff/components/notification-preferences-form";
|
||||
import { updateStaffProfile } from "@/services/staff/staff.service";
|
||||
|
||||
export interface ProfileActionResult {
|
||||
success: boolean;
|
||||
@@ -35,11 +37,18 @@ export async function updateProfileAction(input: {
|
||||
}
|
||||
|
||||
export async function saveNotificationPreferencesAction(
|
||||
cells: NotificationPreferenceCell[],
|
||||
cells: PreferenceCellState[],
|
||||
): 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);
|
||||
for (const cell of cells) {
|
||||
await setNotificationPreference(prisma, {
|
||||
userId: staff.userId,
|
||||
type: NotificationType.STAFF_APPOINTMENT_REMINDER,
|
||||
channel: cell.channel,
|
||||
isEnabled: cell.isEnabled,
|
||||
});
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
@@ -174,7 +174,7 @@ export async function confirmStaffBookingAction(notes?: string): Promise<StaffBo
|
||||
return { success: false, error: getFirstFriendlyMessage(result.validation) };
|
||||
}
|
||||
|
||||
await writeLastBookingReference(result.appointment.id);
|
||||
await writeLastBookingReference({ appointmentId: result.appointment.id });
|
||||
await clearStaffDraft();
|
||||
redirect(`/staff/appointments/${result.appointment.id}`);
|
||||
}
|
||||
|
||||
@@ -5,18 +5,43 @@ import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { setWorkingHours } from "@/services/staff/staff.service";
|
||||
import type { WorkingHoursDayInput } from "@/services/staff/staff.service";
|
||||
|
||||
export interface WorkingHoursConflictItem {
|
||||
id: string;
|
||||
customerName: string;
|
||||
serviceNames: string;
|
||||
startAt: string;
|
||||
}
|
||||
|
||||
export interface WorkingHoursActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
conflicts?: WorkingHoursConflictItem[];
|
||||
}
|
||||
|
||||
export async function saveWorkingHoursAction(days: WorkingHoursDayInput[]): Promise<WorkingHoursActionResult> {
|
||||
export async function saveWorkingHoursAction(
|
||||
days: WorkingHoursDayInput[],
|
||||
acknowledgeConflicts = false,
|
||||
): Promise<WorkingHoursActionResult> {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) return { success: false, error: "You must be signed in as staff." };
|
||||
|
||||
const result = await setWorkingHours(prisma, { staffId: staff.id, days });
|
||||
const result = await setWorkingHours(prisma, {
|
||||
staffId: staff.id,
|
||||
businessTimezone: staff.business.timezone,
|
||||
days,
|
||||
acknowledgeConflicts,
|
||||
});
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't save working hours." };
|
||||
return {
|
||||
success: false,
|
||||
error: result.validation.issues[0]?.message ?? "Couldn't save working hours.",
|
||||
conflicts: result.conflicts.map((c) => ({
|
||||
id: c.id,
|
||||
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
|
||||
serviceNames: c.services.map((s) => s.service.name).join(" + "),
|
||||
startAt: c.startAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
@@ -9,6 +9,20 @@ import { Input } from "@/components/ui/input";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import type { BreakActionResult, BreakConflictItem } from "../actions/breaks";
|
||||
import { createBreakAction, deleteBreakAction, updateBreakAction } from "../actions/breaks";
|
||||
import type { BreakInput } from "@/services/staff/staff.service";
|
||||
|
||||
/** Same DI shape as AppointmentStatusActions' `actions` prop — the owner-on-behalf-of-staff editor injects its own Server Actions bound to a chosen staffId instead of forking this component. */
|
||||
export interface BreakActionSet {
|
||||
create: (input: BreakInput, acknowledgeConflicts?: boolean) => Promise<BreakActionResult>;
|
||||
update: (breakId: string, input: BreakInput, acknowledgeConflicts?: boolean) => Promise<BreakActionResult>;
|
||||
remove: (breakId: string) => Promise<BreakActionResult>;
|
||||
}
|
||||
|
||||
const DEFAULT_ACTIONS: BreakActionSet = {
|
||||
create: createBreakAction,
|
||||
update: updateBreakAction,
|
||||
remove: deleteBreakAction,
|
||||
};
|
||||
|
||||
const DAY_ORDER: Weekday[] = [
|
||||
Weekday.MONDAY,
|
||||
@@ -53,10 +67,14 @@ export function BreakFormSheet({
|
||||
open,
|
||||
onClose,
|
||||
existing,
|
||||
actions = DEFAULT_ACTIONS,
|
||||
appointmentHrefBase = "/staff/appointments",
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
existing?: EditableBreak | null;
|
||||
actions?: BreakActionSet;
|
||||
appointmentHrefBase?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [label, setLabel] = useState(existing?.label ?? "Break");
|
||||
@@ -105,8 +123,8 @@ export function BreakFormSheet({
|
||||
startTransition(async () => {
|
||||
const input = { label, startMinutes, endMinutes, days };
|
||||
const result: BreakActionResult = existing
|
||||
? await updateBreakAction(existing.id, input, acknowledgeConflicts)
|
||||
: await createBreakAction(input, acknowledgeConflicts);
|
||||
? await actions.update(existing.id, input, acknowledgeConflicts)
|
||||
: await actions.create(input, acknowledgeConflicts);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Something went wrong.");
|
||||
setConflicts(result.conflicts ?? null);
|
||||
@@ -120,7 +138,7 @@ export function BreakFormSheet({
|
||||
const handleDelete = () => {
|
||||
if (!existing) return;
|
||||
startDeleteTransition(async () => {
|
||||
const result = await deleteBreakAction(existing.id);
|
||||
const result = await actions.remove(existing.id);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't delete this break.");
|
||||
return;
|
||||
@@ -142,7 +160,7 @@ export function BreakFormSheet({
|
||||
<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">
|
||||
<Link href={`${appointmentHrefBase}/${c.id}`} className="font-bold underline">
|
||||
{c.customerName}
|
||||
</Link>
|
||||
<p className="text-muted">{c.serviceNames}</p>
|
||||
|
||||
@@ -5,7 +5,7 @@ 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";
|
||||
import type { BreakActionSet, EditableBreak } from "./break-form-sheet";
|
||||
|
||||
const DAY_SHORT: Record<Weekday, string> = {
|
||||
MONDAY: "Mon",
|
||||
@@ -40,7 +40,15 @@ function toTimeString(minutes: number): string {
|
||||
return `${h12}:${String(m).padStart(2, "0")} ${period}`;
|
||||
}
|
||||
|
||||
export function BreaksList({ breaks }: { breaks: EditableBreak[] }) {
|
||||
export function BreaksList({
|
||||
breaks,
|
||||
actions,
|
||||
appointmentHrefBase,
|
||||
}: {
|
||||
breaks: EditableBreak[];
|
||||
actions?: BreakActionSet;
|
||||
appointmentHrefBase?: string;
|
||||
}) {
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<EditableBreak | null>(null);
|
||||
|
||||
@@ -90,6 +98,8 @@ export function BreaksList({ breaks }: { breaks: EditableBreak[] }) {
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
existing={editing}
|
||||
actions={actions}
|
||||
appointmentHrefBase={appointmentHrefBase}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,59 +3,48 @@
|
||||
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";
|
||||
import { NotificationChannel } from "@/generated/prisma/enums";
|
||||
|
||||
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" };
|
||||
const CHANNEL_ORDER: NotificationChannel[] = [NotificationChannel.EMAIL, NotificationChannel.PUSH];
|
||||
const CHANNEL_LABEL: Record<NotificationChannel, string> = { EMAIL: "Email", PUSH: "Push", IN_APP: "In-app" };
|
||||
|
||||
export interface PreferenceCellState {
|
||||
category: NotificationCategory;
|
||||
channel: NotificationChannel;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
export function NotificationPreferencesForm({ initial }: { initial: PreferenceCellState[] }) {
|
||||
/**
|
||||
* Every other notification a barber gets (new appointment, reschedule,
|
||||
* cancellation, schedule change) is required — always sent, no toggle —
|
||||
* so this form only ever shows the one type that's genuinely optional:
|
||||
* appointment reminders. See NotificationService/types.ts,
|
||||
* PREFERENCE_CONTROLLED_TYPES, and docs/phase-7-summary.md — showing a
|
||||
* toggle for a required type would just be a no-op switch that does
|
||||
* nothing when flipped, which the Phase 7 brief explicitly calls out to
|
||||
* avoid.
|
||||
*/
|
||||
export interface NotificationPreferencesFormProps {
|
||||
initial: PreferenceCellState[];
|
||||
/** Recipient-specific — see features/staff/actions/profile.ts (staff) and features/notifications/actions/preferences.ts (customer). */
|
||||
action: (cells: PreferenceCellState[]) => Promise<{ success: boolean; error?: string }>;
|
||||
}
|
||||
|
||||
export function NotificationPreferencesForm({ initial, action }: NotificationPreferencesFormProps) {
|
||||
const router = useRouter();
|
||||
const [cells, setCells] = useState(initial);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const isEnabled = (category: NotificationCategory, channel: NotificationChannel) =>
|
||||
cells.find((c) => c.category === category && c.channel === channel)?.isEnabled ?? true;
|
||||
const isEnabled = (channel: NotificationChannel) => cells.find((c) => c.channel === channel)?.isEnabled ?? true;
|
||||
|
||||
const toggle = (category: NotificationCategory, channel: NotificationChannel) => {
|
||||
const toggle = (channel: NotificationChannel) => {
|
||||
setSaved(false);
|
||||
setCells((prev) =>
|
||||
prev.map((c) => (c.category === category && c.channel === channel ? { ...c, isEnabled: !c.isEnabled } : c)),
|
||||
);
|
||||
setCells((prev) => prev.map((c) => (c.channel === channel ? { ...c, isEnabled: !c.isEnabled } : c)));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
startTransition(async () => {
|
||||
await saveNotificationPreferencesAction(cells);
|
||||
await action(cells);
|
||||
setSaved(true);
|
||||
router.refresh();
|
||||
});
|
||||
@@ -64,52 +53,30 @@ export function NotificationPreferencesForm({ initial }: { initial: PreferenceCe
|
||||
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.
|
||||
Booking confirmations, changes, and cancellations always notify you — those aren't
|
||||
optional. Choose how you'd like to hear about upcoming appointment reminders.
|
||||
</p>
|
||||
|
||||
<div className="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 className="rounded-jumbo border-line divide-line divide-y overflow-hidden border bg-white">
|
||||
{CHANNEL_ORDER.map((channel) => (
|
||||
<div key={channel} className="flex items-center justify-between px-4 py-3">
|
||||
<span className="text-sm font-medium">Reminders via {CHANNEL_LABEL[channel]}</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isEnabled(channel)}
|
||||
aria-label={`Appointment reminders via ${CHANNEL_LABEL[channel]}`}
|
||||
onClick={() => toggle(channel)}
|
||||
className={`h-5 w-5 rounded-md border transition-colors ${
|
||||
isEnabled(channel) ? "border-accent bg-accent" : "border-line bg-white"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{saved && (
|
||||
<p role="status" className="text-accent text-sm font-medium">
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -79,7 +79,7 @@ export function ProfileForm({ initial }: { initial: ProfileFormInitial }) {
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { BottomSheet } from "@/components/ui/bottom-sheet";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { TimeOffActionResult, TimeOffConflictItem } from "../actions/time-off";
|
||||
import { createTimeOffAction, deleteTimeOffAction, updateTimeOffAction } from "../actions/time-off";
|
||||
import type { TimeOffInput } from "@/services/staff/staff.service";
|
||||
|
||||
export interface EditableTimeOff {
|
||||
id: string;
|
||||
@@ -16,6 +17,26 @@ export interface EditableTimeOff {
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
/** Same DI shape as AppointmentStatusActions' `actions` prop — the owner-on-behalf-of-staff editor injects its own Server Actions bound to a chosen staffId instead of forking this component. */
|
||||
export interface TimeOffActionSet {
|
||||
create: (
|
||||
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
|
||||
acknowledgeConflicts?: boolean,
|
||||
) => Promise<TimeOffActionResult>;
|
||||
update: (
|
||||
timeOffId: string,
|
||||
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
|
||||
acknowledgeConflicts?: boolean,
|
||||
) => Promise<TimeOffActionResult>;
|
||||
remove: (timeOffId: string) => Promise<TimeOffActionResult>;
|
||||
}
|
||||
|
||||
const DEFAULT_ACTIONS: TimeOffActionSet = {
|
||||
create: createTimeOffAction,
|
||||
update: updateTimeOffAction,
|
||||
remove: deleteTimeOffAction,
|
||||
};
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -24,10 +45,14 @@ export function TimeOffFormSheet({
|
||||
open,
|
||||
onClose,
|
||||
existing,
|
||||
actions = DEFAULT_ACTIONS,
|
||||
appointmentHrefBase = "/staff/appointments",
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
existing?: EditableTimeOff | null;
|
||||
actions?: TimeOffActionSet;
|
||||
appointmentHrefBase?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [startDate, setStartDate] = useState(existing?.startDate ?? todayIso());
|
||||
@@ -60,8 +85,8 @@ export function TimeOffFormSheet({
|
||||
startTransition(async () => {
|
||||
const input = { startDate, endDate, reason: reason.trim() || undefined };
|
||||
const result: TimeOffActionResult = existing
|
||||
? await updateTimeOffAction(existing.id, input, acknowledgeConflicts)
|
||||
: await createTimeOffAction(input, acknowledgeConflicts);
|
||||
? await actions.update(existing.id, input, acknowledgeConflicts)
|
||||
: await actions.create(input, acknowledgeConflicts);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Something went wrong.");
|
||||
setConflicts(result.conflicts ?? null);
|
||||
@@ -75,7 +100,7 @@ export function TimeOffFormSheet({
|
||||
const handleDelete = () => {
|
||||
if (!existing) return;
|
||||
startDeleteTransition(async () => {
|
||||
const result = await deleteTimeOffAction(existing.id);
|
||||
const result = await actions.remove(existing.id);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't remove this time off.");
|
||||
return;
|
||||
@@ -98,7 +123,7 @@ export function TimeOffFormSheet({
|
||||
<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">
|
||||
<Link href={`${appointmentHrefBase}/${c.id}`} className="font-bold underline">
|
||||
{c.customerName}
|
||||
</Link>
|
||||
<p className="text-muted">{c.serviceNames}</p>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { EditableTimeOff } from "./time-off-form-sheet";
|
||||
import type { EditableTimeOff, TimeOffActionSet } from "./time-off-form-sheet";
|
||||
import { TimeOffFormSheet } from "./time-off-form-sheet";
|
||||
|
||||
function formatRange(startDate: string, endDate: string): string {
|
||||
@@ -18,9 +18,13 @@ function formatRange(startDate: string, endDate: string): string {
|
||||
export function TimeOffList({
|
||||
upcoming,
|
||||
past,
|
||||
actions,
|
||||
appointmentHrefBase,
|
||||
}: {
|
||||
upcoming: EditableTimeOff[];
|
||||
past: EditableTimeOff[];
|
||||
actions?: TimeOffActionSet;
|
||||
appointmentHrefBase?: string;
|
||||
}) {
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<EditableTimeOff | null>(null);
|
||||
@@ -86,6 +90,8 @@ export function TimeOffList({
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
existing={editing}
|
||||
actions={actions}
|
||||
appointmentHrefBase={appointmentHrefBase}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import { saveWorkingHoursAction } from "../actions/working-hours";
|
||||
import type { WorkingHoursActionResult } from "../actions/working-hours";
|
||||
import type { WorkingHoursActionResult, WorkingHoursConflictItem } from "../actions/working-hours";
|
||||
import type { WorkingHoursDayInput } from "@/services/staff/staff.service";
|
||||
|
||||
const DAY_ORDER: Weekday[] = [
|
||||
@@ -58,11 +59,17 @@ export function WorkingHoursForm({
|
||||
initialRows,
|
||||
businessHoursLabel,
|
||||
onSave = saveWorkingHoursAction,
|
||||
appointmentHrefBase = "/staff/appointments",
|
||||
}: {
|
||||
initialRows: InitialWorkingHoursRow[];
|
||||
businessHoursLabel: Record<Weekday, string>;
|
||||
/** Owner-on-behalf-of-staff editing (src/app/(owner)/owner/team/[id]/hours) injects its own Server Action here instead of the self-service default — same pattern as AppointmentStatusActions' `actions` prop. */
|
||||
onSave?: (days: WorkingHoursDayInput[]) => Promise<WorkingHoursActionResult>;
|
||||
onSave?: (
|
||||
days: WorkingHoursDayInput[],
|
||||
acknowledgeConflicts?: boolean,
|
||||
) => Promise<WorkingHoursActionResult>;
|
||||
/** Conflict links point at /owner/appointments/[id] when the owner is editing on a staff member's behalf. */
|
||||
appointmentHrefBase?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [days, setDays] = useState<DayState[]>(() =>
|
||||
@@ -78,10 +85,12 @@ export function WorkingHoursForm({
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [conflicts, setConflicts] = useState<WorkingHoursConflictItem[]>([]);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const updateDay = (dayOfWeek: Weekday, patch: Partial<DayState>) => {
|
||||
setSaved(false);
|
||||
setConflicts([]);
|
||||
setDays((prev) => prev.map((d) => (d.dayOfWeek === dayOfWeek ? { ...d, ...patch } : d)));
|
||||
};
|
||||
|
||||
@@ -89,10 +98,11 @@ export function WorkingHoursForm({
|
||||
const monday = days.find((d) => d.dayOfWeek === Weekday.MONDAY);
|
||||
if (!monday) return;
|
||||
setSaved(false);
|
||||
setConflicts([]);
|
||||
setDays((prev) => prev.map((d) => (d.dayOfWeek === Weekday.MONDAY ? d : { ...d, isOff: monday.isOff, startTime: monday.startTime, endTime: monday.endTime })));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const submit = (acknowledgeConflicts: boolean) => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await onSave(
|
||||
@@ -101,49 +111,82 @@ export function WorkingHoursForm({
|
||||
isOff: d.isOff,
|
||||
...(d.isOff ? {} : { startMinutes: toMinutes(d.startTime), endMinutes: toMinutes(d.endTime) }),
|
||||
})),
|
||||
acknowledgeConflicts,
|
||||
);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't save working hours.");
|
||||
setConflicts(result.conflicts ?? []);
|
||||
return;
|
||||
}
|
||||
setSaved(true);
|
||||
setConflicts([]);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
These hours would leave {conflicts.length} upcoming appointment{conflicts.length > 1 ? "s" : ""} outside
|
||||
working hours. They won't be cancelled automatically — review them, then confirm if you still want to
|
||||
save.
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{conflicts.map((c) => (
|
||||
<li key={c.id} className="border-line rounded-lg border bg-white p-3 text-xs">
|
||||
<Link href={`${appointmentHrefBase}/${c.id}`} className="font-bold underline">
|
||||
{c.customerName}
|
||||
</Link>
|
||||
<p className="text-muted">{c.serviceNames}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={() => setConflicts([])}>
|
||||
Go Back
|
||||
</Button>
|
||||
<Button variant="destructive" className="flex-1" isLoading={isPending} onClick={() => submit(true)}>
|
||||
Save Anyway
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button type="button" onClick={copyMondayToAll} className="text-xs font-bold uppercase tracking-widest text-accent underline">
|
||||
<button type="button" onClick={copyMondayToAll} className="text-accent text-xs font-bold tracking-widest uppercase underline">
|
||||
Copy Monday to all days
|
||||
</button>
|
||||
|
||||
<div className="divide-y divide-line rounded-jumbo border border-line bg-white">
|
||||
<div className="divide-line rounded-jumbo border-line divide-y border bg-white">
|
||||
{days.map((day) => (
|
||||
<div key={day.dayOfWeek} className="flex items-center justify-between gap-4 px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch checked={!day.isOff} onCheckedChange={(checked) => updateDay(day.dayOfWeek, { isOff: !checked })} aria-label={`${DAY_LABEL[day.dayOfWeek]} working`} />
|
||||
<div>
|
||||
<p className="text-sm font-bold">{DAY_LABEL[day.dayOfWeek]}</p>
|
||||
<p className="text-[10px] text-muted">Business hours: {businessHoursLabel[day.dayOfWeek]}</p>
|
||||
<p className="text-muted text-[10px]">Business hours: {businessHoursLabel[day.dayOfWeek]}</p>
|
||||
</div>
|
||||
</div>
|
||||
{day.isOff ? (
|
||||
<span className="text-xs text-muted">Off</span>
|
||||
<span className="text-muted text-xs">Off</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="time"
|
||||
value={day.startTime}
|
||||
onChange={(e) => updateDay(day.dayOfWeek, { startTime: e.target.value })}
|
||||
className="rounded-lg border border-line px-2 py-1.5 text-xs"
|
||||
className="border-line rounded-lg border px-2 py-1.5 text-xs"
|
||||
aria-label={`${DAY_LABEL[day.dayOfWeek]} start time`}
|
||||
/>
|
||||
<span className="text-xs text-muted">to</span>
|
||||
<span className="text-muted text-xs">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"
|
||||
className="border-line rounded-lg border px-2 py-1.5 text-xs"
|
||||
aria-label={`${DAY_LABEL[day.dayOfWeek]} end time`}
|
||||
/>
|
||||
</div>
|
||||
@@ -153,16 +196,16 @@ export function WorkingHoursForm({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm font-medium text-danger">
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{saved && !error && (
|
||||
<p role="status" className="text-sm font-medium text-accent">
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
|
||||
<Button className="w-full" isLoading={isPending} onClick={() => submit(false)}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user