Promote uat → main (PROD): GRO-2319 portal StatusBadge palette (#71)
CI / Test (push) Successful in 20s
CI / Lint & Typecheck (push) Successful in 25s
CI / Build & Push Docker Image (push) Successful in 20s

Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
This commit was merged in pull request #71.
This commit is contained in:
2026-06-10 08:57:46 +00:00
committed by Scrubs McBarkley
parent 7ef270312c
commit fe565861b9
3 changed files with 118 additions and 9 deletions
+52 -8
View File
@@ -315,9 +315,19 @@ const STATUS_LABELS: Record<string, string> = {
scheduled: 'Scheduled',
};
// The DB `appointment_status` enum stores `no_show` (underscore), but the badge
// palette is keyed on `no-show` (hyphen). Without normalization a no-show
// appointment renders as a raw gray `no_show` label instead of the styled
// "No-show" badge (GRO-2319 item 1). Map underscore status keys to the hyphen
// palette key so DB-sourced statuses resolve to their intended badge style.
export function normalizeStatusKey(status: string): string {
return status.replace(/_/g, '-');
}
export function StatusBadge({ status }: { status: string }) {
const label = STATUS_LABELS[status] ?? status;
const colorClass = STATUS_COLORS[status] ?? 'bg-stone-100 text-stone-600';
const key = normalizeStatusKey(status);
const label = STATUS_LABELS[key] ?? status;
const colorClass = STATUS_COLORS[key] ?? 'bg-stone-100 text-stone-600';
return (
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${colorClass}`}>
{label}
@@ -325,6 +335,19 @@ export function StatusBadge({ status }: { status: string }) {
);
}
// Derives the badge state shown on an Upcoming/Past card from the appointment's
// raw status plus its confirmationStatus (GRO-2319 item 2, CMPO-approved):
// - a synthetic waitlist entry (status `waitlisted`) always shows Waitlisted
// - an upcoming appointment the groomer has not yet confirmed
// (`confirmationStatus === 'pending'`) shows Pending — semantically honest
// and reduces anxiety-driven follow-up messages
// - otherwise the raw status drives the badge
export function deriveDisplayStatus(appt: Appointment): string {
if (appt.status === 'waitlisted') return 'waitlisted';
if (isUpcoming(appt) && appt.confirmationStatus === 'pending') return 'pending';
return appt.status;
}
const CONFIRMATION_STATUS_COLORS: Record<string, string> = {
confirmed: 'bg-green-100 text-green-700',
pending: 'bg-amber-100 text-amber-700',
@@ -508,11 +531,24 @@ function AppointmentCard({
sessionId: string | null;
onReschedule: (appt: Appointment) => void;
}) {
// A waitlist-backed entry (GRO-2319 item 2, CMPO UX spec GRO-2328) is not a
// confirmed appointment: it gets a muted, dashed-border card and a subtext
// line so the customer can tell it apart from booked appointments, and the
// appointment-only actions (confirm / notes / reschedule / cancel) are hidden.
const isWaitlist = appt.status === 'waitlisted';
return (
<div className="bg-white rounded-xl border border-stone-200 shadow-sm overflow-hidden">
<div
className={
isWaitlist
? 'bg-stone-50/60 rounded-xl border border-dashed border-stone-300 shadow-sm overflow-hidden'
: 'bg-white rounded-xl border border-stone-200 shadow-sm overflow-hidden'
}
>
<button
onClick={onToggle}
className="w-full flex items-center gap-4 p-4 text-left hover:bg-stone-50"
className={`w-full flex items-center gap-4 p-4 text-left ${
isWaitlist ? 'hover:bg-stone-100/60' : 'hover:bg-stone-50'
}`}
>
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center text-lg shrink-0">
{appt.petName?.charAt(0) || 'P'}
@@ -532,8 +568,13 @@ function AppointmentCard({
</span>
<span>with {appt.groomerName || 'First Available'}</span>
</div>
{isWaitlist && (
<p className="text-xs text-stone-400 mt-1">
You're on the waitlist — we'll let you know if a spot opens.
</p>
)}
</div>
<StatusBadge status={appt.status} />
<StatusBadge status={deriveDisplayStatus(appt)} />
{expanded ? (
<ChevronDown size={16} className="text-stone-400" />
) : (
@@ -567,11 +608,14 @@ function AppointmentCard({
{appt.notes}
</p>
)}
{isUpcoming(appt) && !readOnly && (
{!isWaitlist && isUpcoming(appt) && !readOnly && (
<CustomerNotesSection appointment={appt} sessionId={sessionId} />
)}
{isUpcoming(appt) && <ConfirmationSection appointment={appt} sessionId={sessionId} />}
{appt.status !== 'completed' &&
{!isWaitlist && isUpcoming(appt) && (
<ConfirmationSection appointment={appt} sessionId={sessionId} />
)}
{!isWaitlist &&
appt.status !== 'completed' &&
appt.status !== 'cancelled' &&
!readOnly && (
<div className="flex gap-2 mt-3">