Files
NextChair/src/features/staff/components/appointment-status-actions.tsx
T
Stefan Lazic f7fad3d1a0
CI / ci (push) Canceled after 0s
Appointment details layout
fixed button layout on appointment details page.

added an explainer for all the notifications
2026-09-12 10:01:55 +01:00

247 lines
9.3 KiB
TypeScript

"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Modal } from "@/components/ui/modal";
import { Textarea } from "@/components/ui/textarea";
import {
cancelStaffAppointmentAction,
checkInAppointmentAction,
completeAppointmentAction,
confirmAppointmentAction,
markNoShowAction,
startAppointmentAction,
} from "../actions/appointment-actions";
import type { StaffAppointmentActionResult } from "../actions/appointment-actions";
type RawStatus =
"PENDING" | "CONFIRMED" | "CHECKED_IN" | "IN_PROGRESS" | "COMPLETED" | "CANCELLED" | "NO_SHOW";
type ConfirmKind = "complete" | "no_show" | "cancel";
/** Same shape the owner variant (src/features/owner/actions/appointments.ts) implements — see AppointmentStatusActions' `actions` prop. */
export interface AppointmentActionSet {
confirm: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
checkIn: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
start: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
complete: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
markNoShow: (appointmentId: string) => Promise<StaffAppointmentActionResult>;
cancel: (params: { appointmentId: string; reason?: string }) => Promise<StaffAppointmentActionResult>;
}
const DEFAULT_ACTIONS: AppointmentActionSet = {
confirm: confirmAppointmentAction,
checkIn: checkInAppointmentAction,
start: startAppointmentAction,
complete: completeAppointmentAction,
markNoShow: markNoShowAction,
cancel: cancelStaffAppointmentAction,
};
/**
* The status-appropriate action set — shared by Current Appointment and
* Appointment Details, since both need exactly the same buttons for the
* same status. Every button calls a Server Action that delegates to
* AppointmentService's own state machine; this component never decides
* whether a transition is legal, only which buttons to *offer* for a given
* status (offering an illegal one would just get rejected server-side —
* see appointment-actions.ts). `actions` defaults to the staff (own-only)
* Server Actions; the owner appointment details page passes the owner
* variant instead, so this one component serves both contexts without
* forking — see docs/phase-6-summary.md, "Owner appointment access".
*/
export function AppointmentStatusActions({
appointmentId,
status,
actions = DEFAULT_ACTIONS,
layout = "wrap",
}: {
appointmentId: string;
status: RawStatus;
actions?: AppointmentActionSet;
/** "stack" renders one full-width button per row (Current Appointment); "wrap" (default, used by Appointment Details) keeps the original inline small buttons. */
layout?: "wrap" | "stack";
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [confirmKind, setConfirmKind] = useState<ConfirmKind | null>(null);
const [reason, setReason] = useState("");
const [done, setDone] = useState<{ loyaltyStampAwarded?: boolean } | null>(null);
const runQuiet = (action: () => Promise<{ success: boolean; error?: string }>) => {
setError(null);
startTransition(async () => {
const result = await action();
if (!result.success) {
setError(result.error ?? "Something went wrong.");
return;
}
router.refresh();
});
};
const runConfirmed = () => {
if (!confirmKind) return;
setError(null);
startTransition(async () => {
const result =
confirmKind === "complete"
? await actions.complete(appointmentId)
: confirmKind === "no_show"
? await actions.markNoShow(appointmentId)
: await actions.cancel({ appointmentId, reason: reason || undefined });
if (!result.success) {
setError(result.error ?? "Something went wrong.");
return;
}
setDone(
confirmKind === "complete" ? { loyaltyStampAwarded: result.loyaltyStampAwarded } : {},
);
});
};
const closeConfirm = () => {
const shouldRefresh = !!done;
setConfirmKind(null);
setDone(null);
setReason("");
setError(null);
if (shouldRefresh) router.refresh();
};
const modalTitle =
confirmKind === "complete"
? "Complete Appointment?"
: confirmKind === "no_show"
? "Mark as No-Show?"
: "Cancel Appointment?";
// "wrap" buttons are `flex-1` (not intrinsically sized) so a row of
// two or three ends up as an even, aligned segmented row instead of
// packing left at whatever width each label's text happens to need —
// that's what made "Check In"/"Mark No-Show"/"Cancel" (very different
// label lengths) look lopsided together. `min-w` keeps a button from
// getting squeezed illegibly thin before the row wraps.
const buttonSize = layout === "stack" ? undefined : "sm";
const buttonClassName = layout === "stack" ? "w-full" : "min-w-[110px] flex-1";
return (
<div className="space-y-3">
{error && !confirmKind && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<div className={layout === "stack" ? "flex flex-col gap-2" : "flex flex-wrap gap-2"}>
{status === "PENDING" && (
<Button
size={buttonSize}
className={buttonClassName}
isLoading={isPending}
onClick={() => runQuiet(() => actions.confirm(appointmentId))}
>
Confirm Booking
</Button>
)}
{status === "CONFIRMED" && (
<Button
size={buttonSize}
className={buttonClassName}
isLoading={isPending}
onClick={() => runQuiet(() => actions.checkIn(appointmentId))}
>
Check In
</Button>
)}
{status === "CHECKED_IN" && (
<Button
size={buttonSize}
className={buttonClassName}
isLoading={isPending}
onClick={() => runQuiet(() => actions.start(appointmentId))}
>
Start Appointment
</Button>
)}
{status === "IN_PROGRESS" && (
<Button size={buttonSize} className={buttonClassName} onClick={() => setConfirmKind("complete")}>
Complete Appointment
</Button>
)}
{status === "CONFIRMED" && (
<Button size={buttonSize} className={buttonClassName} variant="secondary" onClick={() => setConfirmKind("no_show")}>
Mark No-Show
</Button>
)}
{(status === "PENDING" || status === "CONFIRMED" || status === "CHECKED_IN") && (
<Button size={buttonSize} className={buttonClassName} 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>
);
}