feat/booking-panel #29
@ -30,8 +30,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { DatePicker } from "@/components/DatePicker";
|
import { ScheduleAppointmentDialog } from "@/components/ScheduleAppointmentDialog";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { Appointment } from "@/lib/models/appointments";
|
import type { Appointment } from "@/lib/models/appointments";
|
||||||
|
|
||||||
@ -62,7 +61,6 @@ export default function AppointmentDetailPage() {
|
|||||||
const data = await getAppointmentDetail(appointmentId);
|
const data = await getAppointmentDetail(appointmentId);
|
||||||
setAppointment(data);
|
setAppointment(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch appointment details:", error);
|
|
||||||
toast.error("Failed to load appointment details");
|
toast.error("Failed to load appointment details");
|
||||||
router.push("/admin/booking");
|
router.push("/admin/booking");
|
||||||
} finally {
|
} finally {
|
||||||
@ -139,10 +137,6 @@ export default function AppointmentDetailPage() {
|
|||||||
return status.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase());
|
return status.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase());
|
||||||
};
|
};
|
||||||
|
|
||||||
const timeSlots = Array.from({ length: 24 }, (_, i) => {
|
|
||||||
const hour = i.toString().padStart(2, "0");
|
|
||||||
return `${hour}:00`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSchedule = async () => {
|
const handleSchedule = async () => {
|
||||||
if (!appointment || !scheduledDate) return;
|
if (!appointment || !scheduledDate) return;
|
||||||
@ -165,7 +159,6 @@ export default function AppointmentDetailPage() {
|
|||||||
const updated = await getAppointmentDetail(appointment.id);
|
const updated = await getAppointmentDetail(appointment.id);
|
||||||
setAppointment(updated);
|
setAppointment(updated);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Failed to schedule appointment:", error);
|
|
||||||
toast.error(error.message || "Failed to schedule appointment");
|
toast.error(error.message || "Failed to schedule appointment");
|
||||||
} finally {
|
} finally {
|
||||||
setIsScheduling(false);
|
setIsScheduling(false);
|
||||||
@ -188,7 +181,6 @@ export default function AppointmentDetailPage() {
|
|||||||
const updated = await getAppointmentDetail(appointment.id);
|
const updated = await getAppointmentDetail(appointment.id);
|
||||||
setAppointment(updated);
|
setAppointment(updated);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Failed to reject appointment:", error);
|
|
||||||
toast.error(error.message || "Failed to reject appointment");
|
toast.error(error.message || "Failed to reject appointment");
|
||||||
} finally {
|
} finally {
|
||||||
setIsRejecting(false);
|
setIsRejecting(false);
|
||||||
@ -376,7 +368,7 @@ export default function AppointmentDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Preferred Dates & Times */}
|
{/* Preferred Dates & Times */}
|
||||||
{(appointment.preferred_dates?.length > 0 || appointment.preferred_time_slots?.length > 0) && (
|
{((appointment.preferred_dates && appointment.preferred_dates.length > 0) || (appointment.preferred_time_slots && appointment.preferred_time_slots.length > 0)) && (
|
||||||
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
||||||
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
|
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
|
||||||
<h2 className={`text-lg font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
|
<h2 className={`text-lg font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||||
@ -384,37 +376,53 @@ export default function AppointmentDetailPage() {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
{appointment.preferred_dates && appointment.preferred_dates.length > 0 && (
|
{appointment.preferred_dates && (
|
||||||
<div>
|
<div>
|
||||||
<p className={`text-sm font-medium mb-3 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
<p className={`text-sm font-medium mb-3 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
Preferred Dates
|
Preferred Dates
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{appointment.preferred_dates.map((date, idx) => (
|
{Array.isArray(appointment.preferred_dates) ? (
|
||||||
|
(appointment.preferred_dates as string[]).map((date, idx) => (
|
||||||
<span
|
<span
|
||||||
key={idx}
|
key={idx}
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium ${isDark ? "bg-gray-700 text-gray-200 border border-gray-600" : "bg-gray-100 text-gray-700 border border-gray-200"}`}
|
className={`px-4 py-2 rounded-lg text-sm font-medium ${isDark ? "bg-gray-700 text-gray-200 border border-gray-600" : "bg-gray-100 text-gray-700 border border-gray-200"}`}
|
||||||
>
|
>
|
||||||
{formatShortDate(date)}
|
{formatShortDate(date)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium ${isDark ? "bg-gray-700 text-gray-200 border border-gray-600" : "bg-gray-100 text-gray-700 border border-gray-200"}`}
|
||||||
|
>
|
||||||
|
{appointment.preferred_dates_display || appointment.preferred_dates || 'N/A'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{appointment.preferred_time_slots && appointment.preferred_time_slots.length > 0 && (
|
{appointment.preferred_time_slots && (
|
||||||
<div>
|
<div>
|
||||||
<p className={`text-sm font-medium mb-3 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
<p className={`text-sm font-medium mb-3 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
Preferred Time Slots
|
Preferred Time Slots
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{appointment.preferred_time_slots.map((slot, idx) => (
|
{Array.isArray(appointment.preferred_time_slots) ? (
|
||||||
|
(appointment.preferred_time_slots as string[]).map((slot, idx) => (
|
||||||
<span
|
<span
|
||||||
key={idx}
|
key={idx}
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium capitalize ${isDark ? "bg-rose-500/20 text-rose-300 border border-rose-500/30" : "bg-rose-50 text-rose-700 border border-rose-200"}`}
|
className={`px-4 py-2 rounded-lg text-sm font-medium capitalize ${isDark ? "bg-rose-500/20 text-rose-300 border border-rose-500/30" : "bg-rose-50 text-rose-700 border border-rose-200"}`}
|
||||||
>
|
>
|
||||||
{slot}
|
{slot}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium capitalize ${isDark ? "bg-rose-500/20 text-rose-300 border border-rose-500/30" : "bg-rose-50 text-rose-700 border border-rose-200"}`}
|
||||||
|
>
|
||||||
|
{appointment.preferred_time_slots_display || appointment.preferred_time_slots || 'N/A'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -422,6 +430,64 @@ export default function AppointmentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Matching Availability */}
|
||||||
|
{appointment.matching_availability && Array.isArray(appointment.matching_availability) && appointment.matching_availability.length > 0 && (
|
||||||
|
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
||||||
|
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
|
||||||
|
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||||
|
<CalendarCheck className={`w-5 h-5 ${isDark ? "text-green-400" : "text-green-600"}`} />
|
||||||
|
Matching Availability
|
||||||
|
{appointment.are_preferences_available !== undefined && (
|
||||||
|
<span className={`ml-auto px-3 py-1 text-xs font-medium rounded-full ${appointment.are_preferences_available ? (isDark ? "bg-green-500/20 text-green-300 border border-green-500/30" : "bg-green-50 text-green-700 border border-green-200") : (isDark ? "bg-yellow-500/20 text-yellow-300 border border-yellow-500/30" : "bg-yellow-50 text-yellow-700 border border-yellow-200")}`}>
|
||||||
|
{appointment.are_preferences_available ? "Available" : "Partially Available"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{appointment.matching_availability.map((match: any, idx: number) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<p className={`text-base font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||||
|
{match.day_name || "Unknown Day"}
|
||||||
|
</p>
|
||||||
|
<p className={`text-sm mt-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
|
{formatShortDate(match.date || match.date_obj || "")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{match.available_slots && Array.isArray(match.available_slots) && match.available_slots.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-3">
|
||||||
|
{match.available_slots.map((slot: string, slotIdx: number) => {
|
||||||
|
const timeSlotLabels: Record<string, string> = {
|
||||||
|
morning: "Morning",
|
||||||
|
afternoon: "Lunchtime",
|
||||||
|
evening: "Evening",
|
||||||
|
};
|
||||||
|
const normalizedSlot = String(slot).toLowerCase().trim();
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={slotIdx}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium ${isDark ? "bg-green-500/20 text-green-300 border border-green-500/30" : "bg-green-50 text-green-700 border border-green-200"}`}
|
||||||
|
>
|
||||||
|
{timeSlotLabels[normalizedSlot] || slot}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Reason */}
|
{/* Reason */}
|
||||||
{appointment.reason && (
|
{appointment.reason && (
|
||||||
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
||||||
@ -584,6 +650,7 @@ export default function AppointmentDetailPage() {
|
|||||||
{appointment.status === "scheduled" && appointment.jitsi_meet_url && (
|
{appointment.status === "scheduled" && appointment.jitsi_meet_url && (
|
||||||
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gradient-to-br from-blue-900/20 to-purple-900/20 border-blue-800/30" : "bg-gradient-to-br from-blue-50 to-purple-50 border-blue-200"}`}>
|
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gradient-to-br from-blue-900/20 to-purple-900/20 border-blue-800/30" : "bg-gradient-to-br from-blue-50 to-purple-50 border-blue-200"}`}>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
|
{appointment.can_join_meeting ? (
|
||||||
<a
|
<a
|
||||||
href={appointment.jitsi_meet_url}
|
href={appointment.jitsi_meet_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@ -593,6 +660,15 @@ export default function AppointmentDetailPage() {
|
|||||||
<Video className="w-5 h-5" />
|
<Video className="w-5 h-5" />
|
||||||
Join Meeting
|
Join Meeting
|
||||||
</a>
|
</a>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
className={`flex items-center justify-center gap-2 w-full cursor-not-allowed h-12 rounded-lg text-base font-medium transition-colors ${isDark ? "bg-gray-700 text-gray-500" : "bg-gray-300 text-gray-500"}`}
|
||||||
|
>
|
||||||
|
<Video className="w-5 h-5" />
|
||||||
|
Meeting Not Available Yet
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -600,140 +676,21 @@ export default function AppointmentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Google Meet Style Schedule Dialog */}
|
{/* Schedule Appointment Dialog */}
|
||||||
<Dialog open={scheduleDialogOpen} onOpenChange={setScheduleDialogOpen}>
|
<ScheduleAppointmentDialog
|
||||||
<DialogContent className={`max-w-3xl ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
open={scheduleDialogOpen}
|
||||||
<DialogHeader className="pb-4">
|
onOpenChange={setScheduleDialogOpen}
|
||||||
<DialogTitle className={`text-2xl font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
|
appointment={appointment}
|
||||||
Schedule Appointment
|
scheduledDate={scheduledDate}
|
||||||
</DialogTitle>
|
setScheduledDate={setScheduledDate}
|
||||||
<DialogDescription className={`text-base ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
scheduledTime={scheduledTime}
|
||||||
Set date and time for {appointment.first_name} {appointment.last_name}'s appointment
|
setScheduledTime={setScheduledTime}
|
||||||
</DialogDescription>
|
scheduledDuration={scheduledDuration}
|
||||||
</DialogHeader>
|
setScheduledDuration={setScheduledDuration}
|
||||||
|
onSchedule={handleSchedule}
|
||||||
<div className="space-y-6 py-4">
|
isScheduling={isScheduling}
|
||||||
{/* Date Selection */}
|
isDark={isDark}
|
||||||
<div className="space-y-3">
|
/>
|
||||||
<label className={`text-sm font-semibold ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
|
||||||
Select Date *
|
|
||||||
</label>
|
|
||||||
<div className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}>
|
|
||||||
<DatePicker
|
|
||||||
date={scheduledDate}
|
|
||||||
setDate={setScheduledDate}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Time Selection */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className={`text-sm font-semibold ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
|
||||||
Select Time *
|
|
||||||
</label>
|
|
||||||
<div className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}>
|
|
||||||
<Select value={scheduledTime} onValueChange={setScheduledTime}>
|
|
||||||
<SelectTrigger className={`h-12 text-base ${isDark ? "bg-gray-800 border-gray-600 text-white" : "bg-white border-gray-300"}`}>
|
|
||||||
<SelectValue placeholder="Choose a time" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className={isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}>
|
|
||||||
{timeSlots.map((time) => (
|
|
||||||
<SelectItem
|
|
||||||
key={time}
|
|
||||||
value={time}
|
|
||||||
className={`h-12 text-base ${isDark ? "focus:bg-gray-700" : ""}`}
|
|
||||||
>
|
|
||||||
{new Date(`2000-01-01T${time}`).toLocaleTimeString("en-US", {
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: true,
|
|
||||||
})}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Duration Selection */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className={`text-sm font-semibold ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
|
||||||
Duration
|
|
||||||
</label>
|
|
||||||
<div className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}>
|
|
||||||
<div className="grid grid-cols-4 gap-2">
|
|
||||||
{[30, 60, 90, 120].map((duration) => (
|
|
||||||
<button
|
|
||||||
key={duration}
|
|
||||||
onClick={() => setScheduledDuration(duration)}
|
|
||||||
className={`px-4 py-3 rounded-lg text-sm font-medium transition-all ${
|
|
||||||
scheduledDuration === duration
|
|
||||||
? isDark
|
|
||||||
? "bg-blue-600 text-white"
|
|
||||||
: "bg-blue-600 text-white"
|
|
||||||
: isDark
|
|
||||||
? "bg-gray-800 text-gray-300 hover:bg-gray-700 border border-gray-600"
|
|
||||||
: "bg-white text-gray-700 hover:bg-gray-50 border border-gray-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{duration} min
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Preview */}
|
|
||||||
{scheduledDate && (
|
|
||||||
<div className={`p-4 rounded-xl border ${isDark ? "bg-blue-500/10 border-blue-500/30" : "bg-blue-50 border-blue-200"}`}>
|
|
||||||
<p className={`text-sm font-medium mb-2 ${isDark ? "text-blue-300" : "text-blue-700"}`}>
|
|
||||||
Appointment Preview
|
|
||||||
</p>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className={`text-base font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
|
|
||||||
{formatDate(scheduledDate.toISOString())}
|
|
||||||
</p>
|
|
||||||
<p className={`text-sm ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
|
||||||
{new Date(`2000-01-01T${scheduledTime}`).toLocaleTimeString("en-US", {
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: true,
|
|
||||||
})} • {scheduledDuration} minutes
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="gap-3 pt-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setScheduleDialogOpen(false)}
|
|
||||||
disabled={isScheduling}
|
|
||||||
className={`h-12 px-6 ${isDark ? "border-gray-700 text-gray-300 hover:bg-gray-700" : ""}`}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSchedule}
|
|
||||||
disabled={isScheduling || !scheduledDate}
|
|
||||||
className="h-12 px-6 bg-blue-600 hover:bg-blue-700 text-white"
|
|
||||||
>
|
|
||||||
{isScheduling ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
|
||||||
Scheduling...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CalendarCheck className="w-5 h-5 mr-2" />
|
|
||||||
Schedule Appointment
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Reject Appointment Dialog */}
|
{/* Reject Appointment Dialog */}
|
||||||
<Dialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
|
<Dialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
|
||||||
|
|||||||
@ -27,8 +27,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { DatePicker } from "@/components/DatePicker";
|
import { ScheduleAppointmentDialog } from "@/components/ScheduleAppointmentDialog";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { Appointment } from "@/lib/models/appointments";
|
import type { Appointment } from "@/lib/models/appointments";
|
||||||
|
|
||||||
@ -50,7 +49,13 @@ export default function Booking() {
|
|||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
|
|
||||||
// Availability management
|
// Availability management
|
||||||
const { adminAvailability, isLoadingAdminAvailability, updateAvailability, isUpdatingAvailability, refetchAdminAvailability } = useAppointments();
|
const {
|
||||||
|
adminAvailability,
|
||||||
|
isLoadingAdminAvailability,
|
||||||
|
updateAvailability,
|
||||||
|
isUpdatingAvailability,
|
||||||
|
refetchAdminAvailability,
|
||||||
|
} = useAppointments();
|
||||||
const [selectedDays, setSelectedDays] = useState<number[]>([]);
|
const [selectedDays, setSelectedDays] = useState<number[]>([]);
|
||||||
const [availabilityDialogOpen, setAvailabilityDialogOpen] = useState(false);
|
const [availabilityDialogOpen, setAvailabilityDialogOpen] = useState(false);
|
||||||
const [dayTimeSlots, setDayTimeSlots] = useState<Record<number, string[]>>({});
|
const [dayTimeSlots, setDayTimeSlots] = useState<Record<number, string[]>>({});
|
||||||
@ -65,22 +70,26 @@ export default function Booking() {
|
|||||||
{ value: 6, label: "Sunday" },
|
{ value: 6, label: "Sunday" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Load time slots from localStorage on mount
|
// Time slots will be loaded from API in the useEffect below
|
||||||
useEffect(() => {
|
|
||||||
const savedTimeSlots = localStorage.getItem("adminAvailabilityTimeSlots");
|
|
||||||
if (savedTimeSlots) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(savedTimeSlots);
|
|
||||||
setDayTimeSlots(parsed);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to parse saved time slots:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Initialize selected days and time slots when availability is loaded
|
// Initialize selected days and time slots when availability is loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (adminAvailability?.available_days) {
|
// Try new format first (availability_schedule)
|
||||||
|
if (adminAvailability?.availability_schedule) {
|
||||||
|
const schedule = adminAvailability.availability_schedule;
|
||||||
|
const days = Object.keys(schedule).map(Number);
|
||||||
|
setSelectedDays(days);
|
||||||
|
|
||||||
|
// Convert schedule format to dayTimeSlots format
|
||||||
|
const initialSlots: Record<number, string[]> = {};
|
||||||
|
days.forEach((day) => {
|
||||||
|
initialSlots[day] = schedule[day.toString()] || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
setDayTimeSlots(initialSlots);
|
||||||
|
}
|
||||||
|
// Fallback to legacy format
|
||||||
|
else if (adminAvailability?.available_days) {
|
||||||
setSelectedDays(adminAvailability.available_days);
|
setSelectedDays(adminAvailability.available_days);
|
||||||
// Load saved time slots or use defaults
|
// Load saved time slots or use defaults
|
||||||
const savedTimeSlots = localStorage.getItem("adminAvailabilityTimeSlots");
|
const savedTimeSlots = localStorage.getItem("adminAvailabilityTimeSlots");
|
||||||
@ -91,18 +100,28 @@ export default function Booking() {
|
|||||||
const parsed = JSON.parse(savedTimeSlots);
|
const parsed = JSON.parse(savedTimeSlots);
|
||||||
// Only use saved slots for days that are currently available
|
// Only use saved slots for days that are currently available
|
||||||
adminAvailability.available_days.forEach((day) => {
|
adminAvailability.available_days.forEach((day) => {
|
||||||
initialSlots[day] = parsed[day] || ["morning", "lunchtime", "afternoon"];
|
// Map old time slot names to new ones
|
||||||
|
const oldSlots = parsed[day] || [];
|
||||||
|
initialSlots[day] = oldSlots.map((slot: string) => {
|
||||||
|
if (slot === "lunchtime") return "afternoon";
|
||||||
|
if (slot === "afternoon") return "evening";
|
||||||
|
return slot;
|
||||||
|
}).filter((slot: string) => ["morning", "afternoon", "evening"].includes(slot));
|
||||||
|
// If no valid slots after mapping, use defaults
|
||||||
|
if (initialSlots[day].length === 0) {
|
||||||
|
initialSlots[day] = ["morning", "afternoon"];
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If parsing fails, use defaults
|
// If parsing fails, use defaults
|
||||||
adminAvailability.available_days.forEach((day) => {
|
adminAvailability.available_days.forEach((day) => {
|
||||||
initialSlots[day] = ["morning", "lunchtime", "afternoon"];
|
initialSlots[day] = ["morning", "afternoon"];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No saved slots, use defaults
|
// No saved slots, use defaults
|
||||||
adminAvailability.available_days.forEach((day) => {
|
adminAvailability.available_days.forEach((day) => {
|
||||||
initialSlots[day] = ["morning", "lunchtime", "afternoon"];
|
initialSlots[day] = ["morning", "afternoon"];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,8 +131,8 @@ export default function Booking() {
|
|||||||
|
|
||||||
const timeSlotOptions = [
|
const timeSlotOptions = [
|
||||||
{ value: "morning", label: "Morning" },
|
{ value: "morning", label: "Morning" },
|
||||||
{ value: "lunchtime", label: "Lunchtime" },
|
{ value: "afternoon", label: "Lunchtime" },
|
||||||
{ value: "afternoon", label: "Evening" },
|
{ value: "evening", label: "Evening" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleDayToggle = (day: number) => {
|
const handleDayToggle = (day: number) => {
|
||||||
@ -126,7 +145,7 @@ export default function Booking() {
|
|||||||
if (!prev.includes(day) && !dayTimeSlots[day]) {
|
if (!prev.includes(day) && !dayTimeSlots[day]) {
|
||||||
setDayTimeSlots((prevSlots) => ({
|
setDayTimeSlots((prevSlots) => ({
|
||||||
...prevSlots,
|
...prevSlots,
|
||||||
[day]: ["morning", "lunchtime", "afternoon"],
|
[day]: ["morning", "afternoon"],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -170,14 +189,44 @@ export default function Booking() {
|
|||||||
toast.error(`Please select at least one time slot for ${daysOfWeek.find(d => d.value === day)?.label}`);
|
toast.error(`Please select at least one time slot for ${daysOfWeek.find(d => d.value === day)?.label}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Validate time slots are valid
|
||||||
|
const validSlots = ["morning", "afternoon", "evening"];
|
||||||
|
const invalidSlots = timeSlots.filter(slot => !validSlots.includes(slot));
|
||||||
|
if (invalidSlots.length > 0) {
|
||||||
|
toast.error(`Invalid time slots: ${invalidSlots.join(", ")}. Only morning, afternoon, and evening are allowed.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Ensure selectedDays is an array of numbers
|
// Build availability_schedule format: {"0": ["morning", "evening"], "1": ["afternoon"]}
|
||||||
const daysToSave = selectedDays.map(day => Number(day)).sort();
|
const availabilitySchedule: Record<string, ("morning" | "afternoon" | "evening")[]> = {};
|
||||||
await updateAvailability({ available_days: daysToSave });
|
selectedDays.forEach(day => {
|
||||||
|
const timeSlots = dayTimeSlots[day];
|
||||||
|
if (timeSlots && timeSlots.length > 0) {
|
||||||
|
// Ensure only valid time slots and remove duplicates
|
||||||
|
const validSlots = timeSlots
|
||||||
|
.filter((slot): slot is "morning" | "afternoon" | "evening" =>
|
||||||
|
["morning", "afternoon", "evening"].includes(slot)
|
||||||
|
)
|
||||||
|
.filter((slot, index, self) => self.indexOf(slot) === index); // Remove duplicates
|
||||||
|
|
||||||
|
if (validSlots.length > 0) {
|
||||||
|
availabilitySchedule[day.toString()] = validSlots;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate we have at least one day with slots
|
||||||
|
if (Object.keys(availabilitySchedule).length === 0) {
|
||||||
|
toast.error("Please select at least one day with valid time slots");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send in new format
|
||||||
|
await updateAvailability({ availability_schedule: availabilitySchedule });
|
||||||
|
|
||||||
// Save time slots to localStorage
|
// Also save to localStorage for backwards compatibility
|
||||||
localStorage.setItem("adminAvailabilityTimeSlots", JSON.stringify(dayTimeSlots));
|
localStorage.setItem("adminAvailabilityTimeSlots", JSON.stringify(dayTimeSlots));
|
||||||
|
|
||||||
toast.success("Availability updated successfully!");
|
toast.success("Availability updated successfully!");
|
||||||
@ -187,21 +236,40 @@ export default function Booking() {
|
|||||||
}
|
}
|
||||||
setAvailabilityDialogOpen(false);
|
setAvailabilityDialogOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update availability:", error);
|
|
||||||
const errorMessage = error instanceof Error ? error.message : "Failed to update availability";
|
const errorMessage = error instanceof Error ? error.message : "Failed to update availability";
|
||||||
toast.error(errorMessage);
|
toast.error(`Failed to update availability: ${errorMessage}`, {
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenAvailabilityDialog = () => {
|
const handleOpenAvailabilityDialog = () => {
|
||||||
if (adminAvailability?.available_days) {
|
// Try new format first (availability_schedule)
|
||||||
|
if (adminAvailability?.availability_schedule) {
|
||||||
|
const schedule = adminAvailability.availability_schedule;
|
||||||
|
const days = Object.keys(schedule).map(Number);
|
||||||
|
setSelectedDays(days);
|
||||||
|
|
||||||
|
// Convert schedule format to dayTimeSlots format
|
||||||
|
const initialSlots: Record<number, string[]> = {};
|
||||||
|
days.forEach((day) => {
|
||||||
|
initialSlots[day] = schedule[day.toString()] || ["morning", "afternoon"];
|
||||||
|
});
|
||||||
|
setDayTimeSlots(initialSlots);
|
||||||
|
}
|
||||||
|
// Fallback to legacy format
|
||||||
|
else if (adminAvailability?.available_days) {
|
||||||
setSelectedDays(adminAvailability.available_days);
|
setSelectedDays(adminAvailability.available_days);
|
||||||
// Initialize time slots for each day
|
// Initialize time slots for each day
|
||||||
const initialSlots: Record<number, string[]> = {};
|
const initialSlots: Record<number, string[]> = {};
|
||||||
adminAvailability.available_days.forEach((day) => {
|
adminAvailability.available_days.forEach((day) => {
|
||||||
initialSlots[day] = dayTimeSlots[day] || ["morning", "lunchtime", "afternoon"];
|
initialSlots[day] = dayTimeSlots[day] || ["morning", "afternoon"];
|
||||||
});
|
});
|
||||||
setDayTimeSlots(initialSlots);
|
setDayTimeSlots(initialSlots);
|
||||||
|
} else {
|
||||||
|
// No existing availability, start fresh
|
||||||
|
setSelectedDays([]);
|
||||||
|
setDayTimeSlots({});
|
||||||
}
|
}
|
||||||
setAvailabilityDialogOpen(true);
|
setAvailabilityDialogOpen(true);
|
||||||
};
|
};
|
||||||
@ -213,7 +281,6 @@ export default function Booking() {
|
|||||||
const data = await listAppointments();
|
const data = await listAppointments();
|
||||||
setAppointments(data || []);
|
setAppointments(data || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch appointments:", error);
|
|
||||||
toast.error("Failed to load appointments. Please try again.");
|
toast.error("Failed to load appointments. Please try again.");
|
||||||
setAppointments([]);
|
setAppointments([]);
|
||||||
} finally {
|
} finally {
|
||||||
@ -337,7 +404,6 @@ export default function Booking() {
|
|||||||
const data = await listAppointments();
|
const data = await listAppointments();
|
||||||
setAppointments(data || []);
|
setAppointments(data || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to schedule appointment:", error);
|
|
||||||
const errorMessage = error instanceof Error ? error.message : "Failed to schedule appointment";
|
const errorMessage = error instanceof Error ? error.message : "Failed to schedule appointment";
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
@ -363,7 +429,6 @@ export default function Booking() {
|
|||||||
const data = await listAppointments();
|
const data = await listAppointments();
|
||||||
setAppointments(data || []);
|
setAppointments(data || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to reject appointment:", error);
|
|
||||||
const errorMessage = error instanceof Error ? error.message : "Failed to reject appointment";
|
const errorMessage = error instanceof Error ? error.message : "Failed to reject appointment";
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
@ -371,14 +436,6 @@ export default function Booking() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate time slots
|
|
||||||
const timeSlots = [];
|
|
||||||
for (let hour = 8; hour <= 18; hour++) {
|
|
||||||
for (let minute = 0; minute < 60; minute += 30) {
|
|
||||||
const timeString = `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}`;
|
|
||||||
timeSlots.push(timeString);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredAppointments = appointments.filter(
|
const filteredAppointments = appointments.filter(
|
||||||
(appointment) =>
|
(appointment) =>
|
||||||
@ -442,34 +499,70 @@ export default function Booking() {
|
|||||||
<h3 className={`text-sm font-semibold mb-3 ${isDark ? "text-white" : "text-gray-900"}`}>
|
<h3 className={`text-sm font-semibold mb-3 ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||||
Weekly Availability
|
Weekly Availability
|
||||||
</h3>
|
</h3>
|
||||||
{adminAvailability.available_days_display && adminAvailability.available_days_display.length > 0 ? (
|
{(adminAvailability.availability_schedule || (adminAvailability.available_days_display && adminAvailability.available_days_display.length > 0)) ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{adminAvailability.available_days.map((dayNum, index) => {
|
{(() => {
|
||||||
const dayName = daysOfWeek.find(d => d.value === dayNum)?.label || adminAvailability.available_days_display[index];
|
// Try new format first
|
||||||
const timeSlots = dayTimeSlots[dayNum] || [];
|
if (adminAvailability.availability_schedule) {
|
||||||
const slotLabels = timeSlots.map(slot => {
|
return Object.keys(adminAvailability.availability_schedule).map((dayKey) => {
|
||||||
const option = timeSlotOptions.find(opt => opt.value === slot);
|
const dayNum = parseInt(dayKey);
|
||||||
return option ? option.label : slot;
|
const dayName = daysOfWeek.find(d => d.value === dayNum)?.label || `Day ${dayNum}`;
|
||||||
});
|
const timeSlots = adminAvailability.availability_schedule![dayKey] || [];
|
||||||
return (
|
const slotLabels = timeSlots.map((slot: string) => {
|
||||||
<div
|
const option = timeSlotOptions.find(opt => opt.value === slot);
|
||||||
key={dayNum}
|
return option ? option.label : slot;
|
||||||
className={`flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm whitespace-nowrap ${
|
});
|
||||||
isDark
|
return (
|
||||||
? "bg-rose-500/10 text-rose-200 border border-rose-500/20"
|
<div
|
||||||
: "bg-rose-50 text-rose-700 border border-rose-200"
|
key={dayNum}
|
||||||
}`}
|
className={`flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm whitespace-nowrap ${
|
||||||
>
|
isDark
|
||||||
<Check className={`w-3.5 h-3.5 shrink-0 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
|
? "bg-rose-500/10 text-rose-200 border border-rose-500/20"
|
||||||
<span className="font-medium shrink-0">{dayName}</span>
|
: "bg-rose-50 text-rose-700 border border-rose-200"
|
||||||
{slotLabels.length > 0 && (
|
}`}
|
||||||
<span className={`text-sm shrink-0 ${isDark ? "text-rose-300" : "text-rose-600"}`}>
|
>
|
||||||
({slotLabels.join(", ")})
|
<Check className={`w-3.5 h-3.5 shrink-0 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
|
||||||
</span>
|
<span className="font-medium shrink-0">{dayName}</span>
|
||||||
)}
|
{slotLabels.length > 0 && (
|
||||||
</div>
|
<span className={`text-sm shrink-0 ${isDark ? "text-rose-300" : "text-rose-600"}`}>
|
||||||
);
|
({slotLabels.join(", ")})
|
||||||
})}
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Fallback to legacy format
|
||||||
|
else if (adminAvailability.available_days && adminAvailability.available_days.length > 0) {
|
||||||
|
return adminAvailability.available_days.map((dayNum, index) => {
|
||||||
|
const dayName = daysOfWeek.find(d => d.value === dayNum)?.label || adminAvailability.available_days_display?.[index];
|
||||||
|
const timeSlots = dayTimeSlots[dayNum] || [];
|
||||||
|
const slotLabels = timeSlots.map(slot => {
|
||||||
|
const option = timeSlotOptions.find(opt => opt.value === slot);
|
||||||
|
return option ? option.label : slot;
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={dayNum}
|
||||||
|
className={`flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm whitespace-nowrap ${
|
||||||
|
isDark
|
||||||
|
? "bg-rose-500/10 text-rose-200 border border-rose-500/20"
|
||||||
|
: "bg-rose-50 text-rose-700 border border-rose-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Check className={`w-3.5 h-3.5 shrink-0 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
|
||||||
|
<span className="font-medium shrink-0">{dayName}</span>
|
||||||
|
{slotLabels.length > 0 && (
|
||||||
|
<span className={`text-sm shrink-0 ${isDark ? "text-rose-300" : "text-rose-600"}`}>
|
||||||
|
({slotLabels.join(", ")})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className={`text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
<p className={`text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
@ -586,13 +679,19 @@ export default function Booking() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
<td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
{appointment.preferred_dates && appointment.preferred_dates.length > 0 ? (
|
{appointment.preferred_dates ? (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{appointment.preferred_dates.slice(0, 2).map((date, idx) => (
|
{Array.isArray(appointment.preferred_dates) ? (
|
||||||
<span key={idx}>{formatDate(date)}</span>
|
<>
|
||||||
))}
|
{(appointment.preferred_dates as string[]).slice(0, 2).map((date, idx) => (
|
||||||
{appointment.preferred_dates.length > 2 && (
|
<span key={idx}>{formatDate(date)}</span>
|
||||||
<span className="text-xs">+{appointment.preferred_dates.length - 2} more</span>
|
))}
|
||||||
|
{appointment.preferred_dates.length > 2 && (
|
||||||
|
<span className="text-xs">+{appointment.preferred_dates.length - 2} more</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>{appointment.preferred_dates_display || appointment.preferred_dates}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -655,103 +754,27 @@ export default function Booking() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Schedule Appointment Dialog */}
|
{/* Schedule Appointment Dialog */}
|
||||||
<Dialog open={scheduleDialogOpen} onOpenChange={setScheduleDialogOpen}>
|
<ScheduleAppointmentDialog
|
||||||
<DialogContent className={`${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
open={scheduleDialogOpen}
|
||||||
<DialogHeader>
|
onOpenChange={(open) => {
|
||||||
<DialogTitle className={isDark ? "text-white" : "text-gray-900"}>
|
setScheduleDialogOpen(open);
|
||||||
Schedule Appointment
|
if (!open) {
|
||||||
</DialogTitle>
|
setScheduledDate(undefined);
|
||||||
<DialogDescription className={isDark ? "text-gray-400" : "text-gray-500"}>
|
setScheduledTime("09:00");
|
||||||
{selectedAppointment && (
|
setScheduledDuration(60);
|
||||||
<>Schedule appointment for {selectedAppointment.first_name} {selectedAppointment.last_name}</>
|
}
|
||||||
)}
|
}}
|
||||||
</DialogDescription>
|
appointment={selectedAppointment}
|
||||||
</DialogHeader>
|
scheduledDate={scheduledDate}
|
||||||
|
setScheduledDate={setScheduledDate}
|
||||||
<div className="space-y-4 py-4">
|
scheduledTime={scheduledTime}
|
||||||
<div className="space-y-2">
|
setScheduledTime={setScheduledTime}
|
||||||
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
scheduledDuration={scheduledDuration}
|
||||||
Date *
|
setScheduledDuration={setScheduledDuration}
|
||||||
</label>
|
onSchedule={handleSchedule}
|
||||||
<DatePicker
|
isScheduling={isScheduling}
|
||||||
date={scheduledDate}
|
isDark={isDark}
|
||||||
setDate={setScheduledDate}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
|
||||||
Time *
|
|
||||||
</label>
|
|
||||||
<Select value={scheduledTime} onValueChange={setScheduledTime}>
|
|
||||||
<SelectTrigger className={isDark ? "bg-gray-700 border-gray-600 text-white" : "bg-white border-gray-300"}>
|
|
||||||
<SelectValue placeholder="Select time" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className={isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}>
|
|
||||||
{timeSlots.map((time) => (
|
|
||||||
<SelectItem
|
|
||||||
key={time}
|
|
||||||
value={time}
|
|
||||||
className={isDark ? "focus:bg-gray-700" : ""}
|
|
||||||
>
|
|
||||||
{new Date(`2000-01-01T${time}`).toLocaleTimeString("en-US", {
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: true,
|
|
||||||
})}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
|
||||||
Duration (minutes)
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={scheduledDuration.toString()}
|
|
||||||
onValueChange={(value) => setScheduledDuration(parseInt(value))}
|
|
||||||
>
|
|
||||||
<SelectTrigger className={isDark ? "bg-gray-700 border-gray-600 text-white" : "bg-white border-gray-300"}>
|
|
||||||
<SelectValue placeholder="Select duration" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className={isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}>
|
|
||||||
<SelectItem value="30" className={isDark ? "focus:bg-gray-700" : ""}>30 minutes</SelectItem>
|
|
||||||
<SelectItem value="60" className={isDark ? "focus:bg-gray-700" : ""}>60 minutes</SelectItem>
|
|
||||||
<SelectItem value="90" className={isDark ? "focus:bg-gray-700" : ""}>90 minutes</SelectItem>
|
|
||||||
<SelectItem value="120" className={isDark ? "focus:bg-gray-700" : ""}>120 minutes</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setScheduleDialogOpen(false)}
|
|
||||||
disabled={isScheduling}
|
|
||||||
className={isDark ? "border-gray-700 text-gray-300 hover:bg-gray-700" : ""}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSchedule}
|
|
||||||
disabled={isScheduling || !scheduledDate || !scheduledTime}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
|
||||||
>
|
|
||||||
{isScheduling ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
Scheduling...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Schedule Appointment"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Reject Appointment Dialog */}
|
{/* Reject Appointment Dialog */}
|
||||||
<Dialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
|
<Dialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
|
||||||
@ -836,7 +859,7 @@ export default function Booking() {
|
|||||||
Available Days & Times *
|
Available Days & Times *
|
||||||
</label>
|
</label>
|
||||||
<p className={`text-xs mb-3 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
<p className={`text-xs mb-3 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
Select days and choose time slots (Morning, Lunchtime, Evening) for each day
|
Select days and choose time slots (Morning, Afternoon, Evening) for each day
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -132,7 +132,6 @@ export default function Dashboard() {
|
|||||||
trends,
|
trends,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch dashboard stats:", error);
|
|
||||||
toast.error("Failed to load dashboard statistics");
|
toast.error("Failed to load dashboard statistics");
|
||||||
// Set default values on error
|
// Set default values on error
|
||||||
setStats({
|
setStats({
|
||||||
@ -273,7 +272,7 @@ export default function Dashboard() {
|
|||||||
<div className={`p-2 sm:p-2.5 rounded-lg ${isDark ? "bg-gray-700" : "bg-gray-50"}`}>
|
<div className={`p-2 sm:p-2.5 rounded-lg ${isDark ? "bg-gray-700" : "bg-gray-50"}`}>
|
||||||
<Icon className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? "text-gray-200" : "text-gray-600"}`} />
|
<Icon className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? "text-gray-200" : "text-gray-600"}`} />
|
||||||
</div>
|
</div>
|
||||||
{card.showTrend !== false && card.trend && (
|
{card.trend && (
|
||||||
<div className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${getTrendClasses(card.trendUp)}`}>
|
<div className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${getTrendClasses(card.trendUp)}`}>
|
||||||
{card.trendUp ? (
|
{card.trendUp ? (
|
||||||
<ArrowUpRight className="w-3 h-3" />
|
<ArrowUpRight className="w-3 h-3" />
|
||||||
@ -292,11 +291,9 @@ export default function Dashboard() {
|
|||||||
<p className={`text-xl sm:text-2xl font-bold mb-1 ${isDark ? "text-white" : "text-gray-900"}`}>
|
<p className={`text-xl sm:text-2xl font-bold mb-1 ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||||
{card.value}
|
{card.value}
|
||||||
</p>
|
</p>
|
||||||
{card.showTrend !== false && (
|
<p className={`text-xs ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
<p className={`text-xs ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
vs last month
|
||||||
vs last month
|
</p>
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -55,7 +55,6 @@ export default function AdminSettingsPage() {
|
|||||||
phone: profile.phone_number || "",
|
phone: profile.phone_number || "",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch profile:", error);
|
|
||||||
const errorMessage = error instanceof Error ? error.message : "Failed to load profile";
|
const errorMessage = error instanceof Error ? error.message : "Failed to load profile";
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
@ -102,7 +101,6 @@ export default function AdminSettingsPage() {
|
|||||||
});
|
});
|
||||||
toast.success("Profile updated successfully!");
|
toast.success("Profile updated successfully!");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update profile:", error);
|
|
||||||
const errorMessage = error instanceof Error ? error.message : "Failed to update profile";
|
const errorMessage = error instanceof Error ? error.message : "Failed to update profile";
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useAppTheme } from "@/components/ThemeProvider";
|
import { useAppTheme } from "@/components/ThemeProvider";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@ -35,8 +35,6 @@ import { useAuth } from "@/hooks/useAuth";
|
|||||||
import { useAppointments } from "@/hooks/useAppointments";
|
import { useAppointments } from "@/hooks/useAppointments";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { Appointment } from "@/lib/models/appointments";
|
import type { Appointment } from "@/lib/models/appointments";
|
||||||
import { getPublicAvailability } from "@/lib/actions/appointments";
|
|
||||||
import type { AdminAvailability } from "@/lib/models/appointments";
|
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
ID: number;
|
ID: number;
|
||||||
@ -83,14 +81,21 @@ export default function BookNowPage() {
|
|||||||
const { theme } = useAppTheme();
|
const { theme } = useAppTheme();
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
const { isAuthenticated, logout } = useAuth();
|
const { isAuthenticated, logout } = useAuth();
|
||||||
const { create, isCreating, availableDates, availableDatesResponse, isLoadingAvailableDates } = useAppointments();
|
const {
|
||||||
|
create,
|
||||||
|
isCreating,
|
||||||
|
weeklyAvailability,
|
||||||
|
isLoadingWeeklyAvailability,
|
||||||
|
availabilityOverview,
|
||||||
|
isLoadingAvailabilityOverview,
|
||||||
|
availabilityConfig,
|
||||||
|
} = useAppointments();
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
firstName: "",
|
firstName: "",
|
||||||
lastName: "",
|
lastName: "",
|
||||||
email: "",
|
email: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
preferredDays: [] as string[],
|
selectedSlots: [] as Array<{ day: number; time_slot: string }>, // New format
|
||||||
preferredTimes: [] as string[],
|
|
||||||
message: "",
|
message: "",
|
||||||
});
|
});
|
||||||
const [booking, setBooking] = useState<Booking | null>(null);
|
const [booking, setBooking] = useState<Booking | null>(null);
|
||||||
@ -98,68 +103,97 @@ export default function BookNowPage() {
|
|||||||
const [showLoginDialog, setShowLoginDialog] = useState(false);
|
const [showLoginDialog, setShowLoginDialog] = useState(false);
|
||||||
const [showSignupDialog, setShowSignupDialog] = useState(false);
|
const [showSignupDialog, setShowSignupDialog] = useState(false);
|
||||||
const [loginPrefillEmail, setLoginPrefillEmail] = useState<string | undefined>(undefined);
|
const [loginPrefillEmail, setLoginPrefillEmail] = useState<string | undefined>(undefined);
|
||||||
const [publicAvailability, setPublicAvailability] = useState<AdminAvailability | null>(null);
|
|
||||||
const [availableTimeSlots, setAvailableTimeSlots] = useState<Record<number, string[]>>({});
|
|
||||||
|
|
||||||
// Fetch public availability to get time slots
|
// Helper function to convert day name to day number (0-6)
|
||||||
useEffect(() => {
|
const getDayNumber = (dayName: string): number => {
|
||||||
const fetchAvailability = async () => {
|
const dayMap: Record<string, number> = {
|
||||||
try {
|
'monday': 0,
|
||||||
const availability = await getPublicAvailability();
|
'tuesday': 1,
|
||||||
if (availability) {
|
'wednesday': 2,
|
||||||
setPublicAvailability(availability);
|
'thursday': 3,
|
||||||
// Try to get time slots from localStorage (if admin has set them)
|
'friday': 4,
|
||||||
// Note: This won't work for public users, but we can try
|
'saturday': 5,
|
||||||
const savedTimeSlots = localStorage.getItem("adminAvailabilityTimeSlots");
|
'sunday': 6,
|
||||||
if (savedTimeSlots) {
|
};
|
||||||
try {
|
return dayMap[dayName.toLowerCase()] ?? -1;
|
||||||
const parsed = JSON.parse(savedTimeSlots);
|
};
|
||||||
setAvailableTimeSlots(parsed);
|
|
||||||
} catch (e) {
|
// Get available days from availability overview (primary) or weekly availability (fallback)
|
||||||
console.error("Failed to parse time slots:", e);
|
const availableDaysOfWeek = useMemo(() => {
|
||||||
}
|
// Try availability overview first (preferred)
|
||||||
|
if (availabilityOverview && availabilityOverview.available && availabilityOverview.next_available_dates && availabilityOverview.next_available_dates.length > 0) {
|
||||||
|
// Group by day name and get unique days with their slots from next_available_dates
|
||||||
|
const dayMap = new Map<string, { day: number; dayName: string; availableSlots: Set<string> }>();
|
||||||
|
|
||||||
|
availabilityOverview.next_available_dates.forEach((dateInfo: any) => {
|
||||||
|
if (!dateInfo || !dateInfo.day_name) return;
|
||||||
|
|
||||||
|
const dayName = String(dateInfo.day_name).trim();
|
||||||
|
const dayNum = getDayNumber(dayName);
|
||||||
|
|
||||||
|
if (dayNum >= 0 && dayNum <= 6 && dateInfo.available_slots && Array.isArray(dateInfo.available_slots) && dateInfo.available_slots.length > 0) {
|
||||||
|
const existingDay = dayMap.get(dayName);
|
||||||
|
|
||||||
|
if (existingDay) {
|
||||||
|
// Merge slots if day already exists
|
||||||
|
dateInfo.available_slots.forEach((slot: string) => {
|
||||||
|
existingDay.availableSlots.add(String(slot).toLowerCase().trim());
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Create new day entry
|
||||||
|
const slotsSet = new Set<string>();
|
||||||
|
dateInfo.available_slots.forEach((slot: string) => {
|
||||||
|
slotsSet.add(String(slot).toLowerCase().trim());
|
||||||
|
});
|
||||||
|
dayMap.set(dayName, {
|
||||||
|
day: dayNum,
|
||||||
|
dayName: dayName,
|
||||||
|
availableSlots: slotsSet,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
});
|
||||||
console.error("Failed to fetch public availability:", error);
|
|
||||||
}
|
// Convert Map values to array and sort by day number
|
||||||
};
|
return Array.from(dayMap.values())
|
||||||
fetchAvailability();
|
.map(day => ({
|
||||||
}, []);
|
day: day.day,
|
||||||
|
dayName: day.dayName,
|
||||||
// Use available_days_display from API if available, otherwise extract from dates
|
availableSlots: Array.from(day.availableSlots),
|
||||||
const availableDaysOfWeek = useMemo(() => {
|
}))
|
||||||
// If API provides available_days_display, use it directly
|
.sort((a, b) => a.day - b.day);
|
||||||
if (availableDatesResponse?.available_days_display && availableDatesResponse.available_days_display.length > 0) {
|
|
||||||
return availableDatesResponse.available_days_display;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, extract from dates
|
// Fallback to weekly availability
|
||||||
if (!availableDates || availableDates.length === 0) {
|
if (weeklyAvailability) {
|
||||||
return [];
|
// Handle both array format and object with 'week' property
|
||||||
|
const weekArray = Array.isArray(weeklyAvailability)
|
||||||
|
? weeklyAvailability
|
||||||
|
: (weeklyAvailability as any)?.week;
|
||||||
|
|
||||||
|
if (weekArray && Array.isArray(weekArray)) {
|
||||||
|
return weekArray
|
||||||
|
.filter(day => {
|
||||||
|
const dayNum = Number(day.day);
|
||||||
|
return day.is_available &&
|
||||||
|
day.available_slots &&
|
||||||
|
Array.isArray(day.available_slots) &&
|
||||||
|
day.available_slots.length > 0 &&
|
||||||
|
!isNaN(dayNum) &&
|
||||||
|
dayNum >= 0 &&
|
||||||
|
dayNum <= 6;
|
||||||
|
})
|
||||||
|
.map(day => ({
|
||||||
|
day: Number(day.day),
|
||||||
|
dayName: day.day_name || 'Unknown',
|
||||||
|
availableSlots: day.available_slots || [],
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.day - b.day);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const daysSet = new Set<string>();
|
return [];
|
||||||
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
}, [availabilityOverview, weeklyAvailability]);
|
||||||
|
|
||||||
availableDates.forEach((dateStr: string) => {
|
|
||||||
try {
|
|
||||||
// Parse date string (YYYY-MM-DD format)
|
|
||||||
const [year, month, day] = dateStr.split('-').map(Number);
|
|
||||||
const date = new Date(year, month - 1, day);
|
|
||||||
if (!isNaN(date.getTime())) {
|
|
||||||
const dayIndex = date.getDay();
|
|
||||||
daysSet.add(dayNames[dayIndex]);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Invalid date:', dateStr, e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return in weekday order (Monday first)
|
|
||||||
const weekdayOrder = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
|
||||||
return weekdayOrder.filter(day => daysSet.has(day));
|
|
||||||
}, [availableDates, availableDatesResponse]);
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
@ -217,79 +251,89 @@ export default function BookNowPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (formData.preferredDays.length === 0) {
|
// Get current slots from formData
|
||||||
setError("Please select at least one available day.");
|
const currentSlots = formData.selectedSlots || [];
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.preferredTimes.length === 0) {
|
|
||||||
setError("Please select at least one preferred time.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert day names to dates (YYYY-MM-DD format)
|
|
||||||
// Get next occurrence of each selected day within the next 30 days
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0); // Reset to start of day
|
|
||||||
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
||||||
const preferredDates: string[] = [];
|
|
||||||
|
|
||||||
formData.preferredDays.forEach((dayName) => {
|
// Check if slots are selected
|
||||||
const targetDayIndex = days.indexOf(dayName);
|
if (!currentSlots || currentSlots.length === 0) {
|
||||||
if (targetDayIndex === -1) {
|
setError("Please select at least one day and time slot combination by clicking on the time slot buttons.");
|
||||||
console.warn(`Invalid day name: ${dayName}`);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare and validate slots - be very lenient
|
||||||
|
const validSlots = currentSlots
|
||||||
|
.map(slot => {
|
||||||
|
if (!slot) return null;
|
||||||
|
|
||||||
|
// Get day - handle any format
|
||||||
|
let dayNum: number;
|
||||||
|
if (typeof slot.day === 'number') {
|
||||||
|
dayNum = slot.day;
|
||||||
|
} else {
|
||||||
|
dayNum = parseInt(String(slot.day || 0), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate day
|
||||||
|
if (isNaN(dayNum) || dayNum < 0 || dayNum > 6) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get time_slot - normalize
|
||||||
|
const timeSlot = String(slot.time_slot || '').trim().toLowerCase();
|
||||||
|
|
||||||
|
// Validate time_slot - accept morning, afternoon, evening
|
||||||
|
if (!timeSlot || !['morning', 'afternoon', 'evening'].includes(timeSlot)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
day: dayNum,
|
||||||
|
time_slot: timeSlot as "morning" | "afternoon" | "evening",
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((slot): slot is { day: number; time_slot: "morning" | "afternoon" | "evening" } => slot !== null);
|
||||||
|
|
||||||
|
// Final validation check
|
||||||
|
if (!validSlots || validSlots.length === 0) {
|
||||||
|
setError("Please select at least one day and time slot combination by clicking on the time slot buttons.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the next occurrence of this day within the next 30 days
|
// Validate and limit field lengths to prevent database errors
|
||||||
for (let i = 1; i <= 30; i++) {
|
const firstName = formData.firstName.trim().substring(0, 100);
|
||||||
const checkDate = new Date(today);
|
const lastName = formData.lastName.trim().substring(0, 100);
|
||||||
checkDate.setDate(today.getDate() + i);
|
const email = formData.email.trim().toLowerCase().substring(0, 100);
|
||||||
|
const phone = formData.phone ? formData.phone.trim().substring(0, 100) : undefined;
|
||||||
if (checkDate.getDay() === targetDayIndex) {
|
const reason = formData.message ? formData.message.trim().substring(0, 100) : undefined;
|
||||||
const dateString = checkDate.toISOString().split("T")[0];
|
|
||||||
if (!preferredDates.includes(dateString)) {
|
|
||||||
preferredDates.push(dateString);
|
|
||||||
}
|
|
||||||
break; // Only take the first occurrence
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort dates
|
|
||||||
preferredDates.sort();
|
|
||||||
|
|
||||||
if (preferredDates.length === 0) {
|
// Validate required fields
|
||||||
setError("Please select at least one available day.");
|
if (!firstName || firstName.length === 0) {
|
||||||
|
setError("First name is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!lastName || lastName.length === 0) {
|
||||||
|
setError("Last name is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!email || email.length === 0) {
|
||||||
|
setError("Email address is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!email.includes('@')) {
|
||||||
|
setError("Please enter a valid email address.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map time slots - API expects "morning", "afternoon", "evening"
|
// Prepare payload with validated and limited fields
|
||||||
// Form has "morning", "lunchtime", "afternoon" (where "afternoon" label is "Evening")
|
|
||||||
const timeSlotMap: { [key: string]: "morning" | "afternoon" | "evening" } = {
|
|
||||||
morning: "morning",
|
|
||||||
lunchtime: "afternoon", // Map lunchtime to afternoon
|
|
||||||
afternoon: "evening", // Form's "afternoon" value (labeled "Evening") maps to API's "evening"
|
|
||||||
};
|
|
||||||
|
|
||||||
const preferredTimeSlots = formData.preferredTimes
|
|
||||||
.map((time) => timeSlotMap[time] || "morning")
|
|
||||||
.filter((time, index, self) => self.indexOf(time) === index) as ("morning" | "afternoon" | "evening")[]; // Remove duplicates
|
|
||||||
|
|
||||||
// Prepare request payload according to API spec
|
|
||||||
const payload = {
|
const payload = {
|
||||||
first_name: formData.firstName.trim(),
|
first_name: firstName,
|
||||||
last_name: formData.lastName.trim(),
|
last_name: lastName,
|
||||||
email: formData.email.trim().toLowerCase(),
|
email: email,
|
||||||
preferred_dates: preferredDates,
|
selected_slots: validSlots,
|
||||||
preferred_time_slots: preferredTimeSlots,
|
...(phone && phone.length > 0 && { phone: phone }),
|
||||||
...(formData.phone && formData.phone.trim() && { phone: formData.phone.trim() }),
|
...(reason && reason.length > 0 && { reason: reason }),
|
||||||
...(formData.message && formData.message.trim() && { reason: formData.message.trim() }),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate payload before sending
|
|
||||||
console.log("Booking payload:", JSON.stringify(payload, null, 2));
|
|
||||||
|
|
||||||
// Call the actual API using the hook
|
// Call the actual API using the hook
|
||||||
const appointmentData = await create(payload);
|
const appointmentData = await create(payload);
|
||||||
|
|
||||||
@ -328,15 +372,11 @@ export default function BookNowPage() {
|
|||||||
setBooking(bookingData);
|
setBooking(bookingData);
|
||||||
toast.success("Appointment request submitted successfully! We'll review and get back to you soon.");
|
toast.success("Appointment request submitted successfully! We'll review and get back to you soon.");
|
||||||
|
|
||||||
// Redirect to user dashboard after 3 seconds
|
// Stay on the booking page to show the receipt - no redirect
|
||||||
setTimeout(() => {
|
|
||||||
router.push("/user/dashboard");
|
|
||||||
}, 3000);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : "Failed to submit booking. Please try again.";
|
const errorMessage = err instanceof Error ? err.message : "Failed to submit booking. Please try again.";
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
console.error("Booking error:", err);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -344,22 +384,50 @@ export default function BookNowPage() {
|
|||||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDayToggle = (day: string) => {
|
// Handle slot selection (day + time slot combination)
|
||||||
|
const handleSlotToggle = (day: number, timeSlot: string) => {
|
||||||
setFormData((prev) => {
|
setFormData((prev) => {
|
||||||
const days = prev.preferredDays.includes(day)
|
const normalizedDay = Number(day);
|
||||||
? prev.preferredDays.filter((d) => d !== day)
|
const normalizedTimeSlot = String(timeSlot).toLowerCase().trim();
|
||||||
: [...prev.preferredDays, day];
|
const currentSlots = prev.selectedSlots || [];
|
||||||
return { ...prev, preferredDays: days };
|
|
||||||
|
// Helper to check if two slots match
|
||||||
|
const slotsMatch = (slot1: { day: number; time_slot: string }, slot2: { day: number; time_slot: string }) => {
|
||||||
|
return Number(slot1.day) === Number(slot2.day) &&
|
||||||
|
String(slot1.time_slot).toLowerCase().trim() === String(slot2.time_slot).toLowerCase().trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetSlot = { day: normalizedDay, time_slot: normalizedTimeSlot };
|
||||||
|
|
||||||
|
// Check if this exact slot exists
|
||||||
|
const slotExists = currentSlots.some(slot => slotsMatch(slot, targetSlot));
|
||||||
|
|
||||||
|
if (slotExists) {
|
||||||
|
// Remove the slot
|
||||||
|
const newSlots = currentSlots.filter(slot => !slotsMatch(slot, targetSlot));
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
selectedSlots: newSlots,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Add the slot
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
selectedSlots: [...currentSlots, targetSlot],
|
||||||
|
};
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTimeToggle = (time: string) => {
|
// Check if a slot is selected
|
||||||
setFormData((prev) => {
|
const isSlotSelected = (day: number, timeSlot: string): boolean => {
|
||||||
const times = prev.preferredTimes.includes(time)
|
const normalizedDay = Number(day);
|
||||||
? prev.preferredTimes.filter((t) => t !== time)
|
const normalizedTimeSlot = String(timeSlot).toLowerCase().trim();
|
||||||
: [...prev.preferredTimes, time];
|
|
||||||
return { ...prev, preferredTimes: times };
|
return (formData.selectedSlots || []).some(
|
||||||
});
|
slot => Number(slot.day) === normalizedDay &&
|
||||||
|
String(slot.time_slot).toLowerCase().trim() === normalizedTimeSlot
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDateTime = (dateString: string) => {
|
const formatDateTime = (dateString: string) => {
|
||||||
@ -473,77 +541,51 @@ export default function BookNowPage() {
|
|||||||
<div className="px-4 sm:px-6 lg:px-12 pb-6 sm:pb-8 lg:pb-12">
|
<div className="px-4 sm:px-6 lg:px-12 pb-6 sm:pb-8 lg:pb-12">
|
||||||
{booking ? (
|
{booking ? (
|
||||||
<div className={`rounded-xl sm:rounded-2xl shadow-lg p-4 sm:p-6 lg:p-8 border ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}>
|
<div className={`rounded-xl sm:rounded-2xl shadow-lg p-4 sm:p-6 lg:p-8 border ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}>
|
||||||
<div className="text-center space-y-4">
|
<div className="text-center space-y-6">
|
||||||
<div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center ${isDark ? 'bg-green-900/30' : 'bg-green-100'}`}>
|
<div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center ${isDark ? 'bg-green-900/30' : 'bg-green-100'}`}>
|
||||||
<CheckCircle className={`w-8 h-8 ${isDark ? 'text-green-400' : 'text-green-600'}`} />
|
<CheckCircle className={`w-8 h-8 ${isDark ? 'text-green-400' : 'text-green-600'}`} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className={`text-2xl font-semibold mb-2 ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
<h2 className={`text-2xl font-semibold mb-2 ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||||
Booking Confirmed!
|
Booking Request Submitted!
|
||||||
</h2>
|
</h2>
|
||||||
<p className={isDark ? 'text-gray-300' : 'text-gray-600'}>
|
<p className={`text-base ${isDark ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||||
Your appointment has been successfully booked.
|
Your appointment request has been received.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={`rounded-lg p-6 space-y-4 text-left ${isDark ? 'bg-gray-700/50' : 'bg-gray-50'}`}>
|
<div className={`rounded-lg p-6 space-y-4 text-left ${isDark ? 'bg-gray-700/50' : 'bg-gray-50'}`}>
|
||||||
<div>
|
<div>
|
||||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Booking ID</p>
|
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Name</p>
|
||||||
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>#{booking.ID}</p>
|
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Patient</p>
|
|
||||||
<p className={`text-base ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
|
||||||
{booking.user.first_name} {booking.user.last_name}
|
{booking.user.first_name} {booking.user.last_name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Scheduled Time</p>
|
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Email</p>
|
||||||
<p className={`text-base ${isDark ? 'text-white' : 'text-gray-900'}`}>{formatDateTime(booking.scheduled_at)}</p>
|
<p className={`text-base ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||||
|
{booking.user.email}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{booking.user.phone && (
|
||||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Duration</p>
|
|
||||||
<p className={`text-base ${isDark ? 'text-white' : 'text-gray-900'}`}>{booking.duration} minutes</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Status</p>
|
|
||||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${isDark ? 'bg-blue-900/50 text-blue-200' : 'bg-blue-100 text-blue-800'}`}>
|
|
||||||
{booking.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Amount</p>
|
|
||||||
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>${booking.amount}</p>
|
|
||||||
</div>
|
|
||||||
{booking.notes && (
|
|
||||||
<div>
|
<div>
|
||||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Notes</p>
|
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Phone</p>
|
||||||
<p className={`text-base ${isDark ? 'text-white' : 'text-gray-900'}`}>{booking.notes}</p>
|
<p className={`text-base ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||||
|
{booking.user.phone}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-4 flex flex-col sm:flex-row gap-3 justify-center">
|
<div className={`rounded-lg p-4 ${isDark ? 'bg-blue-900/20 border border-blue-800/50' : 'bg-blue-50 border border-blue-200'}`}>
|
||||||
|
<p className={`text-sm ${isDark ? 'text-blue-300' : 'text-blue-800'}`}>
|
||||||
|
You will be contacted shortly to confirm your appointment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="pt-4 flex justify-center">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => router.back()}
|
||||||
setBooking(null);
|
|
||||||
setFormData({
|
|
||||||
firstName: "",
|
|
||||||
lastName: "",
|
|
||||||
email: "",
|
|
||||||
phone: "",
|
|
||||||
preferredDays: [],
|
|
||||||
preferredTimes: [],
|
|
||||||
message: "",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
Book Another Appointment
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => router.push("/")}
|
|
||||||
className="bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white"
|
className="bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white"
|
||||||
>
|
>
|
||||||
Return to Home
|
Go Back
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -580,6 +622,7 @@ export default function BookNowPage() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleChange("firstName", e.target.value)
|
handleChange("firstName", e.target.value)
|
||||||
}
|
}
|
||||||
|
maxLength={100}
|
||||||
required
|
required
|
||||||
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900 placeholder:text-gray-500'}`}
|
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900 placeholder:text-gray-500'}`}
|
||||||
/>
|
/>
|
||||||
@ -600,6 +643,7 @@ export default function BookNowPage() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleChange("lastName", e.target.value)
|
handleChange("lastName", e.target.value)
|
||||||
}
|
}
|
||||||
|
maxLength={100}
|
||||||
required
|
required
|
||||||
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900 placeholder:text-gray-500'}`}
|
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900 placeholder:text-gray-500'}`}
|
||||||
/>
|
/>
|
||||||
@ -620,6 +664,7 @@ export default function BookNowPage() {
|
|||||||
placeholder="john.doe@example.com"
|
placeholder="john.doe@example.com"
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={(e) => handleChange("email", e.target.value)}
|
onChange={(e) => handleChange("email", e.target.value)}
|
||||||
|
maxLength={100}
|
||||||
required
|
required
|
||||||
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900 placeholder:text-gray-500'}`}
|
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900 placeholder:text-gray-500'}`}
|
||||||
/>
|
/>
|
||||||
@ -639,6 +684,7 @@ export default function BookNowPage() {
|
|||||||
placeholder="+1 (555) 123-4567"
|
placeholder="+1 (555) 123-4567"
|
||||||
value={formData.phone}
|
value={formData.phone}
|
||||||
onChange={(e) => handleChange("phone", e.target.value)}
|
onChange={(e) => handleChange("phone", e.target.value)}
|
||||||
|
maxLength={100}
|
||||||
required
|
required
|
||||||
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900 placeholder:text-gray-500'}`}
|
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900 placeholder:text-gray-500'}`}
|
||||||
/>
|
/>
|
||||||
@ -658,12 +704,12 @@ export default function BookNowPage() {
|
|||||||
className={`text-sm font-medium flex items-center gap-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}
|
className={`text-sm font-medium flex items-center gap-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}
|
||||||
>
|
>
|
||||||
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-500'}`} />
|
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-500'}`} />
|
||||||
Available Days *
|
Available Days & Times *
|
||||||
</label>
|
</label>
|
||||||
{isLoadingAvailableDates ? (
|
{(isLoadingWeeklyAvailability || isLoadingAvailabilityOverview) ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
Loading available days...
|
Loading availability...
|
||||||
</div>
|
</div>
|
||||||
) : availableDaysOfWeek.length === 0 ? (
|
) : availableDaysOfWeek.length === 0 ? (
|
||||||
<p className={`text-sm ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
|
<p className={`text-sm ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||||
@ -671,92 +717,77 @@ export default function BookNowPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-wrap gap-3">
|
<p className={`text-xs ${isDark ? 'text-gray-400' : 'text-gray-500'} mb-3`}>
|
||||||
{availableDaysOfWeek.map((day: string) => (
|
Select one or more day-time combinations that work for you
|
||||||
<label
|
</p>
|
||||||
key={day}
|
<div className="space-y-4">
|
||||||
className={`flex items-center gap-2 cursor-pointer px-4 py-2 rounded-lg border transition-all ${
|
{availableDaysOfWeek.map((dayInfo, dayIndex) => {
|
||||||
formData.preferredDays.includes(day)
|
// Ensure day is always a valid number (already validated in useMemo)
|
||||||
|
const currentDay = typeof dayInfo.day === 'number' && !isNaN(dayInfo.day)
|
||||||
|
? dayInfo.day
|
||||||
|
: dayIndex; // Fallback to index if invalid
|
||||||
|
|
||||||
|
// Skip if day is still invalid
|
||||||
|
if (isNaN(currentDay) || currentDay < 0 || currentDay > 6) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`day-wrapper-${currentDay}-${dayIndex}`} className="space-y-2">
|
||||||
|
<h4 className={`text-sm font-semibold ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||||
|
{dayInfo.dayName || `Day ${currentDay}`}
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{dayInfo.availableSlots.map((timeSlot: string, slotIndex: number) => {
|
||||||
|
if (!timeSlot) return null;
|
||||||
|
|
||||||
|
const timeSlotLabels: Record<string, string> = {
|
||||||
|
morning: "Morning",
|
||||||
|
afternoon: "Lunchtime",
|
||||||
|
evening: "Evening",
|
||||||
|
};
|
||||||
|
// Normalize time slot for consistent comparison
|
||||||
|
const normalizedTimeSlot = String(timeSlot).toLowerCase().trim();
|
||||||
|
// Create unique key combining day, time slot, and index to ensure uniqueness
|
||||||
|
const slotKey = `day-${currentDay}-slot-${normalizedTimeSlot}-${slotIndex}`;
|
||||||
|
// Check if THIS specific day-time combination is selected
|
||||||
|
const isSelected = isSlotSelected(currentDay, normalizedTimeSlot);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={slotKey}
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
// Pass the specific day and time slot for this button
|
||||||
|
handleSlotToggle(currentDay, normalizedTimeSlot);
|
||||||
|
}}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
className={`flex items-center gap-2 cursor-pointer px-4 py-2 rounded-lg border-2 transition-all focus:outline-none focus:ring-2 focus:ring-rose-500 ${
|
||||||
|
isSelected
|
||||||
? isDark
|
? isDark
|
||||||
? 'bg-rose-600 border-rose-500 text-white'
|
? 'bg-rose-600 border-rose-500 text-white hover:bg-rose-700'
|
||||||
: 'bg-rose-500 border-rose-500 text-white'
|
: 'bg-rose-500 border-rose-500 text-white hover:bg-rose-600'
|
||||||
: isDark
|
: isDark
|
||||||
? 'bg-gray-700 border-gray-600 text-gray-300 hover:border-rose-500'
|
? 'bg-gray-700 border-gray-600 text-gray-300 hover:border-rose-500 hover:bg-gray-650'
|
||||||
: 'bg-white border-gray-300 text-gray-700 hover:border-rose-500'
|
: 'bg-white border-gray-300 text-gray-700 hover:border-rose-500 hover:bg-rose-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<input
|
<span className="text-sm font-medium">
|
||||||
type="checkbox"
|
{timeSlotLabels[normalizedTimeSlot] || timeSlot}
|
||||||
checked={formData.preferredDays.includes(day)}
|
</span>
|
||||||
onChange={() => handleDayToggle(day)}
|
</button>
|
||||||
className="sr-only"
|
);
|
||||||
/>
|
})}
|
||||||
<span className="text-sm font-medium">{day}</span>
|
</div>
|
||||||
</label>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label
|
|
||||||
className={`text-sm font-medium flex items-center gap-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}
|
|
||||||
>
|
|
||||||
<Clock className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-500'}`} />
|
|
||||||
Preferred Time *
|
|
||||||
</label>
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
{(() => {
|
|
||||||
// Get available time slots based on selected days
|
|
||||||
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
||||||
const dayIndices = formData.preferredDays.map(day => dayNames.indexOf(day));
|
|
||||||
|
|
||||||
// Get all unique time slots from selected days
|
|
||||||
let allAvailableSlots = new Set<string>();
|
|
||||||
dayIndices.forEach(dayIndex => {
|
|
||||||
if (dayIndex !== -1 && availableTimeSlots[dayIndex]) {
|
|
||||||
availableTimeSlots[dayIndex].forEach(slot => allAvailableSlots.add(slot));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// If no time slots found in localStorage, show all (fallback)
|
|
||||||
const slotsToShow = allAvailableSlots.size > 0
|
|
||||||
? Array.from(allAvailableSlots)
|
|
||||||
: ['morning', 'lunchtime', 'afternoon'];
|
|
||||||
|
|
||||||
const timeSlotMap = [
|
|
||||||
{ value: 'morning', label: 'Morning' },
|
|
||||||
{ value: 'lunchtime', label: 'Lunchtime' },
|
|
||||||
{ value: 'afternoon', label: 'Evening' }
|
|
||||||
];
|
|
||||||
|
|
||||||
// Only show time slots that are available
|
|
||||||
return timeSlotMap.filter(ts => slotsToShow.includes(ts.value));
|
|
||||||
})().map((time) => (
|
|
||||||
<label
|
|
||||||
key={time.value}
|
|
||||||
className={`flex items-center gap-2 cursor-pointer px-4 py-2 rounded-lg border transition-all ${
|
|
||||||
formData.preferredTimes.includes(time.value)
|
|
||||||
? isDark
|
|
||||||
? 'bg-rose-600 border-rose-500 text-white'
|
|
||||||
: 'bg-rose-500 border-rose-500 text-white'
|
|
||||||
: isDark
|
|
||||||
? 'bg-gray-700 border-gray-600 text-gray-300 hover:border-rose-500'
|
|
||||||
: 'bg-white border-gray-300 text-gray-700 hover:border-rose-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formData.preferredTimes.includes(time.value)}
|
|
||||||
onChange={() => handleTimeToggle(time.value)}
|
|
||||||
className="sr-only"
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium">{time.label}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -775,6 +806,7 @@ export default function BookNowPage() {
|
|||||||
placeholder="Tell us about any specific concerns or preferences..."
|
placeholder="Tell us about any specific concerns or preferences..."
|
||||||
value={formData.message}
|
value={formData.message}
|
||||||
onChange={(e) => handleChange("message", e.target.value)}
|
onChange={(e) => handleChange("message", e.target.value)}
|
||||||
|
maxLength={100}
|
||||||
className={`w-full rounded-md border px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-rose-500 focus-visible:border-rose-500 disabled:cursor-not-allowed disabled:opacity-50 ${isDark ? 'border-gray-600 bg-gray-700 text-white placeholder:text-gray-400 focus-visible:ring-rose-400 focus-visible:border-rose-400' : 'border-gray-300 bg-white text-gray-900 placeholder:text-gray-500'}`}
|
className={`w-full rounded-md border px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-rose-500 focus-visible:border-rose-500 disabled:cursor-not-allowed disabled:opacity-50 ${isDark ? 'border-gray-600 bg-gray-700 text-white placeholder:text-gray-400 focus-visible:ring-rose-400 focus-visible:border-rose-400' : 'border-gray-300 bg-white text-gray-900 placeholder:text-gray-500'}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -784,7 +816,7 @@ export default function BookNowPage() {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
size="lg"
|
size="lg"
|
||||||
disabled={isCreating || availableDaysOfWeek.length === 0}
|
disabled={isCreating || availableDaysOfWeek.length === 0 || formData.selectedSlots.length === 0}
|
||||||
className="w-full bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all h-12 text-base font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all h-12 text-base font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{isCreating ? (
|
{isCreating ? (
|
||||||
@ -804,19 +836,6 @@ export default function BookNowPage() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contact Information */}
|
|
||||||
<div className="mt-6 text-center">
|
|
||||||
<p className={isDark ? 'text-gray-300' : 'text-gray-600'}>
|
|
||||||
Prefer to book by phone?{" "}
|
|
||||||
<a
|
|
||||||
href="tel:+17548162311"
|
|
||||||
className={`font-medium underline ${isDark ? 'text-rose-400 hover:text-rose-300' : 'text-rose-600 hover:text-rose-700'}`}
|
|
||||||
>
|
|
||||||
Call us at (754) 816-2311
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Logout Button - Only show when authenticated */}
|
{/* Logout Button - Only show when authenticated */}
|
||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<div className="mt-6 flex justify-center">
|
<div className="mt-6 flex justify-center">
|
||||||
|
|||||||
@ -297,7 +297,7 @@ For technical assistance, questions, or issues:
|
|||||||
<div className="bg-white p-8 rounded-lg shadow-md">
|
<div className="bg-white p-8 rounded-lg shadow-md">
|
||||||
<Button
|
<Button
|
||||||
className="bg-gray-100 hover:bg-gray-50 shadow-md text-black"
|
className="bg-gray-100 hover:bg-gray-50 shadow-md text-black"
|
||||||
onClick={() => router.push("/")}
|
onClick={() => router.back()}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="mr-2" />
|
<ArrowLeft className="mr-2" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo, useEffect, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
@ -21,23 +21,69 @@ import {
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Navbar } from "@/components/Navbar";
|
import { Navbar } from "@/components/Navbar";
|
||||||
import { useAppTheme } from "@/components/ThemeProvider";
|
import { useAppTheme } from "@/components/ThemeProvider";
|
||||||
import { useAppointments } from "@/hooks/useAppointments";
|
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import type { Appointment } from "@/lib/models/appointments";
|
import { listAppointments, getUserAppointmentStats } from "@/lib/actions/appointments";
|
||||||
|
import type { Appointment, UserAppointmentStats } from "@/lib/models/appointments";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export default function UserDashboard() {
|
export default function UserDashboard() {
|
||||||
const { theme } = useAppTheme();
|
const { theme } = useAppTheme();
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const {
|
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||||
userAppointments,
|
const [loading, setLoading] = useState(true);
|
||||||
userAppointmentStats,
|
const [stats, setStats] = useState<UserAppointmentStats | null>(null);
|
||||||
isLoadingUserAppointments,
|
const [loadingStats, setLoadingStats] = useState(true);
|
||||||
isLoadingUserStats,
|
|
||||||
refetchUserAppointments,
|
// Fetch appointments using the same endpoint as admin booking table
|
||||||
refetchUserStats,
|
useEffect(() => {
|
||||||
} = useAppointments();
|
const fetchAppointments = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await listAppointments();
|
||||||
|
setAppointments(data || []);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to load appointments. Please try again.");
|
||||||
|
setAppointments([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchAppointments();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch stats from API using user email
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchStats = async () => {
|
||||||
|
if (!user?.email) {
|
||||||
|
setLoadingStats(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingStats(true);
|
||||||
|
try {
|
||||||
|
const statsData = await getUserAppointmentStats(user.email);
|
||||||
|
setStats(statsData);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to load appointment statistics.");
|
||||||
|
// Set default stats on error
|
||||||
|
setStats({
|
||||||
|
total_requests: 0,
|
||||||
|
pending_review: 0,
|
||||||
|
scheduled: 0,
|
||||||
|
rejected: 0,
|
||||||
|
completed: 0,
|
||||||
|
completion_rate: 0,
|
||||||
|
email: user.email,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoadingStats(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchStats();
|
||||||
|
}, [user?.email]);
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
@ -68,58 +114,67 @@ export default function UserDashboard() {
|
|||||||
|
|
||||||
// Filter appointments by status
|
// Filter appointments by status
|
||||||
const upcomingAppointments = useMemo(() => {
|
const upcomingAppointments = useMemo(() => {
|
||||||
return userAppointments.filter(
|
return appointments.filter(
|
||||||
(appointment) => appointment.status === "scheduled"
|
(appointment) => appointment.status === "scheduled"
|
||||||
);
|
);
|
||||||
}, [userAppointments]);
|
}, [appointments]);
|
||||||
|
|
||||||
|
const pendingAppointments = useMemo(() => {
|
||||||
|
return appointments.filter(
|
||||||
|
(appointment) => appointment.status === "pending_review"
|
||||||
|
);
|
||||||
|
}, [appointments]);
|
||||||
|
|
||||||
const completedAppointments = useMemo(() => {
|
const completedAppointments = useMemo(() => {
|
||||||
return userAppointments.filter(
|
return appointments.filter(
|
||||||
(appointment) => appointment.status === "completed"
|
(appointment) => appointment.status === "completed"
|
||||||
);
|
);
|
||||||
}, [userAppointments]);
|
}, [appointments]);
|
||||||
|
|
||||||
const stats = userAppointmentStats || {
|
const rejectedAppointments = useMemo(() => {
|
||||||
total_requests: 0,
|
return appointments.filter(
|
||||||
pending_review: 0,
|
(appointment) => appointment.status === "rejected"
|
||||||
scheduled: 0,
|
);
|
||||||
rejected: 0,
|
}, [appointments]);
|
||||||
completed: 0,
|
|
||||||
completion_rate: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const statCards = [
|
// Sort appointments by created_at (newest first)
|
||||||
{
|
const allAppointments = useMemo(() => {
|
||||||
title: "Upcoming Appointments",
|
return [...appointments].sort((a, b) => {
|
||||||
value: stats.scheduled,
|
const dateA = new Date(a.created_at).getTime();
|
||||||
icon: CalendarCheck,
|
const dateB = new Date(b.created_at).getTime();
|
||||||
trend: stats.scheduled > 0 ? `+${stats.scheduled}` : "0",
|
return dateB - dateA;
|
||||||
trendUp: true,
|
});
|
||||||
},
|
}, [appointments]);
|
||||||
{
|
|
||||||
title: "Completed Sessions",
|
|
||||||
value: stats.completed || 0,
|
|
||||||
icon: CheckCircle2,
|
|
||||||
trend: stats.completed > 0 ? `+${stats.completed}` : "0",
|
|
||||||
trendUp: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Total Appointments",
|
|
||||||
value: stats.total_requests,
|
|
||||||
icon: Calendar,
|
|
||||||
trend: `${Math.round(stats.completion_rate || 0)}%`,
|
|
||||||
trendUp: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Pending Review",
|
|
||||||
value: stats.pending_review,
|
|
||||||
icon: Calendar,
|
|
||||||
trend: stats.pending_review > 0 ? `${stats.pending_review}` : "0",
|
|
||||||
trendUp: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const loading = isLoadingUserAppointments || isLoadingUserStats;
|
// Use stats from API, fallback to calculated stats if API stats not available
|
||||||
|
const displayStats = useMemo(() => {
|
||||||
|
if (stats) {
|
||||||
|
return {
|
||||||
|
scheduled: stats.scheduled || 0,
|
||||||
|
completed: stats.completed || 0,
|
||||||
|
pending_review: stats.pending_review || 0,
|
||||||
|
rejected: stats.rejected || 0,
|
||||||
|
total_requests: stats.total_requests || 0,
|
||||||
|
completion_rate: stats.completion_rate || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Fallback: calculate from appointments if stats not loaded yet
|
||||||
|
const scheduled = appointments.filter(a => a.status === "scheduled").length;
|
||||||
|
const completed = appointments.filter(a => a.status === "completed").length;
|
||||||
|
const pending_review = appointments.filter(a => a.status === "pending_review").length;
|
||||||
|
const rejected = appointments.filter(a => a.status === "rejected").length;
|
||||||
|
const total_requests = appointments.length;
|
||||||
|
const completion_rate = total_requests > 0 ? (scheduled / total_requests) * 100 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
scheduled,
|
||||||
|
completed,
|
||||||
|
pending_review,
|
||||||
|
rejected,
|
||||||
|
total_requests,
|
||||||
|
completion_rate,
|
||||||
|
};
|
||||||
|
}, [stats, appointments]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-screen ${isDark ? 'bg-gray-900' : 'bg-gray-50'}`}>
|
<div className={`min-h-screen ${isDark ? 'bg-gray-900' : 'bg-gray-50'}`}>
|
||||||
@ -166,116 +221,278 @@ export default function UserDashboard() {
|
|||||||
<>
|
<>
|
||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-4">
|
||||||
{statCards.map((card, index) => {
|
<div
|
||||||
const Icon = card.icon;
|
className={`rounded-lg border p-4 sm:p-5 md:p-6 hover:shadow-md transition-shadow ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}
|
||||||
return (
|
>
|
||||||
<div
|
<div className="flex items-start justify-between mb-3 sm:mb-4">
|
||||||
key={index}
|
<div className={`p-2 sm:p-2.5 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
|
||||||
className={`rounded-lg border p-4 sm:p-5 md:p-6 hover:shadow-md transition-shadow ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}
|
<CalendarCheck className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between mb-3 sm:mb-4">
|
|
||||||
<div className={`p-2 sm:p-2.5 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
|
|
||||||
<Icon className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${
|
|
||||||
card.trendUp
|
|
||||||
? isDark ? "bg-green-900/30 text-green-400" : "bg-green-50 text-green-700"
|
|
||||||
: isDark ? "bg-red-900/30 text-red-400" : "bg-red-50 text-red-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{card.trendUp ? (
|
|
||||||
<ArrowUpRight className="w-3 h-3" />
|
|
||||||
) : (
|
|
||||||
<ArrowUpRight className="w-3 h-3" />
|
|
||||||
)}
|
|
||||||
<span>{card.trend}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className={`text-xs font-medium mb-1 sm:mb-2 uppercase tracking-wider ${isDark ? 'text-rose-400' : 'text-rose-600'}`}>
|
|
||||||
{card.title}
|
|
||||||
</h3>
|
|
||||||
<p className={`text-xl sm:text-2xl font-bold mb-1 ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
|
||||||
{card.value}
|
|
||||||
</p>
|
|
||||||
<p className={`text-xs font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>vs last month</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<div
|
||||||
})}
|
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${isDark ? "bg-green-900/30 text-green-400" : "bg-green-50 text-green-700"}`}
|
||||||
|
>
|
||||||
|
<ArrowUpRight className="w-3 h-3" />
|
||||||
|
<span>{displayStats.scheduled > 0 ? `+${displayStats.scheduled}` : "0"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className={`text-xs font-medium mb-1 sm:mb-2 uppercase tracking-wider ${isDark ? 'text-rose-400' : 'text-rose-600'}`}>
|
||||||
|
Upcoming Appointments
|
||||||
|
</h3>
|
||||||
|
<p className={`text-xl sm:text-2xl font-bold mb-1 ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||||
|
{displayStats.scheduled}
|
||||||
|
</p>
|
||||||
|
<p className={`text-xs font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>vs last month</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`rounded-lg border p-4 sm:p-5 md:p-6 hover:shadow-md transition-shadow ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3 sm:mb-4">
|
||||||
|
<div className={`p-2 sm:p-2.5 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
|
||||||
|
<CheckCircle2 className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${isDark ? "bg-green-900/30 text-green-400" : "bg-green-50 text-green-700"}`}
|
||||||
|
>
|
||||||
|
<ArrowUpRight className="w-3 h-3" />
|
||||||
|
<span>{displayStats.completed > 0 ? `+${displayStats.completed}` : "0"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className={`text-xs font-medium mb-1 sm:mb-2 uppercase tracking-wider ${isDark ? 'text-rose-400' : 'text-rose-600'}`}>
|
||||||
|
Completed Sessions
|
||||||
|
</h3>
|
||||||
|
<p className={`text-xl sm:text-2xl font-bold mb-1 ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||||
|
{displayStats.completed}
|
||||||
|
</p>
|
||||||
|
<p className={`text-xs font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>vs last month</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`rounded-lg border p-4 sm:p-5 md:p-6 hover:shadow-md transition-shadow ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3 sm:mb-4">
|
||||||
|
<div className={`p-2 sm:p-2.5 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
|
||||||
|
<Calendar className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${isDark ? "bg-green-900/30 text-green-400" : "bg-green-50 text-green-700"}`}
|
||||||
|
>
|
||||||
|
<ArrowUpRight className="w-3 h-3" />
|
||||||
|
<span>{`${Math.round(displayStats.completion_rate || 0)}%`}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className={`text-xs font-medium mb-1 sm:mb-2 uppercase tracking-wider ${isDark ? 'text-rose-400' : 'text-rose-600'}`}>
|
||||||
|
Total Appointments
|
||||||
|
</h3>
|
||||||
|
<p className={`text-xl sm:text-2xl font-bold mb-1 ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||||
|
{displayStats.total_requests}
|
||||||
|
</p>
|
||||||
|
<p className={`text-xs font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>vs last month</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`rounded-lg border p-4 sm:p-5 md:p-6 hover:shadow-md transition-shadow ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3 sm:mb-4">
|
||||||
|
<div className={`p-2 sm:p-2.5 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
|
||||||
|
<Calendar className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${isDark ? "bg-red-900/30 text-red-400" : "bg-red-50 text-red-700"}`}
|
||||||
|
>
|
||||||
|
<ArrowUpRight className="w-3 h-3" />
|
||||||
|
<span>{displayStats.pending_review > 0 ? `${displayStats.pending_review}` : "0"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className={`text-xs font-medium mb-1 sm:mb-2 uppercase tracking-wider ${isDark ? 'text-rose-400' : 'text-rose-600'}`}>
|
||||||
|
Pending Review
|
||||||
|
</h3>
|
||||||
|
<p className={`text-xl sm:text-2xl font-bold mb-1 ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||||
|
{displayStats.pending_review}
|
||||||
|
</p>
|
||||||
|
<p className={`text-xs font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>vs last month</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Upcoming Appointments Section */}
|
{/* All Appointments Section */}
|
||||||
{upcomingAppointments.length > 0 ? (
|
{allAppointments.length > 0 ? (
|
||||||
<div className={`rounded-lg border p-4 sm:p-5 md:p-6 ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}>
|
<div className={`rounded-lg border overflow-hidden ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}>
|
||||||
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
<div className={`px-4 sm:px-5 md:px-6 py-4 border-b ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
|
||||||
Upcoming Appointments
|
<h2 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||||
</h2>
|
All Appointments
|
||||||
<div className="space-y-3">
|
</h2>
|
||||||
{upcomingAppointments.map((appointment) => (
|
</div>
|
||||||
<div
|
<div className="overflow-x-auto">
|
||||||
key={appointment.id}
|
<table className="w-full">
|
||||||
className={`border rounded-lg p-4 hover:shadow-md transition-shadow ${isDark ? 'border-gray-700 bg-gray-700/50' : 'border-gray-200'}`}
|
<thead className={`${isDark ? "bg-gray-800 border-b border-gray-700" : "bg-gray-50 border-b border-gray-200"}`}>
|
||||||
>
|
<tr>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
<div className="flex-1">
|
Appointment
|
||||||
{appointment.scheduled_datetime && (
|
</th>
|
||||||
<>
|
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden md:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
Date & Time
|
||||||
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
</th>
|
||||||
<span className={`font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
{formatDate(appointment.scheduled_datetime)}
|
Duration
|
||||||
</span>
|
</th>
|
||||||
|
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
|
Preferred Times
|
||||||
|
</th>
|
||||||
|
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden xl:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
|
Created
|
||||||
|
</th>
|
||||||
|
<th className={`px-3 sm:px-4 md:px-6 py-3 text-right text-xs font-medium uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className={`${isDark ? "bg-gray-800 divide-gray-700" : "bg-white divide-gray-200"}`}>
|
||||||
|
{allAppointments.map((appointment) => {
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "scheduled":
|
||||||
|
return isDark ? 'bg-green-900/30 text-green-400' : 'bg-green-50 text-green-700';
|
||||||
|
case "pending_review":
|
||||||
|
return isDark ? 'bg-yellow-900/30 text-yellow-400' : 'bg-yellow-50 text-yellow-700';
|
||||||
|
case "completed":
|
||||||
|
return isDark ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-50 text-blue-700';
|
||||||
|
case "rejected":
|
||||||
|
return isDark ? 'bg-red-900/30 text-red-400' : 'bg-red-50 text-red-700';
|
||||||
|
default:
|
||||||
|
return isDark ? 'bg-gray-700 text-gray-300' : 'bg-gray-100 text-gray-700';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatStatus = (status: string) => {
|
||||||
|
return status.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||||
|
const timeSlotLabels: Record<string, string> = {
|
||||||
|
morning: 'Morning',
|
||||||
|
afternoon: 'Lunchtime',
|
||||||
|
evening: 'Evening',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={appointment.id}
|
||||||
|
className={`transition-colors ${isDark ? "hover:bg-gray-700" : "hover:bg-gray-50"}`}
|
||||||
|
>
|
||||||
|
<td className="px-3 sm:px-4 md:px-6 py-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className={`shrink-0 h-8 w-8 sm:h-10 sm:w-10 rounded-full flex items-center justify-center ${isDark ? "bg-gray-700" : "bg-gray-100"}`}>
|
||||||
|
<User className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? "text-gray-200" : "text-gray-600"}`} />
|
||||||
|
</div>
|
||||||
|
<div className="ml-2 sm:ml-4 min-w-0">
|
||||||
|
<div className={`text-xs sm:text-sm font-medium truncate ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||||
|
{appointment.first_name} {appointment.last_name}
|
||||||
|
</div>
|
||||||
|
{appointment.reason && (
|
||||||
|
<div className={`text-xs sm:text-sm truncate mt-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
|
{appointment.reason}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{appointment.scheduled_datetime && (
|
||||||
|
<div className={`text-xs sm:hidden mt-0.5 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
|
{formatDate(appointment.scheduled_datetime)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
</td>
|
||||||
<Clock className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap hidden md:table-cell">
|
||||||
<span className={isDark ? 'text-gray-300' : 'text-gray-700'}>
|
{appointment.scheduled_datetime ? (
|
||||||
{formatTime(appointment.scheduled_datetime)}
|
<>
|
||||||
</span>
|
<div className={`text-xs sm:text-sm ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||||
{appointment.scheduled_duration && (
|
{formatDate(appointment.scheduled_datetime)}
|
||||||
<span className={`text-sm font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
</div>
|
||||||
({appointment.scheduled_duration} minutes)
|
<div className={`text-xs sm:text-sm flex items-center gap-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
</span>
|
<Clock className="w-3 h-3" />
|
||||||
|
{formatTime(appointment.scheduled_datetime)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className={`text-xs sm:text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
|
Not scheduled
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm hidden lg:table-cell ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||||
|
{appointment.scheduled_duration ? `${appointment.scheduled_duration} min` : "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(
|
||||||
|
appointment.status
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{formatStatus(appointment.status)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={`px-3 sm:px-4 md:px-6 py-4 hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
|
{appointment.selected_slots && appointment.selected_slots.length > 0 ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{appointment.selected_slots.slice(0, 2).map((slot, idx) => (
|
||||||
|
<span key={idx} className="text-xs sm:text-sm">
|
||||||
|
{dayNames[slot.day]} - {timeSlotLabels[slot.time_slot] || slot.time_slot}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{appointment.selected_slots.length > 2 && (
|
||||||
|
<span className="text-xs">+{appointment.selected_slots.length - 2} more</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm hidden xl:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
|
{formatDate(appointment.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<div className="flex items-center justify-end gap-1 sm:gap-2">
|
||||||
|
{appointment.jitsi_meet_url && (
|
||||||
|
<a
|
||||||
|
href={appointment.jitsi_meet_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={`p-1.5 sm:p-2 rounded-lg transition-colors ${
|
||||||
|
appointment.can_join_meeting
|
||||||
|
? isDark
|
||||||
|
? "bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
|
: "bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
|
: isDark
|
||||||
|
? "text-gray-400 hover:text-gray-300 hover:bg-gray-700"
|
||||||
|
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
|
||||||
|
}`}
|
||||||
|
title={appointment.can_join_meeting ? "Join Meeting" : "Meeting Not Available"}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!appointment.can_join_meeting) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Video className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</td>
|
||||||
)}
|
</tr>
|
||||||
{appointment.reason && (
|
);
|
||||||
<p className={`text-sm mt-2 font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
|
})}
|
||||||
{appointment.reason}
|
</tbody>
|
||||||
</p>
|
</table>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col sm:items-end gap-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
|
|
||||||
appointment.status === "scheduled"
|
|
||||||
? isDark ? 'bg-green-900/30 text-green-400' : 'bg-green-50 text-green-700'
|
|
||||||
: appointment.status === "pending_review"
|
|
||||||
? isDark ? 'bg-yellow-900/30 text-yellow-400' : 'bg-yellow-50 text-yellow-700'
|
|
||||||
: isDark ? 'bg-red-900/30 text-red-400' : 'bg-red-50 text-red-700'
|
|
||||||
}`}>
|
|
||||||
{appointment.status.charAt(0).toUpperCase() +
|
|
||||||
appointment.status.slice(1).replace('_', ' ')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{appointment.jitsi_meet_url && appointment.can_join_meeting && (
|
|
||||||
<a
|
|
||||||
href={appointment.jitsi_meet_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors"
|
|
||||||
>
|
|
||||||
<Video className="w-4 h-4" />
|
|
||||||
Join Session
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : !loading && (
|
) : !loading && (
|
||||||
@ -283,10 +500,10 @@ export default function UserDashboard() {
|
|||||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
<CalendarCheck className={`w-12 h-12 mb-4 ${isDark ? 'text-gray-500' : 'text-gray-400'}`} />
|
<CalendarCheck className={`w-12 h-12 mb-4 ${isDark ? 'text-gray-500' : 'text-gray-400'}`} />
|
||||||
<p className={`text-lg font-medium mb-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
|
<p className={`text-lg font-medium mb-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||||
No Upcoming Appointments
|
No Appointments
|
||||||
</p>
|
</p>
|
||||||
<p className={`text-sm mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
<p className={`text-sm mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||||
You don't have any scheduled appointments yet. Book an appointment to get started.
|
You don't have any appointments yet. Book an appointment to get started.
|
||||||
</p>
|
</p>
|
||||||
<Link href="/book-now">
|
<Link href="/book-now">
|
||||||
<Button className="bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white">
|
<Button className="bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white">
|
||||||
|
|||||||
@ -58,7 +58,6 @@ export default function SettingsPage() {
|
|||||||
phone: profile.phone_number || "",
|
phone: profile.phone_number || "",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch profile:", error);
|
|
||||||
const errorMessage = error instanceof Error ? error.message : "Failed to load profile";
|
const errorMessage = error instanceof Error ? error.message : "Failed to load profile";
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
@ -105,7 +104,6 @@ export default function SettingsPage() {
|
|||||||
});
|
});
|
||||||
toast.success("Profile updated successfully");
|
toast.success("Profile updated successfully");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update profile:", error);
|
|
||||||
const errorMessage = error instanceof Error ? error.message : "Failed to update profile";
|
const errorMessage = error instanceof Error ? error.message : "Failed to update profile";
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
@ -140,7 +138,6 @@ export default function SettingsPage() {
|
|||||||
confirmPassword: "",
|
confirmPassword: "",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update password:", error);
|
|
||||||
const errorMessage = error instanceof Error ? error.message : "Failed to update password";
|
const errorMessage = error instanceof Error ? error.message : "Failed to update password";
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
177
components/ClockDurationPicker.tsx
Normal file
177
components/ClockDurationPicker.tsx
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Timer } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface ClockDurationPickerProps {
|
||||||
|
duration: number; // Duration in minutes
|
||||||
|
setDuration: (duration: number) => void;
|
||||||
|
label?: string;
|
||||||
|
isDark?: boolean;
|
||||||
|
options?: number[]; // Optional custom duration options
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClockDurationPicker({
|
||||||
|
duration,
|
||||||
|
setDuration,
|
||||||
|
label,
|
||||||
|
isDark = false,
|
||||||
|
options = [15, 30, 45, 60, 75, 90, 105, 120]
|
||||||
|
}: ClockDurationPickerProps) {
|
||||||
|
const [isOpen, setIsOpen] = React.useState(false);
|
||||||
|
const wrapperRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close picker when clicking outside
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Handle duration selection
|
||||||
|
const handleDurationClick = (selectedDuration: number) => {
|
||||||
|
setDuration(selectedDuration);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate position for clock numbers
|
||||||
|
const getClockPosition = (index: number, total: number, radius: number = 130) => {
|
||||||
|
const angle = (index * 360) / total - 90; // Start from top (-90 degrees)
|
||||||
|
const radian = (angle * Math.PI) / 180;
|
||||||
|
const x = Math.cos(radian) * radius;
|
||||||
|
const y = Math.sin(radian) * radius;
|
||||||
|
return { x, y };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format duration display
|
||||||
|
const formatDuration = (minutes: number) => {
|
||||||
|
if (minutes < 60) {
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayDuration = duration ? formatDuration(duration) : 'Select duration';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{label && (
|
||||||
|
<label className={cn(
|
||||||
|
"text-sm font-semibold",
|
||||||
|
isDark ? "text-gray-300" : "text-gray-700"
|
||||||
|
)}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="relative w-full" ref={wrapperRef}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-start text-left font-normal h-12 text-base",
|
||||||
|
!duration && "text-muted-foreground",
|
||||||
|
isDark
|
||||||
|
? "bg-gray-800 border-gray-600 text-white hover:bg-gray-700"
|
||||||
|
: "bg-white border-gray-300 text-gray-900 hover:bg-gray-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Timer className="mr-2 h-5 w-5" />
|
||||||
|
{displayDuration}
|
||||||
|
</Button>
|
||||||
|
{isOpen && (
|
||||||
|
<div className={cn(
|
||||||
|
"absolute z-[9999] top-full left-0 right-0 mt-2 rounded-lg shadow-xl border p-6 w-[420px] mx-auto overflow-visible",
|
||||||
|
isDark
|
||||||
|
? "bg-gray-800 border-gray-700"
|
||||||
|
: "bg-white border-gray-200"
|
||||||
|
)}>
|
||||||
|
{/* Clock face */}
|
||||||
|
<div className="relative w-[360px] h-[360px] mx-auto my-6 overflow-visible">
|
||||||
|
{/* Clock circle */}
|
||||||
|
<div className={cn(
|
||||||
|
"absolute inset-0 rounded-full border-2",
|
||||||
|
isDark ? "border-gray-600" : "border-gray-300"
|
||||||
|
)} />
|
||||||
|
|
||||||
|
{/* Center dot */}
|
||||||
|
<div className={cn(
|
||||||
|
"absolute top-1/2 left-1/2 w-2 h-2 rounded-full -translate-x-1/2 -translate-y-1/2 z-10",
|
||||||
|
isDark ? "bg-gray-400" : "bg-gray-600"
|
||||||
|
)} />
|
||||||
|
|
||||||
|
{/* Duration options arranged in a circle */}
|
||||||
|
{options.map((option, index) => {
|
||||||
|
const { x, y } = getClockPosition(index, options.length, 130);
|
||||||
|
const isSelected = duration === option;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDurationClick(option)}
|
||||||
|
className={cn(
|
||||||
|
"absolute w-16 h-16 rounded-full flex items-center justify-center text-xs font-semibold transition-all z-20 whitespace-nowrap",
|
||||||
|
isSelected
|
||||||
|
? isDark
|
||||||
|
? "bg-blue-600 text-white scale-110 shadow-lg ring-2 ring-blue-400"
|
||||||
|
: "bg-blue-600 text-white scale-110 shadow-lg ring-2 ring-blue-400"
|
||||||
|
: isDark
|
||||||
|
? "bg-gray-700 text-gray-200 hover:bg-gray-600 hover:scale-105"
|
||||||
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200 hover:scale-105"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: `calc(50% + ${x}px)`,
|
||||||
|
top: `calc(50% + ${y}px)`,
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
}}
|
||||||
|
title={`${option} minutes`}
|
||||||
|
>
|
||||||
|
{formatDuration(option)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick select buttons for common durations */}
|
||||||
|
<div className="flex gap-2 mt-4 justify-center flex-wrap">
|
||||||
|
{[30, 60, 90, 120].map((quickDuration) => (
|
||||||
|
<button
|
||||||
|
key={quickDuration}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDurationClick(quickDuration)}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 rounded text-sm font-medium transition-colors",
|
||||||
|
duration === quickDuration
|
||||||
|
? isDark
|
||||||
|
? "bg-blue-600 text-white"
|
||||||
|
: "bg-blue-600 text-white"
|
||||||
|
: isDark
|
||||||
|
? "bg-gray-700 text-gray-300 hover:bg-gray-600"
|
||||||
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatDuration(quickDuration)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
278
components/ClockTimePicker.tsx
Normal file
278
components/ClockTimePicker.tsx
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Clock } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface ClockTimePickerProps {
|
||||||
|
time: string; // HH:mm format (e.g., "09:00")
|
||||||
|
setTime: (time: string) => void;
|
||||||
|
label?: string;
|
||||||
|
isDark?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClockTimePicker({ time, setTime, label, isDark = false }: ClockTimePickerProps) {
|
||||||
|
const [isOpen, setIsOpen] = React.useState(false);
|
||||||
|
const [mode, setMode] = React.useState<'hour' | 'minute'>('hour');
|
||||||
|
const wrapperRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Parse time string to hours and minutes
|
||||||
|
const [hours, minutes] = React.useMemo(() => {
|
||||||
|
if (!time) return [9, 0];
|
||||||
|
const parts = time.split(':').map(Number);
|
||||||
|
return [parts[0] || 9, parts[1] || 0];
|
||||||
|
}, [time]);
|
||||||
|
|
||||||
|
// Convert to 12-hour format for display
|
||||||
|
const displayHours = hours % 12 || 12;
|
||||||
|
const ampm = hours >= 12 ? 'PM' : 'AM';
|
||||||
|
|
||||||
|
// Close picker when clicking outside
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
setMode('hour');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Handle hour selection
|
||||||
|
const handleHourClick = (selectedHour: number) => {
|
||||||
|
const newHours = ampm === 'PM' && selectedHour !== 12
|
||||||
|
? selectedHour + 12
|
||||||
|
: ampm === 'AM' && selectedHour === 12
|
||||||
|
? 0
|
||||||
|
: selectedHour;
|
||||||
|
|
||||||
|
setTime(`${newHours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`);
|
||||||
|
setMode('minute');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle minute selection
|
||||||
|
const handleMinuteClick = (selectedMinute: number) => {
|
||||||
|
setTime(`${hours.toString().padStart(2, '0')}:${selectedMinute.toString().padStart(2, '0')}`);
|
||||||
|
setIsOpen(false);
|
||||||
|
setMode('hour');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate hour numbers (1-12)
|
||||||
|
const hourNumbers = Array.from({ length: 12 }, (_, i) => i + 1);
|
||||||
|
|
||||||
|
// Generate minute numbers (0, 15, 30, 45 or 0-59)
|
||||||
|
const minuteNumbers = Array.from({ length: 12 }, (_, i) => i * 5); // 0, 5, 10, 15, ..., 55
|
||||||
|
|
||||||
|
// Calculate position for clock numbers
|
||||||
|
const getClockPosition = (index: number, total: number, radius: number = 90) => {
|
||||||
|
const angle = (index * 360) / total - 90; // Start from top (-90 degrees)
|
||||||
|
const radian = (angle * Math.PI) / 180;
|
||||||
|
const x = Math.cos(radian) * radius;
|
||||||
|
const y = Math.sin(radian) * radius;
|
||||||
|
return { x, y };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format display time
|
||||||
|
const displayTime = time
|
||||||
|
? `${displayHours}:${minutes.toString().padStart(2, '0')} ${ampm}`
|
||||||
|
: 'Select time';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{label && (
|
||||||
|
<label className={cn(
|
||||||
|
"text-sm font-semibold",
|
||||||
|
isDark ? "text-gray-300" : "text-gray-700"
|
||||||
|
)}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="relative" ref={wrapperRef}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-start text-left font-normal h-12 text-base",
|
||||||
|
!time && "text-muted-foreground",
|
||||||
|
isDark
|
||||||
|
? "bg-gray-800 border-gray-600 text-white hover:bg-gray-700"
|
||||||
|
: "bg-white border-gray-300 text-gray-900 hover:bg-gray-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Clock className="mr-2 h-5 w-5" />
|
||||||
|
{displayTime}
|
||||||
|
</Button>
|
||||||
|
{isOpen && (
|
||||||
|
<div className={cn(
|
||||||
|
"absolute z-[9999] mt-1 rounded-lg shadow-lg border p-4 -translate-y-1",
|
||||||
|
isDark
|
||||||
|
? "bg-gray-800 border-gray-700"
|
||||||
|
: "bg-white border-gray-200"
|
||||||
|
)}>
|
||||||
|
{/* Mode selector */}
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('hour')}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 rounded text-sm font-medium transition-colors",
|
||||||
|
mode === 'hour'
|
||||||
|
? isDark
|
||||||
|
? "bg-blue-600 text-white"
|
||||||
|
: "bg-blue-600 text-white"
|
||||||
|
: isDark
|
||||||
|
? "bg-gray-700 text-gray-300 hover:bg-gray-600"
|
||||||
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Hour
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('minute')}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 rounded text-sm font-medium transition-colors",
|
||||||
|
mode === 'minute'
|
||||||
|
? isDark
|
||||||
|
? "bg-blue-600 text-white"
|
||||||
|
: "bg-blue-600 text-white"
|
||||||
|
: isDark
|
||||||
|
? "bg-gray-700 text-gray-300 hover:bg-gray-600"
|
||||||
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Minute
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clock face */}
|
||||||
|
<div className="relative w-64 h-64 mx-auto my-4">
|
||||||
|
{/* Clock circle */}
|
||||||
|
<div className={cn(
|
||||||
|
"absolute inset-0 rounded-full border-2",
|
||||||
|
isDark ? "border-gray-600" : "border-gray-300"
|
||||||
|
)} />
|
||||||
|
|
||||||
|
{/* Center dot */}
|
||||||
|
<div className={cn(
|
||||||
|
"absolute top-1/2 left-1/2 w-2 h-2 rounded-full -translate-x-1/2 -translate-y-1/2 z-10",
|
||||||
|
isDark ? "bg-gray-400" : "bg-gray-600"
|
||||||
|
)} />
|
||||||
|
|
||||||
|
{/* Hour numbers */}
|
||||||
|
{mode === 'hour' && hourNumbers.map((hour, index) => {
|
||||||
|
const { x, y } = getClockPosition(index, 12, 90);
|
||||||
|
const isSelected = displayHours === hour;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={hour}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleHourClick(hour)}
|
||||||
|
className={cn(
|
||||||
|
"absolute w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold transition-all z-20",
|
||||||
|
isSelected
|
||||||
|
? isDark
|
||||||
|
? "bg-blue-600 text-white scale-110 shadow-lg"
|
||||||
|
: "bg-blue-600 text-white scale-110 shadow-lg"
|
||||||
|
: isDark
|
||||||
|
? "bg-gray-700 text-gray-200 hover:bg-gray-600 hover:scale-105"
|
||||||
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200 hover:scale-105"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: `calc(50% + ${x}px)`,
|
||||||
|
top: `calc(50% + ${y}px)`,
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hour}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Minute numbers */}
|
||||||
|
{mode === 'minute' && minuteNumbers.map((minute, index) => {
|
||||||
|
const { x, y } = getClockPosition(index, 12, 90);
|
||||||
|
const isSelected = minutes === minute;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={minute}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleMinuteClick(minute)}
|
||||||
|
className={cn(
|
||||||
|
"absolute w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold transition-all z-20",
|
||||||
|
isSelected
|
||||||
|
? isDark
|
||||||
|
? "bg-blue-600 text-white scale-110 shadow-lg"
|
||||||
|
: "bg-blue-600 text-white scale-110 shadow-lg"
|
||||||
|
: isDark
|
||||||
|
? "bg-gray-700 text-gray-200 hover:bg-gray-600 hover:scale-105"
|
||||||
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200 hover:scale-105"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: `calc(50% + ${x}px)`,
|
||||||
|
top: `calc(50% + ${y}px)`,
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{minute}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AM/PM toggle */}
|
||||||
|
<div className="flex gap-2 mt-4 justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const newHours = ampm === 'PM' ? hours - 12 : hours;
|
||||||
|
setTime(`${newHours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-2 rounded text-sm font-medium transition-colors",
|
||||||
|
ampm === 'AM'
|
||||||
|
? isDark
|
||||||
|
? "bg-blue-600 text-white"
|
||||||
|
: "bg-blue-600 text-white"
|
||||||
|
: isDark
|
||||||
|
? "bg-gray-700 text-gray-300 hover:bg-gray-600"
|
||||||
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
AM
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const newHours = ampm === 'AM' ? hours + 12 : hours;
|
||||||
|
setTime(`${newHours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-2 rounded text-sm font-medium transition-colors",
|
||||||
|
ampm === 'PM'
|
||||||
|
? isDark
|
||||||
|
? "bg-blue-600 text-white"
|
||||||
|
: "bg-blue-600 text-white"
|
||||||
|
: isDark
|
||||||
|
? "bg-gray-700 text-gray-300 hover:bg-gray-600"
|
||||||
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
PM
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
72
components/DurationPicker.tsx
Normal file
72
components/DurationPicker.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Timer } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface DurationPickerProps {
|
||||||
|
duration: number; // Duration in minutes
|
||||||
|
setDuration: (duration: number) => void;
|
||||||
|
label?: string;
|
||||||
|
isDark?: boolean;
|
||||||
|
options?: number[]; // Optional custom duration options
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DurationPicker({
|
||||||
|
duration,
|
||||||
|
setDuration,
|
||||||
|
label,
|
||||||
|
isDark = false,
|
||||||
|
options = [15, 30, 45, 60, 120]
|
||||||
|
}: DurationPickerProps) {
|
||||||
|
// Format duration display
|
||||||
|
const formatDuration = (minutes: number) => {
|
||||||
|
if (minutes < 60) {
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayDuration = duration ? formatDuration(duration) : 'Select duration';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{label && (
|
||||||
|
<label className={cn(
|
||||||
|
"text-sm font-semibold",
|
||||||
|
isDark ? "text-gray-300" : "text-gray-700"
|
||||||
|
)}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = duration === option;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDuration(option)}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-3 rounded-lg text-sm font-medium transition-all min-w-[80px]",
|
||||||
|
isSelected
|
||||||
|
? isDark
|
||||||
|
? "bg-blue-600 text-white shadow-md"
|
||||||
|
: "bg-blue-600 text-white shadow-md"
|
||||||
|
: isDark
|
||||||
|
? "bg-gray-800 text-gray-300 hover:bg-gray-700 border border-gray-600"
|
||||||
|
: "bg-white text-gray-700 hover:bg-gray-50 border border-gray-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatDuration(option)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -462,3 +462,4 @@ export function ForgotPasswordDialog({ open, onOpenChange, onSuccess }: ForgotPa
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
170
components/ScheduleAppointmentDialog.tsx
Normal file
170
components/ScheduleAppointmentDialog.tsx
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { CalendarCheck, Loader2, X } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { DatePicker } from '@/components/DatePicker';
|
||||||
|
import { ClockTimePicker } from '@/components/ClockTimePicker';
|
||||||
|
import { DurationPicker } from '@/components/DurationPicker';
|
||||||
|
import type { Appointment } from '@/lib/models/appointments';
|
||||||
|
|
||||||
|
interface ScheduleAppointmentDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
appointment: Appointment | null;
|
||||||
|
scheduledDate: Date | undefined;
|
||||||
|
setScheduledDate: (date: Date | undefined) => void;
|
||||||
|
scheduledTime: string;
|
||||||
|
setScheduledTime: (time: string) => void;
|
||||||
|
scheduledDuration: number;
|
||||||
|
setScheduledDuration: (duration: number) => void;
|
||||||
|
onSchedule: () => Promise<void>;
|
||||||
|
isScheduling: boolean;
|
||||||
|
isDark?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScheduleAppointmentDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
appointment,
|
||||||
|
scheduledDate,
|
||||||
|
setScheduledDate,
|
||||||
|
scheduledTime,
|
||||||
|
setScheduledTime,
|
||||||
|
scheduledDuration,
|
||||||
|
setScheduledDuration,
|
||||||
|
onSchedule,
|
||||||
|
isScheduling,
|
||||||
|
isDark = false,
|
||||||
|
}: ScheduleAppointmentDialogProps) {
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
weekday: "long",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (timeString: string) => {
|
||||||
|
const [hours, minutes] = timeString.split(":").map(Number);
|
||||||
|
const date = new Date();
|
||||||
|
date.setHours(hours);
|
||||||
|
date.setMinutes(minutes);
|
||||||
|
return date.toLocaleTimeString("en-US", {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className={`max-w-4xl max-h-[90vh] overflow-y-auto ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
||||||
|
<DialogHeader className="pb-4">
|
||||||
|
<DialogTitle className={`text-2xl font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||||
|
Schedule Appointment
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className={`text-base ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
|
{appointment
|
||||||
|
? `Set date and time for ${appointment.first_name} ${appointment.last_name}'s appointment`
|
||||||
|
: "Set date and time for this appointment"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6 py-4">
|
||||||
|
{/* Date Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className={`text-sm font-semibold ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||||
|
Select Date *
|
||||||
|
</label>
|
||||||
|
<div className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}>
|
||||||
|
<DatePicker
|
||||||
|
date={scheduledDate}
|
||||||
|
setDate={setScheduledDate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time Selection */}
|
||||||
|
<div className="space-y-3 -mt-2">
|
||||||
|
<div className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}>
|
||||||
|
<ClockTimePicker
|
||||||
|
time={scheduledTime}
|
||||||
|
setTime={setScheduledTime}
|
||||||
|
label="Select Time *"
|
||||||
|
isDark={isDark}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Duration Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}>
|
||||||
|
<DurationPicker
|
||||||
|
duration={scheduledDuration}
|
||||||
|
setDuration={setScheduledDuration}
|
||||||
|
label="Duration"
|
||||||
|
isDark={isDark}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
{scheduledDate && scheduledTime && (
|
||||||
|
<div className={`p-4 rounded-xl border ${isDark ? "bg-blue-500/10 border-blue-500/30" : "bg-blue-50 border-blue-200"}`}>
|
||||||
|
<p className={`text-sm font-medium mb-2 ${isDark ? "text-blue-300" : "text-blue-700"}`}>
|
||||||
|
Appointment Preview
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className={`text-base font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||||
|
{formatDate(scheduledDate)}
|
||||||
|
</p>
|
||||||
|
<p className={`text-sm ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||||
|
{formatTime(scheduledTime)} • {scheduledDuration} minutes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isScheduling}
|
||||||
|
className={`h-12 px-6 ${isDark ? "border-gray-700 text-gray-300 hover:bg-gray-700" : ""}`}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onSchedule}
|
||||||
|
disabled={isScheduling || !scheduledDate || !scheduledTime}
|
||||||
|
className="h-12 px-6 bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
|
>
|
||||||
|
{isScheduling ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
||||||
|
Scheduling...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CalendarCheck className="w-5 h-5 mr-2" />
|
||||||
|
Schedule Appointment
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
115
components/TimePicker.tsx
Normal file
115
components/TimePicker.tsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Clock } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import DatePickerLib from 'react-datepicker';
|
||||||
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
|
|
||||||
|
interface TimePickerProps {
|
||||||
|
time: string; // HH:mm format (e.g., "09:00")
|
||||||
|
setTime: (time: string) => void;
|
||||||
|
label?: string;
|
||||||
|
isDark?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimePicker({ time, setTime, label, isDark = false }: TimePickerProps) {
|
||||||
|
const [isOpen, setIsOpen] = React.useState(false);
|
||||||
|
const wrapperRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Convert HH:mm string to Date object for the time picker
|
||||||
|
const timeValue = React.useMemo(() => {
|
||||||
|
if (!time) return null;
|
||||||
|
const [hours, minutes] = time.split(':').map(Number);
|
||||||
|
const date = new Date();
|
||||||
|
date.setHours(hours || 9);
|
||||||
|
date.setMinutes(minutes || 0);
|
||||||
|
date.setSeconds(0);
|
||||||
|
return date;
|
||||||
|
}, [time]);
|
||||||
|
|
||||||
|
// Handle time change from the picker
|
||||||
|
const handleTimeChange = (date: Date | null) => {
|
||||||
|
if (date) {
|
||||||
|
const hours = date.getHours().toString().padStart(2, '0');
|
||||||
|
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||||
|
setTime(`${hours}:${minutes}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close picker when clicking outside
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Format display time
|
||||||
|
const displayTime = timeValue
|
||||||
|
? format(timeValue, 'h:mm a') // e.g., "9:00 AM"
|
||||||
|
: 'Select time';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{label && (
|
||||||
|
<label className={cn(
|
||||||
|
"text-sm font-medium",
|
||||||
|
isDark ? "text-gray-300" : "text-gray-700"
|
||||||
|
)}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="relative" ref={wrapperRef}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-start text-left font-normal h-12 text-base",
|
||||||
|
!timeValue && "text-muted-foreground",
|
||||||
|
isDark
|
||||||
|
? "bg-gray-800 border-gray-600 text-white hover:bg-gray-700"
|
||||||
|
: "bg-white border-gray-300 text-gray-900 hover:bg-gray-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Clock className="mr-2 h-5 w-5" />
|
||||||
|
{displayTime}
|
||||||
|
</Button>
|
||||||
|
{isOpen && (
|
||||||
|
<div className={cn(
|
||||||
|
"absolute z-[9999] mt-2 rounded-lg shadow-lg border",
|
||||||
|
isDark
|
||||||
|
? "bg-gray-800 border-gray-700"
|
||||||
|
: "bg-white border-gray-200"
|
||||||
|
)}>
|
||||||
|
<DatePickerLib
|
||||||
|
selected={timeValue}
|
||||||
|
onChange={handleTimeChange}
|
||||||
|
showTimeSelect
|
||||||
|
showTimeSelectOnly
|
||||||
|
timeIntervals={15}
|
||||||
|
timeCaption="Time"
|
||||||
|
dateFormat="h:mm aa"
|
||||||
|
inline
|
||||||
|
className="time-picker"
|
||||||
|
wrapperClassName="time-picker-wrapper"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -15,6 +15,10 @@ import {
|
|||||||
updateAdminAvailability,
|
updateAdminAvailability,
|
||||||
getAppointmentStats,
|
getAppointmentStats,
|
||||||
getJitsiMeetingInfo,
|
getJitsiMeetingInfo,
|
||||||
|
getWeeklyAvailability,
|
||||||
|
getAvailabilityConfig,
|
||||||
|
checkDateAvailability,
|
||||||
|
getAvailabilityOverview,
|
||||||
} from "@/lib/actions/appointments";
|
} from "@/lib/actions/appointments";
|
||||||
import type {
|
import type {
|
||||||
CreateAppointmentInput,
|
CreateAppointmentInput,
|
||||||
@ -29,29 +33,85 @@ import type {
|
|||||||
UserAppointmentStats,
|
UserAppointmentStats,
|
||||||
AvailableDatesResponse,
|
AvailableDatesResponse,
|
||||||
JitsiMeetingInfo,
|
JitsiMeetingInfo,
|
||||||
|
WeeklyAvailabilityResponse,
|
||||||
|
AvailabilityConfig,
|
||||||
|
CheckDateAvailabilityResponse,
|
||||||
|
AvailabilityOverview,
|
||||||
} from "@/lib/models/appointments";
|
} from "@/lib/models/appointments";
|
||||||
|
|
||||||
export function useAppointments() {
|
export function useAppointments(options?: {
|
||||||
|
enableAvailableDates?: boolean;
|
||||||
|
enableStats?: boolean;
|
||||||
|
enableConfig?: boolean;
|
||||||
|
enableWeeklyAvailability?: boolean;
|
||||||
|
enableOverview?: boolean;
|
||||||
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const enableAvailableDates = options?.enableAvailableDates ?? false;
|
||||||
|
const enableStats = options?.enableStats ?? true;
|
||||||
|
const enableConfig = options?.enableConfig ?? true;
|
||||||
|
const enableWeeklyAvailability = options?.enableWeeklyAvailability ?? true;
|
||||||
|
const enableOverview = options?.enableOverview ?? true;
|
||||||
|
|
||||||
// Get available dates query
|
// Get available dates query (optional, disabled by default - using weekly_availability as primary source)
|
||||||
|
// Can be enabled when explicitly needed (e.g., on admin booking page)
|
||||||
const availableDatesQuery = useQuery<AvailableDatesResponse>({
|
const availableDatesQuery = useQuery<AvailableDatesResponse>({
|
||||||
queryKey: ["appointments", "available-dates"],
|
queryKey: ["appointments", "available-dates"],
|
||||||
queryFn: () => getAvailableDates(),
|
queryFn: () => getAvailableDates(),
|
||||||
|
enabled: enableAvailableDates, // Can be enabled when needed
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
retry: 0, // Don't retry failed requests
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get weekly availability query
|
||||||
|
const weeklyAvailabilityQuery = useQuery<WeeklyAvailabilityResponse>({
|
||||||
|
queryKey: ["appointments", "weekly-availability"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const data = await getWeeklyAvailability();
|
||||||
|
// Normalize response format - ensure it's always an object with week array
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return { week: data };
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: enableWeeklyAvailability,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get availability config query
|
||||||
|
const availabilityConfigQuery = useQuery<AvailabilityConfig>({
|
||||||
|
queryKey: ["appointments", "availability-config"],
|
||||||
|
queryFn: () => getAvailabilityConfig(),
|
||||||
|
enabled: enableConfig,
|
||||||
|
staleTime: 60 * 60 * 1000, // 1 hour (config rarely changes)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get availability overview query
|
||||||
|
const availabilityOverviewQuery = useQuery<AvailabilityOverview>({
|
||||||
|
queryKey: ["appointments", "availability-overview"],
|
||||||
|
queryFn: () => getAvailabilityOverview(),
|
||||||
|
enabled: enableOverview,
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
});
|
});
|
||||||
|
|
||||||
// List appointments query
|
// List appointments query
|
||||||
const appointmentsQuery = useQuery<Appointment[]>({
|
const appointmentsQuery = useQuery<Appointment[]>({
|
||||||
queryKey: ["appointments", "list"],
|
queryKey: ["appointments", "list"],
|
||||||
queryFn: () => listAppointments(),
|
queryFn: async () => {
|
||||||
enabled: false, // Only fetch when explicitly called
|
const data = await listAppointments();
|
||||||
|
return data || [];
|
||||||
|
},
|
||||||
|
enabled: true, // Enable by default to fetch user appointments
|
||||||
|
staleTime: 30 * 1000, // 30 seconds
|
||||||
|
retry: 1, // Retry once on failure
|
||||||
|
refetchOnMount: true, // Always refetch when component mounts
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get user appointments query
|
// Get user appointments query (disabled - using listAppointments instead)
|
||||||
const userAppointmentsQuery = useQuery<Appointment[]>({
|
const userAppointmentsQuery = useQuery<Appointment[]>({
|
||||||
queryKey: ["appointments", "user"],
|
queryKey: ["appointments", "user"],
|
||||||
queryFn: () => getUserAppointments(),
|
queryFn: () => getUserAppointments(),
|
||||||
|
enabled: false, // Disabled - using listAppointments endpoint instead
|
||||||
staleTime: 30 * 1000, // 30 seconds
|
staleTime: 30 * 1000, // 30 seconds
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -78,10 +138,16 @@ export function useAppointments() {
|
|||||||
staleTime: 1 * 60 * 1000, // 1 minute
|
staleTime: 1 * 60 * 1000, // 1 minute
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get user appointment stats query
|
// Get user appointment stats query - disabled because it requires email parameter
|
||||||
|
// Use getUserAppointmentStats(email) directly where email is available
|
||||||
const userAppointmentStatsQuery = useQuery<UserAppointmentStats>({
|
const userAppointmentStatsQuery = useQuery<UserAppointmentStats>({
|
||||||
queryKey: ["appointments", "user", "stats"],
|
queryKey: ["appointments", "user", "stats"],
|
||||||
queryFn: () => getUserAppointmentStats(),
|
queryFn: async () => {
|
||||||
|
// This query is disabled - getUserAppointmentStats requires email parameter
|
||||||
|
// Use getUserAppointmentStats(email) directly in components where email is available
|
||||||
|
return {} as UserAppointmentStats;
|
||||||
|
},
|
||||||
|
enabled: false, // Disabled - requires email parameter which hook doesn't have access to
|
||||||
staleTime: 1 * 60 * 1000, // 1 minute
|
staleTime: 1 * 60 * 1000, // 1 minute
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -172,6 +238,9 @@ export function useAppointments() {
|
|||||||
// Queries
|
// Queries
|
||||||
availableDates: availableDatesQuery.data?.dates || [],
|
availableDates: availableDatesQuery.data?.dates || [],
|
||||||
availableDatesResponse: availableDatesQuery.data,
|
availableDatesResponse: availableDatesQuery.data,
|
||||||
|
weeklyAvailability: weeklyAvailabilityQuery.data,
|
||||||
|
availabilityConfig: availabilityConfigQuery.data,
|
||||||
|
availabilityOverview: availabilityOverviewQuery.data,
|
||||||
appointments: appointmentsQuery.data || [],
|
appointments: appointmentsQuery.data || [],
|
||||||
userAppointments: userAppointmentsQuery.data || [],
|
userAppointments: userAppointmentsQuery.data || [],
|
||||||
adminAvailability: adminAvailabilityQuery.data,
|
adminAvailability: adminAvailabilityQuery.data,
|
||||||
@ -180,6 +249,9 @@ export function useAppointments() {
|
|||||||
|
|
||||||
// Query states
|
// Query states
|
||||||
isLoadingAvailableDates: availableDatesQuery.isLoading,
|
isLoadingAvailableDates: availableDatesQuery.isLoading,
|
||||||
|
isLoadingWeeklyAvailability: weeklyAvailabilityQuery.isLoading,
|
||||||
|
isLoadingAvailabilityConfig: availabilityConfigQuery.isLoading,
|
||||||
|
isLoadingAvailabilityOverview: availabilityOverviewQuery.isLoading,
|
||||||
isLoadingAppointments: appointmentsQuery.isLoading,
|
isLoadingAppointments: appointmentsQuery.isLoading,
|
||||||
isLoadingUserAppointments: userAppointmentsQuery.isLoading,
|
isLoadingUserAppointments: userAppointmentsQuery.isLoading,
|
||||||
isLoadingAdminAvailability: adminAvailabilityQuery.isLoading,
|
isLoadingAdminAvailability: adminAvailabilityQuery.isLoading,
|
||||||
@ -188,12 +260,20 @@ export function useAppointments() {
|
|||||||
|
|
||||||
// Query refetch functions
|
// Query refetch functions
|
||||||
refetchAvailableDates: availableDatesQuery.refetch,
|
refetchAvailableDates: availableDatesQuery.refetch,
|
||||||
|
refetchWeeklyAvailability: weeklyAvailabilityQuery.refetch,
|
||||||
|
refetchAvailabilityConfig: availabilityConfigQuery.refetch,
|
||||||
|
refetchAvailabilityOverview: availabilityOverviewQuery.refetch,
|
||||||
refetchAppointments: appointmentsQuery.refetch,
|
refetchAppointments: appointmentsQuery.refetch,
|
||||||
refetchUserAppointments: userAppointmentsQuery.refetch,
|
refetchUserAppointments: userAppointmentsQuery.refetch,
|
||||||
refetchAdminAvailability: adminAvailabilityQuery.refetch,
|
refetchAdminAvailability: adminAvailabilityQuery.refetch,
|
||||||
refetchStats: appointmentStatsQuery.refetch,
|
refetchStats: appointmentStatsQuery.refetch,
|
||||||
refetchUserStats: userAppointmentStatsQuery.refetch,
|
refetchUserStats: userAppointmentStatsQuery.refetch,
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
checkDateAvailability: async (date: string) => {
|
||||||
|
return await checkDateAvailability(date);
|
||||||
|
},
|
||||||
|
|
||||||
// Hooks for specific queries
|
// Hooks for specific queries
|
||||||
useAppointmentDetail,
|
useAppointmentDetail,
|
||||||
useJitsiMeetingInfo,
|
useJitsiMeetingInfo,
|
||||||
|
|||||||
@ -16,6 +16,11 @@ import type {
|
|||||||
UserAppointmentStats,
|
UserAppointmentStats,
|
||||||
JitsiMeetingInfo,
|
JitsiMeetingInfo,
|
||||||
ApiError,
|
ApiError,
|
||||||
|
WeeklyAvailabilityResponse,
|
||||||
|
AvailabilityConfig,
|
||||||
|
CheckDateAvailabilityResponse,
|
||||||
|
AvailabilityOverview,
|
||||||
|
SelectedSlot,
|
||||||
} from "@/lib/models/appointments";
|
} from "@/lib/models/appointments";
|
||||||
|
|
||||||
// Helper function to extract error message from API response
|
// Helper function to extract error message from API response
|
||||||
@ -55,58 +60,89 @@ export async function createAppointment(
|
|||||||
if (!input.first_name || !input.last_name || !input.email) {
|
if (!input.first_name || !input.last_name || !input.email) {
|
||||||
throw new Error("First name, last name, and email are required");
|
throw new Error("First name, last name, and email are required");
|
||||||
}
|
}
|
||||||
if (!input.preferred_dates || input.preferred_dates.length === 0) {
|
|
||||||
throw new Error("At least one preferred date is required");
|
// New API format: use selected_slots
|
||||||
}
|
if (!input.selected_slots || input.selected_slots.length === 0) {
|
||||||
if (!input.preferred_time_slots || input.preferred_time_slots.length === 0) {
|
throw new Error("At least one time slot must be selected");
|
||||||
throw new Error("At least one preferred time slot is required");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate date format (YYYY-MM-DD)
|
// Validate and clean selected_slots to ensure all have day and time_slot
|
||||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
// This filters out any invalid slots and ensures proper format
|
||||||
for (const date of input.preferred_dates) {
|
const validSlots: SelectedSlot[] = input.selected_slots
|
||||||
if (!dateRegex.test(date)) {
|
.filter((slot, index) => {
|
||||||
throw new Error(`Invalid date format: ${date}. Expected YYYY-MM-DD format.`);
|
// Check if slot exists and is an object
|
||||||
}
|
if (!slot || typeof slot !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Check if both day and time_slot properties exist
|
||||||
|
if (typeof slot.day === 'undefined' || typeof slot.time_slot === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Validate day is a number between 0-6
|
||||||
|
const dayNum = Number(slot.day);
|
||||||
|
if (isNaN(dayNum) || dayNum < 0 || dayNum > 6) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Validate time_slot is a valid string (normalize to lowercase)
|
||||||
|
const timeSlot = String(slot.time_slot).toLowerCase().trim();
|
||||||
|
if (!['morning', 'afternoon', 'evening'].includes(timeSlot)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map(slot => ({
|
||||||
|
day: Number(slot.day),
|
||||||
|
time_slot: String(slot.time_slot).toLowerCase().trim() as "morning" | "afternoon" | "evening",
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (validSlots.length === 0) {
|
||||||
|
throw new Error("At least one valid time slot must be selected. Each slot must have both 'day' (0-6) and 'time_slot' (morning, afternoon, or evening).");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate time slots
|
// Limit field lengths to prevent database errors (100 char limit for all string fields)
|
||||||
const validTimeSlots = ["morning", "afternoon", "evening"];
|
// Truncate all string fields BEFORE trimming to handle edge cases
|
||||||
for (const slot of input.preferred_time_slots) {
|
const firstName = input.first_name ? String(input.first_name).trim().substring(0, 100) : '';
|
||||||
if (!validTimeSlots.includes(slot)) {
|
const lastName = input.last_name ? String(input.last_name).trim().substring(0, 100) : '';
|
||||||
throw new Error(`Invalid time slot: ${slot}. Must be one of: ${validTimeSlots.join(", ")}`);
|
const email = input.email ? String(input.email).trim().toLowerCase().substring(0, 100) : '';
|
||||||
}
|
const phone = input.phone ? String(input.phone).trim().substring(0, 100) : undefined;
|
||||||
}
|
const reason = input.reason ? String(input.reason).trim().substring(0, 100) : undefined;
|
||||||
|
|
||||||
// Prepare the payload exactly as the API expects
|
// Build payload with only the fields the API expects - no extra fields
|
||||||
// Only include fields that the API accepts - no jitsi_room_id or other fields
|
|
||||||
const payload: {
|
const payload: {
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
email: string;
|
email: string;
|
||||||
preferred_dates: string[];
|
selected_slots: Array<{ day: number; time_slot: string }>;
|
||||||
preferred_time_slots: string[];
|
|
||||||
phone?: string;
|
phone?: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
} = {
|
} = {
|
||||||
first_name: input.first_name.trim(),
|
first_name: firstName,
|
||||||
last_name: input.last_name.trim(),
|
last_name: lastName,
|
||||||
email: input.email.trim().toLowerCase(),
|
email: email,
|
||||||
preferred_dates: input.preferred_dates,
|
selected_slots: validSlots.map(slot => ({
|
||||||
preferred_time_slots: input.preferred_time_slots,
|
day: Number(slot.day),
|
||||||
|
time_slot: String(slot.time_slot).toLowerCase().trim(),
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only add optional fields if they have values
|
// Only add optional fields if they have values (and are within length limits)
|
||||||
if (input.phone && input.phone.trim()) {
|
if (phone && phone.length > 0 && phone.length <= 100) {
|
||||||
payload.phone = input.phone.trim();
|
payload.phone = phone;
|
||||||
}
|
}
|
||||||
if (input.reason && input.reason.trim()) {
|
if (reason && reason.length > 0 && reason.length <= 100) {
|
||||||
payload.reason = input.reason.trim();
|
payload.reason = reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the payload for debugging
|
// Final validation: ensure all string fields in payload are exactly 100 chars or less
|
||||||
console.log("Creating appointment with payload:", JSON.stringify(payload, null, 2));
|
// This is a safety check to prevent any encoding or serialization issues
|
||||||
console.log("API endpoint:", API_ENDPOINTS.meetings.createAppointment);
|
const finalPayload = {
|
||||||
|
first_name: payload.first_name.substring(0, 100),
|
||||||
|
last_name: payload.last_name.substring(0, 100),
|
||||||
|
email: payload.email.substring(0, 100),
|
||||||
|
selected_slots: payload.selected_slots,
|
||||||
|
...(payload.phone && { phone: payload.phone.substring(0, 100) }),
|
||||||
|
...(payload.reason && { reason: payload.reason.substring(0, 100) }),
|
||||||
|
};
|
||||||
|
|
||||||
const response = await fetch(API_ENDPOINTS.meetings.createAppointment, {
|
const response = await fetch(API_ENDPOINTS.meetings.createAppointment, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@ -114,7 +150,7 @@ export async function createAppointment(
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${tokens.access}`,
|
Authorization: `Bearer ${tokens.access}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(finalPayload),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Read response text first (can only be read once)
|
// Read response text first (can only be read once)
|
||||||
@ -131,14 +167,6 @@ export async function createAppointment(
|
|||||||
}
|
}
|
||||||
data = JSON.parse(responseText);
|
data = JSON.parse(responseText);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If JSON parsing fails, log the actual response
|
|
||||||
console.error("Failed to parse JSON response:", {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
contentType,
|
|
||||||
url: API_ENDPOINTS.meetings.createAppointment,
|
|
||||||
preview: responseText.substring(0, 500)
|
|
||||||
});
|
|
||||||
throw new Error(`Server error (${response.status}): ${response.statusText || 'Invalid response format'}`);
|
throw new Error(`Server error (${response.status}): ${response.statusText || 'Invalid response format'}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -159,15 +187,6 @@ export async function createAppointment(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error("Non-JSON response received:", {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
contentType,
|
|
||||||
url: API_ENDPOINTS.meetings.createAppointment,
|
|
||||||
payload: input,
|
|
||||||
preview: responseText.substring(0, 1000)
|
|
||||||
});
|
|
||||||
|
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,7 +195,27 @@ export async function createAppointment(
|
|||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle different response formats
|
// Handle API response format: { appointment_id, message }
|
||||||
|
// According to API docs, response includes appointment_id and message
|
||||||
|
if (data.appointment_id) {
|
||||||
|
// Construct a minimal Appointment object from the response
|
||||||
|
// We'll use the input data plus the appointment_id from response
|
||||||
|
const appointment: Appointment = {
|
||||||
|
id: data.appointment_id,
|
||||||
|
first_name: input.first_name.trim(),
|
||||||
|
last_name: input.last_name.trim(),
|
||||||
|
email: input.email.trim().toLowerCase(),
|
||||||
|
phone: input.phone?.trim(),
|
||||||
|
reason: input.reason?.trim(),
|
||||||
|
selected_slots: validSlots,
|
||||||
|
status: "pending_review",
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
return appointment;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle different response formats for backward compatibility
|
||||||
if (data.appointment) {
|
if (data.appointment) {
|
||||||
return data.appointment;
|
return data.appointment;
|
||||||
}
|
}
|
||||||
@ -188,8 +227,9 @@ export async function createAppointment(
|
|||||||
return data as unknown as Appointment;
|
return data as unknown as Appointment;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get available dates
|
// Get available dates (optional endpoint - may fail if admin hasn't set availability)
|
||||||
export async function getAvailableDates(): Promise<AvailableDatesResponse> {
|
export async function getAvailableDates(): Promise<AvailableDatesResponse> {
|
||||||
|
try {
|
||||||
const response = await fetch(API_ENDPOINTS.meetings.availableDates, {
|
const response = await fetch(API_ENDPOINTS.meetings.availableDates, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
@ -197,11 +237,29 @@ export async function getAvailableDates(): Promise<AvailableDatesResponse> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const data: AvailableDatesResponse | string[] = await response.json();
|
// Handle different response formats
|
||||||
|
const contentType = response.headers.get("content-type");
|
||||||
|
let data: any;
|
||||||
|
|
||||||
|
if (contentType && contentType.includes("application/json")) {
|
||||||
|
const responseText = await response.text();
|
||||||
|
if (!responseText) {
|
||||||
|
throw new Error(`Server returned empty response (${response.status})`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
data = JSON.parse(responseText);
|
||||||
|
} catch (parseError) {
|
||||||
|
throw new Error(`Invalid response format (${response.status})`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`Server error (${response.status}): ${response.statusText || 'Invalid response'}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
// Return empty response instead of throwing - this endpoint is optional
|
||||||
throw new Error(errorMessage);
|
return {
|
||||||
|
dates: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// If API returns array directly, wrap it in response object
|
// If API returns array directly, wrap it in response object
|
||||||
@ -212,6 +270,95 @@ export async function getAvailableDates(): Promise<AvailableDatesResponse> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return data as AvailableDatesResponse;
|
return data as AvailableDatesResponse;
|
||||||
|
} catch (error) {
|
||||||
|
// Return empty response - don't break the app
|
||||||
|
return {
|
||||||
|
dates: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get weekly availability (Public)
|
||||||
|
export async function getWeeklyAvailability(): Promise<WeeklyAvailabilityResponse> {
|
||||||
|
const response = await fetch(API_ENDPOINTS.meetings.weeklyAvailability, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: any = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle different response formats - API might return array directly or wrapped
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If wrapped in an object, return as is (our interface supports it)
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get availability configuration (Public)
|
||||||
|
export async function getAvailabilityConfig(): Promise<AvailabilityConfig> {
|
||||||
|
const response = await fetch(API_ENDPOINTS.meetings.availabilityConfig, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: AvailabilityConfig = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check date availability (Public)
|
||||||
|
export async function checkDateAvailability(date: string): Promise<CheckDateAvailabilityResponse> {
|
||||||
|
const response = await fetch(API_ENDPOINTS.meetings.checkDateAvailability, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ date }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: CheckDateAvailabilityResponse = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get availability overview (Public)
|
||||||
|
export async function getAvailabilityOverview(): Promise<AvailabilityOverview> {
|
||||||
|
const response = await fetch(API_ENDPOINTS.meetings.availabilityOverview, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: AvailabilityOverview = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// List appointments (Admin sees all, users see their own)
|
// List appointments (Admin sees all, users see their own)
|
||||||
@ -234,23 +381,49 @@ export async function listAppointments(email?: string): Promise<Appointment[]> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const responseText = await response.text();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
let errorData: any;
|
||||||
|
try {
|
||||||
|
errorData = JSON.parse(responseText);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Server error (${response.status}): ${response.statusText || 'Internal Server Error'}`);
|
||||||
|
}
|
||||||
|
const errorMessage = extractErrorMessage(errorData as unknown as ApiError);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse JSON response
|
||||||
|
let data: any;
|
||||||
|
try {
|
||||||
|
if (!responseText || responseText.trim().length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
data = JSON.parse(responseText);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to parse response: Invalid JSON format`);
|
||||||
|
}
|
||||||
|
|
||||||
// Handle different response formats
|
// Handle different response formats
|
||||||
// API might return array directly or wrapped in an object
|
// API returns array directly: [{ id, first_name, ... }, ...]
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
// Handle wrapped responses (if any)
|
||||||
|
if (data && typeof data === 'object') {
|
||||||
if (data.appointments && Array.isArray(data.appointments)) {
|
if (data.appointments && Array.isArray(data.appointments)) {
|
||||||
return data.appointments;
|
return data.appointments;
|
||||||
}
|
}
|
||||||
if (data.results && Array.isArray(data.results)) {
|
if (data.results && Array.isArray(data.results)) {
|
||||||
return data.results;
|
return data.results;
|
||||||
|
}
|
||||||
|
// If data is an object but not an array and doesn't have appointments/results, return empty
|
||||||
|
// This shouldn't happen but handle gracefully
|
||||||
|
if (data.id || data.first_name) {
|
||||||
|
// Single appointment object, wrap in array
|
||||||
|
return [data];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
@ -398,13 +571,6 @@ export async function scheduleAppointment(
|
|||||||
errorMessage = `Server error: ${response.status} ${response.statusText || 'Unknown error'}`;
|
errorMessage = `Server error: ${response.status} ${response.statusText || 'Unknown error'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error("Schedule appointment error:", {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
data,
|
|
||||||
errorMessage,
|
|
||||||
});
|
|
||||||
|
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -449,37 +615,41 @@ export async function rejectAppointment(
|
|||||||
return data as unknown as Appointment;
|
return data as unknown as Appointment;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get admin availability (public version - tries without auth first)
|
// Get admin availability (public version - uses weekly availability endpoint instead)
|
||||||
export async function getPublicAvailability(): Promise<AdminAvailability | null> {
|
export async function getPublicAvailability(): Promise<AdminAvailability | null> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(API_ENDPOINTS.meetings.adminAvailability, {
|
// Use weekly availability endpoint which is public
|
||||||
method: "GET",
|
const weeklyAvailability = await getWeeklyAvailability();
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
// Normalize to array format
|
||||||
},
|
const weekArray = Array.isArray(weeklyAvailability)
|
||||||
});
|
? weeklyAvailability
|
||||||
|
: (weeklyAvailability as any).week || [];
|
||||||
if (!response.ok) {
|
|
||||||
|
if (!weekArray || weekArray.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: any = await response.json();
|
// Convert weekly availability to AdminAvailability format
|
||||||
|
const availabilitySchedule: Record<string, string[]> = {};
|
||||||
|
const availableDays: number[] = [];
|
||||||
|
const availableDaysDisplay: string[] = [];
|
||||||
|
|
||||||
// Handle both string and array formats for available_days
|
weekArray.forEach((day: any) => {
|
||||||
let availableDays: number[] = [];
|
if (day.is_available && day.available_slots && day.available_slots.length > 0) {
|
||||||
if (typeof data.available_days === 'string') {
|
availabilitySchedule[day.day.toString()] = day.available_slots;
|
||||||
try {
|
availableDays.push(day.day);
|
||||||
availableDays = JSON.parse(data.available_days);
|
availableDaysDisplay.push(day.day_name);
|
||||||
} catch {
|
|
||||||
availableDays = data.available_days.split(',').map((d: string) => parseInt(d.trim())).filter((d: number) => !isNaN(d));
|
|
||||||
}
|
}
|
||||||
} else if (Array.isArray(data.available_days)) {
|
});
|
||||||
availableDays = data.available_days;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
available_days: availableDays,
|
available_days: availableDays,
|
||||||
available_days_display: data.available_days_display || [],
|
available_days_display: availableDaysDisplay,
|
||||||
|
availability_schedule: availabilitySchedule,
|
||||||
|
all_available_slots: weekArray
|
||||||
|
.filter((d: any) => d.is_available)
|
||||||
|
.flatMap((d: any) => d.available_slots.map((slot: string) => ({ day: d.day, time_slot: slot as "morning" | "afternoon" | "evening" }))),
|
||||||
} as AdminAvailability;
|
} as AdminAvailability;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return null;
|
return null;
|
||||||
@ -509,7 +679,67 @@ export async function getAdminAvailability(): Promise<AdminAvailability> {
|
|||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle both string and array formats for available_days
|
// Handle new format with availability_schedule
|
||||||
|
// API returns availability_schedule, which may be a JSON string or object
|
||||||
|
// Time slots are strings: "morning", "afternoon", "evening"
|
||||||
|
if (data.availability_schedule) {
|
||||||
|
let availabilitySchedule: Record<string, string[]>;
|
||||||
|
|
||||||
|
// Map numeric indices to string names (in case API returns numeric indices)
|
||||||
|
const numberToTimeSlot: Record<number, string> = {
|
||||||
|
0: 'morning',
|
||||||
|
1: 'afternoon',
|
||||||
|
2: 'evening',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse if it's a string, otherwise use as-is
|
||||||
|
let rawSchedule: Record<string, number[] | string[]>;
|
||||||
|
if (typeof data.availability_schedule === 'string') {
|
||||||
|
try {
|
||||||
|
rawSchedule = JSON.parse(data.availability_schedule);
|
||||||
|
} catch (parseError) {
|
||||||
|
rawSchedule = {};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rawSchedule = data.availability_schedule;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to string format, handling both numeric indices and string values
|
||||||
|
availabilitySchedule = {};
|
||||||
|
Object.keys(rawSchedule).forEach(day => {
|
||||||
|
const slots = rawSchedule[day];
|
||||||
|
if (Array.isArray(slots) && slots.length > 0) {
|
||||||
|
// Check if slots are numbers (indices) or already strings
|
||||||
|
if (typeof slots[0] === 'number') {
|
||||||
|
// Convert numeric indices to string names
|
||||||
|
availabilitySchedule[day] = (slots as number[])
|
||||||
|
.map((num: number) => numberToTimeSlot[num])
|
||||||
|
.filter((slot: string | undefined) => slot !== undefined) as string[];
|
||||||
|
} else {
|
||||||
|
// Already strings, validate and use as-is
|
||||||
|
availabilitySchedule[day] = (slots as string[]).filter(slot =>
|
||||||
|
['morning', 'afternoon', 'evening'].includes(slot)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableDays = Object.keys(availabilitySchedule).map(Number);
|
||||||
|
|
||||||
|
// Generate available_days_display if not provided
|
||||||
|
const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||||
|
const availableDaysDisplay = availableDays.map(day => dayNames[day] || `Day ${day}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
available_days: availableDays,
|
||||||
|
available_days_display: data.availability_schedule_display ? [data.availability_schedule_display] : availableDaysDisplay,
|
||||||
|
availability_schedule: availabilitySchedule,
|
||||||
|
availability_schedule_display: data.availability_schedule_display,
|
||||||
|
all_available_slots: data.all_available_slots || [],
|
||||||
|
} as AdminAvailability;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle legacy format
|
||||||
let availableDays: number[] = [];
|
let availableDays: number[] = [];
|
||||||
if (typeof data.available_days === 'string') {
|
if (typeof data.available_days === 'string') {
|
||||||
try {
|
try {
|
||||||
@ -538,14 +768,69 @@ export async function updateAdminAvailability(
|
|||||||
throw new Error("Authentication required.");
|
throw new Error("Authentication required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure available_days is an array of numbers
|
// Prepare payload using new format (availability_schedule)
|
||||||
const payload = {
|
// API expects availability_schedule as an object with string keys (day numbers) and string arrays (time slot names)
|
||||||
available_days: Array.isArray(input.available_days)
|
// Format: { "0": ["morning", "afternoon"], "1": ["evening"], ... }
|
||||||
|
const payload: any = {};
|
||||||
|
|
||||||
|
if (input.availability_schedule) {
|
||||||
|
// Validate and clean the schedule object
|
||||||
|
// API expects: { "0": ["morning", "evening"], "1": ["afternoon"], ... }
|
||||||
|
// Time slots are strings: "morning", "afternoon", "evening"
|
||||||
|
const cleanedSchedule: Record<string, string[]> = {};
|
||||||
|
Object.keys(input.availability_schedule).forEach(key => {
|
||||||
|
// Ensure key is a valid day (0-6)
|
||||||
|
const dayNum = parseInt(key);
|
||||||
|
if (isNaN(dayNum) || dayNum < 0 || dayNum > 6) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slots = input.availability_schedule[key];
|
||||||
|
if (Array.isArray(slots) && slots.length > 0) {
|
||||||
|
// Filter to only valid time slot strings and remove duplicates
|
||||||
|
const validSlots = slots
|
||||||
|
.filter((slot: string) =>
|
||||||
|
typeof slot === 'string' && ['morning', 'afternoon', 'evening'].includes(slot)
|
||||||
|
)
|
||||||
|
.filter((slot: string, index: number, self: string[]) =>
|
||||||
|
self.indexOf(slot) === index
|
||||||
|
); // Remove duplicates
|
||||||
|
|
||||||
|
if (validSlots.length > 0) {
|
||||||
|
// Ensure day key is a string (as per API spec)
|
||||||
|
cleanedSchedule[key.toString()] = validSlots;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Object.keys(cleanedSchedule).length === 0) {
|
||||||
|
throw new Error("At least one day with valid time slots must be provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort the schedule keys for consistency
|
||||||
|
const sortedSchedule: Record<string, string[]> = {};
|
||||||
|
Object.keys(cleanedSchedule)
|
||||||
|
.sort((a, b) => parseInt(a) - parseInt(b))
|
||||||
|
.forEach(key => {
|
||||||
|
sortedSchedule[key] = cleanedSchedule[key];
|
||||||
|
});
|
||||||
|
|
||||||
|
// IMPORTANT: API expects availability_schedule as an object (not stringified)
|
||||||
|
// Format: { "0": ["morning", "afternoon"], "1": ["evening"], ... }
|
||||||
|
payload.availability_schedule = sortedSchedule;
|
||||||
|
} else if (input.available_days) {
|
||||||
|
// Legacy format: available_days
|
||||||
|
payload.available_days = Array.isArray(input.available_days)
|
||||||
? input.available_days.map(day => Number(day))
|
? input.available_days.map(day => Number(day))
|
||||||
: input.available_days
|
: input.available_days;
|
||||||
};
|
} else {
|
||||||
|
throw new Error("Either availability_schedule or available_days must be provided");
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(API_ENDPOINTS.meetings.adminAvailability, {
|
|
||||||
|
// Try PUT first, fallback to PATCH if needed
|
||||||
|
// The payload object will be JSON stringified, including availability_schedule as an object
|
||||||
|
let response = await fetch(API_ENDPOINTS.meetings.adminAvailability, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@ -554,26 +839,251 @@ export async function updateAdminAvailability(
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// If PUT fails with 500, try PATCH (some APIs prefer PATCH for updates)
|
||||||
|
if (!response.ok && response.status === 500) {
|
||||||
|
response = await fetch(API_ENDPOINTS.meetings.adminAvailability, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${tokens.access}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read response text first (can only be read once)
|
||||||
|
const responseText = await response.text();
|
||||||
let data: any;
|
let data: any;
|
||||||
try {
|
|
||||||
data = await response.json();
|
// Get content type
|
||||||
|
const contentType = response.headers.get("content-type") || "";
|
||||||
|
|
||||||
|
// Handle empty response
|
||||||
|
if (!responseText || responseText.trim().length === 0) {
|
||||||
|
// If successful status but empty response, refetch the availability
|
||||||
|
if (response.ok) {
|
||||||
|
return await getAdminAvailability();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Server error (${response.status}): ${response.statusText || 'Empty response from server'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse as JSON
|
||||||
|
if (contentType.includes("application/json")) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(responseText);
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
// If response is not JSON, use status text
|
throw new Error(`Server error (${response.status}): Invalid JSON response format`);
|
||||||
throw new Error(response.statusText || "Failed to update availability");
|
}
|
||||||
|
} else {
|
||||||
|
// Response is not JSON - try to extract useful information
|
||||||
|
// Try to extract error message from HTML if it's an HTML error page
|
||||||
|
let errorMessage = `Server error (${response.status}): ${response.statusText || 'Internal Server Error'}`;
|
||||||
|
let actualError = '';
|
||||||
|
let errorType = '';
|
||||||
|
let fullTraceback = '';
|
||||||
|
|
||||||
|
if (responseText) {
|
||||||
|
// Extract Django error details from HTML
|
||||||
|
const titleMatch = responseText.match(/<title[^>]*>(.*?)<\/title>/i);
|
||||||
|
const h1Match = responseText.match(/<h1[^>]*>(.*?)<\/h1>/i);
|
||||||
|
|
||||||
|
// Try to find the actual error traceback in <pre> tags (Django debug pages)
|
||||||
|
const tracebackMatch = responseText.match(/<pre[^>]*class="[^"]*traceback[^"]*"[^>]*>([\s\S]*?)<\/pre>/i) ||
|
||||||
|
responseText.match(/<pre[^>]*>([\s\S]*?)<\/pre>/i);
|
||||||
|
|
||||||
|
// Extract the actual error type and message
|
||||||
|
const errorTypeMatch = responseText.match(/<h2[^>]*>(.*?)<\/h2>/i);
|
||||||
|
|
||||||
|
if (tracebackMatch && tracebackMatch[1]) {
|
||||||
|
// Extract the full traceback and find the actual error
|
||||||
|
const tracebackText = tracebackMatch[1].replace(/<[^>]*>/g, ''); // Remove HTML tags
|
||||||
|
fullTraceback = tracebackText;
|
||||||
|
const tracebackLines = tracebackText.split('\n').filter(line => line.trim());
|
||||||
|
|
||||||
|
// Look for the actual error message - Django errors usually appear at the end
|
||||||
|
// First, try to find error patterns in the entire traceback
|
||||||
|
const errorPatterns = [
|
||||||
|
// Database column errors
|
||||||
|
/column\s+[\w.]+\.(\w+)\s+(does not exist|already exists|is missing)/i,
|
||||||
|
// Programming errors
|
||||||
|
/(ProgrammingError|OperationalError|IntegrityError|DatabaseError|ValueError|TypeError|AttributeError|KeyError):\s*(.+?)(?:\n|$)/i,
|
||||||
|
// Generic error patterns
|
||||||
|
/^(\w+Error):\s*(.+)$/i,
|
||||||
|
// Error messages without type
|
||||||
|
/^(.+Error[:\s]+.+)$/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Search from the end backwards (errors are usually at the end)
|
||||||
|
for (let i = tracebackLines.length - 1; i >= 0; i--) {
|
||||||
|
const line = tracebackLines[i];
|
||||||
|
|
||||||
|
// Check each pattern
|
||||||
|
for (const pattern of errorPatterns) {
|
||||||
|
const match = line.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
// For database column errors, capture the full message
|
||||||
|
if (pattern.source.includes('column')) {
|
||||||
|
actualError = match[0];
|
||||||
|
errorType = 'DatabaseError';
|
||||||
|
} else if (match[1] && match[2]) {
|
||||||
|
errorType = match[1];
|
||||||
|
actualError = match[2].trim();
|
||||||
|
} else {
|
||||||
|
actualError = match[0];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (actualError) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no pattern match, look for lines containing "Error" or common error keywords
|
||||||
|
if (!actualError) {
|
||||||
|
for (let i = tracebackLines.length - 1; i >= Math.max(0, tracebackLines.length - 10); i--) {
|
||||||
|
const line = tracebackLines[i];
|
||||||
|
if (line.match(/(Error|Exception|Failed|Invalid|Missing|does not exist|already exists)/i)) {
|
||||||
|
actualError = line;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: get the last line
|
||||||
|
if (!actualError && tracebackLines.length > 0) {
|
||||||
|
actualError = tracebackLines[tracebackLines.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up the error message
|
||||||
|
if (actualError) {
|
||||||
|
actualError = actualError.trim();
|
||||||
|
// Remove common prefixes
|
||||||
|
actualError = actualError.replace(/^(Traceback|File|Error|Exception):\s*/i, '');
|
||||||
|
}
|
||||||
|
} else if (errorTypeMatch && errorTypeMatch[1]) {
|
||||||
|
errorType = errorTypeMatch[1].replace(/<[^>]*>/g, '').trim();
|
||||||
|
actualError = errorType;
|
||||||
|
if (errorType && errorType.length < 200) {
|
||||||
|
errorMessage += `. ${errorType}`;
|
||||||
|
}
|
||||||
|
} else if (h1Match && h1Match[1]) {
|
||||||
|
actualError = h1Match[1].replace(/<[^>]*>/g, '').trim();
|
||||||
|
if (actualError && actualError.length < 200) {
|
||||||
|
errorMessage += `. ${actualError}`;
|
||||||
|
}
|
||||||
|
} else if (titleMatch && titleMatch[1]) {
|
||||||
|
actualError = titleMatch[1].replace(/<[^>]*>/g, '').trim();
|
||||||
|
if (actualError && actualError.length < 200) {
|
||||||
|
errorMessage += `. ${actualError}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update error message with the extracted error
|
||||||
|
if (actualError) {
|
||||||
|
errorMessage = `Server error (${response.status}): ${actualError}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
||||||
console.error("Availability update error:", {
|
|
||||||
status: response.status,
|
// Build detailed error message
|
||||||
statusText: response.statusText,
|
let detailedError = `Server error (${response.status}): `;
|
||||||
data,
|
if (data && typeof data === 'object') {
|
||||||
payload
|
if (data.detail) {
|
||||||
});
|
detailedError += Array.isArray(data.detail) ? data.detail.join(", ") : String(data.detail);
|
||||||
throw new Error(errorMessage);
|
} else if (data.error) {
|
||||||
|
detailedError += Array.isArray(data.error) ? data.error.join(", ") : String(data.error);
|
||||||
|
} else if (data.message) {
|
||||||
|
detailedError += Array.isArray(data.message) ? data.message.join(", ") : String(data.message);
|
||||||
|
} else {
|
||||||
|
detailedError += response.statusText || 'Failed to update availability';
|
||||||
|
}
|
||||||
|
} else if (responseText && responseText.length > 0) {
|
||||||
|
// Try to extract error from HTML response if it's not JSON
|
||||||
|
detailedError += responseText.substring(0, 200);
|
||||||
|
} else {
|
||||||
|
detailedError += response.statusText || 'Failed to update availability';
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(detailedError);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle both string and array formats for available_days in response
|
// Handle new format with availability_schedule in response
|
||||||
|
// API returns availability_schedule, which may be a JSON string or object
|
||||||
|
// Time slots may be strings or numeric indices
|
||||||
|
if (data && data.availability_schedule) {
|
||||||
|
let availabilitySchedule: Record<string, string[]>;
|
||||||
|
|
||||||
|
// Map numeric indices to string names (in case API returns numeric indices)
|
||||||
|
const numberToTimeSlot: Record<number, string> = {
|
||||||
|
0: 'morning',
|
||||||
|
1: 'afternoon',
|
||||||
|
2: 'evening',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse if it's a string, otherwise use as-is
|
||||||
|
let rawSchedule: Record<string, number[] | string[]>;
|
||||||
|
if (typeof data.availability_schedule === 'string') {
|
||||||
|
try {
|
||||||
|
rawSchedule = JSON.parse(data.availability_schedule);
|
||||||
|
} catch (parseError) {
|
||||||
|
rawSchedule = {};
|
||||||
|
}
|
||||||
|
} else if (typeof data.availability_schedule === 'object') {
|
||||||
|
rawSchedule = data.availability_schedule;
|
||||||
|
} else {
|
||||||
|
rawSchedule = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to string format, handling both numeric indices and string values
|
||||||
|
availabilitySchedule = {};
|
||||||
|
Object.keys(rawSchedule).forEach(day => {
|
||||||
|
const slots = rawSchedule[day];
|
||||||
|
if (Array.isArray(slots) && slots.length > 0) {
|
||||||
|
// Check if slots are numbers (indices) or already strings
|
||||||
|
if (typeof slots[0] === 'number') {
|
||||||
|
// Convert numeric indices to string names
|
||||||
|
availabilitySchedule[day] = (slots as number[])
|
||||||
|
.map((num: number) => numberToTimeSlot[num])
|
||||||
|
.filter((slot: string | undefined) => slot !== undefined) as string[];
|
||||||
|
} else {
|
||||||
|
// Already strings, validate and use as-is
|
||||||
|
availabilitySchedule[day] = (slots as string[]).filter(slot =>
|
||||||
|
['morning', 'afternoon', 'evening'].includes(slot)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableDays = Object.keys(availabilitySchedule).map(Number);
|
||||||
|
const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||||
|
const availableDaysDisplay = availableDays.map(day => dayNames[day] || `Day ${day}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
available_days: availableDays,
|
||||||
|
available_days_display: data.availability_schedule_display ?
|
||||||
|
(Array.isArray(data.availability_schedule_display) ?
|
||||||
|
data.availability_schedule_display :
|
||||||
|
[data.availability_schedule_display]) :
|
||||||
|
availableDaysDisplay,
|
||||||
|
availability_schedule: availabilitySchedule,
|
||||||
|
availability_schedule_display: data.availability_schedule_display,
|
||||||
|
all_available_slots: data.all_available_slots || [],
|
||||||
|
} as AdminAvailability;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If response is empty but successful (200), return empty availability
|
||||||
|
// This might happen if the server doesn't return data on success
|
||||||
|
if (response.ok && (!data || Object.keys(data).length === 0)) {
|
||||||
|
// Refetch the availability to get the updated data
|
||||||
|
return getAdminAvailability();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle legacy format
|
||||||
let availableDays: number[] = [];
|
let availableDays: number[] = [];
|
||||||
if (typeof data.available_days === 'string') {
|
if (typeof data.available_days === 'string') {
|
||||||
try {
|
try {
|
||||||
@ -619,28 +1129,49 @@ export async function getAppointmentStats(): Promise<AppointmentStats> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get user appointment stats
|
// Get user appointment stats
|
||||||
export async function getUserAppointmentStats(): Promise<UserAppointmentStats> {
|
export async function getUserAppointmentStats(email: string): Promise<UserAppointmentStats> {
|
||||||
const tokens = getStoredTokens();
|
const tokens = getStoredTokens();
|
||||||
|
|
||||||
if (!tokens.access) {
|
if (!tokens.access) {
|
||||||
throw new Error("Authentication required.");
|
throw new Error("Authentication required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
throw new Error("Email is required to fetch user appointment stats.");
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(API_ENDPOINTS.meetings.userAppointmentStats, {
|
const response = await fetch(API_ENDPOINTS.meetings.userAppointmentStats, {
|
||||||
method: "GET",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${tokens.access}`,
|
Authorization: `Bearer ${tokens.access}`,
|
||||||
},
|
},
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data: UserAppointmentStats = await response.json();
|
const responseText = await response.text();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
let errorData: any;
|
||||||
|
try {
|
||||||
|
errorData = JSON.parse(responseText);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Server error (${response.status}): ${response.statusText || 'Internal Server Error'}`);
|
||||||
|
}
|
||||||
|
const errorMessage = extractErrorMessage(errorData as unknown as ApiError);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let data: UserAppointmentStats;
|
||||||
|
try {
|
||||||
|
if (!responseText || responseText.trim().length === 0) {
|
||||||
|
throw new Error("Empty response from server");
|
||||||
|
}
|
||||||
|
data = JSON.parse(responseText);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to parse response: Invalid JSON format`);
|
||||||
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,10 @@ export const API_ENDPOINTS = {
|
|||||||
userAppointments: `${API_BASE_URL}/meetings/user/appointments/`,
|
userAppointments: `${API_BASE_URL}/meetings/user/appointments/`,
|
||||||
userAppointmentStats: `${API_BASE_URL}/meetings/user/appointments/stats/`,
|
userAppointmentStats: `${API_BASE_URL}/meetings/user/appointments/stats/`,
|
||||||
adminAvailability: `${API_BASE_URL}/meetings/admin/availability/`,
|
adminAvailability: `${API_BASE_URL}/meetings/admin/availability/`,
|
||||||
|
weeklyAvailability: `${API_BASE_URL}/meetings/availability/weekly/`,
|
||||||
|
availabilityConfig: `${API_BASE_URL}/meetings/availability/config/`,
|
||||||
|
checkDateAvailability: `${API_BASE_URL}/meetings/availability/check/`,
|
||||||
|
availabilityOverview: `${API_BASE_URL}/meetings/availability/overview/`,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@ -7,9 +7,10 @@ export interface Appointment {
|
|||||||
email: string;
|
email: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
preferred_dates: string[]; // YYYY-MM-DD format
|
preferred_dates?: string; // YYYY-MM-DD format (legacy) - API returns as string, not array
|
||||||
preferred_time_slots: string[]; // "morning", "afternoon", "evening"
|
preferred_time_slots?: string; // "morning", "afternoon", "evening" (legacy) - API returns as string
|
||||||
status: "pending_review" | "scheduled" | "rejected" | "completed";
|
selected_slots?: SelectedSlot[]; // New format: day-time combinations
|
||||||
|
status: "pending_review" | "scheduled" | "rejected" | "completed" | "cancelled";
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
scheduled_datetime?: string;
|
scheduled_datetime?: string;
|
||||||
@ -17,9 +18,28 @@ export interface Appointment {
|
|||||||
rejection_reason?: string;
|
rejection_reason?: string;
|
||||||
jitsi_meet_url?: string;
|
jitsi_meet_url?: string;
|
||||||
jitsi_room_id?: string;
|
jitsi_room_id?: string;
|
||||||
has_jitsi_meeting?: boolean;
|
has_jitsi_meeting?: boolean | string;
|
||||||
can_join_meeting?: boolean;
|
can_join_meeting?: boolean | string;
|
||||||
meeting_status?: string;
|
meeting_status?: string;
|
||||||
|
matching_availability?: MatchingAvailability | Array<{
|
||||||
|
date: string;
|
||||||
|
day_name: string;
|
||||||
|
available_slots: string[];
|
||||||
|
date_obj?: string;
|
||||||
|
}>;
|
||||||
|
are_preferences_available?: boolean | string;
|
||||||
|
// Additional fields from API response
|
||||||
|
full_name?: string;
|
||||||
|
formatted_created_at?: string;
|
||||||
|
formatted_scheduled_datetime?: string;
|
||||||
|
preferred_dates_display?: string;
|
||||||
|
preferred_time_slots_display?: string;
|
||||||
|
meeting_duration_display?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectedSlot {
|
||||||
|
day: number; // 0-6 (Monday-Sunday)
|
||||||
|
time_slot: "morning" | "afternoon" | "evening";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppointmentResponse {
|
export interface AppointmentResponse {
|
||||||
@ -36,14 +56,74 @@ export interface AppointmentsListResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AvailableDatesResponse {
|
export interface AvailableDatesResponse {
|
||||||
dates: string[]; // YYYY-MM-DD format
|
dates?: string[]; // YYYY-MM-DD format (legacy)
|
||||||
available_days?: number[]; // 0-6 (Monday-Sunday)
|
available_days?: number[]; // 0-6 (Monday-Sunday)
|
||||||
available_days_display?: string[];
|
available_days_display?: string[];
|
||||||
|
// New format - array of date objects with time slots
|
||||||
|
available_dates?: Array<{
|
||||||
|
date: string; // YYYY-MM-DD
|
||||||
|
day_name: string;
|
||||||
|
available_slots: string[];
|
||||||
|
available_slots_display?: string[];
|
||||||
|
is_available: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklyAvailabilityDay {
|
||||||
|
day: number; // 0-6 (Monday-Sunday)
|
||||||
|
day_name: string;
|
||||||
|
available_slots: string[]; // ["morning", "afternoon", "evening"]
|
||||||
|
available_slots_display?: string[];
|
||||||
|
is_available: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WeeklyAvailabilityResponse = WeeklyAvailabilityDay[] | {
|
||||||
|
week?: WeeklyAvailabilityDay[];
|
||||||
|
[key: string]: any; // Allow for different response formats
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AvailabilityConfig {
|
||||||
|
days_of_week: Record<string, string>; // {"0": "Monday", ...}
|
||||||
|
time_slots: Record<string, string>; // {"morning": "Morning (9AM - 12PM)", ...}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckDateAvailabilityResponse {
|
||||||
|
date: string;
|
||||||
|
day_name: string;
|
||||||
|
available_slots: string[];
|
||||||
|
available_slots_display?: string[];
|
||||||
|
is_available: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvailabilityOverview {
|
||||||
|
available: boolean;
|
||||||
|
total_available_slots: number;
|
||||||
|
available_days: string[];
|
||||||
|
next_available_dates: Array<{
|
||||||
|
date: string;
|
||||||
|
day_name: string;
|
||||||
|
available_slots: string[];
|
||||||
|
is_available: boolean;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminAvailability {
|
export interface AdminAvailability {
|
||||||
available_days: number[]; // 0-6 (Monday-Sunday)
|
available_days?: number[]; // 0-6 (Monday-Sunday) (legacy)
|
||||||
available_days_display: string[];
|
available_days_display?: string[];
|
||||||
|
availability_schedule?: Record<string, string[]>; // {"0": ["morning", "evening"], "1": ["afternoon"]}
|
||||||
|
availability_schedule_display?: string;
|
||||||
|
all_available_slots?: SelectedSlot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchingAvailability {
|
||||||
|
appointment_id: string;
|
||||||
|
preferences_match_availability: boolean;
|
||||||
|
matching_slots: Array<{
|
||||||
|
date: string; // YYYY-MM-DD
|
||||||
|
time_slot: string;
|
||||||
|
day: number;
|
||||||
|
}>;
|
||||||
|
total_matching_slots: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppointmentStats {
|
export interface AppointmentStats {
|
||||||
@ -62,6 +142,7 @@ export interface UserAppointmentStats {
|
|||||||
rejected: number;
|
rejected: number;
|
||||||
completed: number;
|
completed: number;
|
||||||
completion_rate: number;
|
completion_rate: number;
|
||||||
|
email?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JitsiMeetingInfo {
|
export interface JitsiMeetingInfo {
|
||||||
|
|||||||
@ -1,19 +1,37 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
// Create Appointment Schema
|
// Selected Slot Schema (for new API format)
|
||||||
|
export const selectedSlotSchema = z.object({
|
||||||
|
day: z.number().int().min(0).max(6),
|
||||||
|
time_slot: z.enum(["morning", "afternoon", "evening"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SelectedSlotInput = z.infer<typeof selectedSlotSchema>;
|
||||||
|
|
||||||
|
// Create Appointment Schema (updated to use selected_slots)
|
||||||
export const createAppointmentSchema = z.object({
|
export const createAppointmentSchema = z.object({
|
||||||
first_name: z.string().min(1, "First name is required"),
|
first_name: z.string().min(1, "First name is required").max(100, "First name must be 100 characters or less"),
|
||||||
last_name: z.string().min(1, "Last name is required"),
|
last_name: z.string().min(1, "Last name is required").max(100, "Last name must be 100 characters or less"),
|
||||||
email: z.string().email("Invalid email address"),
|
email: z.string().email("Invalid email address").max(100, "Email must be 100 characters or less"),
|
||||||
|
selected_slots: z
|
||||||
|
.array(selectedSlotSchema)
|
||||||
|
.min(1, "At least one time slot must be selected"),
|
||||||
|
phone: z.string().max(100, "Phone must be 100 characters or less").optional(),
|
||||||
|
reason: z.string().max(100, "Reason must be 100 characters or less").optional(),
|
||||||
|
// Legacy fields (optional, for backward compatibility - but should not be sent)
|
||||||
preferred_dates: z
|
preferred_dates: z
|
||||||
.array(z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format"))
|
.array(z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format"))
|
||||||
.min(1, "At least one preferred date is required"),
|
.optional(),
|
||||||
preferred_time_slots: z
|
preferred_time_slots: z
|
||||||
.array(z.enum(["morning", "afternoon", "evening"]))
|
.array(z.enum(["morning", "afternoon", "evening"]))
|
||||||
.min(1, "At least one preferred time slot is required"),
|
.optional(),
|
||||||
phone: z.string().optional(),
|
}).refine(
|
||||||
reason: z.string().optional(),
|
(data) => data.selected_slots && data.selected_slots.length > 0,
|
||||||
});
|
{
|
||||||
|
message: "At least one time slot must be selected",
|
||||||
|
path: ["selected_slots"],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export type CreateAppointmentInput = z.infer<typeof createAppointmentSchema>;
|
export type CreateAppointmentInput = z.infer<typeof createAppointmentSchema>;
|
||||||
|
|
||||||
@ -32,11 +50,18 @@ export const rejectAppointmentSchema = z.object({
|
|||||||
|
|
||||||
export type RejectAppointmentInput = z.infer<typeof rejectAppointmentSchema>;
|
export type RejectAppointmentInput = z.infer<typeof rejectAppointmentSchema>;
|
||||||
|
|
||||||
// Update Admin Availability Schema
|
// Update Admin Availability Schema (updated to use availability_schedule)
|
||||||
export const updateAvailabilitySchema = z.object({
|
export const updateAvailabilitySchema = z.object({
|
||||||
|
availability_schedule: z
|
||||||
|
.record(z.string(), z.array(z.enum(["morning", "afternoon", "evening"])))
|
||||||
|
.refine(
|
||||||
|
(schedule) => Object.keys(schedule).length > 0,
|
||||||
|
{ message: "At least one day must have availability" }
|
||||||
|
),
|
||||||
|
// Legacy field (optional, for backward compatibility)
|
||||||
available_days: z
|
available_days: z
|
||||||
.array(z.number().int().min(0).max(6))
|
.array(z.number().int().min(0).max(6))
|
||||||
.min(1, "At least one day must be selected"),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UpdateAvailabilityInput = z.infer<typeof updateAvailabilitySchema>;
|
export type UpdateAvailabilityInput = z.infer<typeof updateAvailabilitySchema>;
|
||||||
|
|||||||
@ -119,7 +119,6 @@ export async function encryptValue(value: string): Promise<string> {
|
|||||||
const binaryString = String.fromCharCode(...combined);
|
const binaryString = String.fromCharCode(...combined);
|
||||||
return btoa(binaryString);
|
return btoa(binaryString);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Encryption error:", error);
|
|
||||||
// If encryption fails, return original value (graceful degradation)
|
// If encryption fails, return original value (graceful degradation)
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
@ -152,7 +151,6 @@ export async function decryptValue(encryptedValue: string): Promise<string> {
|
|||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
return decoder.decode(decrypted);
|
return decoder.decode(decrypted);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Decryption error:", error);
|
|
||||||
// If decryption fails, try to return as-is (might be unencrypted legacy data)
|
// If decryption fails, try to return as-is (might be unencrypted legacy data)
|
||||||
return encryptedValue;
|
return encryptedValue;
|
||||||
}
|
}
|
||||||
@ -191,7 +189,6 @@ export async function decryptUserData(user: any): Promise<any> {
|
|||||||
decrypted[field] = await decryptValue(String(decrypted[field]));
|
decrypted[field] = await decryptValue(String(decrypted[field]));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If decryption fails, keep original value (might be unencrypted)
|
// If decryption fails, keep original value (might be unencrypted)
|
||||||
console.warn(`Failed to decrypt field ${field}:`, error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -228,7 +225,7 @@ export async function smartDecryptUserData(user: any): Promise<any> {
|
|||||||
try {
|
try {
|
||||||
decrypted[field] = await decryptValue(decrypted[field]);
|
decrypted[field] = await decryptValue(decrypted[field]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Failed to decrypt field ${field}:`, error);
|
// Failed to decrypt field, keep original value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If not encrypted, keep as-is (backward compatibility)
|
// If not encrypted, keep as-is (backward compatibility)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user