ISSUE 1: refactor date picker component to prevent greater from than to time and disabled overlapping slots
This commit is contained in:
@@ -1,4 +1,9 @@
|
||||
import { fullMonthsMapping, hourlySlots, monthsMapping, daysMapping } from "@/utils/date-time-utils";
|
||||
import {
|
||||
fullMonthsMapping,
|
||||
hourlySlots,
|
||||
monthsMapping,
|
||||
daysMapping,
|
||||
} from "@/utils/date-time-utils";
|
||||
import { formatScheduleDate, parseJsonSafely } from "@/utils/utils";
|
||||
import moment from "moment";
|
||||
import React, { useState } from "react";
|
||||
@@ -7,12 +12,57 @@ import CalendarIcon from "./icons/CalendarIcon";
|
||||
import NextIcon from "./icons/NextIcon";
|
||||
import PrevIcon from "./icons/PrevIcon";
|
||||
|
||||
const DateTimePicker = ({ defaultDate, register, fieldNames, setValue, showCalendar, setShowCalendar, toDefault, fromDefault, bookedSlots, scheduleTemplate, defaultMessage }) => {
|
||||
const DateTimePicker = ({
|
||||
defaultDate,
|
||||
register,
|
||||
fieldNames,
|
||||
setValue,
|
||||
showCalendar,
|
||||
setShowCalendar,
|
||||
toDefault,
|
||||
fromDefault,
|
||||
bookedSlots,
|
||||
scheduleTemplate,
|
||||
defaultMessage,
|
||||
}) => {
|
||||
const [selectedDate, setSelectedDate] = useState(defaultDate ?? new Date());
|
||||
const [from, setFrom] = useState(fromDefault ?? "");
|
||||
const [to, setTo] = useState(toDefault ?? "");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Helper to check if a slot is booked
|
||||
const isSlotBooked = (slotTime) => {
|
||||
if (!bookedSlots || !Array.isArray(bookedSlots)) return false;
|
||||
// Only check slots for the selected date
|
||||
const selectedDateStr = moment(selectedDate).format("YYYY-MM-DD");
|
||||
return bookedSlots.some((booking) => {
|
||||
// booking should have start and end in ISO or parseable format
|
||||
const bookingStart = moment(booking.start);
|
||||
const bookingEnd = moment(booking.end);
|
||||
// Only check if booking is on the same day
|
||||
if (bookingStart.format("YYYY-MM-DD") !== selectedDateStr) return false;
|
||||
// If slotTime is within booking range
|
||||
return (
|
||||
slotTime >= bookingStart.toDate() && slotTime < bookingEnd.toDate()
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Validate time range
|
||||
const isTimeRangeValid = () => {
|
||||
if (!from || !to) return false;
|
||||
const formattedDate = moment(selectedDate).format("MM/DD/YY");
|
||||
const fromTime = new Date(formattedDate + " " + from);
|
||||
const toTime = new Date(formattedDate + " " + to);
|
||||
return fromTime < toTime;
|
||||
};
|
||||
|
||||
const onApply = () => {
|
||||
if (!isTimeRangeValid()) {
|
||||
setError("Start time must be before end time.");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setValue("from", from);
|
||||
setValue("to", to);
|
||||
setValue("selectedDate", selectedDate);
|
||||
@@ -25,16 +75,14 @@ const DateTimePicker = ({ defaultDate, register, fieldNames, setValue, showCalen
|
||||
onClick={() => setShowCalendar((prev) => !prev)}
|
||||
>
|
||||
{fieldNames.map((field, idx) => (
|
||||
<input
|
||||
key={idx}
|
||||
type="hidden"
|
||||
{...register(field)}
|
||||
/>
|
||||
<input key={idx} type='hidden' {...register(field)} />
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`${showCalendar ? "" : "border-2"} md:border-2 p-2 w-full md:relative flex pr-16 gap-2 items-center`}
|
||||
type='button'
|
||||
className={`${
|
||||
showCalendar ? "" : "border-2"
|
||||
} flex w-full items-center gap-2 p-2 pr-16 md:relative md:border-2`}
|
||||
onClick={(e) => {
|
||||
setShowCalendar((prev) => !prev);
|
||||
e.stopPropagation();
|
||||
@@ -44,31 +92,44 @@ const DateTimePicker = ({ defaultDate, register, fieldNames, setValue, showCalen
|
||||
<CalendarIcon />
|
||||
</div>
|
||||
<span
|
||||
id="booking-time"
|
||||
className={showCalendar ? "hidden" : "inline whitespace-nowrap md:text-base text-sm"}
|
||||
id='booking-time'
|
||||
className={
|
||||
showCalendar
|
||||
? "hidden"
|
||||
: "inline whitespace-nowrap text-sm md:text-base"
|
||||
}
|
||||
>
|
||||
{from && to
|
||||
? monthsMapping[selectedDate.getMonth()] + " " + selectedDate.getDate() + "/" + selectedDate.getFullYear() + " - " + from + " to " + to
|
||||
? monthsMapping[selectedDate.getMonth()] +
|
||||
" " +
|
||||
selectedDate.getDate() +
|
||||
"/" +
|
||||
selectedDate.getFullYear() +
|
||||
" - " +
|
||||
from +
|
||||
" to " +
|
||||
to
|
||||
: defaultMessage ?? "Select date and time"}
|
||||
</span>
|
||||
{
|
||||
<div
|
||||
className={`${showCalendar ? "block" : "hidden"
|
||||
} absolute md:w-[unset] w-[80vw] bottom-[15px] top-[0%] md:-top-[22.5rem] 2xl:-top-[20rem] md:-left-10 lg:left-[-150px] left-0 md:right-[unset] right-0 text-center mx-auto shadow-lg bg-white border-2 text-sm md:max-h-[unset] min-h-[55vh] overflow-y-auto`}
|
||||
className={`${
|
||||
showCalendar ? "block" : "hidden"
|
||||
} absolute bottom-[15px] left-0 right-0 top-[0%] mx-auto min-h-[55vh] w-[80vw] overflow-y-auto border-2 bg-white text-center text-sm shadow-lg md:-left-10 md:-top-[22.5rem] md:right-[unset] md:max-h-[unset] md:w-[unset] lg:left-[-150px] 2xl:-top-[20rem]`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex justify-between items-center border-b p-[16px]">
|
||||
<h3 className="text-xl font-semibold">Select date and time</h3>
|
||||
<div className='flex items-center justify-between border-b p-[16px]'>
|
||||
<h3 className='text-xl font-semibold'>Select date and time</h3>
|
||||
<button
|
||||
type="button"
|
||||
type='button'
|
||||
onClick={() => setShowCalendar(false)}
|
||||
className="p-1 border hover:bg-gray-200 active:bg-gray-300 duration-100 px-3 text-2xl font-normal rounded-full"
|
||||
className='rounded-full border p-1 px-3 text-2xl font-normal duration-100 hover:bg-gray-200 active:bg-gray-300'
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex md:flex-row flex-col">
|
||||
<div className="">
|
||||
<div className='flex flex-col md:flex-row'>
|
||||
<div className=''>
|
||||
<Calendar
|
||||
onChange={(newDate) => {
|
||||
setSelectedDate(newDate);
|
||||
@@ -84,33 +145,51 @@ const DateTimePicker = ({ defaultDate, register, fieldNames, setValue, showCalen
|
||||
tileDisabled={({ date }) => {
|
||||
let customSlots = [];
|
||||
try {
|
||||
if (scheduleTemplate?.custom_slots && (Object.keys(scheduleTemplate?.custom_slots))?.length > 0) {
|
||||
customSlots = JSON.parse(scheduleTemplate?.custom_slots || "[]");
|
||||
if (
|
||||
scheduleTemplate?.custom_slots &&
|
||||
Object.keys(scheduleTemplate?.custom_slots)?.length > 0
|
||||
) {
|
||||
customSlots = JSON.parse(
|
||||
scheduleTemplate?.custom_slots || "[]"
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Invalid JSON in custom_slots", e);
|
||||
}
|
||||
if (customSlots.length > 0 && customSlots[(formatScheduleDate(date)).toString()]?.length === 0) {
|
||||
if (
|
||||
customSlots.length > 0 &&
|
||||
customSlots[formatScheduleDate(date).toString()]
|
||||
?.length === 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (scheduleTemplate?.id && scheduleTemplate[daysMapping[date.getDay()]] != 1) {
|
||||
if (
|
||||
scheduleTemplate?.id &&
|
||||
scheduleTemplate[daysMapping[date.getDay()]] != 1
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}}
|
||||
minDate={new Date()}
|
||||
maxDetail="month"
|
||||
maxDetail='month'
|
||||
/>
|
||||
<p className="text-left p-[16px] text-[#667085]">Pacific Time - US & Canada</p>
|
||||
<div className="md:flex hidden px-[16px] py-1 cursor-default text-left">
|
||||
<p className="min-w-[150px]">From - {from}</p>
|
||||
<p className='p-[16px] text-left text-[#667085]'>
|
||||
Pacific Time - US & Canada
|
||||
</p>
|
||||
<div className='hidden cursor-default px-[16px] py-1 text-left md:flex'>
|
||||
<p className='min-w-[150px]'>From - {from}</p>
|
||||
<p>Until - {to}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<p className="font-semibold mb-4 text-center">
|
||||
<span className="capitalize">{daysMapping[selectedDate.getDay()]}</span> , {fullMonthsMapping[selectedDate.getMonth()]} {selectedDate.getDate()}
|
||||
<div className='p-2'>
|
||||
<p className='mb-4 text-center font-semibold'>
|
||||
<span className='capitalize'>
|
||||
{daysMapping[selectedDate.getDay()]}
|
||||
</span>{" "}
|
||||
, {fullMonthsMapping[selectedDate.getMonth()]}{" "}
|
||||
{selectedDate.getDate()}
|
||||
</p>
|
||||
<div className="flex flex-col gap-[12px] custom-calendar-scroll review-scroll overflow-y-auto overflow-x-hidden md:max-h-[270px] max-h-[150px] md:px-6 px-3 text-[#667085]">
|
||||
<div className='custom-calendar-scroll review-scroll flex max-h-[150px] flex-col gap-[12px] overflow-y-auto overflow-x-hidden px-3 text-[#667085] md:max-h-[270px] md:px-6'>
|
||||
{hourlySlots.map((tm, idx) => {
|
||||
var formattedDate = moment(selectedDate).format("MM/DD/YY");
|
||||
var fromTime = new Date(formattedDate + " " + from);
|
||||
@@ -120,29 +199,66 @@ const DateTimePicker = ({ defaultDate, register, fieldNames, setValue, showCalen
|
||||
var json = scheduleTemplate.custom_slots ?? "[]";
|
||||
var custom_slots_obj = parseJsonSafely(json, {});
|
||||
var custom_slots = custom_slots_obj[formattedDate] ?? [];
|
||||
custom_slots = custom_slots.map((slot) => ({ fromTime: new Date(slot.start), toTime: new Date(slot.end) }));
|
||||
var template_slots = Array.isArray(scheduleTemplate.slots) ? scheduleTemplate.slots.map((slot) => ({ fromTime: new Date(slot.start), toTime: new Date(slot.end) })) : [];
|
||||
custom_slots = custom_slots.map((slot) => ({
|
||||
fromTime: new Date(slot.start),
|
||||
toTime: new Date(slot.end),
|
||||
}));
|
||||
var template_slots = Array.isArray(scheduleTemplate.slots)
|
||||
? scheduleTemplate.slots.map((slot) => ({
|
||||
fromTime: new Date(slot.start),
|
||||
toTime: new Date(slot.end),
|
||||
}))
|
||||
: [];
|
||||
|
||||
// --- NEW: Check if slot is booked ---
|
||||
const slotBooked = isSlotBooked(slotTime);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
type='button'
|
||||
key={idx}
|
||||
className={`${from == tm || to == tm ? "border-black border-2" : "border disabled:bg-[#F2F4F7] disabled:line-through border-[#EAECF0]"
|
||||
} md:w-[152px] w-full text-center py-[8px] ${from && to && fromTime <= slotTime && toTime >= slotTime ? "font-semibold between-slots" : ""}`}
|
||||
className={`${
|
||||
from == tm || to == tm
|
||||
? "border-2 border-black"
|
||||
: "border border-[#EAECF0] disabled:bg-[#F2F4F7] disabled:line-through"
|
||||
} w-full py-[8px] text-center md:w-[152px] ${
|
||||
from &&
|
||||
to &&
|
||||
fromTime <= slotTime &&
|
||||
toTime >= slotTime
|
||||
? "between-slots font-semibold"
|
||||
: ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
if (from == tm) {
|
||||
setFrom("");
|
||||
setTo("");
|
||||
setError("");
|
||||
return;
|
||||
}
|
||||
if (to == tm) {
|
||||
setTo("");
|
||||
setError("");
|
||||
return;
|
||||
}
|
||||
if (from == "") {
|
||||
setFrom(e.target.innerText);
|
||||
setError("");
|
||||
} else {
|
||||
setTo(e.target.innerText);
|
||||
// Only allow setting 'to' if after 'from'
|
||||
const formattedDate =
|
||||
moment(selectedDate).format("MM/DD/YY");
|
||||
const fromTime = new Date(
|
||||
formattedDate + " " + from
|
||||
);
|
||||
const toTime = new Date(formattedDate + " " + tm);
|
||||
if (toTime <= fromTime) {
|
||||
setError("End time must be after start time.");
|
||||
return;
|
||||
} else {
|
||||
setError("");
|
||||
setTo(e.target.innerText);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={(() => {
|
||||
@@ -152,11 +268,17 @@ const DateTimePicker = ({ defaultDate, register, fieldNames, setValue, showCalen
|
||||
// disable if time is < current time
|
||||
if (slotTime < new Date()) return true;
|
||||
|
||||
// --- NEW: Disable if slot is booked ---
|
||||
if (slotBooked) return true;
|
||||
|
||||
if (custom_slots.length > 0) {
|
||||
var shouldDisable = false;
|
||||
for (let i = 0; i < custom_slots.length; i++) {
|
||||
const slot = custom_slots[i];
|
||||
if (slot.fromTime <= slotTime && slot.toTime >= slotTime) {
|
||||
if (
|
||||
slot.fromTime <= slotTime &&
|
||||
slot.toTime >= slotTime
|
||||
) {
|
||||
shouldDisable = false;
|
||||
break;
|
||||
} else {
|
||||
@@ -164,12 +286,14 @@ const DateTimePicker = ({ defaultDate, register, fieldNames, setValue, showCalen
|
||||
}
|
||||
}
|
||||
if (shouldDisable) return true;
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
var shouldDisable = false;
|
||||
for (let i = 0; i < template_slots.length; i++) {
|
||||
const slot = template_slots[i];
|
||||
if (slot.fromTime <= slotTimeOnly && slot.toTime >= slotTimeOnly) {
|
||||
if (
|
||||
slot.fromTime <= slotTimeOnly &&
|
||||
slot.toTime >= slotTimeOnly
|
||||
) {
|
||||
shouldDisable = false;
|
||||
break;
|
||||
} else {
|
||||
@@ -185,18 +309,22 @@ const DateTimePicker = ({ defaultDate, register, fieldNames, setValue, showCalen
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-8 px-6">
|
||||
{/* Error message for invalid time range */}
|
||||
{error && (
|
||||
<div className='mt-2 text-sm text-red-500'>{error}</div>
|
||||
)}
|
||||
<div className='mt-8 px-6'>
|
||||
<button
|
||||
type="button"
|
||||
className="login-btn-gradient w-[152px] text-center py-[8px] rounded-sm text-white"
|
||||
disabled={from == "" || to == ""}
|
||||
type='button'
|
||||
className='login-btn-gradient w-[152px] rounded-sm py-[8px] text-center text-white'
|
||||
disabled={from == "" || to == "" || !isTimeRangeValid()}
|
||||
onClick={onApply}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
<div className="md:hidden flex px-1 py-1 mt-2 cursor-default text-left">
|
||||
<p className="min-w-[150px]">From - {from}</p>
|
||||
<div className='mt-2 flex cursor-default px-1 py-1 text-left md:hidden'>
|
||||
<p className='min-w-[150px]'>From - {from}</p>
|
||||
<p>Until - {to}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user