CI / ci (push) Canceled after 0s
fixed button layout on appointment details page. added an explainer for all the notifications
247 lines
9.3 KiB
TypeScript
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>
|
|
);
|
|
}
|