Phase 5: User appointments view and cancellation with UI and e2e tests
This commit is contained in:
parent
8ff03a8ec3
commit
5108a620e7
14 changed files with 1539 additions and 4 deletions
|
|
@ -48,4 +48,5 @@ app.include_router(invites_routes.router)
|
|||
app.include_router(invites_routes.admin_router)
|
||||
app.include_router(availability_routes.router)
|
||||
app.include_router(booking_routes.router)
|
||||
app.include_router(booking_routes.appointments_router)
|
||||
app.include_router(meta_routes.router)
|
||||
|
|
|
|||
|
|
@ -195,3 +195,86 @@ async def create_booking(
|
|||
cancelled_at=appointment.cancelled_at,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# User's Appointments Endpoints
|
||||
# =============================================================================
|
||||
|
||||
appointments_router = APIRouter(prefix="/api/appointments", tags=["appointments"])
|
||||
|
||||
|
||||
@appointments_router.get("", response_model=list[AppointmentResponse])
|
||||
async def get_my_appointments(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_permission(Permission.VIEW_OWN_APPOINTMENTS)),
|
||||
) -> list[AppointmentResponse]:
|
||||
"""Get the current user's appointments, sorted by date (upcoming first)."""
|
||||
result = await db.execute(
|
||||
select(Appointment)
|
||||
.where(Appointment.user_id == current_user.id)
|
||||
.order_by(Appointment.slot_start.desc())
|
||||
)
|
||||
appointments = result.scalars().all()
|
||||
|
||||
return [
|
||||
AppointmentResponse(
|
||||
id=apt.id,
|
||||
user_id=apt.user_id,
|
||||
user_email=current_user.email,
|
||||
slot_start=apt.slot_start,
|
||||
slot_end=apt.slot_end,
|
||||
note=apt.note,
|
||||
status=apt.status.value,
|
||||
created_at=apt.created_at,
|
||||
cancelled_at=apt.cancelled_at,
|
||||
)
|
||||
for apt in appointments
|
||||
]
|
||||
|
||||
|
||||
@appointments_router.post("/{appointment_id}/cancel", response_model=AppointmentResponse)
|
||||
async def cancel_my_appointment(
|
||||
appointment_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_permission(Permission.CANCEL_OWN_APPOINTMENT)),
|
||||
) -> AppointmentResponse:
|
||||
"""Cancel one of the current user's appointments."""
|
||||
# Get the appointment
|
||||
result = await db.execute(
|
||||
select(Appointment).where(Appointment.id == appointment_id)
|
||||
)
|
||||
appointment = result.scalar_one_or_none()
|
||||
|
||||
if not appointment:
|
||||
raise HTTPException(status_code=404, detail="Appointment not found")
|
||||
|
||||
# Verify ownership
|
||||
if appointment.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Cannot cancel another user's appointment")
|
||||
|
||||
# Check if already cancelled
|
||||
if appointment.status != AppointmentStatus.BOOKED:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot cancel appointment with status '{appointment.status.value}'"
|
||||
)
|
||||
|
||||
# Cancel the appointment
|
||||
appointment.status = AppointmentStatus.CANCELLED_BY_USER
|
||||
appointment.cancelled_at = datetime.now(timezone.utc)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(appointment)
|
||||
|
||||
return AppointmentResponse(
|
||||
id=appointment.id,
|
||||
user_id=appointment.user_id,
|
||||
user_email=current_user.email,
|
||||
slot_start=appointment.slot_start,
|
||||
slot_end=appointment.slot_end,
|
||||
note=appointment.note,
|
||||
status=appointment.status.value,
|
||||
created_at=appointment.created_at,
|
||||
cancelled_at=appointment.cancelled_at,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -447,3 +447,212 @@ class TestBookingNoteValidation:
|
|||
assert response.status_code == 200
|
||||
assert response.json()["note"] == note
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# User Appointments Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestUserAppointments:
|
||||
"""Test user appointments endpoints."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_my_appointments_empty(self, client_factory, regular_user):
|
||||
"""Returns empty list when user has no appointments."""
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
response = await client.get("/api/appointments")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_my_appointments_with_bookings(self, client_factory, regular_user, admin_user):
|
||||
"""Returns user's appointments."""
|
||||
# Admin sets availability
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
|
||||
await admin_client.put(
|
||||
"/api/admin/availability",
|
||||
json={
|
||||
"date": str(tomorrow()),
|
||||
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
|
||||
},
|
||||
)
|
||||
|
||||
# User books two slots
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
await client.post(
|
||||
"/api/booking",
|
||||
json={"slot_start": f"{tomorrow()}T09:00:00Z", "note": "First"},
|
||||
)
|
||||
await client.post(
|
||||
"/api/booking",
|
||||
json={"slot_start": f"{tomorrow()}T09:15:00Z", "note": "Second"},
|
||||
)
|
||||
|
||||
# Get appointments
|
||||
response = await client.get("/api/appointments")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 2
|
||||
# Sorted by date descending
|
||||
notes = [apt["note"] for apt in data]
|
||||
assert "First" in notes
|
||||
assert "Second" in notes
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_cannot_view_user_appointments(self, client_factory, admin_user):
|
||||
"""Admin cannot access user appointments endpoint."""
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||
response = await client.get("/api/appointments")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthenticated_cannot_view_appointments(self, client):
|
||||
"""Unauthenticated user cannot view appointments."""
|
||||
response = await client.get("/api/appointments")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestCancelAppointment:
|
||||
"""Test cancelling appointments."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_own_appointment(self, client_factory, regular_user, admin_user):
|
||||
"""User can cancel their own appointment."""
|
||||
# Admin sets availability
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
|
||||
await admin_client.put(
|
||||
"/api/admin/availability",
|
||||
json={
|
||||
"date": str(tomorrow()),
|
||||
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
|
||||
},
|
||||
)
|
||||
|
||||
# User books
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
book_response = await client.post(
|
||||
"/api/booking",
|
||||
json={"slot_start": f"{tomorrow()}T09:00:00Z"},
|
||||
)
|
||||
apt_id = book_response.json()["id"]
|
||||
|
||||
# Cancel
|
||||
response = await client.post(f"/api/appointments/{apt_id}/cancel")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "cancelled_by_user"
|
||||
assert data["cancelled_at"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_cancel_others_appointment(self, client_factory, regular_user, alt_regular_user, admin_user):
|
||||
"""User cannot cancel another user's appointment."""
|
||||
# Admin sets availability
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
|
||||
await admin_client.put(
|
||||
"/api/admin/availability",
|
||||
json={
|
||||
"date": str(tomorrow()),
|
||||
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
|
||||
},
|
||||
)
|
||||
|
||||
# First user books
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
book_response = await client.post(
|
||||
"/api/booking",
|
||||
json={"slot_start": f"{tomorrow()}T09:00:00Z"},
|
||||
)
|
||||
apt_id = book_response.json()["id"]
|
||||
|
||||
# Second user tries to cancel
|
||||
async with client_factory.create(cookies=alt_regular_user["cookies"]) as client:
|
||||
response = await client.post(f"/api/appointments/{apt_id}/cancel")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "another user" in response.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_cancel_nonexistent_appointment(self, client_factory, regular_user):
|
||||
"""Returns 404 for non-existent appointment."""
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
response = await client.post("/api/appointments/99999/cancel")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_cancel_already_cancelled(self, client_factory, regular_user, admin_user):
|
||||
"""Cannot cancel an already cancelled appointment."""
|
||||
# Admin sets availability
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
|
||||
await admin_client.put(
|
||||
"/api/admin/availability",
|
||||
json={
|
||||
"date": str(tomorrow()),
|
||||
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
|
||||
},
|
||||
)
|
||||
|
||||
# User books and cancels
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
book_response = await client.post(
|
||||
"/api/booking",
|
||||
json={"slot_start": f"{tomorrow()}T09:00:00Z"},
|
||||
)
|
||||
apt_id = book_response.json()["id"]
|
||||
await client.post(f"/api/appointments/{apt_id}/cancel")
|
||||
|
||||
# Try to cancel again
|
||||
response = await client.post(f"/api/appointments/{apt_id}/cancel")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "cancelled_by_user" in response.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_cannot_use_user_cancel_endpoint(self, client_factory, admin_user):
|
||||
"""Admin cannot use user cancel endpoint."""
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as client:
|
||||
response = await client.post("/api/appointments/1/cancel")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_slot_becomes_available(self, client_factory, regular_user, admin_user):
|
||||
"""After cancelling, the slot becomes available again."""
|
||||
# Admin sets availability
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
|
||||
await admin_client.put(
|
||||
"/api/admin/availability",
|
||||
json={
|
||||
"date": str(tomorrow()),
|
||||
"slots": [{"start_time": "09:00:00", "end_time": "09:30:00"}],
|
||||
},
|
||||
)
|
||||
|
||||
# User books
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
book_response = await client.post(
|
||||
"/api/booking",
|
||||
json={"slot_start": f"{tomorrow()}T09:00:00Z"},
|
||||
)
|
||||
apt_id = book_response.json()["id"]
|
||||
|
||||
# Check slots - should have 1 slot left (09:15)
|
||||
slots_response = await client.get(
|
||||
"/api/booking/slots",
|
||||
params={"date": str(tomorrow())},
|
||||
)
|
||||
assert len(slots_response.json()["slots"]) == 1
|
||||
|
||||
# Cancel
|
||||
await client.post(f"/api/appointments/{apt_id}/cancel")
|
||||
|
||||
# Check slots - should have 2 slots now
|
||||
slots_response = await client.get(
|
||||
"/api/booking/slots",
|
||||
params={"date": str(tomorrow())},
|
||||
)
|
||||
assert len(slots_response.json()["slots"]) == 2
|
||||
|
||||
|
|
|
|||
290
frontend/app/appointments/page.tsx
Normal file
290
frontend/app/appointments/page.tsx
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Permission } from "../auth-context";
|
||||
import { api } from "../api";
|
||||
import { sharedStyles } from "../styles/shared";
|
||||
import { Header } from "../components/Header";
|
||||
import { useRequireAuth } from "../hooks/useRequireAuth";
|
||||
import { components } from "../generated/api";
|
||||
|
||||
type AppointmentResponse = components["schemas"]["AppointmentResponse"];
|
||||
|
||||
// Helper to format datetime
|
||||
function formatDateTime(isoString: string): string {
|
||||
const d = new Date(isoString);
|
||||
return d.toLocaleString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to get status display
|
||||
function getStatusDisplay(status: string): { text: string; color: string } {
|
||||
switch (status) {
|
||||
case "booked":
|
||||
return { text: "Booked", color: "#28a745" };
|
||||
case "cancelled_by_user":
|
||||
return { text: "Cancelled by you", color: "#dc3545" };
|
||||
case "cancelled_by_admin":
|
||||
return { text: "Cancelled by admin", color: "#dc3545" };
|
||||
default:
|
||||
return { text: status, color: "#666" };
|
||||
}
|
||||
}
|
||||
|
||||
export default function AppointmentsPage() {
|
||||
const { user, isLoading, isAuthorized } = useRequireAuth({
|
||||
requiredPermission: Permission.VIEW_OWN_APPOINTMENTS,
|
||||
fallbackRedirect: "/",
|
||||
});
|
||||
|
||||
const [appointments, setAppointments] = useState<AppointmentResponse[]>([]);
|
||||
const [isLoadingAppointments, setIsLoadingAppointments] = useState(true);
|
||||
const [cancellingId, setCancellingId] = useState<number | null>(null);
|
||||
const [confirmCancelId, setConfirmCancelId] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchAppointments = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get<AppointmentResponse[]>("/api/appointments");
|
||||
setAppointments(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch appointments:", err);
|
||||
setError("Failed to load appointments");
|
||||
} finally {
|
||||
setIsLoadingAppointments(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user && isAuthorized) {
|
||||
fetchAppointments();
|
||||
}
|
||||
}, [user, isAuthorized, fetchAppointments]);
|
||||
|
||||
const handleCancel = async (appointmentId: number) => {
|
||||
setCancellingId(appointmentId);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await api.post<AppointmentResponse>(`/api/appointments/${appointmentId}/cancel`, {});
|
||||
await fetchAppointments();
|
||||
setConfirmCancelId(null);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("Failed to cancel appointment");
|
||||
}
|
||||
} finally {
|
||||
setCancellingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={sharedStyles.pageContainer}>
|
||||
<Header currentPage="appointments" />
|
||||
<main style={sharedStyles.mainContent}>
|
||||
<p>Loading...</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthorized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const upcomingAppointments = appointments.filter(
|
||||
(apt) => apt.status === "booked" && new Date(apt.slot_start) > new Date()
|
||||
);
|
||||
const pastOrCancelledAppointments = appointments.filter(
|
||||
(apt) => apt.status !== "booked" || new Date(apt.slot_start) <= new Date()
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={sharedStyles.pageContainer}>
|
||||
<Header currentPage="appointments" />
|
||||
<main style={sharedStyles.mainContent}>
|
||||
<h1 style={{ marginBottom: "0.5rem" }}>My Appointments</h1>
|
||||
<p style={{ color: "#666", marginBottom: "1.5rem" }}>
|
||||
View and manage your booked appointments
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
background: "#f8d7da",
|
||||
border: "1px solid #f5c6cb",
|
||||
color: "#721c24",
|
||||
padding: "1rem",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "1rem",
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoadingAppointments ? (
|
||||
<p>Loading appointments...</p>
|
||||
) : appointments.length === 0 ? (
|
||||
<div style={{
|
||||
textAlign: "center",
|
||||
padding: "2rem",
|
||||
color: "#666",
|
||||
}}>
|
||||
<p>You don't have any appointments yet.</p>
|
||||
<a href="/booking" style={{ color: "#0070f3" }}>Book an appointment</a>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Upcoming Appointments */}
|
||||
{upcomingAppointments.length > 0 && (
|
||||
<div style={{ marginBottom: "2rem" }}>
|
||||
<h2 style={{ fontSize: "1.1rem", marginBottom: "1rem" }}>
|
||||
Upcoming ({upcomingAppointments.length})
|
||||
</h2>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
{upcomingAppointments.map((apt) => {
|
||||
const status = getStatusDisplay(apt.status);
|
||||
return (
|
||||
<div
|
||||
key={apt.id}
|
||||
style={{
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "8px",
|
||||
padding: "1rem",
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: "0.25rem" }}>
|
||||
{formatDateTime(apt.slot_start)}
|
||||
</div>
|
||||
{apt.note && (
|
||||
<div style={{ color: "#666", fontSize: "0.875rem", marginBottom: "0.5rem" }}>
|
||||
{apt.note}
|
||||
</div>
|
||||
)}
|
||||
<span style={{
|
||||
color: status.color,
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{status.text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{apt.status === "booked" && (
|
||||
<div>
|
||||
{confirmCancelId === apt.id ? (
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<button
|
||||
onClick={() => handleCancel(apt.id)}
|
||||
disabled={cancellingId === apt.id}
|
||||
style={{
|
||||
padding: "0.25rem 0.75rem",
|
||||
background: "#dc3545",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: cancellingId === apt.id ? "not-allowed" : "pointer",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{cancellingId === apt.id ? "..." : "Confirm"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmCancelId(null)}
|
||||
style={{
|
||||
padding: "0.25rem 0.75rem",
|
||||
background: "#fff",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmCancelId(apt.id)}
|
||||
style={{
|
||||
padding: "0.25rem 0.75rem",
|
||||
background: "#fff",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.75rem",
|
||||
color: "#666",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Past/Cancelled Appointments */}
|
||||
{pastOrCancelledAppointments.length > 0 && (
|
||||
<div>
|
||||
<h2 style={{ fontSize: "1.1rem", marginBottom: "1rem", color: "#666" }}>
|
||||
Past & Cancelled ({pastOrCancelledAppointments.length})
|
||||
</h2>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
{pastOrCancelledAppointments.map((apt) => {
|
||||
const status = getStatusDisplay(apt.status);
|
||||
return (
|
||||
<div
|
||||
key={apt.id}
|
||||
style={{
|
||||
border: "1px solid #eee",
|
||||
borderRadius: "8px",
|
||||
padding: "1rem",
|
||||
background: "#fafafa",
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 500, marginBottom: "0.25rem" }}>
|
||||
{formatDateTime(apt.slot_start)}
|
||||
</div>
|
||||
{apt.note && (
|
||||
<div style={{ color: "#888", fontSize: "0.875rem", marginBottom: "0.5rem" }}>
|
||||
{apt.note}
|
||||
</div>
|
||||
)}
|
||||
<span style={{
|
||||
color: status.color,
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{status.text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -7,7 +7,7 @@ import constants from "../../../shared/constants.json";
|
|||
|
||||
const { ADMIN, REGULAR } = constants.roles;
|
||||
|
||||
type PageId = "counter" | "sum" | "profile" | "invites" | "booking" | "audit" | "admin-invites" | "admin-availability";
|
||||
type PageId = "counter" | "sum" | "profile" | "invites" | "booking" | "appointments" | "audit" | "admin-invites" | "admin-availability";
|
||||
|
||||
interface HeaderProps {
|
||||
currentPage: PageId;
|
||||
|
|
@ -25,6 +25,7 @@ const REGULAR_NAV_ITEMS: NavItem[] = [
|
|||
{ id: "counter", label: "Counter", href: "/" },
|
||||
{ id: "sum", label: "Sum", href: "/sum" },
|
||||
{ id: "booking", label: "Book", href: "/booking", regularOnly: true },
|
||||
{ id: "appointments", label: "Appointments", href: "/appointments", regularOnly: true },
|
||||
{ id: "invites", label: "My Invites", href: "/invites", regularOnly: true },
|
||||
{ id: "profile", label: "My Profile", href: "/profile", regularOnly: true },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -396,6 +396,46 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/appointments": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get My Appointments
|
||||
* @description Get the current user's appointments, sorted by date (upcoming first).
|
||||
*/
|
||||
get: operations["get_my_appointments_api_appointments_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/appointments/{appointment_id}/cancel": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Cancel My Appointment
|
||||
* @description Cancel one of the current user's appointments.
|
||||
*/
|
||||
post: operations["cancel_my_appointment_api_appointments__appointment_id__cancel_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/meta/constants": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -1484,6 +1524,57 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
get_my_appointments_api_appointments_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AppointmentResponse"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
cancel_my_appointment_api_appointments__appointment_id__cancel_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
appointment_id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AppointmentResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_constants_api_meta_constants_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
278
frontend/e2e/appointments.spec.ts
Normal file
278
frontend/e2e/appointments.spec.ts
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
import { test, expect, Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Appointments Page E2E Tests
|
||||
*
|
||||
* Tests for viewing and cancelling user appointments.
|
||||
*/
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||
|
||||
function getRequiredEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`Required environment variable ${name} is not set.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const REGULAR_USER = {
|
||||
email: getRequiredEnv("DEV_USER_EMAIL"),
|
||||
password: getRequiredEnv("DEV_USER_PASSWORD"),
|
||||
};
|
||||
|
||||
const ADMIN_USER = {
|
||||
email: getRequiredEnv("DEV_ADMIN_EMAIL"),
|
||||
password: getRequiredEnv("DEV_ADMIN_PASSWORD"),
|
||||
};
|
||||
|
||||
async function clearAuth(page: Page) {
|
||||
await page.context().clearCookies();
|
||||
}
|
||||
|
||||
async function loginUser(page: Page, email: string, password: string) {
|
||||
await page.goto("/login");
|
||||
await page.fill('input[type="email"]', email);
|
||||
await page.fill('input[type="password"]', password);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL((url) => !url.pathname.includes("/login"), { timeout: 10000 });
|
||||
}
|
||||
|
||||
// Helper to format date as YYYY-MM-DD in local timezone
|
||||
function formatDateLocal(d: Date): string {
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function getTomorrowDateStr(): string {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
return formatDateLocal(tomorrow);
|
||||
}
|
||||
|
||||
// Set up availability and create a booking
|
||||
async function createTestBooking(page: Page) {
|
||||
const dateStr = getTomorrowDateStr();
|
||||
|
||||
// First login as admin to set availability
|
||||
await clearAuth(page);
|
||||
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
||||
|
||||
const adminCookies = await page.context().cookies();
|
||||
const adminAuthCookie = adminCookies.find(c => c.name === "auth_token");
|
||||
|
||||
if (!adminAuthCookie) throw new Error("No admin auth cookie");
|
||||
|
||||
await page.request.put(`${API_URL}/api/admin/availability`, {
|
||||
headers: {
|
||||
Cookie: `auth_token=${adminAuthCookie.value}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
data: {
|
||||
date: dateStr,
|
||||
slots: [{ start_time: "09:00:00", end_time: "12:00:00" }],
|
||||
},
|
||||
});
|
||||
|
||||
// Login as regular user
|
||||
await clearAuth(page);
|
||||
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
||||
|
||||
const userCookies = await page.context().cookies();
|
||||
const userAuthCookie = userCookies.find(c => c.name === "auth_token");
|
||||
|
||||
if (!userAuthCookie) throw new Error("No user auth cookie");
|
||||
|
||||
// Create booking - use a random minute to avoid conflicts with parallel tests
|
||||
const randomMinute = Math.floor(Math.random() * 11) * 15; // 0, 15, 30, 45 etc up to 165 min
|
||||
const hour = 9 + Math.floor(randomMinute / 60);
|
||||
const minute = randomMinute % 60;
|
||||
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:00`;
|
||||
|
||||
const response = await page.request.post(`${API_URL}/api/booking`, {
|
||||
headers: {
|
||||
Cookie: `auth_token=${userAuthCookie.value}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
data: {
|
||||
slot_start: `${dateStr}T${timeStr}Z`,
|
||||
note: "Test appointment",
|
||||
},
|
||||
});
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
test.describe("Appointments Page - Regular User Access", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await clearAuth(page);
|
||||
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
||||
});
|
||||
|
||||
test("regular user can access appointments page", async ({ page }) => {
|
||||
await page.goto("/appointments");
|
||||
|
||||
await expect(page).toHaveURL("/appointments");
|
||||
await expect(page.getByRole("heading", { name: "My Appointments" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("regular user sees Appointments link in navigation", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByRole("link", { name: "Appointments" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows empty state when no appointments", async ({ page }) => {
|
||||
await page.goto("/appointments");
|
||||
|
||||
await expect(page.getByText("don't have any appointments")).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Book an appointment" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Appointments Page - With Bookings", () => {
|
||||
test("shows user's appointments", async ({ page }) => {
|
||||
// Create a booking first
|
||||
await createTestBooking(page);
|
||||
|
||||
// Go to appointments page
|
||||
await page.goto("/appointments");
|
||||
|
||||
// Should see the appointment
|
||||
await expect(page.getByText("Test appointment")).toBeVisible();
|
||||
await expect(page.getByText("Booked", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("can cancel an appointment", async ({ page }) => {
|
||||
// Create a booking
|
||||
await createTestBooking(page);
|
||||
|
||||
// Go to appointments page
|
||||
await page.goto("/appointments");
|
||||
|
||||
// Click cancel button
|
||||
await page.getByRole("button", { name: "Cancel" }).first().click();
|
||||
|
||||
// Confirm cancellation
|
||||
await page.getByRole("button", { name: "Confirm" }).click();
|
||||
|
||||
// Should show cancelled status
|
||||
await expect(page.getByText("Cancelled by you")).toBeVisible();
|
||||
});
|
||||
|
||||
test("can abort cancellation", async ({ page }) => {
|
||||
// Create a booking
|
||||
await createTestBooking(page);
|
||||
|
||||
// Go to appointments page
|
||||
await page.goto("/appointments");
|
||||
|
||||
// Wait for appointments to load
|
||||
await expect(page.getByRole("heading", { name: /Upcoming/ })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click cancel button
|
||||
await page.getByRole("button", { name: "Cancel" }).first().click();
|
||||
|
||||
// Click No to abort
|
||||
await page.getByRole("button", { name: "No" }).click();
|
||||
|
||||
// Should still show as booked
|
||||
await expect(page.getByText("Booked", { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Appointments Page - Access Control", () => {
|
||||
test("admin cannot access appointments page", async ({ page }) => {
|
||||
await clearAuth(page);
|
||||
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
||||
|
||||
await page.goto("/appointments");
|
||||
|
||||
// Should be redirected
|
||||
await expect(page).not.toHaveURL("/appointments");
|
||||
});
|
||||
|
||||
test("admin does not see Appointments link", async ({ page }) => {
|
||||
await clearAuth(page);
|
||||
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
||||
|
||||
await page.goto("/audit");
|
||||
|
||||
await expect(page.getByRole("link", { name: "Appointments" })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("unauthenticated user redirected to login", async ({ page }) => {
|
||||
await clearAuth(page);
|
||||
|
||||
await page.goto("/appointments");
|
||||
|
||||
await expect(page).toHaveURL("/login");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Appointments API", () => {
|
||||
test("regular user can view appointments via API", async ({ page }) => {
|
||||
await clearAuth(page);
|
||||
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
||||
|
||||
const cookies = await page.context().cookies();
|
||||
const authCookie = cookies.find(c => c.name === "auth_token");
|
||||
|
||||
if (authCookie) {
|
||||
const response = await page.request.get(`${API_URL}/api/appointments`, {
|
||||
headers: {
|
||||
Cookie: `auth_token=${authCookie.value}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
expect(Array.isArray(await response.json())).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("regular user can cancel appointment via API", async ({ page }) => {
|
||||
// Create a booking
|
||||
const booking = await createTestBooking(page);
|
||||
|
||||
const cookies = await page.context().cookies();
|
||||
const authCookie = cookies.find(c => c.name === "auth_token");
|
||||
|
||||
if (authCookie && booking && booking.id) {
|
||||
const response = await page.request.post(
|
||||
`${API_URL}/api/appointments/${booking.id}/cancel`,
|
||||
{
|
||||
headers: {
|
||||
Cookie: `auth_token=${authCookie.value}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
data: {},
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data.status).toBe("cancelled_by_user");
|
||||
}
|
||||
});
|
||||
|
||||
test("admin cannot view user appointments via API", async ({ page }) => {
|
||||
await clearAuth(page);
|
||||
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
||||
|
||||
const cookies = await page.context().cookies();
|
||||
const authCookie = cookies.find(c => c.name === "auth_token");
|
||||
|
||||
if (authCookie) {
|
||||
const response = await page.request.get(`${API_URL}/api/appointments`, {
|
||||
headers: {
|
||||
Cookie: `auth_token=${authCookie.value}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(403);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -315,13 +315,14 @@ test.describe("Booking API", () => {
|
|||
const authCookie = cookies.find(c => c.name === "auth_token");
|
||||
|
||||
if (authCookie) {
|
||||
// Use 11:45 to avoid conflicts with other tests using 10:00
|
||||
const response = await request.post(`${API_URL}/api/booking`, {
|
||||
headers: {
|
||||
Cookie: `auth_token=${authCookie.value}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
data: {
|
||||
slot_start: `${dateStr}T10:00:00Z`,
|
||||
slot_start: `${dateStr}T11:45:00Z`,
|
||||
note: "API test booking",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"647d672ac99574a52088-7123e0baf27b194c0b82",
|
||||
"647d672ac99574a52088-a2e9f69e9c9ef92dc2bb",
|
||||
"647d672ac99574a52088-f95d3555fba5395917f0",
|
||||
"a3a10f2351dcc49e1cb9-ac24d656b0f11d39342d",
|
||||
"50879ed375f8988ef978-7be9cdef76510785fb98"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [active] [ref=e1]:
|
||||
- main [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- link "Audit" [ref=e6] [cursor=pointer]:
|
||||
- /url: /audit
|
||||
- generic [ref=e7]:
|
||||
- text: •
|
||||
- link "Invites" [ref=e8] [cursor=pointer]:
|
||||
- /url: /admin/invites
|
||||
- generic [ref=e9]: •Availability
|
||||
- generic [ref=e10]:
|
||||
- generic [ref=e11]: admin@example.com
|
||||
- button "Sign out" [ref=e12] [cursor=pointer]
|
||||
- generic [ref=e14]:
|
||||
- generic [ref=e16]:
|
||||
- heading "Availability" [level=1] [ref=e17]
|
||||
- paragraph [ref=e18]: Configure your available time slots for the next 30 days
|
||||
- generic [ref=e19]:
|
||||
- generic [ref=e20] [cursor=pointer]:
|
||||
- generic [ref=e21]:
|
||||
- generic [ref=e22]: Mon, Dec 22
|
||||
- button "⎘" [ref=e23]
|
||||
- generic [ref=e25]: 09:00 - 12:00
|
||||
- generic [ref=e26] [cursor=pointer]:
|
||||
- generic [ref=e28]: Tue, Dec 23
|
||||
- generic [ref=e30]: No availability
|
||||
- generic [ref=e31] [cursor=pointer]:
|
||||
- generic [ref=e33]: Wed, Dec 24
|
||||
- generic [ref=e35]: No availability
|
||||
- generic [ref=e36] [cursor=pointer]:
|
||||
- generic [ref=e38]: Thu, Dec 25
|
||||
- generic [ref=e40]: No availability
|
||||
- generic [ref=e41] [cursor=pointer]:
|
||||
- generic [ref=e43]: Fri, Dec 26
|
||||
- generic [ref=e45]: No availability
|
||||
- generic [ref=e46] [cursor=pointer]:
|
||||
- generic [ref=e48]: Sat, Dec 27
|
||||
- generic [ref=e50]: No availability
|
||||
- generic [ref=e51] [cursor=pointer]:
|
||||
- generic [ref=e53]: Sun, Dec 28
|
||||
- generic [ref=e55]: No availability
|
||||
- generic [ref=e56] [cursor=pointer]:
|
||||
- generic [ref=e58]: Mon, Dec 29
|
||||
- generic [ref=e60]: No availability
|
||||
- generic [ref=e61] [cursor=pointer]:
|
||||
- generic [ref=e63]: Tue, Dec 30
|
||||
- generic [ref=e65]: No availability
|
||||
- generic [ref=e66] [cursor=pointer]:
|
||||
- generic [ref=e68]: Wed, Dec 31
|
||||
- generic [ref=e70]: No availability
|
||||
- generic [ref=e71] [cursor=pointer]:
|
||||
- generic [ref=e73]: Thu, Jan 1
|
||||
- generic [ref=e75]: No availability
|
||||
- generic [ref=e76] [cursor=pointer]:
|
||||
- generic [ref=e78]: Fri, Jan 2
|
||||
- generic [ref=e80]: No availability
|
||||
- generic [ref=e81] [cursor=pointer]:
|
||||
- generic [ref=e83]: Sat, Jan 3
|
||||
- generic [ref=e85]: No availability
|
||||
- generic [ref=e86] [cursor=pointer]:
|
||||
- generic [ref=e88]: Sun, Jan 4
|
||||
- generic [ref=e90]: No availability
|
||||
- generic [ref=e91] [cursor=pointer]:
|
||||
- generic [ref=e93]: Mon, Jan 5
|
||||
- generic [ref=e95]: No availability
|
||||
- generic [ref=e96] [cursor=pointer]:
|
||||
- generic [ref=e98]: Tue, Jan 6
|
||||
- generic [ref=e100]: No availability
|
||||
- generic [ref=e101] [cursor=pointer]:
|
||||
- generic [ref=e103]: Wed, Jan 7
|
||||
- generic [ref=e105]: No availability
|
||||
- generic [ref=e106] [cursor=pointer]:
|
||||
- generic [ref=e108]: Thu, Jan 8
|
||||
- generic [ref=e110]: No availability
|
||||
- generic [ref=e111] [cursor=pointer]:
|
||||
- generic [ref=e113]: Fri, Jan 9
|
||||
- generic [ref=e115]: No availability
|
||||
- generic [ref=e116] [cursor=pointer]:
|
||||
- generic [ref=e118]: Sat, Jan 10
|
||||
- generic [ref=e120]: No availability
|
||||
- generic [ref=e121] [cursor=pointer]:
|
||||
- generic [ref=e123]: Sun, Jan 11
|
||||
- generic [ref=e125]: No availability
|
||||
- generic [ref=e126] [cursor=pointer]:
|
||||
- generic [ref=e128]: Mon, Jan 12
|
||||
- generic [ref=e130]: No availability
|
||||
- generic [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e133]: Tue, Jan 13
|
||||
- generic [ref=e135]: No availability
|
||||
- generic [ref=e136] [cursor=pointer]:
|
||||
- generic [ref=e138]: Wed, Jan 14
|
||||
- generic [ref=e140]: No availability
|
||||
- generic [ref=e141] [cursor=pointer]:
|
||||
- generic [ref=e143]: Thu, Jan 15
|
||||
- generic [ref=e145]: No availability
|
||||
- generic [ref=e146] [cursor=pointer]:
|
||||
- generic [ref=e148]: Fri, Jan 16
|
||||
- generic [ref=e150]: No availability
|
||||
- generic [ref=e151] [cursor=pointer]:
|
||||
- generic [ref=e153]: Sat, Jan 17
|
||||
- generic [ref=e155]: No availability
|
||||
- generic [ref=e156] [cursor=pointer]:
|
||||
- generic [ref=e158]: Sun, Jan 18
|
||||
- generic [ref=e160]: No availability
|
||||
- generic [ref=e161] [cursor=pointer]:
|
||||
- generic [ref=e163]: Mon, Jan 19
|
||||
- generic [ref=e165]: No availability
|
||||
- generic [ref=e166] [cursor=pointer]:
|
||||
- generic [ref=e168]: Tue, Jan 20
|
||||
- generic [ref=e170]: No availability
|
||||
- status [ref=e171]:
|
||||
- generic [ref=e172]:
|
||||
- img [ref=e174]
|
||||
- generic [ref=e176]:
|
||||
- text: Static route
|
||||
- button "Hide static indicator" [ref=e177] [cursor=pointer]:
|
||||
- img [ref=e178]
|
||||
- alert [ref=e181]
|
||||
```
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [active] [ref=e1]:
|
||||
- main [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- link "Audit" [ref=e6] [cursor=pointer]:
|
||||
- /url: /audit
|
||||
- generic [ref=e7]:
|
||||
- text: •
|
||||
- link "Invites" [ref=e8] [cursor=pointer]:
|
||||
- /url: /admin/invites
|
||||
- generic [ref=e9]: •Availability
|
||||
- generic [ref=e10]:
|
||||
- generic [ref=e11]: admin@example.com
|
||||
- button "Sign out" [ref=e12] [cursor=pointer]
|
||||
- generic [ref=e14]:
|
||||
- generic [ref=e16]:
|
||||
- heading "Availability" [level=1] [ref=e17]
|
||||
- paragraph [ref=e18]: Configure your available time slots for the next 30 days
|
||||
- generic [ref=e19]:
|
||||
- generic [ref=e20] [cursor=pointer]:
|
||||
- generic [ref=e21]:
|
||||
- generic [ref=e22]: Mon, Dec 22
|
||||
- button "⎘" [ref=e23]
|
||||
- generic [ref=e25]: 09:00 - 12:00
|
||||
- generic [ref=e26] [cursor=pointer]:
|
||||
- generic [ref=e28]: Tue, Dec 23
|
||||
- generic [ref=e30]: No availability
|
||||
- generic [ref=e31] [cursor=pointer]:
|
||||
- generic [ref=e33]: Wed, Dec 24
|
||||
- generic [ref=e35]: No availability
|
||||
- generic [ref=e36] [cursor=pointer]:
|
||||
- generic [ref=e38]: Thu, Dec 25
|
||||
- generic [ref=e40]: No availability
|
||||
- generic [ref=e41] [cursor=pointer]:
|
||||
- generic [ref=e43]: Fri, Dec 26
|
||||
- generic [ref=e45]: No availability
|
||||
- generic [ref=e46] [cursor=pointer]:
|
||||
- generic [ref=e48]: Sat, Dec 27
|
||||
- generic [ref=e50]: No availability
|
||||
- generic [ref=e51] [cursor=pointer]:
|
||||
- generic [ref=e53]: Sun, Dec 28
|
||||
- generic [ref=e55]: No availability
|
||||
- generic [ref=e56] [cursor=pointer]:
|
||||
- generic [ref=e58]: Mon, Dec 29
|
||||
- generic [ref=e60]: No availability
|
||||
- generic [ref=e61] [cursor=pointer]:
|
||||
- generic [ref=e63]: Tue, Dec 30
|
||||
- generic [ref=e65]: No availability
|
||||
- generic [ref=e66] [cursor=pointer]:
|
||||
- generic [ref=e68]: Wed, Dec 31
|
||||
- generic [ref=e70]: No availability
|
||||
- generic [ref=e71] [cursor=pointer]:
|
||||
- generic [ref=e73]: Thu, Jan 1
|
||||
- generic [ref=e75]: No availability
|
||||
- generic [ref=e76] [cursor=pointer]:
|
||||
- generic [ref=e78]: Fri, Jan 2
|
||||
- generic [ref=e80]: No availability
|
||||
- generic [ref=e81] [cursor=pointer]:
|
||||
- generic [ref=e83]: Sat, Jan 3
|
||||
- generic [ref=e85]: No availability
|
||||
- generic [ref=e86] [cursor=pointer]:
|
||||
- generic [ref=e88]: Sun, Jan 4
|
||||
- generic [ref=e90]: No availability
|
||||
- generic [ref=e91] [cursor=pointer]:
|
||||
- generic [ref=e93]: Mon, Jan 5
|
||||
- generic [ref=e95]: No availability
|
||||
- generic [ref=e96] [cursor=pointer]:
|
||||
- generic [ref=e98]: Tue, Jan 6
|
||||
- generic [ref=e100]: No availability
|
||||
- generic [ref=e101] [cursor=pointer]:
|
||||
- generic [ref=e103]: Wed, Jan 7
|
||||
- generic [ref=e105]: No availability
|
||||
- generic [ref=e106] [cursor=pointer]:
|
||||
- generic [ref=e108]: Thu, Jan 8
|
||||
- generic [ref=e110]: No availability
|
||||
- generic [ref=e111] [cursor=pointer]:
|
||||
- generic [ref=e113]: Fri, Jan 9
|
||||
- generic [ref=e115]: No availability
|
||||
- generic [ref=e116] [cursor=pointer]:
|
||||
- generic [ref=e118]: Sat, Jan 10
|
||||
- generic [ref=e120]: No availability
|
||||
- generic [ref=e121] [cursor=pointer]:
|
||||
- generic [ref=e123]: Sun, Jan 11
|
||||
- generic [ref=e125]: No availability
|
||||
- generic [ref=e126] [cursor=pointer]:
|
||||
- generic [ref=e128]: Mon, Jan 12
|
||||
- generic [ref=e130]: No availability
|
||||
- generic [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e133]: Tue, Jan 13
|
||||
- generic [ref=e135]: No availability
|
||||
- generic [ref=e136] [cursor=pointer]:
|
||||
- generic [ref=e138]: Wed, Jan 14
|
||||
- generic [ref=e140]: No availability
|
||||
- generic [ref=e141] [cursor=pointer]:
|
||||
- generic [ref=e143]: Thu, Jan 15
|
||||
- generic [ref=e145]: No availability
|
||||
- generic [ref=e146] [cursor=pointer]:
|
||||
- generic [ref=e148]: Fri, Jan 16
|
||||
- generic [ref=e150]: No availability
|
||||
- generic [ref=e151] [cursor=pointer]:
|
||||
- generic [ref=e153]: Sat, Jan 17
|
||||
- generic [ref=e155]: No availability
|
||||
- generic [ref=e156] [cursor=pointer]:
|
||||
- generic [ref=e158]: Sun, Jan 18
|
||||
- generic [ref=e160]: No availability
|
||||
- generic [ref=e161] [cursor=pointer]:
|
||||
- generic [ref=e163]: Mon, Jan 19
|
||||
- generic [ref=e165]: No availability
|
||||
- generic [ref=e166] [cursor=pointer]:
|
||||
- generic [ref=e168]: Tue, Jan 20
|
||||
- generic [ref=e170]: No availability
|
||||
- status [ref=e171]:
|
||||
- generic [ref=e172]:
|
||||
- img [ref=e174]
|
||||
- generic [ref=e176]:
|
||||
- text: Static route
|
||||
- button "Hide static indicator" [ref=e177] [cursor=pointer]:
|
||||
- img [ref=e178]
|
||||
- alert [ref=e181]
|
||||
```
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [active] [ref=e1]:
|
||||
- main [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- link "Audit" [ref=e6] [cursor=pointer]:
|
||||
- /url: /audit
|
||||
- generic [ref=e7]:
|
||||
- text: •
|
||||
- link "Invites" [ref=e8] [cursor=pointer]:
|
||||
- /url: /admin/invites
|
||||
- generic [ref=e9]: •Availability
|
||||
- generic [ref=e10]:
|
||||
- generic [ref=e11]: admin@example.com
|
||||
- button "Sign out" [ref=e12] [cursor=pointer]
|
||||
- generic [ref=e14]:
|
||||
- generic [ref=e16]:
|
||||
- heading "Availability" [level=1] [ref=e17]
|
||||
- paragraph [ref=e18]: Configure your available time slots for the next 30 days
|
||||
- generic [ref=e19]:
|
||||
- generic [ref=e20] [cursor=pointer]:
|
||||
- generic [ref=e21]:
|
||||
- generic [ref=e22]: Mon, Dec 22
|
||||
- button "⎘" [ref=e23]
|
||||
- generic [ref=e25]: 09:00 - 12:00
|
||||
- generic [ref=e26] [cursor=pointer]:
|
||||
- generic [ref=e28]: Tue, Dec 23
|
||||
- generic [ref=e30]: No availability
|
||||
- generic [ref=e31] [cursor=pointer]:
|
||||
- generic [ref=e33]: Wed, Dec 24
|
||||
- generic [ref=e35]: No availability
|
||||
- generic [ref=e36] [cursor=pointer]:
|
||||
- generic [ref=e38]: Thu, Dec 25
|
||||
- generic [ref=e40]: No availability
|
||||
- generic [ref=e41] [cursor=pointer]:
|
||||
- generic [ref=e43]: Fri, Dec 26
|
||||
- generic [ref=e45]: No availability
|
||||
- generic [ref=e46] [cursor=pointer]:
|
||||
- generic [ref=e48]: Sat, Dec 27
|
||||
- generic [ref=e50]: No availability
|
||||
- generic [ref=e51] [cursor=pointer]:
|
||||
- generic [ref=e53]: Sun, Dec 28
|
||||
- generic [ref=e55]: No availability
|
||||
- generic [ref=e56] [cursor=pointer]:
|
||||
- generic [ref=e58]: Mon, Dec 29
|
||||
- generic [ref=e60]: No availability
|
||||
- generic [ref=e61] [cursor=pointer]:
|
||||
- generic [ref=e63]: Tue, Dec 30
|
||||
- generic [ref=e65]: No availability
|
||||
- generic [ref=e66] [cursor=pointer]:
|
||||
- generic [ref=e68]: Wed, Dec 31
|
||||
- generic [ref=e70]: No availability
|
||||
- generic [ref=e71] [cursor=pointer]:
|
||||
- generic [ref=e73]: Thu, Jan 1
|
||||
- generic [ref=e75]: No availability
|
||||
- generic [ref=e76] [cursor=pointer]:
|
||||
- generic [ref=e78]: Fri, Jan 2
|
||||
- generic [ref=e80]: No availability
|
||||
- generic [ref=e81] [cursor=pointer]:
|
||||
- generic [ref=e83]: Sat, Jan 3
|
||||
- generic [ref=e85]: No availability
|
||||
- generic [ref=e86] [cursor=pointer]:
|
||||
- generic [ref=e88]: Sun, Jan 4
|
||||
- generic [ref=e90]: No availability
|
||||
- generic [ref=e91] [cursor=pointer]:
|
||||
- generic [ref=e93]: Mon, Jan 5
|
||||
- generic [ref=e95]: No availability
|
||||
- generic [ref=e96] [cursor=pointer]:
|
||||
- generic [ref=e98]: Tue, Jan 6
|
||||
- generic [ref=e100]: No availability
|
||||
- generic [ref=e101] [cursor=pointer]:
|
||||
- generic [ref=e103]: Wed, Jan 7
|
||||
- generic [ref=e105]: No availability
|
||||
- generic [ref=e106] [cursor=pointer]:
|
||||
- generic [ref=e108]: Thu, Jan 8
|
||||
- generic [ref=e110]: No availability
|
||||
- generic [ref=e111] [cursor=pointer]:
|
||||
- generic [ref=e113]: Fri, Jan 9
|
||||
- generic [ref=e115]: No availability
|
||||
- generic [ref=e116] [cursor=pointer]:
|
||||
- generic [ref=e118]: Sat, Jan 10
|
||||
- generic [ref=e120]: No availability
|
||||
- generic [ref=e121] [cursor=pointer]:
|
||||
- generic [ref=e123]: Sun, Jan 11
|
||||
- generic [ref=e125]: No availability
|
||||
- generic [ref=e126] [cursor=pointer]:
|
||||
- generic [ref=e128]: Mon, Jan 12
|
||||
- generic [ref=e130]: No availability
|
||||
- generic [ref=e131] [cursor=pointer]:
|
||||
- generic [ref=e133]: Tue, Jan 13
|
||||
- generic [ref=e135]: No availability
|
||||
- generic [ref=e136] [cursor=pointer]:
|
||||
- generic [ref=e138]: Wed, Jan 14
|
||||
- generic [ref=e140]: No availability
|
||||
- generic [ref=e141] [cursor=pointer]:
|
||||
- generic [ref=e143]: Thu, Jan 15
|
||||
- generic [ref=e145]: No availability
|
||||
- generic [ref=e146] [cursor=pointer]:
|
||||
- generic [ref=e148]: Fri, Jan 16
|
||||
- generic [ref=e150]: No availability
|
||||
- generic [ref=e151] [cursor=pointer]:
|
||||
- generic [ref=e153]: Sat, Jan 17
|
||||
- generic [ref=e155]: No availability
|
||||
- generic [ref=e156] [cursor=pointer]:
|
||||
- generic [ref=e158]: Sun, Jan 18
|
||||
- generic [ref=e160]: No availability
|
||||
- generic [ref=e161] [cursor=pointer]:
|
||||
- generic [ref=e163]: Mon, Jan 19
|
||||
- generic [ref=e165]: No availability
|
||||
- generic [ref=e166] [cursor=pointer]:
|
||||
- generic [ref=e168]: Tue, Jan 20
|
||||
- generic [ref=e170]: No availability
|
||||
- status [ref=e171]:
|
||||
- generic [ref=e172]:
|
||||
- img [ref=e174]
|
||||
- generic [ref=e176]:
|
||||
- text: Static route
|
||||
- button "Hide static indicator" [ref=e177] [cursor=pointer]:
|
||||
- img [ref=e178]
|
||||
- alert [ref=e181]
|
||||
```
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [active] [ref=e1]:
|
||||
- generic [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- generic [ref=e4]:
|
||||
- link "Counter" [ref=e6] [cursor=pointer]:
|
||||
- /url: /
|
||||
- generic [ref=e7]:
|
||||
- text: •
|
||||
- link "Sum" [ref=e8] [cursor=pointer]:
|
||||
- /url: /sum
|
||||
- generic [ref=e9]: •Book
|
||||
- generic [ref=e10]:
|
||||
- text: •
|
||||
- link "Appointments" [ref=e11] [cursor=pointer]:
|
||||
- /url: /appointments
|
||||
- generic [ref=e12]:
|
||||
- text: •
|
||||
- link "My Invites" [ref=e13] [cursor=pointer]:
|
||||
- /url: /invites
|
||||
- generic [ref=e14]:
|
||||
- text: •
|
||||
- link "My Profile" [ref=e15] [cursor=pointer]:
|
||||
- /url: /profile
|
||||
- generic [ref=e16]:
|
||||
- generic [ref=e17]: user@example.com
|
||||
- button "Sign out" [ref=e18] [cursor=pointer]
|
||||
- main [ref=e19]:
|
||||
- heading "Book an Appointment" [level=1] [ref=e20]
|
||||
- paragraph [ref=e21]: Select a date to see available 15-minute slots
|
||||
- generic [ref=e22]: "Request failed: 409"
|
||||
- generic [ref=e23]:
|
||||
- heading "Select a Date" [level=2] [ref=e24]
|
||||
- generic [ref=e25]:
|
||||
- button "Mon Dec 22" [ref=e26] [cursor=pointer]:
|
||||
- generic [ref=e27]: Mon
|
||||
- generic [ref=e28]: Dec 22
|
||||
- button "Tue Dec 23" [ref=e29] [cursor=pointer]:
|
||||
- generic [ref=e30]: Tue
|
||||
- generic [ref=e31]: Dec 23
|
||||
- button "Wed Dec 24" [ref=e32] [cursor=pointer]:
|
||||
- generic [ref=e33]: Wed
|
||||
- generic [ref=e34]: Dec 24
|
||||
- button "Thu Dec 25" [ref=e35] [cursor=pointer]:
|
||||
- generic [ref=e36]: Thu
|
||||
- generic [ref=e37]: Dec 25
|
||||
- button "Fri Dec 26" [ref=e38] [cursor=pointer]:
|
||||
- generic [ref=e39]: Fri
|
||||
- generic [ref=e40]: Dec 26
|
||||
- button "Sat Dec 27" [ref=e41] [cursor=pointer]:
|
||||
- generic [ref=e42]: Sat
|
||||
- generic [ref=e43]: Dec 27
|
||||
- button "Sun Dec 28" [ref=e44] [cursor=pointer]:
|
||||
- generic [ref=e45]: Sun
|
||||
- generic [ref=e46]: Dec 28
|
||||
- button "Mon Dec 29" [ref=e47] [cursor=pointer]:
|
||||
- generic [ref=e48]: Mon
|
||||
- generic [ref=e49]: Dec 29
|
||||
- button "Tue Dec 30" [ref=e50] [cursor=pointer]:
|
||||
- generic [ref=e51]: Tue
|
||||
- generic [ref=e52]: Dec 30
|
||||
- button "Wed Dec 31" [ref=e53] [cursor=pointer]:
|
||||
- generic [ref=e54]: Wed
|
||||
- generic [ref=e55]: Dec 31
|
||||
- button "Thu Jan 1" [ref=e56] [cursor=pointer]:
|
||||
- generic [ref=e57]: Thu
|
||||
- generic [ref=e58]: Jan 1
|
||||
- button "Fri Jan 2" [ref=e59] [cursor=pointer]:
|
||||
- generic [ref=e60]: Fri
|
||||
- generic [ref=e61]: Jan 2
|
||||
- button "Sat Jan 3" [ref=e62] [cursor=pointer]:
|
||||
- generic [ref=e63]: Sat
|
||||
- generic [ref=e64]: Jan 3
|
||||
- button "Sun Jan 4" [ref=e65] [cursor=pointer]:
|
||||
- generic [ref=e66]: Sun
|
||||
- generic [ref=e67]: Jan 4
|
||||
- button "Mon Jan 5" [ref=e68] [cursor=pointer]:
|
||||
- generic [ref=e69]: Mon
|
||||
- generic [ref=e70]: Jan 5
|
||||
- button "Tue Jan 6" [ref=e71] [cursor=pointer]:
|
||||
- generic [ref=e72]: Tue
|
||||
- generic [ref=e73]: Jan 6
|
||||
- button "Wed Jan 7" [ref=e74] [cursor=pointer]:
|
||||
- generic [ref=e75]: Wed
|
||||
- generic [ref=e76]: Jan 7
|
||||
- button "Thu Jan 8" [ref=e77] [cursor=pointer]:
|
||||
- generic [ref=e78]: Thu
|
||||
- generic [ref=e79]: Jan 8
|
||||
- button "Fri Jan 9" [ref=e80] [cursor=pointer]:
|
||||
- generic [ref=e81]: Fri
|
||||
- generic [ref=e82]: Jan 9
|
||||
- button "Sat Jan 10" [ref=e83] [cursor=pointer]:
|
||||
- generic [ref=e84]: Sat
|
||||
- generic [ref=e85]: Jan 10
|
||||
- button "Sun Jan 11" [ref=e86] [cursor=pointer]:
|
||||
- generic [ref=e87]: Sun
|
||||
- generic [ref=e88]: Jan 11
|
||||
- button "Mon Jan 12" [ref=e89] [cursor=pointer]:
|
||||
- generic [ref=e90]: Mon
|
||||
- generic [ref=e91]: Jan 12
|
||||
- button "Tue Jan 13" [ref=e92] [cursor=pointer]:
|
||||
- generic [ref=e93]: Tue
|
||||
- generic [ref=e94]: Jan 13
|
||||
- button "Wed Jan 14" [ref=e95] [cursor=pointer]:
|
||||
- generic [ref=e96]: Wed
|
||||
- generic [ref=e97]: Jan 14
|
||||
- button "Thu Jan 15" [ref=e98] [cursor=pointer]:
|
||||
- generic [ref=e99]: Thu
|
||||
- generic [ref=e100]: Jan 15
|
||||
- button "Fri Jan 16" [ref=e101] [cursor=pointer]:
|
||||
- generic [ref=e102]: Fri
|
||||
- generic [ref=e103]: Jan 16
|
||||
- button "Sat Jan 17" [ref=e104] [cursor=pointer]:
|
||||
- generic [ref=e105]: Sat
|
||||
- generic [ref=e106]: Jan 17
|
||||
- button "Sun Jan 18" [ref=e107] [cursor=pointer]:
|
||||
- generic [ref=e108]: Sun
|
||||
- generic [ref=e109]: Jan 18
|
||||
- button "Mon Jan 19" [ref=e110] [cursor=pointer]:
|
||||
- generic [ref=e111]: Mon
|
||||
- generic [ref=e112]: Jan 19
|
||||
- button "Tue Jan 20" [ref=e113] [cursor=pointer]:
|
||||
- generic [ref=e114]: Tue
|
||||
- generic [ref=e115]: Jan 20
|
||||
- generic [ref=e116]:
|
||||
- heading "Available Slots for Monday, December 22" [level=2] [ref=e117]
|
||||
- generic [ref=e118]:
|
||||
- button "10:00" [ref=e119] [cursor=pointer]
|
||||
- button "10:45" [ref=e120] [cursor=pointer]
|
||||
- button "11:00" [ref=e121] [cursor=pointer]
|
||||
- button "11:15" [ref=e122] [cursor=pointer]
|
||||
- button "11:30" [ref=e123] [cursor=pointer]
|
||||
- button "11:45" [ref=e124] [cursor=pointer]
|
||||
- button "12:00" [ref=e125] [cursor=pointer]
|
||||
- button "12:15" [ref=e126] [cursor=pointer]
|
||||
- button "12:30" [ref=e127] [cursor=pointer]
|
||||
- button "12:45" [ref=e128] [cursor=pointer]
|
||||
- generic [ref=e129]:
|
||||
- heading "Confirm Booking" [level=3] [ref=e130]
|
||||
- paragraph [ref=e131]:
|
||||
- strong [ref=e132]: "Time:"
|
||||
- text: 11:00 - 11:15
|
||||
- generic [ref=e133]:
|
||||
- generic [ref=e134]: Note (optional, max 144 chars)
|
||||
- textbox "Add a note about your appointment..." [ref=e135]
|
||||
- generic [ref=e136]: 0/144
|
||||
- generic [ref=e137]:
|
||||
- button "Book Appointment" [ref=e138] [cursor=pointer]
|
||||
- button "Cancel" [ref=e139] [cursor=pointer]
|
||||
- status [ref=e140]:
|
||||
- generic [ref=e141]:
|
||||
- img [ref=e143]
|
||||
- generic [ref=e145]:
|
||||
- text: Static route
|
||||
- button "Hide static indicator" [ref=e146] [cursor=pointer]:
|
||||
- img [ref=e147]
|
||||
- alert [ref=e150]
|
||||
```
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [active] [ref=e1]:
|
||||
- status [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- img [ref=e5]
|
||||
- generic [ref=e7]:
|
||||
- text: Static route
|
||||
- button "Hide static indicator" [ref=e8] [cursor=pointer]:
|
||||
- img [ref=e9]
|
||||
- alert [ref=e12]: ...
|
||||
- main [ref=e13]:
|
||||
- generic [ref=e14]:
|
||||
- generic [ref=e15]:
|
||||
- generic [ref=e16]: Counter
|
||||
- generic [ref=e17]:
|
||||
- text: •
|
||||
- link "Sum" [ref=e18] [cursor=pointer]:
|
||||
- /url: /sum
|
||||
- generic [ref=e19]:
|
||||
- text: •
|
||||
- link "Book" [ref=e20] [cursor=pointer]:
|
||||
- /url: /booking
|
||||
- generic [ref=e21]:
|
||||
- text: •
|
||||
- link "Appointments" [ref=e22] [cursor=pointer]:
|
||||
- /url: /appointments
|
||||
- generic [ref=e23]:
|
||||
- text: •
|
||||
- link "My Invites" [ref=e24] [cursor=pointer]:
|
||||
- /url: /invites
|
||||
- generic [ref=e25]:
|
||||
- text: •
|
||||
- link "My Profile" [ref=e26] [cursor=pointer]:
|
||||
- /url: /profile
|
||||
- generic [ref=e27]:
|
||||
- generic [ref=e28]: counter-1766272960115-d79hw5@example.com
|
||||
- button "Sign out" [ref=e29] [cursor=pointer]
|
||||
- generic [ref=e31]:
|
||||
- generic [ref=e32]: Current Count
|
||||
- heading "0" [level=1] [ref=e33]
|
||||
- button "+ Increment" [ref=e34] [cursor=pointer]:
|
||||
- generic [ref=e35]: +
|
||||
- text: Increment
|
||||
```
|
||||
Loading…
Add table
Add a link
Reference in a new issue