Appointment details layout 2
CI / ci (push) Canceled after 0s

"Add to Calendar" removed from the staff appointment details page — it never showed for owner/customer views anyway, only here.
Buttons now stack in one column, in exactly the order you asked: Check In → Reschedule → Mark No-Show → Cancel. Reschedule moved from a separate top row into appointment-status-actions.tsx itself (new rescheduleUrl prop) so it can sit in the right spot relative to the status-specific buttons — this also means it correctly disappears for statuses that were never reschedulable (e.g. once In Progress), same as before.
Add Loyalty Stamp is now an icon-only button (add-stamp-button.tsx gained an iconOnly prop) sitting directly opposite "X/Y stamps" inside the card — same click-to-open-modal behavior, just compact.
This commit is contained in:
Stefan Lazic committed 2026-09-12 10:21:29 +01:00
1 parent f7fad3d1a0
commit b1fce9c002
3 files changed
+52 -46

No files matched your search

@@ -1,5 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { CalendarPlus, Mail, Phone } from "lucide-react"; import { Mail, Phone } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
@@ -213,15 +213,20 @@ export default async function StaffAppointmentDetailsPage({
{loyalty?.isEnabled && ( {loyalty?.isEnabled && (
<div className="border-line border-t pt-4"> <div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase"> <div className="flex items-center justify-between gap-3">
Loyalty <div>
</span> <span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
<p className="text-sm font-bold"> Loyalty
{loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps </span>
{loyalty.availableReward && ( <p className="text-sm font-bold">
<span className="text-accent-loyalty"> · Reward available</span> {loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps
)} {loyalty.availableReward && (
</p> <span className="text-accent-loyalty"> · Reward available</span>
)}
</p>
</div>
<AddStampButton customerId={appointment.customerId} appointmentId={appointment.id} iconOnly />
</div>
{loyaltyTransactions.length > 0 && ( {loyaltyTransactions.length > 0 && (
<ul className="mt-2 space-y-1"> <ul className="mt-2 space-y-1">
{loyaltyTransactions.map((t) => ( {loyaltyTransactions.map((t) => (
@@ -255,41 +260,29 @@ export default async function StaffAppointmentDetailsPage({
)} )}
</Card> </Card>
<div className="flex flex-wrap gap-3"> {appointment.status === "COMPLETED" && (
<a <BookFollowUpButton appointmentId={appointment.id} className="w-full text-center" />
href={`/api/appointments/${appointment.id}/calendar.ics`} )}
className="rounded-md border-line flex min-w-[140px] flex-1 items-center justify-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
>
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
</a>
{RESCHEDULABLE.has(appointment.status) && (
<Link
href={`/staff/appointments/${appointment.id}/reschedule`}
className="rounded-md border-line min-w-[110px] flex-1 border bg-white px-5 py-3 text-center text-sm font-bold"
>
Reschedule
</Link>
)}
{appointment.status === "COMPLETED" && (
<BookFollowUpButton appointmentId={appointment.id} className="min-w-[110px] flex-1 text-center" />
)}
</div>
<AppointmentStatusActions appointmentId={appointment.id} status={appointment.status} /> <AppointmentStatusActions
appointmentId={appointment.id}
status={appointment.status}
layout="stack"
rescheduleUrl={
RESCHEDULABLE.has(appointment.status)
? `/staff/appointments/${appointment.id}/reschedule`
: undefined
}
/>
<div className="flex flex-wrap gap-3"> {canRedeemReward && loyalty?.availableReward && (
{loyalty?.isEnabled && ( <RedeemRewardButton
<AddStampButton customerId={appointment.customerId} appointmentId={appointment.id} /> appointmentId={appointment.id}
)} rewardId={loyalty.availableReward.id}
{canRedeemReward && loyalty?.availableReward && ( rewardLabel={loyalty.availableReward.label}
<RedeemRewardButton subtotal={totalPrice}
appointmentId={appointment.id} />
rewardId={loyalty.availableReward.id} )}
rewardLabel={loyalty.availableReward.label}
subtotal={totalPrice}
/>
)}
</div>
</main> </main>
</div> </div>
); );
@@ -14,11 +14,14 @@ export function AddStampButton({
appointmentId, appointmentId,
className, className,
size = "sm", size = "sm",
iconOnly = false,
}: { }: {
customerId: string; customerId: string;
appointmentId?: string; appointmentId?: string;
className?: string; className?: string;
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
/** Renders just the stamp icon (e.g. opposite a "X/Y stamps" line) instead of icon + label — same action, just compact enough to sit inline with other text. */
iconOnly?: boolean;
}) { }) {
const router = useRouter(); const router = useRouter();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -64,12 +67,13 @@ export function AddStampButton({
<> <>
<Button <Button
variant="secondary" variant="secondary"
size={size} size={iconOnly ? "icon" : size}
className={cn("shrink-0", className)} className={cn("shrink-0", className)}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
aria-label={iconOnly ? "Add loyalty stamp" : undefined}
> >
<Stamp className="h-4 w-4" aria-hidden="true" /> <Stamp className="h-4 w-4" aria-hidden="true" />
Add Loyalty Stamp {!iconOnly && "Add Loyalty Stamp"}
</Button> </Button>
<Modal open={open} onClose={close} title={result ? "Stamp Added" : "Add Loyalty Stamp"}> <Modal open={open} onClose={close} title={result ? "Stamp Added" : "Add Loyalty Stamp"}>
{result ? ( {result ? (
@@ -1,5 +1,6 @@
"use client"; "use client";
import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -55,12 +56,15 @@ export function AppointmentStatusActions({
status, status,
actions = DEFAULT_ACTIONS, actions = DEFAULT_ACTIONS,
layout = "wrap", layout = "wrap",
rescheduleUrl,
}: { }: {
appointmentId: string; appointmentId: string;
status: RawStatus; status: RawStatus;
actions?: AppointmentActionSet; actions?: AppointmentActionSet;
/** "stack" renders one full-width button per row (Current Appointment); "wrap" (default, used by Appointment Details) keeps the original inline small buttons. */ /** "stack" renders one full-width button per row (used by both Current Appointment and Appointment Details); "wrap" (default) keeps the original inline small buttons. */
layout?: "wrap" | "stack"; layout?: "wrap" | "stack";
/** When given, renders a "Reschedule" link right after the primary status button and before Mark No-Show/Cancel — the caller (Appointment Details) decides whether the status is actually reschedulable, this component just renders what it's given. */
rescheduleUrl?: string;
}) { }) {
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@@ -169,6 +173,11 @@ export function AppointmentStatusActions({
Complete Appointment Complete Appointment
</Button> </Button>
)} )}
{rescheduleUrl && (
<Button asChild size={buttonSize} className={buttonClassName} variant="secondary">
<Link href={rescheduleUrl}>Reschedule</Link>
</Button>
)}
{status === "CONFIRMED" && ( {status === "CONFIRMED" && (
<Button size={buttonSize} className={buttonClassName} variant="secondary" onClick={() => setConfirmKind("no_show")}> <Button size={buttonSize} className={buttonClassName} variant="secondary" onClick={() => setConfirmKind("no_show")}>
Mark No-Show Mark No-Show