Phase 7: Final cleanup - Remove deprecated booking/appointment code
Deleted deprecated files: - backend/routes/booking.py - frontend/app/admin/appointments/, booking/, appointments/, sum/, audit/ - frontend/app/utils/appointment.ts - frontend/e2e/booking.spec.ts, appointments.spec.ts Updated references: - exchange/page.tsx: Use /api/exchange/slots instead of /api/booking/slots - useRequireAuth.ts: Redirect to /admin/trades and /exchange - profile.tsx, invites.tsx: Update fallback redirect - E2E tests: Update all /audit references to /admin/trades - profile.test.tsx: Update admin redirect test
This commit is contained in:
parent
9e8d0af435
commit
bbd9fae763
16 changed files with 29 additions and 2103 deletions
|
|
@ -1,319 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Permission } from "../../auth-context";
|
||||
import { api } from "../../api";
|
||||
import { Header } from "../../components/Header";
|
||||
import { useRequireAuth } from "../../hooks/useRequireAuth";
|
||||
import { components } from "../../generated/api";
|
||||
import { formatDateTime } from "../../utils/date";
|
||||
import { getStatusDisplay } from "../../utils/appointment";
|
||||
import { sharedStyles } from "../../styles/shared";
|
||||
|
||||
type AppointmentResponse = components["schemas"]["AppointmentResponse"];
|
||||
type PaginatedAppointments = components["schemas"]["PaginatedResponse_AppointmentResponse_"];
|
||||
|
||||
const pageStyles: 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",
|
||||
},
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
const styles = { ...sharedStyles, ...pageStyles };
|
||||
|
||||
export default function AdminAppointmentsPage() {
|
||||
const { user, isLoading, isAuthorized } = useRequireAuth({
|
||||
requiredPermission: Permission.VIEW_ALL_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 [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
|
||||
const fetchAppointments = useCallback(async () => {
|
||||
try {
|
||||
// Fetch with large per_page to get all appointments for now
|
||||
const data = await api.get<PaginatedAppointments>("/api/admin/appointments?per_page=100");
|
||||
setAppointments(data.records);
|
||||
} 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/admin/appointments/${appointmentId}/cancel`, {});
|
||||
await fetchAppointments();
|
||||
setConfirmCancelId(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to cancel appointment");
|
||||
} finally {
|
||||
setCancellingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<main style={styles.main}>
|
||||
<div style={styles.loader}>Loading...</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthorized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filteredAppointments = appointments.filter((apt) => {
|
||||
if (statusFilter === "all") return true;
|
||||
return apt.status === statusFilter;
|
||||
});
|
||||
|
||||
const bookedCount = appointments.filter((a) => a.status === "booked").length;
|
||||
|
||||
return (
|
||||
<main style={styles.main}>
|
||||
<Header currentPage="admin-appointments" />
|
||||
<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={styles.errorBanner}>{error}</div>}
|
||||
|
||||
{/* Status Filter */}
|
||||
<div style={styles.filterRow}>
|
||||
<span style={styles.filterLabel}>Filter:</span>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
style={styles.filterSelect}
|
||||
>
|
||||
<option value="all">All ({appointments.length})</option>
|
||||
<option value="booked">Booked ({bookedCount})</option>
|
||||
<option value="cancelled_by_user">Cancelled by User</option>
|
||||
<option value="cancelled_by_admin">Cancelled by Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{isLoadingAppointments ? (
|
||||
<div style={styles.emptyState}>Loading appointments...</div>
|
||||
) : appointments.length === 0 ? (
|
||||
<div style={styles.emptyState}>No appointments yet.</div>
|
||||
) : filteredAppointments.length === 0 ? (
|
||||
<div style={styles.emptyState}>No appointments match the filter.</div>
|
||||
) : (
|
||||
<div style={styles.appointmentList}>
|
||||
{filteredAppointments.map((apt) => {
|
||||
const status = getStatusDisplay(apt.status);
|
||||
const isPast = new Date(apt.slot_start) <= new Date();
|
||||
return (
|
||||
<div
|
||||
key={apt.id}
|
||||
style={{
|
||||
...styles.appointmentCard,
|
||||
...(isPast ? styles.appointmentCardPast : {}),
|
||||
}}
|
||||
>
|
||||
<div style={styles.appointmentHeader}>
|
||||
<div>
|
||||
<div style={styles.appointmentTime}>{formatDateTime(apt.slot_start)}</div>
|
||||
<div style={styles.appointmentUser}>{apt.user_email}</div>
|
||||
{apt.note && <div style={styles.appointmentNote}>"{apt.note}"</div>}
|
||||
<span
|
||||
style={{
|
||||
...styles.statusBadge,
|
||||
background: status.bgColor,
|
||||
color: status.textColor,
|
||||
}}
|
||||
>
|
||||
{status.text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{apt.status === "booked" && (
|
||||
<div style={styles.buttonGroup}>
|
||||
{confirmCancelId === apt.id ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleCancel(apt.id)}
|
||||
disabled={cancellingId === apt.id}
|
||||
style={styles.confirmButton}
|
||||
>
|
||||
{cancellingId === apt.id ? "..." : "Confirm"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmCancelId(null)}
|
||||
style={styles.cancelButton}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmCancelId(apt.id)}
|
||||
style={styles.cancelButton}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,285 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Permission } from "../auth-context";
|
||||
import { api } from "../api";
|
||||
import { Header } from "../components/Header";
|
||||
import { useRequireAuth } from "../hooks/useRequireAuth";
|
||||
import { components } from "../generated/api";
|
||||
import { formatDateTime } from "../utils/date";
|
||||
import { getStatusDisplay } from "../utils/appointment";
|
||||
import {
|
||||
layoutStyles,
|
||||
typographyStyles,
|
||||
bannerStyles,
|
||||
badgeStyles,
|
||||
buttonStyles,
|
||||
} from "../styles/shared";
|
||||
|
||||
type AppointmentResponse = components["schemas"]["AppointmentResponse"];
|
||||
|
||||
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) {
|
||||
setError(err instanceof Error ? err.message : "Failed to cancel appointment");
|
||||
} finally {
|
||||
setCancellingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<main style={layoutStyles.main}>
|
||||
<div style={layoutStyles.loader}>Loading...</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<main style={layoutStyles.main}>
|
||||
<Header currentPage="appointments" />
|
||||
<div style={styles.content}>
|
||||
<h1 style={typographyStyles.pageTitle}>My Appointments</h1>
|
||||
<p style={typographyStyles.pageSubtitle}>View and manage your booked appointments</p>
|
||||
|
||||
{error && <div style={bannerStyles.errorBanner}>{error}</div>}
|
||||
|
||||
{isLoadingAppointments ? (
|
||||
<div style={styles.emptyState}>Loading appointments...</div>
|
||||
) : appointments.length === 0 ? (
|
||||
<div style={styles.emptyState}>
|
||||
<p>You don't have any appointments yet.</p>
|
||||
<a href="/booking" style={styles.emptyStateLink}>
|
||||
Book an appointment
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Upcoming Appointments */}
|
||||
{upcomingAppointments.length > 0 && (
|
||||
<div style={styles.section}>
|
||||
<h2 style={styles.sectionTitle}>Upcoming ({upcomingAppointments.length})</h2>
|
||||
<div style={styles.appointmentList}>
|
||||
{upcomingAppointments.map((apt) => {
|
||||
const status = getStatusDisplay(apt.status, true);
|
||||
return (
|
||||
<div key={apt.id} style={styles.appointmentCard}>
|
||||
<div style={styles.appointmentHeader}>
|
||||
<div>
|
||||
<div style={styles.appointmentTime}>
|
||||
{formatDateTime(apt.slot_start)}
|
||||
</div>
|
||||
{apt.note && <div style={styles.appointmentNote}>{apt.note}</div>}
|
||||
<span
|
||||
style={{
|
||||
...badgeStyles.badge,
|
||||
background: status.bgColor,
|
||||
color: status.textColor,
|
||||
}}
|
||||
>
|
||||
{status.text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{apt.status === "booked" && (
|
||||
<div style={styles.buttonGroup}>
|
||||
{confirmCancelId === apt.id ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleCancel(apt.id)}
|
||||
disabled={cancellingId === apt.id}
|
||||
style={styles.confirmButton}
|
||||
>
|
||||
{cancellingId === apt.id ? "..." : "Confirm"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmCancelId(null)}
|
||||
style={buttonStyles.secondaryButton}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmCancelId(apt.id)}
|
||||
style={buttonStyles.secondaryButton}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Past/Cancelled Appointments */}
|
||||
{pastOrCancelledAppointments.length > 0 && (
|
||||
<div style={styles.section}>
|
||||
<h2 style={typographyStyles.sectionTitleMuted}>
|
||||
Past & Cancelled ({pastOrCancelledAppointments.length})
|
||||
</h2>
|
||||
<div style={styles.appointmentList}>
|
||||
{pastOrCancelledAppointments.map((apt) => {
|
||||
const status = getStatusDisplay(apt.status, true);
|
||||
return (
|
||||
<div
|
||||
key={apt.id}
|
||||
style={{ ...styles.appointmentCard, ...styles.appointmentCardPast }}
|
||||
>
|
||||
<div style={styles.appointmentTime}>{formatDateTime(apt.slot_start)}</div>
|
||||
{apt.note && <div style={styles.appointmentNote}>{apt.note}</div>}
|
||||
<span
|
||||
style={{
|
||||
...badgeStyles.badge,
|
||||
background: status.bgColor,
|
||||
color: status.textColor,
|
||||
}}
|
||||
>
|
||||
{status.text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Page-specific styles
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: "2rem",
|
||||
maxWidth: "800px",
|
||||
margin: "0 auto",
|
||||
width: "100%",
|
||||
},
|
||||
section: {
|
||||
marginBottom: "2rem",
|
||||
},
|
||||
sectionTitle: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "1.1rem",
|
||||
fontWeight: 500,
|
||||
color: "#fff",
|
||||
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",
|
||||
},
|
||||
buttonGroup: {
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
|
@ -1,428 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { Permission } from "../auth-context";
|
||||
import { api } from "../api";
|
||||
import { Header } from "../components/Header";
|
||||
import { useRequireAuth } from "../hooks/useRequireAuth";
|
||||
import { components } from "../generated/api";
|
||||
import constants from "../../../shared/constants.json";
|
||||
import { formatDate, formatTime, getDateRange } from "../utils/date";
|
||||
import {
|
||||
layoutStyles,
|
||||
typographyStyles,
|
||||
bannerStyles,
|
||||
formStyles,
|
||||
buttonStyles,
|
||||
} from "../styles/shared";
|
||||
|
||||
const { slotDurationMinutes, maxAdvanceDays, minAdvanceDays, noteMaxLength } = constants.booking;
|
||||
|
||||
type BookableSlot = components["schemas"]["BookableSlot"];
|
||||
type AvailableSlotsResponse = components["schemas"]["AvailableSlotsResponse"];
|
||||
type AppointmentResponse = components["schemas"]["AppointmentResponse"];
|
||||
|
||||
export default function BookingPage() {
|
||||
const { user, isLoading, isAuthorized } = useRequireAuth({
|
||||
requiredPermission: Permission.BOOK_APPOINTMENT,
|
||||
fallbackRedirect: "/",
|
||||
});
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||
const [availableSlots, setAvailableSlots] = useState<BookableSlot[]>([]);
|
||||
const [selectedSlot, setSelectedSlot] = useState<BookableSlot | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
const [isLoadingSlots, setIsLoadingSlots] = useState(false);
|
||||
const [isBooking, setIsBooking] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [datesWithAvailability, setDatesWithAvailability] = useState<Set<string>>(new Set());
|
||||
const [isLoadingAvailability, setIsLoadingAvailability] = useState(true);
|
||||
|
||||
// Memoize dates to prevent infinite re-renders
|
||||
const dates = useMemo(
|
||||
() => getDateRange(minAdvanceDays, maxAdvanceDays),
|
||||
[minAdvanceDays, maxAdvanceDays]
|
||||
);
|
||||
|
||||
const fetchSlots = useCallback(async (date: Date) => {
|
||||
setIsLoadingSlots(true);
|
||||
setError(null);
|
||||
setAvailableSlots([]);
|
||||
setSelectedSlot(null);
|
||||
|
||||
try {
|
||||
const dateStr = formatDate(date);
|
||||
const data = await api.get<AvailableSlotsResponse>(`/api/booking/slots?date=${dateStr}`);
|
||||
setAvailableSlots(data.slots);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch slots:", err);
|
||||
setError("Failed to load available slots");
|
||||
} finally {
|
||||
setIsLoadingSlots(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch availability for all dates on mount
|
||||
useEffect(() => {
|
||||
if (!user || !isAuthorized) return;
|
||||
|
||||
const fetchAllAvailability = async () => {
|
||||
setIsLoadingAvailability(true);
|
||||
const availabilitySet = new Set<string>();
|
||||
|
||||
// Fetch availability for all dates in parallel
|
||||
const promises = dates.map(async (date) => {
|
||||
try {
|
||||
const dateStr = formatDate(date);
|
||||
const data = await api.get<AvailableSlotsResponse>(`/api/booking/slots?date=${dateStr}`);
|
||||
if (data.slots.length > 0) {
|
||||
availabilitySet.add(dateStr);
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently fail for individual dates - they'll just be marked as unavailable
|
||||
console.error(`Failed to fetch availability for ${formatDate(date)}:`, err);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
setDatesWithAvailability(availabilitySet);
|
||||
setIsLoadingAvailability(false);
|
||||
};
|
||||
|
||||
fetchAllAvailability();
|
||||
}, [user, isAuthorized]); // Removed dates from dependencies - dates is memoized and stable
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDate && user && isAuthorized) {
|
||||
fetchSlots(selectedDate);
|
||||
}
|
||||
}, [selectedDate, user, isAuthorized, fetchSlots]);
|
||||
|
||||
const handleDateSelect = (date: Date) => {
|
||||
const dateStr = formatDate(date);
|
||||
// Only allow selection if date has availability
|
||||
if (datesWithAvailability.has(dateStr)) {
|
||||
setSelectedDate(date);
|
||||
setSuccessMessage(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSlotSelect = (slot: BookableSlot) => {
|
||||
setSelectedSlot(slot);
|
||||
setNote("");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleBook = async () => {
|
||||
if (!selectedSlot) return;
|
||||
|
||||
setIsBooking(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const appointment = await api.post<AppointmentResponse>("/api/booking", {
|
||||
slot_start: selectedSlot.start_time,
|
||||
note: note || null,
|
||||
});
|
||||
|
||||
setSuccessMessage(
|
||||
`Appointment booked for ${formatTime(appointment.slot_start)} - ${formatTime(appointment.slot_end)}`
|
||||
);
|
||||
setSelectedSlot(null);
|
||||
setNote("");
|
||||
|
||||
// Refresh slots to show the booked one is gone
|
||||
if (selectedDate) {
|
||||
await fetchSlots(selectedDate);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to book appointment");
|
||||
} finally {
|
||||
setIsBooking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelSlotSelection = () => {
|
||||
setSelectedSlot(null);
|
||||
setNote("");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<main style={layoutStyles.main}>
|
||||
<div style={layoutStyles.loader}>Loading...</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthorized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={layoutStyles.main}>
|
||||
<Header currentPage="booking" />
|
||||
<div style={styles.content}>
|
||||
<h1 style={typographyStyles.pageTitle}>Book an Appointment</h1>
|
||||
<p style={typographyStyles.pageSubtitle}>
|
||||
Select a date to see available {slotDurationMinutes}-minute slots
|
||||
</p>
|
||||
|
||||
{successMessage && <div style={bannerStyles.successBanner}>{successMessage}</div>}
|
||||
|
||||
{error && <div style={bannerStyles.errorBanner}>{error}</div>}
|
||||
|
||||
{/* Date Selection */}
|
||||
<div style={styles.section}>
|
||||
<h2 style={styles.sectionTitle}>Select a Date</h2>
|
||||
<div style={styles.dateGrid}>
|
||||
{dates.map((date) => {
|
||||
const dateStr = formatDate(date);
|
||||
const isSelected = selectedDate && formatDate(selectedDate) === dateStr;
|
||||
const hasAvailability = datesWithAvailability.has(dateStr);
|
||||
const isDisabled = !hasAvailability || isLoadingAvailability;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dateStr}
|
||||
onClick={() => handleDateSelect(date)}
|
||||
disabled={isDisabled}
|
||||
style={{
|
||||
...styles.dateButton,
|
||||
...(isSelected ? styles.dateButtonSelected : {}),
|
||||
...(isDisabled ? styles.dateButtonDisabled : {}),
|
||||
}}
|
||||
>
|
||||
<div style={styles.dateWeekday}>
|
||||
{date.toLocaleDateString("en-US", { weekday: "short" })}
|
||||
</div>
|
||||
<div style={styles.dateDay}>
|
||||
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available Slots */}
|
||||
{selectedDate && (
|
||||
<div style={styles.section}>
|
||||
<h2 style={styles.sectionTitle}>
|
||||
Available Slots for{" "}
|
||||
{selectedDate.toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</h2>
|
||||
|
||||
{isLoadingSlots ? (
|
||||
<div style={styles.emptyState}>Loading slots...</div>
|
||||
) : availableSlots.length === 0 ? (
|
||||
<div style={styles.emptyState}>No available slots for this date</div>
|
||||
) : (
|
||||
<div style={styles.slotGrid}>
|
||||
{availableSlots.map((slot) => {
|
||||
const isSelected = selectedSlot?.start_time === slot.start_time;
|
||||
return (
|
||||
<button
|
||||
key={slot.start_time}
|
||||
onClick={() => handleSlotSelect(slot)}
|
||||
style={{
|
||||
...styles.slotButton,
|
||||
...(isSelected ? styles.slotButtonSelected : {}),
|
||||
}}
|
||||
>
|
||||
{formatTime(slot.start_time)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Booking Form */}
|
||||
{selectedSlot && (
|
||||
<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>
|
||||
<label style={formStyles.label}>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={formStyles.textarea}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
...formStyles.charCount,
|
||||
...(note.length >= noteMaxLength ? formStyles.charCountWarning : {}),
|
||||
}}
|
||||
>
|
||||
{note.length}/{noteMaxLength}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.buttonRow}>
|
||||
<button
|
||||
onClick={handleBook}
|
||||
disabled={isBooking}
|
||||
style={{
|
||||
...styles.bookButton,
|
||||
...(isBooking ? buttonStyles.buttonDisabled : {}),
|
||||
}}
|
||||
>
|
||||
{isBooking ? "Booking..." : "Book Appointment"}
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelSlotSelection}
|
||||
disabled={isBooking}
|
||||
style={styles.cancelButton}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Page-specific styles
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: "2rem",
|
||||
maxWidth: "900px",
|
||||
margin: "0 auto",
|
||||
width: "100%",
|
||||
},
|
||||
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)",
|
||||
border: "1px solid #a78bfa",
|
||||
},
|
||||
dateButtonDisabled: {
|
||||
opacity: 0.4,
|
||||
cursor: "not-allowed",
|
||||
background: "rgba(255, 255, 255, 0.01)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.04)",
|
||||
},
|
||||
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)",
|
||||
border: "1px solid #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",
|
||||
},
|
||||
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",
|
||||
},
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
|
@ -152,7 +152,7 @@ export default function ExchangePage() {
|
|||
|
||||
try {
|
||||
const dateStr = formatDate(date);
|
||||
const data = await api.get<AvailableSlotsResponse>(`/api/booking/slots?date=${dateStr}`);
|
||||
const data = await api.get<AvailableSlotsResponse>(`/api/exchange/slots?date=${dateStr}`);
|
||||
setAvailableSlots(data.slots);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch slots:", err);
|
||||
|
|
@ -173,7 +173,7 @@ export default function ExchangePage() {
|
|||
const promises = dates.map(async (date) => {
|
||||
try {
|
||||
const dateStr = formatDate(date);
|
||||
const data = await api.get<AvailableSlotsResponse>(`/api/booking/slots?date=${dateStr}`);
|
||||
const data = await api.get<AvailableSlotsResponse>(`/api/exchange/slots?date=${dateStr}`);
|
||||
if (data.slots.length > 0) {
|
||||
availabilitySet.add(dateStr);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,10 +48,10 @@ export function useRequireAuth(options: UseRequireAuthOptions = {}): UseRequireA
|
|||
// Redirect to the most appropriate page based on permissions
|
||||
const redirect =
|
||||
fallbackRedirect ??
|
||||
(hasPermission(Permission.VIEW_AUDIT)
|
||||
? "/audit"
|
||||
: hasPermission(Permission.VIEW_COUNTER)
|
||||
? "/"
|
||||
(hasPermission(Permission.VIEW_ALL_EXCHANGES)
|
||||
? "/admin/trades"
|
||||
: hasPermission(Permission.CREATE_EXCHANGE)
|
||||
? "/exchange"
|
||||
: "/login");
|
||||
router.push(redirect);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ type Invite = components["schemas"]["UserInviteResponse"];
|
|||
export default function InvitesPage() {
|
||||
const { user, isLoading, isAuthorized } = useRequireAuth({
|
||||
requiredPermission: Permission.VIEW_OWN_INVITES,
|
||||
fallbackRedirect: "/audit",
|
||||
fallbackRedirect: "/admin/trades",
|
||||
});
|
||||
const [invites, setInvites] = useState<Invite[]>([]);
|
||||
const [isLoadingInvites, setIsLoadingInvites] = useState(true);
|
||||
|
|
|
|||
|
|
@ -242,18 +242,18 @@ describe("ProfilePage - Access Control", () => {
|
|||
});
|
||||
});
|
||||
|
||||
test("redirects admin to audit page", async () => {
|
||||
test("redirects admin to admin trades page", async () => {
|
||||
mockUser = {
|
||||
id: 1,
|
||||
email: "admin@example.com",
|
||||
roles: ["admin"],
|
||||
permissions: ["view_audit"],
|
||||
permissions: ["view_all_exchanges"],
|
||||
};
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith("/audit");
|
||||
expect(mockPush).toHaveBeenCalledWith("/admin/trades");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ function toFormData(data: ProfileData): FormData {
|
|||
export default function ProfilePage() {
|
||||
const { user, isLoading, isAuthorized } = useRequireAuth({
|
||||
requiredPermission: Permission.MANAGE_OWN_PROFILE,
|
||||
fallbackRedirect: "/audit",
|
||||
fallbackRedirect: "/admin/trades",
|
||||
});
|
||||
const [originalData, setOriginalData] = useState<FormData | null>(null);
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
/**
|
||||
* Appointment-related utilities.
|
||||
*/
|
||||
|
||||
export interface StatusDisplay {
|
||||
text: string;
|
||||
bgColor: string;
|
||||
textColor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display information for an appointment status.
|
||||
*
|
||||
* @param status - The appointment status string
|
||||
* @param isOwnView - If true, uses "Cancelled by you" instead of "Cancelled by user"
|
||||
*/
|
||||
export function getStatusDisplay(status: string, isOwnView: boolean = false): StatusDisplay {
|
||||
switch (status) {
|
||||
case "booked":
|
||||
return { text: "Booked", bgColor: "rgba(34, 197, 94, 0.2)", textColor: "#4ade80" };
|
||||
case "cancelled_by_user":
|
||||
return {
|
||||
text: isOwnView ? "Cancelled by you" : "Cancelled by user",
|
||||
bgColor: "rgba(239, 68, 68, 0.2)",
|
||||
textColor: "#f87171",
|
||||
};
|
||||
case "cancelled_by_admin":
|
||||
return {
|
||||
text: "Cancelled by admin",
|
||||
bgColor: "rgba(239, 68, 68, 0.2)",
|
||||
textColor: "#f87171",
|
||||
};
|
||||
default:
|
||||
return { text: status, bgColor: "rgba(255,255,255,0.1)", textColor: "rgba(255,255,255,0.6)" };
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue