Fix: Prevent cancellation of past appointments
Add check to both user and admin cancel endpoints to reject cancellation of appointments whose slot_start is in the past. This matches the spec requirement that cancellations can only happen 'before the appointment'. Added tests for both user and admin cancel endpoints. Also includes frontend styling updates.
This commit is contained in:
parent
89eec1e9c4
commit
63cf46c230
5 changed files with 679 additions and 291 deletions
|
|
@ -266,6 +266,13 @@ async def cancel_my_appointment(
|
|||
detail=f"Cannot cancel appointment with status '{appointment.status.value}'"
|
||||
)
|
||||
|
||||
# Check if appointment is in the past
|
||||
if appointment.slot_start <= datetime.now(timezone.utc):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot cancel a past appointment"
|
||||
)
|
||||
|
||||
# Cancel the appointment
|
||||
appointment.status = AppointmentStatus.CANCELLED_BY_USER
|
||||
appointment.cancelled_at = datetime.now(timezone.utc)
|
||||
|
|
@ -346,6 +353,13 @@ async def admin_cancel_appointment(
|
|||
detail=f"Cannot cancel appointment with status '{appointment.status.value}'"
|
||||
)
|
||||
|
||||
# Check if appointment is in the past
|
||||
if appointment.slot_start <= datetime.now(timezone.utc):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot cancel a past appointment"
|
||||
)
|
||||
|
||||
# Cancel the appointment
|
||||
appointment.status = AppointmentStatus.CANCELLED_BY_ADMIN
|
||||
appointment.cancelled_at = datetime.now(timezone.utc)
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ Booking API Tests
|
|||
|
||||
Tests for the user booking endpoints.
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
import pytest
|
||||
|
||||
from models import Appointment, AppointmentStatus
|
||||
|
||||
|
||||
def tomorrow() -> date:
|
||||
return date.today() + timedelta(days=1)
|
||||
|
|
@ -656,6 +658,30 @@ class TestCancelAppointment:
|
|||
)
|
||||
assert len(slots_response.json()["slots"]) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_cancel_past_appointment(self, client_factory, regular_user):
|
||||
"""User cannot cancel a past appointment."""
|
||||
# Create a past appointment directly in DB
|
||||
async with client_factory.get_db_session() as db:
|
||||
past_time = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
appointment = Appointment(
|
||||
user_id=regular_user["user"]["id"],
|
||||
slot_start=past_time,
|
||||
slot_end=past_time + timedelta(minutes=15),
|
||||
status=AppointmentStatus.BOOKED,
|
||||
)
|
||||
db.add(appointment)
|
||||
await db.commit()
|
||||
await db.refresh(appointment)
|
||||
apt_id = appointment.id
|
||||
|
||||
# Try to cancel
|
||||
async with client_factory.create(cookies=regular_user["cookies"]) as client:
|
||||
response = await client.post(f"/api/appointments/{apt_id}/cancel")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "past" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Admin Appointments Tests
|
||||
|
|
@ -806,3 +832,27 @@ class TestAdminCancelAppointment:
|
|||
assert response.status_code == 400
|
||||
assert "cancelled_by_user" in response.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_cannot_cancel_past_appointment(self, client_factory, regular_user, admin_user):
|
||||
"""Admin cannot cancel a past appointment."""
|
||||
# Create a past appointment directly in DB
|
||||
async with client_factory.get_db_session() as db:
|
||||
past_time = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
appointment = Appointment(
|
||||
user_id=regular_user["user"]["id"],
|
||||
slot_start=past_time,
|
||||
slot_end=past_time + timedelta(minutes=15),
|
||||
status=AppointmentStatus.BOOKED,
|
||||
)
|
||||
db.add(appointment)
|
||||
await db.commit()
|
||||
await db.refresh(appointment)
|
||||
apt_id = appointment.id
|
||||
|
||||
# Admin tries to cancel
|
||||
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
|
||||
response = await admin_client.post(f"/api/admin/appointments/{apt_id}/cancel")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "past" in response.json()["detail"].lower()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
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";
|
||||
|
|
@ -24,19 +24,167 @@ function formatDateTime(isoString: string): string {
|
|||
}
|
||||
|
||||
// Helper to get status display
|
||||
function getStatusDisplay(status: string): { text: string; color: string } {
|
||||
function getStatusDisplay(status: string): { text: string; bgColor: string; textColor: string } {
|
||||
switch (status) {
|
||||
case "booked":
|
||||
return { text: "Booked", color: "#28a745" };
|
||||
return { text: "Booked", bgColor: "rgba(34, 197, 94, 0.2)", textColor: "#4ade80" };
|
||||
case "cancelled_by_user":
|
||||
return { text: "Cancelled by user", color: "#dc3545" };
|
||||
return { text: "Cancelled by user", bgColor: "rgba(239, 68, 68, 0.2)", textColor: "#f87171" };
|
||||
case "cancelled_by_admin":
|
||||
return { text: "Cancelled by admin", color: "#dc3545" };
|
||||
return { text: "Cancelled by admin", bgColor: "rgba(239, 68, 68, 0.2)", textColor: "#f87171" };
|
||||
default:
|
||||
return { text: status, color: "#666" };
|
||||
return { text: status, bgColor: "rgba(255,255,255,0.1)", textColor: "rgba(255,255,255,0.6)" };
|
||||
}
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
main: {
|
||||
minHeight: "100vh",
|
||||
background: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #2d1b4e 100%)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
loader: {
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
color: "rgba(255, 255, 255, 0.5)",
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: "2rem",
|
||||
maxWidth: "900px",
|
||||
margin: "0 auto",
|
||||
width: "100%",
|
||||
},
|
||||
pageTitle: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "1.75rem",
|
||||
fontWeight: 600,
|
||||
color: "#fff",
|
||||
marginBottom: "0.5rem",
|
||||
},
|
||||
pageSubtitle: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
color: "rgba(255, 255, 255, 0.5)",
|
||||
fontSize: "0.9rem",
|
||||
marginBottom: "1.5rem",
|
||||
},
|
||||
errorBanner: {
|
||||
background: "rgba(239, 68, 68, 0.15)",
|
||||
border: "1px solid rgba(239, 68, 68, 0.3)",
|
||||
color: "#f87171",
|
||||
padding: "1rem",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "1rem",
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "0.875rem",
|
||||
},
|
||||
filterRow: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
marginBottom: "1.5rem",
|
||||
},
|
||||
filterLabel: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
color: "rgba(255, 255, 255, 0.6)",
|
||||
fontSize: "0.875rem",
|
||||
},
|
||||
filterSelect: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
padding: "0.5rem 1rem",
|
||||
background: "rgba(255, 255, 255, 0.05)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||
borderRadius: "6px",
|
||||
color: "#fff",
|
||||
fontSize: "0.875rem",
|
||||
},
|
||||
appointmentList: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
appointmentCard: {
|
||||
background: "rgba(255, 255, 255, 0.03)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.08)",
|
||||
borderRadius: "12px",
|
||||
padding: "1.25rem",
|
||||
transition: "all 0.2s",
|
||||
},
|
||||
appointmentCardPast: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
appointmentHeader: {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
gap: "1rem",
|
||||
},
|
||||
appointmentTime: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "1rem",
|
||||
fontWeight: 500,
|
||||
color: "#fff",
|
||||
marginBottom: "0.25rem",
|
||||
},
|
||||
appointmentUser: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "0.875rem",
|
||||
color: "rgba(255, 255, 255, 0.5)",
|
||||
marginBottom: "0.25rem",
|
||||
},
|
||||
appointmentNote: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "0.875rem",
|
||||
color: "rgba(255, 255, 255, 0.4)",
|
||||
fontStyle: "italic",
|
||||
marginBottom: "0.5rem",
|
||||
},
|
||||
statusBadge: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
padding: "0.25rem 0.75rem",
|
||||
borderRadius: "9999px",
|
||||
display: "inline-block",
|
||||
},
|
||||
buttonGroup: {
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
cancelButton: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
padding: "0.35rem 0.75rem",
|
||||
fontSize: "0.75rem",
|
||||
background: "rgba(255, 255, 255, 0.05)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||
borderRadius: "6px",
|
||||
color: "rgba(255, 255, 255, 0.7)",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s",
|
||||
},
|
||||
confirmButton: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
padding: "0.35rem 0.75rem",
|
||||
fontSize: "0.75rem",
|
||||
background: "rgba(239, 68, 68, 0.2)",
|
||||
border: "1px solid rgba(239, 68, 68, 0.3)",
|
||||
borderRadius: "6px",
|
||||
color: "#f87171",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s",
|
||||
},
|
||||
emptyState: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
color: "rgba(255, 255, 255, 0.4)",
|
||||
textAlign: "center",
|
||||
padding: "3rem",
|
||||
},
|
||||
};
|
||||
|
||||
export default function AdminAppointmentsPage() {
|
||||
const { user, isLoading, isAuthorized } = useRequireAuth({
|
||||
requiredPermission: Permission.VIEW_ALL_APPOINTMENTS,
|
||||
|
|
@ -89,12 +237,9 @@ export default function AdminAppointmentsPage() {
|
|||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={sharedStyles.pageContainer}>
|
||||
<Header currentPage="admin-appointments" />
|
||||
<main style={sharedStyles.mainContent}>
|
||||
<p>Loading...</p>
|
||||
</main>
|
||||
</div>
|
||||
<main style={styles.main}>
|
||||
<div style={styles.loader}>Loading...</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -108,41 +253,27 @@ export default function AdminAppointmentsPage() {
|
|||
});
|
||||
|
||||
const bookedCount = appointments.filter((a) => a.status === "booked").length;
|
||||
const cancelledCount = appointments.filter((a) => a.status !== "booked").length;
|
||||
|
||||
return (
|
||||
<div style={sharedStyles.pageContainer}>
|
||||
<main style={styles.main}>
|
||||
<Header currentPage="admin-appointments" />
|
||||
<main style={sharedStyles.mainContent}>
|
||||
<h1 style={{ marginBottom: "0.5rem" }}>All Appointments</h1>
|
||||
<p style={{ color: "#666", marginBottom: "1.5rem" }}>
|
||||
<div style={styles.content}>
|
||||
<h1 style={styles.pageTitle}>All Appointments</h1>
|
||||
<p style={styles.pageSubtitle}>
|
||||
View and manage all user appointments
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
background: "#f8d7da",
|
||||
border: "1px solid #f5c6cb",
|
||||
color: "#721c24",
|
||||
padding: "1rem",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "1rem",
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
<div style={styles.errorBanner}>{error}</div>
|
||||
)}
|
||||
|
||||
{/* Status Filter */}
|
||||
<div style={{ marginBottom: "1rem", display: "flex", gap: "0.5rem", alignItems: "center" }}>
|
||||
<label style={{ fontWeight: 500 }}>Filter:</label>
|
||||
<div style={styles.filterRow}>
|
||||
<span style={styles.filterLabel}>Filter:</span>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
style={{
|
||||
padding: "0.5rem",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
style={styles.filterSelect}
|
||||
>
|
||||
<option value="all">All ({appointments.length})</option>
|
||||
<option value="booked">Booked ({bookedCount})</option>
|
||||
|
|
@ -152,13 +283,13 @@ export default function AdminAppointmentsPage() {
|
|||
</div>
|
||||
|
||||
{isLoadingAppointments ? (
|
||||
<p>Loading appointments...</p>
|
||||
<div style={styles.emptyState}>Loading appointments...</div>
|
||||
) : appointments.length === 0 ? (
|
||||
<p style={{ color: "#666" }}>No appointments yet.</p>
|
||||
<div style={styles.emptyState}>No appointments yet.</div>
|
||||
) : filteredAppointments.length === 0 ? (
|
||||
<p style={{ color: "#666" }}>No appointments match the filter.</p>
|
||||
<div style={styles.emptyState}>No appointments match the filter.</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
<div style={styles.appointmentList}>
|
||||
{filteredAppointments.map((apt) => {
|
||||
const status = getStatusDisplay(apt.status);
|
||||
const isPast = new Date(apt.slot_start) <= new Date();
|
||||
|
|
@ -166,80 +297,54 @@ export default function AdminAppointmentsPage() {
|
|||
<div
|
||||
key={apt.id}
|
||||
style={{
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "8px",
|
||||
padding: "1rem",
|
||||
background: isPast ? "#fafafa" : "#fff",
|
||||
opacity: isPast && apt.status !== "booked" ? 0.7 : 1,
|
||||
...styles.appointmentCard,
|
||||
...(isPast ? styles.appointmentCardPast : {}),
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<div style={styles.appointmentHeader}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: "0.25rem" }}>
|
||||
<div style={styles.appointmentTime}>
|
||||
{formatDateTime(apt.slot_start)}
|
||||
</div>
|
||||
<div style={{ color: "#666", fontSize: "0.875rem", marginBottom: "0.25rem" }}>
|
||||
User: {apt.user_email}
|
||||
<div style={styles.appointmentUser}>
|
||||
{apt.user_email}
|
||||
</div>
|
||||
{apt.note && (
|
||||
<div style={{ color: "#888", fontSize: "0.875rem", marginBottom: "0.5rem", fontStyle: "italic" }}>
|
||||
<div style={styles.appointmentNote}>
|
||||
"{apt.note}"
|
||||
</div>
|
||||
)}
|
||||
<span style={{
|
||||
color: status.color,
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
...styles.statusBadge,
|
||||
background: status.bgColor,
|
||||
color: status.textColor,
|
||||
}}>
|
||||
{status.text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{apt.status === "booked" && (
|
||||
<div>
|
||||
<div style={styles.buttonGroup}>
|
||||
{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",
|
||||
}}
|
||||
style={styles.confirmButton}
|
||||
>
|
||||
{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",
|
||||
}}
|
||||
style={styles.cancelButton}
|
||||
>
|
||||
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",
|
||||
}}
|
||||
style={styles.cancelButton}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
|
@ -252,8 +357,8 @@ export default function AdminAppointmentsPage() {
|
|||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
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";
|
||||
|
|
@ -24,19 +24,162 @@ function formatDateTime(isoString: string): string {
|
|||
}
|
||||
|
||||
// Helper to get status display
|
||||
function getStatusDisplay(status: string): { text: string; color: string } {
|
||||
function getStatusDisplay(status: string): { text: string; bgColor: string; textColor: string } {
|
||||
switch (status) {
|
||||
case "booked":
|
||||
return { text: "Booked", color: "#28a745" };
|
||||
return { text: "Booked", bgColor: "rgba(34, 197, 94, 0.2)", textColor: "#4ade80" };
|
||||
case "cancelled_by_user":
|
||||
return { text: "Cancelled by you", color: "#dc3545" };
|
||||
return { text: "Cancelled by you", bgColor: "rgba(239, 68, 68, 0.2)", textColor: "#f87171" };
|
||||
case "cancelled_by_admin":
|
||||
return { text: "Cancelled by admin", color: "#dc3545" };
|
||||
return { text: "Cancelled by admin", bgColor: "rgba(239, 68, 68, 0.2)", textColor: "#f87171" };
|
||||
default:
|
||||
return { text: status, color: "#666" };
|
||||
return { text: status, bgColor: "rgba(255,255,255,0.1)", textColor: "rgba(255,255,255,0.6)" };
|
||||
}
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
main: {
|
||||
minHeight: "100vh",
|
||||
background: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #2d1b4e 100%)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
loader: {
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
color: "rgba(255, 255, 255, 0.5)",
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: "2rem",
|
||||
maxWidth: "800px",
|
||||
margin: "0 auto",
|
||||
width: "100%",
|
||||
},
|
||||
pageTitle: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "1.75rem",
|
||||
fontWeight: 600,
|
||||
color: "#fff",
|
||||
marginBottom: "0.5rem",
|
||||
},
|
||||
pageSubtitle: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
color: "rgba(255, 255, 255, 0.5)",
|
||||
fontSize: "0.9rem",
|
||||
marginBottom: "1.5rem",
|
||||
},
|
||||
errorBanner: {
|
||||
background: "rgba(239, 68, 68, 0.15)",
|
||||
border: "1px solid rgba(239, 68, 68, 0.3)",
|
||||
color: "#f87171",
|
||||
padding: "1rem",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "1rem",
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "0.875rem",
|
||||
},
|
||||
section: {
|
||||
marginBottom: "2rem",
|
||||
},
|
||||
sectionTitle: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "1.1rem",
|
||||
fontWeight: 500,
|
||||
color: "#fff",
|
||||
marginBottom: "1rem",
|
||||
},
|
||||
sectionTitleMuted: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "1.1rem",
|
||||
fontWeight: 500,
|
||||
color: "rgba(255, 255, 255, 0.5)",
|
||||
marginBottom: "1rem",
|
||||
},
|
||||
appointmentList: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
appointmentCard: {
|
||||
background: "rgba(255, 255, 255, 0.03)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.08)",
|
||||
borderRadius: "12px",
|
||||
padding: "1.25rem",
|
||||
transition: "all 0.2s",
|
||||
},
|
||||
appointmentCardPast: {
|
||||
opacity: 0.6,
|
||||
background: "rgba(255, 255, 255, 0.01)",
|
||||
},
|
||||
appointmentHeader: {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
gap: "1rem",
|
||||
},
|
||||
appointmentTime: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "1rem",
|
||||
fontWeight: 500,
|
||||
color: "#fff",
|
||||
marginBottom: "0.25rem",
|
||||
},
|
||||
appointmentNote: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "0.875rem",
|
||||
color: "rgba(255, 255, 255, 0.5)",
|
||||
marginBottom: "0.5rem",
|
||||
},
|
||||
statusBadge: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
padding: "0.25rem 0.75rem",
|
||||
borderRadius: "9999px",
|
||||
display: "inline-block",
|
||||
},
|
||||
buttonGroup: {
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
cancelButton: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
padding: "0.35rem 0.75rem",
|
||||
fontSize: "0.75rem",
|
||||
background: "rgba(255, 255, 255, 0.05)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||
borderRadius: "6px",
|
||||
color: "rgba(255, 255, 255, 0.7)",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s",
|
||||
},
|
||||
confirmButton: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
padding: "0.35rem 0.75rem",
|
||||
fontSize: "0.75rem",
|
||||
background: "rgba(239, 68, 68, 0.2)",
|
||||
border: "1px solid rgba(239, 68, 68, 0.3)",
|
||||
borderRadius: "6px",
|
||||
color: "#f87171",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s",
|
||||
},
|
||||
emptyState: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
color: "rgba(255, 255, 255, 0.4)",
|
||||
textAlign: "center",
|
||||
padding: "3rem",
|
||||
},
|
||||
emptyStateLink: {
|
||||
color: "#a78bfa",
|
||||
textDecoration: "none",
|
||||
},
|
||||
};
|
||||
|
||||
export default function AppointmentsPage() {
|
||||
const { user, isLoading, isAuthorized } = useRequireAuth({
|
||||
requiredPermission: Permission.VIEW_OWN_APPOINTMENTS,
|
||||
|
|
@ -88,12 +231,9 @@ export default function AppointmentsPage() {
|
|||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={sharedStyles.pageContainer}>
|
||||
<Header currentPage="appointments" />
|
||||
<main style={sharedStyles.mainContent}>
|
||||
<p>Loading...</p>
|
||||
</main>
|
||||
</div>
|
||||
<main style={styles.main}>
|
||||
<div style={styles.loader}>Loading...</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -109,123 +249,79 @@ export default function AppointmentsPage() {
|
|||
);
|
||||
|
||||
return (
|
||||
<div style={sharedStyles.pageContainer}>
|
||||
<main style={styles.main}>
|
||||
<Header currentPage="appointments" />
|
||||
<main style={sharedStyles.mainContent}>
|
||||
<h1 style={{ marginBottom: "0.5rem" }}>My Appointments</h1>
|
||||
<p style={{ color: "#666", marginBottom: "1.5rem" }}>
|
||||
<div style={styles.content}>
|
||||
<h1 style={styles.pageTitle}>My Appointments</h1>
|
||||
<p style={styles.pageSubtitle}>
|
||||
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>
|
||||
<div style={styles.errorBanner}>{error}</div>
|
||||
)}
|
||||
|
||||
{isLoadingAppointments ? (
|
||||
<p>Loading appointments...</p>
|
||||
<div style={styles.emptyState}>Loading appointments...</div>
|
||||
) : appointments.length === 0 ? (
|
||||
<div style={{
|
||||
textAlign: "center",
|
||||
padding: "2rem",
|
||||
color: "#666",
|
||||
}}>
|
||||
<div style={styles.emptyState}>
|
||||
<p>You don't have any appointments yet.</p>
|
||||
<a href="/booking" style={{ color: "#0070f3" }}>Book an appointment</a>
|
||||
<a href="/booking" style={styles.emptyStateLink}>Book an appointment</a>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Upcoming Appointments */}
|
||||
{upcomingAppointments.length > 0 && (
|
||||
<div style={{ marginBottom: "2rem" }}>
|
||||
<h2 style={{ fontSize: "1.1rem", marginBottom: "1rem" }}>
|
||||
<div style={styles.section}>
|
||||
<h2 style={styles.sectionTitle}>
|
||||
Upcoming ({upcomingAppointments.length})
|
||||
</h2>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
<div style={styles.appointmentList}>
|
||||
{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 key={apt.id} style={styles.appointmentCard}>
|
||||
<div style={styles.appointmentHeader}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: "0.25rem" }}>
|
||||
<div style={styles.appointmentTime}>
|
||||
{formatDateTime(apt.slot_start)}
|
||||
</div>
|
||||
{apt.note && (
|
||||
<div style={{ color: "#666", fontSize: "0.875rem", marginBottom: "0.5rem" }}>
|
||||
<div style={styles.appointmentNote}>
|
||||
{apt.note}
|
||||
</div>
|
||||
)}
|
||||
<span style={{
|
||||
color: status.color,
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
...styles.statusBadge,
|
||||
background: status.bgColor,
|
||||
color: status.textColor,
|
||||
}}>
|
||||
{status.text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{apt.status === "booked" && (
|
||||
<div>
|
||||
<div style={styles.buttonGroup}>
|
||||
{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",
|
||||
}}
|
||||
style={styles.confirmButton}
|
||||
>
|
||||
{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",
|
||||
}}
|
||||
style={styles.cancelButton}
|
||||
>
|
||||
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",
|
||||
}}
|
||||
style={styles.cancelButton}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
|
@ -242,36 +338,27 @@ export default function AppointmentsPage() {
|
|||
|
||||
{/* Past/Cancelled Appointments */}
|
||||
{pastOrCancelledAppointments.length > 0 && (
|
||||
<div>
|
||||
<h2 style={{ fontSize: "1.1rem", marginBottom: "1rem", color: "#666" }}>
|
||||
<div style={styles.section}>
|
||||
<h2 style={styles.sectionTitleMuted}>
|
||||
Past & Cancelled ({pastOrCancelledAppointments.length})
|
||||
</h2>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
<div style={styles.appointmentList}>
|
||||
{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" }}>
|
||||
<div key={apt.id} style={{...styles.appointmentCard, ...styles.appointmentCardPast}}>
|
||||
<div style={styles.appointmentTime}>
|
||||
{formatDateTime(apt.slot_start)}
|
||||
</div>
|
||||
{apt.note && (
|
||||
<div style={{ color: "#888", fontSize: "0.875rem", marginBottom: "0.5rem" }}>
|
||||
<div style={styles.appointmentNote}>
|
||||
{apt.note}
|
||||
</div>
|
||||
)}
|
||||
<span style={{
|
||||
color: status.color,
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
...styles.statusBadge,
|
||||
background: status.bgColor,
|
||||
color: status.textColor,
|
||||
}}>
|
||||
{status.text}
|
||||
</span>
|
||||
|
|
@ -283,8 +370,8 @@ export default function AppointmentsPage() {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
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";
|
||||
|
|
@ -46,8 +45,208 @@ function getBookableDates(): Date[] {
|
|||
return dates;
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
main: {
|
||||
minHeight: "100vh",
|
||||
background: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #2d1b4e 100%)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
loader: {
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
color: "rgba(255, 255, 255, 0.5)",
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: "2rem",
|
||||
maxWidth: "900px",
|
||||
margin: "0 auto",
|
||||
width: "100%",
|
||||
},
|
||||
pageTitle: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "1.75rem",
|
||||
fontWeight: 600,
|
||||
color: "#fff",
|
||||
marginBottom: "0.5rem",
|
||||
},
|
||||
pageSubtitle: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
color: "rgba(255, 255, 255, 0.5)",
|
||||
fontSize: "0.9rem",
|
||||
marginBottom: "1.5rem",
|
||||
},
|
||||
successBanner: {
|
||||
background: "rgba(34, 197, 94, 0.15)",
|
||||
border: "1px solid rgba(34, 197, 94, 0.3)",
|
||||
color: "#4ade80",
|
||||
padding: "1rem",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "1rem",
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "0.875rem",
|
||||
},
|
||||
errorBanner: {
|
||||
background: "rgba(239, 68, 68, 0.15)",
|
||||
border: "1px solid rgba(239, 68, 68, 0.3)",
|
||||
color: "#f87171",
|
||||
padding: "1rem",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "1rem",
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "0.875rem",
|
||||
},
|
||||
section: {
|
||||
marginBottom: "2rem",
|
||||
},
|
||||
sectionTitle: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "1.1rem",
|
||||
fontWeight: 500,
|
||||
color: "#fff",
|
||||
marginBottom: "1rem",
|
||||
},
|
||||
dateGrid: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
dateButton: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
padding: "0.75rem 1rem",
|
||||
background: "rgba(255, 255, 255, 0.03)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.08)",
|
||||
borderRadius: "10px",
|
||||
cursor: "pointer",
|
||||
minWidth: "90px",
|
||||
textAlign: "center" as const,
|
||||
transition: "all 0.2s",
|
||||
},
|
||||
dateButtonSelected: {
|
||||
background: "rgba(167, 139, 250, 0.15)",
|
||||
borderColor: "#a78bfa",
|
||||
},
|
||||
dateWeekday: {
|
||||
color: "#fff",
|
||||
fontWeight: 500,
|
||||
fontSize: "0.875rem",
|
||||
marginBottom: "0.25rem",
|
||||
},
|
||||
dateDay: {
|
||||
color: "rgba(255, 255, 255, 0.5)",
|
||||
fontSize: "0.8rem",
|
||||
},
|
||||
slotGrid: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
slotButton: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
padding: "0.6rem 1.25rem",
|
||||
background: "rgba(255, 255, 255, 0.03)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.08)",
|
||||
borderRadius: "8px",
|
||||
color: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.9rem",
|
||||
transition: "all 0.2s",
|
||||
},
|
||||
slotButtonSelected: {
|
||||
background: "rgba(167, 139, 250, 0.15)",
|
||||
borderColor: "#a78bfa",
|
||||
},
|
||||
emptyState: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
color: "rgba(255, 255, 255, 0.4)",
|
||||
padding: "1rem 0",
|
||||
},
|
||||
confirmCard: {
|
||||
background: "rgba(255, 255, 255, 0.03)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.08)",
|
||||
borderRadius: "12px",
|
||||
padding: "1.5rem",
|
||||
maxWidth: "400px",
|
||||
},
|
||||
confirmTitle: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "1.1rem",
|
||||
fontWeight: 500,
|
||||
color: "#fff",
|
||||
marginBottom: "1rem",
|
||||
},
|
||||
confirmTime: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
color: "rgba(255, 255, 255, 0.7)",
|
||||
marginBottom: "1rem",
|
||||
},
|
||||
inputLabel: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
display: "block",
|
||||
color: "rgba(255, 255, 255, 0.7)",
|
||||
fontSize: "0.875rem",
|
||||
marginBottom: "0.5rem",
|
||||
},
|
||||
textarea: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
width: "100%",
|
||||
padding: "0.75rem",
|
||||
background: "rgba(255, 255, 255, 0.05)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||
borderRadius: "8px",
|
||||
color: "#fff",
|
||||
fontSize: "0.875rem",
|
||||
minHeight: "80px",
|
||||
resize: "vertical" as const,
|
||||
},
|
||||
charCount: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "0.75rem",
|
||||
color: "rgba(255, 255, 255, 0.4)",
|
||||
textAlign: "right" as const,
|
||||
marginTop: "0.25rem",
|
||||
},
|
||||
charCountWarning: {
|
||||
color: "#f87171",
|
||||
},
|
||||
buttonRow: {
|
||||
display: "flex",
|
||||
gap: "0.75rem",
|
||||
marginTop: "1.5rem",
|
||||
},
|
||||
bookButton: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
flex: 1,
|
||||
padding: "0.75rem",
|
||||
background: "linear-gradient(135deg, #a78bfa 0%, #7c3aed 100%)",
|
||||
border: "none",
|
||||
borderRadius: "8px",
|
||||
color: "#fff",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s",
|
||||
},
|
||||
bookButtonDisabled: {
|
||||
opacity: 0.5,
|
||||
cursor: "not-allowed",
|
||||
},
|
||||
cancelButton: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
padding: "0.75rem 1.25rem",
|
||||
background: "rgba(255, 255, 255, 0.05)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||
borderRadius: "8px",
|
||||
color: "rgba(255, 255, 255, 0.7)",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s",
|
||||
},
|
||||
};
|
||||
|
||||
export default function BookingPage() {
|
||||
const router = useRouter();
|
||||
const { user, isLoading, isAuthorized } = useRequireAuth({
|
||||
requiredPermission: Permission.BOOK_APPOINTMENT,
|
||||
fallbackRedirect: "/",
|
||||
|
|
@ -138,12 +337,9 @@ export default function BookingPage() {
|
|||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={sharedStyles.pageContainer}>
|
||||
<Header currentPage="booking" />
|
||||
<main style={sharedStyles.mainContent}>
|
||||
<p>Loading...</p>
|
||||
</main>
|
||||
</div>
|
||||
<main style={styles.main}>
|
||||
<div style={styles.loader}>Loading...</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -152,48 +348,26 @@ export default function BookingPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div style={sharedStyles.pageContainer}>
|
||||
<main style={styles.main}>
|
||||
<Header currentPage="booking" />
|
||||
<main style={sharedStyles.mainContent}>
|
||||
<h1 style={{ marginBottom: "0.5rem" }}>Book an Appointment</h1>
|
||||
<p style={{ color: "#666", marginBottom: "1.5rem" }}>
|
||||
<div style={styles.content}>
|
||||
<h1 style={styles.pageTitle}>Book an Appointment</h1>
|
||||
<p style={styles.pageSubtitle}>
|
||||
Select a date to see available {slotDurationMinutes}-minute slots
|
||||
</p>
|
||||
|
||||
{successMessage && (
|
||||
<div style={{
|
||||
background: "#d4edda",
|
||||
border: "1px solid #c3e6cb",
|
||||
color: "#155724",
|
||||
padding: "1rem",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "1rem",
|
||||
}}>
|
||||
{successMessage}
|
||||
</div>
|
||||
<div style={styles.successBanner}>{successMessage}</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
background: "#f8d7da",
|
||||
border: "1px solid #f5c6cb",
|
||||
color: "#721c24",
|
||||
padding: "1rem",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "1rem",
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
<div style={styles.errorBanner}>{error}</div>
|
||||
)}
|
||||
|
||||
{/* Date Selection */}
|
||||
<div style={{ marginBottom: "1.5rem" }}>
|
||||
<h2 style={{ fontSize: "1.1rem", marginBottom: "0.75rem" }}>Select a Date</h2>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.5rem",
|
||||
}}>
|
||||
<div style={styles.section}>
|
||||
<h2 style={styles.sectionTitle}>Select a Date</h2>
|
||||
<div style={styles.dateGrid}>
|
||||
{dates.map((date) => {
|
||||
const isSelected = selectedDate && formatDate(selectedDate) === formatDate(date);
|
||||
return (
|
||||
|
|
@ -201,19 +375,14 @@ export default function BookingPage() {
|
|||
key={formatDate(date)}
|
||||
onClick={() => handleDateSelect(date)}
|
||||
style={{
|
||||
padding: "0.5rem 1rem",
|
||||
border: isSelected ? "2px solid #0070f3" : "1px solid #ddd",
|
||||
borderRadius: "8px",
|
||||
background: isSelected ? "#e7f3ff" : "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.875rem",
|
||||
minWidth: "100px",
|
||||
...styles.dateButton,
|
||||
...(isSelected ? styles.dateButtonSelected : {}),
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
<div style={styles.dateWeekday}>
|
||||
{date.toLocaleDateString("en-US", { weekday: "short" })}
|
||||
</div>
|
||||
<div style={{ color: "#666" }}>
|
||||
<div style={styles.dateDay}>
|
||||
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
|
||||
</div>
|
||||
</button>
|
||||
|
|
@ -224,8 +393,8 @@ export default function BookingPage() {
|
|||
|
||||
{/* Available Slots */}
|
||||
{selectedDate && (
|
||||
<div style={{ marginBottom: "1.5rem" }}>
|
||||
<h2 style={{ fontSize: "1.1rem", marginBottom: "0.75rem" }}>
|
||||
<div style={styles.section}>
|
||||
<h2 style={styles.sectionTitle}>
|
||||
Available Slots for {selectedDate.toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
|
|
@ -234,15 +403,11 @@ export default function BookingPage() {
|
|||
</h2>
|
||||
|
||||
{isLoadingSlots ? (
|
||||
<p>Loading slots...</p>
|
||||
<div style={styles.emptyState}>Loading slots...</div>
|
||||
) : availableSlots.length === 0 ? (
|
||||
<p style={{ color: "#666" }}>No available slots for this date</p>
|
||||
<div style={styles.emptyState}>No available slots for this date</div>
|
||||
) : (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.5rem",
|
||||
}}>
|
||||
<div style={styles.slotGrid}>
|
||||
{availableSlots.map((slot) => {
|
||||
const isSelected = selectedSlot?.start_time === slot.start_time;
|
||||
return (
|
||||
|
|
@ -250,12 +415,8 @@ export default function BookingPage() {
|
|||
key={slot.start_time}
|
||||
onClick={() => handleSlotSelect(slot)}
|
||||
style={{
|
||||
padding: "0.5rem 1rem",
|
||||
border: isSelected ? "2px solid #0070f3" : "1px solid #ddd",
|
||||
borderRadius: "8px",
|
||||
background: isSelected ? "#e7f3ff" : "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.875rem",
|
||||
...styles.slotButton,
|
||||
...(isSelected ? styles.slotButtonSelected : {}),
|
||||
}}
|
||||
>
|
||||
{formatTime(slot.start_time)}
|
||||
|
|
@ -269,60 +430,37 @@ export default function BookingPage() {
|
|||
|
||||
{/* Booking Form */}
|
||||
{selectedSlot && (
|
||||
<div style={{
|
||||
background: "#f9f9f9",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "8px",
|
||||
padding: "1.5rem",
|
||||
maxWidth: "400px",
|
||||
}}>
|
||||
<h3 style={{ marginBottom: "1rem" }}>
|
||||
Confirm Booking
|
||||
</h3>
|
||||
<p style={{ marginBottom: "1rem" }}>
|
||||
<div style={styles.confirmCard}>
|
||||
<h3 style={styles.confirmTitle}>Confirm Booking</h3>
|
||||
<p style={styles.confirmTime}>
|
||||
<strong>Time:</strong> {formatTime(selectedSlot.start_time)} - {formatTime(selectedSlot.end_time)}
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<label style={{ display: "block", marginBottom: "0.5rem", fontWeight: 500 }}>
|
||||
<div>
|
||||
<label style={styles.inputLabel}>
|
||||
Note (optional, max {noteMaxLength} chars)
|
||||
</label>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value.slice(0, noteMaxLength))}
|
||||
placeholder="Add a note about your appointment..."
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.5rem",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
minHeight: "80px",
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
style={styles.textarea}
|
||||
/>
|
||||
<div style={{
|
||||
fontSize: "0.75rem",
|
||||
color: note.length >= noteMaxLength ? "#dc3545" : "#666",
|
||||
textAlign: "right",
|
||||
<div style={{
|
||||
...styles.charCount,
|
||||
...(note.length >= noteMaxLength ? styles.charCountWarning : {}),
|
||||
}}>
|
||||
{note.length}/{noteMaxLength}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<div style={styles.buttonRow}>
|
||||
<button
|
||||
onClick={handleBook}
|
||||
disabled={isBooking}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "0.75rem",
|
||||
background: isBooking ? "#ccc" : "#0070f3",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: isBooking ? "not-allowed" : "pointer",
|
||||
fontWeight: 500,
|
||||
...styles.bookButton,
|
||||
...(isBooking ? styles.bookButtonDisabled : {}),
|
||||
}}
|
||||
>
|
||||
{isBooking ? "Booking..." : "Book Appointment"}
|
||||
|
|
@ -330,21 +468,15 @@ export default function BookingPage() {
|
|||
<button
|
||||
onClick={cancelSlotSelection}
|
||||
disabled={isBooking}
|
||||
style={{
|
||||
padding: "0.75rem 1rem",
|
||||
background: "#fff",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
style={styles.cancelButton}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue