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 { formatScheduleDate, parseJsonSafely } from "@/utils/utils";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
@@ -7,12 +12,57 @@ import CalendarIcon from "./icons/CalendarIcon";
|
|||||||
import NextIcon from "./icons/NextIcon";
|
import NextIcon from "./icons/NextIcon";
|
||||||
import PrevIcon from "./icons/PrevIcon";
|
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 [selectedDate, setSelectedDate] = useState(defaultDate ?? new Date());
|
||||||
const [from, setFrom] = useState(fromDefault ?? "");
|
const [from, setFrom] = useState(fromDefault ?? "");
|
||||||
const [to, setTo] = useState(toDefault ?? "");
|
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 = () => {
|
const onApply = () => {
|
||||||
|
if (!isTimeRangeValid()) {
|
||||||
|
setError("Start time must be before end time.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError("");
|
||||||
setValue("from", from);
|
setValue("from", from);
|
||||||
setValue("to", to);
|
setValue("to", to);
|
||||||
setValue("selectedDate", selectedDate);
|
setValue("selectedDate", selectedDate);
|
||||||
@@ -25,16 +75,14 @@ const DateTimePicker = ({ defaultDate, register, fieldNames, setValue, showCalen
|
|||||||
onClick={() => setShowCalendar((prev) => !prev)}
|
onClick={() => setShowCalendar((prev) => !prev)}
|
||||||
>
|
>
|
||||||
{fieldNames.map((field, idx) => (
|
{fieldNames.map((field, idx) => (
|
||||||
<input
|
<input key={idx} type='hidden' {...register(field)} />
|
||||||
key={idx}
|
|
||||||
type="hidden"
|
|
||||||
{...register(field)}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type='button'
|
||||||
className={`${showCalendar ? "" : "border-2"} md:border-2 p-2 w-full md:relative flex pr-16 gap-2 items-center`}
|
className={`${
|
||||||
|
showCalendar ? "" : "border-2"
|
||||||
|
} flex w-full items-center gap-2 p-2 pr-16 md:relative md:border-2`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
setShowCalendar((prev) => !prev);
|
setShowCalendar((prev) => !prev);
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -44,31 +92,44 @@ const DateTimePicker = ({ defaultDate, register, fieldNames, setValue, showCalen
|
|||||||
<CalendarIcon />
|
<CalendarIcon />
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
id="booking-time"
|
id='booking-time'
|
||||||
className={showCalendar ? "hidden" : "inline whitespace-nowrap md:text-base text-sm"}
|
className={
|
||||||
|
showCalendar
|
||||||
|
? "hidden"
|
||||||
|
: "inline whitespace-nowrap text-sm md:text-base"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{from && to
|
{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"}
|
: defaultMessage ?? "Select date and time"}
|
||||||
</span>
|
</span>
|
||||||
{
|
{
|
||||||
<div
|
<div
|
||||||
className={`${showCalendar ? "block" : "hidden"
|
className={`${
|
||||||
} 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`}
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center border-b p-[16px]">
|
<div className='flex items-center justify-between border-b p-[16px]'>
|
||||||
<h3 className="text-xl font-semibold">Select date and time</h3>
|
<h3 className='text-xl font-semibold'>Select date and time</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type='button'
|
||||||
onClick={() => setShowCalendar(false)}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex md:flex-row flex-col">
|
<div className='flex flex-col md:flex-row'>
|
||||||
<div className="">
|
<div className=''>
|
||||||
<Calendar
|
<Calendar
|
||||||
onChange={(newDate) => {
|
onChange={(newDate) => {
|
||||||
setSelectedDate(newDate);
|
setSelectedDate(newDate);
|
||||||
@@ -84,33 +145,51 @@ const DateTimePicker = ({ defaultDate, register, fieldNames, setValue, showCalen
|
|||||||
tileDisabled={({ date }) => {
|
tileDisabled={({ date }) => {
|
||||||
let customSlots = [];
|
let customSlots = [];
|
||||||
try {
|
try {
|
||||||
if (scheduleTemplate?.custom_slots && (Object.keys(scheduleTemplate?.custom_slots))?.length > 0) {
|
if (
|
||||||
customSlots = JSON.parse(scheduleTemplate?.custom_slots || "[]");
|
scheduleTemplate?.custom_slots &&
|
||||||
|
Object.keys(scheduleTemplate?.custom_slots)?.length > 0
|
||||||
|
) {
|
||||||
|
customSlots = JSON.parse(
|
||||||
|
scheduleTemplate?.custom_slots || "[]"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Invalid JSON in custom_slots", 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;
|
return true;
|
||||||
}
|
}
|
||||||
if (scheduleTemplate?.id && scheduleTemplate[daysMapping[date.getDay()]] != 1) {
|
if (
|
||||||
|
scheduleTemplate?.id &&
|
||||||
|
scheduleTemplate[daysMapping[date.getDay()]] != 1
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
minDate={new Date()}
|
minDate={new Date()}
|
||||||
maxDetail="month"
|
maxDetail='month'
|
||||||
/>
|
/>
|
||||||
<p className="text-left p-[16px] text-[#667085]">Pacific Time - US & Canada</p>
|
<p className='p-[16px] text-left text-[#667085]'>
|
||||||
<div className="md:flex hidden px-[16px] py-1 cursor-default text-left">
|
Pacific Time - US & Canada
|
||||||
<p className="min-w-[150px]">From - {from}</p>
|
</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>
|
<p>Until - {to}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2">
|
<div className='p-2'>
|
||||||
<p className="font-semibold mb-4 text-center">
|
<p className='mb-4 text-center font-semibold'>
|
||||||
<span className="capitalize">{daysMapping[selectedDate.getDay()]}</span> , {fullMonthsMapping[selectedDate.getMonth()]} {selectedDate.getDate()}
|
<span className='capitalize'>
|
||||||
|
{daysMapping[selectedDate.getDay()]}
|
||||||
|
</span>{" "}
|
||||||
|
, {fullMonthsMapping[selectedDate.getMonth()]}{" "}
|
||||||
|
{selectedDate.getDate()}
|
||||||
</p>
|
</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) => {
|
{hourlySlots.map((tm, idx) => {
|
||||||
var formattedDate = moment(selectedDate).format("MM/DD/YY");
|
var formattedDate = moment(selectedDate).format("MM/DD/YY");
|
||||||
var fromTime = new Date(formattedDate + " " + from);
|
var fromTime = new Date(formattedDate + " " + from);
|
||||||
@@ -120,30 +199,67 @@ const DateTimePicker = ({ defaultDate, register, fieldNames, setValue, showCalen
|
|||||||
var json = scheduleTemplate.custom_slots ?? "[]";
|
var json = scheduleTemplate.custom_slots ?? "[]";
|
||||||
var custom_slots_obj = parseJsonSafely(json, {});
|
var custom_slots_obj = parseJsonSafely(json, {});
|
||||||
var custom_slots = custom_slots_obj[formattedDate] ?? [];
|
var custom_slots = custom_slots_obj[formattedDate] ?? [];
|
||||||
custom_slots = custom_slots.map((slot) => ({ fromTime: new Date(slot.start), toTime: new Date(slot.end) }));
|
custom_slots = custom_slots.map((slot) => ({
|
||||||
var template_slots = Array.isArray(scheduleTemplate.slots) ? scheduleTemplate.slots.map((slot) => ({ fromTime: new Date(slot.start), toTime: new Date(slot.end) })) : [];
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type='button'
|
||||||
key={idx}
|
key={idx}
|
||||||
className={`${from == tm || to == tm ? "border-black border-2" : "border disabled:bg-[#F2F4F7] disabled:line-through border-[#EAECF0]"
|
className={`${
|
||||||
} md:w-[152px] w-full text-center py-[8px] ${from && to && fromTime <= slotTime && toTime >= slotTime ? "font-semibold between-slots" : ""}`}
|
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) => {
|
onClick={(e) => {
|
||||||
if (from == tm) {
|
if (from == tm) {
|
||||||
setFrom("");
|
setFrom("");
|
||||||
setTo("");
|
setTo("");
|
||||||
|
setError("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (to == tm) {
|
if (to == tm) {
|
||||||
setTo("");
|
setTo("");
|
||||||
|
setError("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (from == "") {
|
if (from == "") {
|
||||||
setFrom(e.target.innerText);
|
setFrom(e.target.innerText);
|
||||||
|
setError("");
|
||||||
} else {
|
} else {
|
||||||
|
// 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);
|
setTo(e.target.innerText);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
disabled={(() => {
|
disabled={(() => {
|
||||||
// disabled slots that are not available in template only if a custom slot was not defined for the selectedDay
|
// disabled slots that are not available in template only if a custom slot was not defined for the selectedDay
|
||||||
@@ -152,11 +268,17 @@ const DateTimePicker = ({ defaultDate, register, fieldNames, setValue, showCalen
|
|||||||
// disable if time is < current time
|
// disable if time is < current time
|
||||||
if (slotTime < new Date()) return true;
|
if (slotTime < new Date()) return true;
|
||||||
|
|
||||||
|
// --- NEW: Disable if slot is booked ---
|
||||||
|
if (slotBooked) return true;
|
||||||
|
|
||||||
if (custom_slots.length > 0) {
|
if (custom_slots.length > 0) {
|
||||||
var shouldDisable = false;
|
var shouldDisable = false;
|
||||||
for (let i = 0; i < custom_slots.length; i++) {
|
for (let i = 0; i < custom_slots.length; i++) {
|
||||||
const slot = custom_slots[i];
|
const slot = custom_slots[i];
|
||||||
if (slot.fromTime <= slotTime && slot.toTime >= slotTime) {
|
if (
|
||||||
|
slot.fromTime <= slotTime &&
|
||||||
|
slot.toTime >= slotTime
|
||||||
|
) {
|
||||||
shouldDisable = false;
|
shouldDisable = false;
|
||||||
break;
|
break;
|
||||||
} else {
|
} else {
|
||||||
@@ -164,12 +286,14 @@ const DateTimePicker = ({ defaultDate, register, fieldNames, setValue, showCalen
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (shouldDisable) return true;
|
if (shouldDisable) return true;
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
var shouldDisable = false;
|
var shouldDisable = false;
|
||||||
for (let i = 0; i < template_slots.length; i++) {
|
for (let i = 0; i < template_slots.length; i++) {
|
||||||
const slot = template_slots[i];
|
const slot = template_slots[i];
|
||||||
if (slot.fromTime <= slotTimeOnly && slot.toTime >= slotTimeOnly) {
|
if (
|
||||||
|
slot.fromTime <= slotTimeOnly &&
|
||||||
|
slot.toTime >= slotTimeOnly
|
||||||
|
) {
|
||||||
shouldDisable = false;
|
shouldDisable = false;
|
||||||
break;
|
break;
|
||||||
} else {
|
} else {
|
||||||
@@ -185,18 +309,22 @@ const DateTimePicker = ({ defaultDate, register, fieldNames, setValue, showCalen
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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
|
<button
|
||||||
type="button"
|
type='button'
|
||||||
className="login-btn-gradient w-[152px] text-center py-[8px] rounded-sm text-white"
|
className='login-btn-gradient w-[152px] rounded-sm py-[8px] text-center text-white'
|
||||||
disabled={from == "" || to == ""}
|
disabled={from == "" || to == "" || !isTimeRangeValid()}
|
||||||
onClick={onApply}
|
onClick={onApply}
|
||||||
>
|
>
|
||||||
Apply
|
Apply
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:hidden flex px-1 py-1 mt-2 cursor-default text-left">
|
<div className='mt-2 flex cursor-default px-1 py-1 text-left md:hidden'>
|
||||||
<p className="min-w-[150px]">From - {from}</p>
|
<p className='min-w-[150px]'>From - {from}</p>
|
||||||
<p>Until - {to}</p>
|
<p>Until - {to}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user