"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; checkIn: (appointmentId: string) => Promise; start: (appointmentId: string) => Promise; complete: (appointmentId: string) => Promise; markNoShow: (appointmentId: string) => Promise; cancel: (params: { appointmentId: string; reason?: string }) => Promise; } 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(null); const [confirmKind, setConfirmKind] = useState(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 (
{error && !confirmKind && (

{error}

)}
{status === "PENDING" && ( )} {status === "CONFIRMED" && ( )} {status === "CHECKED_IN" && ( )} {status === "IN_PROGRESS" && ( )} {status === "CONFIRMED" && ( )} {(status === "PENDING" || status === "CONFIRMED" || status === "CHECKED_IN") && ( )}
{done ? (

{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."}

{done.loyaltyStampAwarded && (

A loyalty stamp was added for this visit.

)}
) : (

{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."}

{confirmKind === "cancel" && (