Promote uat → main (PROD): GRO-2319 portal StatusBadge palette #71
@@ -217,6 +217,25 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
|
||||
| TC-WEB-5.12.16 | Badge status from data | Compare badge label to appointment.status field | Badge label matches the API appointment status exactly |
|
||||
| TC-WEB-5.12.17 | Unknown status fallback | Render badge with unknown status value | Badge renders with the raw status string as label and fallback CSS class |
|
||||
|
||||
#### 5.12f Live StatusBadge palette — no-show / pending / waitlisted (GRO-2319)
|
||||
|
||||
These cases exercise the full StatusBadge palette as it is now produced live by
|
||||
the seeded UAT customer (`uat-customer@groombook.dev`), not just unit-rendered.
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.12.26 | No-show badge (item 1) | Sign in as `uat-customer@groombook.dev`, open `Appointments` → **Past** tab, find the seeded `no_show` appointment | A styled yellow **"No-show"** badge renders (`bg-yellow-100 text-yellow-700`) — **not** a raw gray `no_show` label. The DB `no_show` (underscore) status is normalized to the `no-show` palette key. |
|
||||
| TC-WEB-5.12.27 | Pending derivation (item 2) | On the **Upcoming** tab, find the seeded upcoming appointment whose `confirmationStatus` is `pending` (groomer-unconfirmed) | The card's top-row badge reads amber **"Pending"** (derived from `confirmationStatus`), even though the raw appointment status is `scheduled`. |
|
||||
| TC-WEB-5.12.28 | Confirmed not overridden | On the **Upcoming** tab, find the seeded confirmed appointment (`confirmationStatus = confirmed`) | Badge still reads green **"Confirmed"** — the pending derivation does not override a confirmed appointment. |
|
||||
| TC-WEB-5.12.29 | Waitlisted card (item 2) | On the **Upcoming** tab, find the seeded waitlist entry for the customer | A card renders with a blue **"Waitlisted"** badge, a **dashed muted border**, and the subtext _"You're on the waitlist — we'll let you know if a spot opens."_ The Confirm / Reschedule / Cancel / Notes actions are **not** shown for this entry (it is not a booked appointment). |
|
||||
|
||||
> **GRO-2319 note:** the DB `appointment_status` enum cannot represent `pending`
|
||||
> or `waitlisted`, so those badges are derived in the portal: `pending` from an
|
||||
> upcoming appointment's `confirmationStatus`, and `waitlisted` from active
|
||||
> `waitlist_entries` surfaced by `GET /api/portal/appointments` as synthetic
|
||||
> cards. The `no_show` → `no-show` key normalization fixes the cosmetic badge
|
||||
> mismatch (item 1).
|
||||
|
||||
#### 5.12d Appointment API Shape Normalization (GRO-2180)
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { parseTimeTo24Hour, isUpcoming, normalizeAppointment, normalizeService, formatServicePrice, CustomerNotesSection, ConfirmationSection, StatusBadge, formatSlotLabel, slotToTime, BookingFlow } from "../portal/sections/Appointments.tsx";
|
||||
import { parseTimeTo24Hour, isUpcoming, normalizeAppointment, normalizeService, formatServicePrice, CustomerNotesSection, ConfirmationSection, StatusBadge, normalizeStatusKey, deriveDisplayStatus, formatSlotLabel, slotToTime, BookingFlow } from "../portal/sections/Appointments.tsx";
|
||||
|
||||
const UPCOMING_APPT = {
|
||||
id: "appt-1",
|
||||
@@ -517,6 +517,52 @@ describe("StatusBadge", () => {
|
||||
expect(badge?.className).toContain("bg-stone-100");
|
||||
expect(badge?.className).toContain("text-stone-600");
|
||||
});
|
||||
|
||||
// GRO-2319 item 1: DB stores `no_show` (underscore) but the palette key is
|
||||
// `no-show` (hyphen) — without normalization it rendered raw gray text.
|
||||
it("renders the styled No-show badge for DB `no_show` status", () => {
|
||||
render(<StatusBadge status="no_show" />);
|
||||
const badge = screen.getByText("No-show").closest('span');
|
||||
expect(badge?.className).toContain("bg-yellow-100");
|
||||
expect(badge?.className).toContain("text-yellow-700");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeStatusKey (GRO-2319 item 1)", () => {
|
||||
it("maps underscore status keys to the hyphen palette key", () => {
|
||||
expect(normalizeStatusKey("no_show")).toBe("no-show");
|
||||
});
|
||||
|
||||
it("leaves already-hyphenated / single-word keys unchanged", () => {
|
||||
expect(normalizeStatusKey("no-show")).toBe("no-show");
|
||||
expect(normalizeStatusKey("confirmed")).toBe("confirmed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveDisplayStatus (GRO-2319 item 2)", () => {
|
||||
it("derives Pending for an upcoming, unconfirmed appointment", () => {
|
||||
expect(
|
||||
deriveDisplayStatus({ ...UPCOMING_APPT, status: "scheduled", confirmationStatus: "pending" }),
|
||||
).toBe("pending");
|
||||
});
|
||||
|
||||
it("keeps the raw status when the appointment is confirmed", () => {
|
||||
expect(
|
||||
deriveDisplayStatus({ ...UPCOMING_APPT, status: "confirmed", confirmationStatus: "confirmed" }),
|
||||
).toBe("confirmed");
|
||||
});
|
||||
|
||||
it("does not derive Pending for a past appointment", () => {
|
||||
expect(
|
||||
deriveDisplayStatus({ ...PAST_APPT, status: "completed", confirmationStatus: "pending" }),
|
||||
).toBe("completed");
|
||||
});
|
||||
|
||||
it("always shows Waitlisted for a waitlist-backed entry", () => {
|
||||
expect(
|
||||
deriveDisplayStatus({ ...UPCOMING_APPT, status: "waitlisted", confirmationStatus: undefined }),
|
||||
).toBe("waitlisted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RescheduleFlow dynamic time slots", () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user