Initial PHP rebuild of the app
This commit is contained in:
690 files changed
+33944
-12046
No files matched your search
@@ -1,251 +0,0 @@
|
||||
"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?";
|
||||
|
||||
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={layout === "stack" ? undefined : "sm"}
|
||||
className={layout === "stack" ? "w-full" : undefined}
|
||||
isLoading={isPending}
|
||||
onClick={() => runQuiet(() => actions.confirm(appointmentId))}
|
||||
>
|
||||
Confirm Booking
|
||||
</Button>
|
||||
)}
|
||||
{status === "CONFIRMED" && (
|
||||
<Button
|
||||
size={layout === "stack" ? undefined : "sm"}
|
||||
className={layout === "stack" ? "w-full" : undefined}
|
||||
isLoading={isPending}
|
||||
onClick={() => runQuiet(() => actions.checkIn(appointmentId))}
|
||||
>
|
||||
Check In
|
||||
</Button>
|
||||
)}
|
||||
{status === "CHECKED_IN" && (
|
||||
<Button
|
||||
size={layout === "stack" ? undefined : "sm"}
|
||||
className={layout === "stack" ? "w-full" : undefined}
|
||||
isLoading={isPending}
|
||||
onClick={() => runQuiet(() => actions.start(appointmentId))}
|
||||
>
|
||||
Start Appointment
|
||||
</Button>
|
||||
)}
|
||||
{status === "IN_PROGRESS" && (
|
||||
<Button
|
||||
size={layout === "stack" ? undefined : "sm"}
|
||||
className={layout === "stack" ? "w-full" : undefined}
|
||||
onClick={() => setConfirmKind("complete")}
|
||||
>
|
||||
Complete Appointment
|
||||
</Button>
|
||||
)}
|
||||
{status === "CONFIRMED" && (
|
||||
<Button
|
||||
size={layout === "stack" ? undefined : "sm"}
|
||||
className={layout === "stack" ? "w-full" : undefined}
|
||||
variant="secondary"
|
||||
onClick={() => setConfirmKind("no_show")}
|
||||
>
|
||||
Mark No-Show
|
||||
</Button>
|
||||
)}
|
||||
{(status === "PENDING" || status === "CONFIRMED" || status === "CHECKED_IN") && (
|
||||
<Button
|
||||
size={layout === "stack" ? undefined : "sm"}
|
||||
className={layout === "stack" ? "w-full" : undefined}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user