From 711a5c5d6b016cac47f0ba690ffbc98557c6c7ff Mon Sep 17 00:00:00 2001 From: dwindown Date: Thu, 25 Dec 2025 13:46:03 +0700 Subject: [PATCH] Add webinar calendar integration and consulting slot blocking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix webinar duration field reference in Calendar (duration_minutes) - Calculate and display webinar end times in calendar view - Fetch webinars for selected date in consulting booking - Block consulting slots that overlap with webinar times - Show warning when webinars are scheduled on selected date - Properly handle webinar time range conflicts This prevents booking conflicts when users try to schedule consulting sessions during webinar times. Example: Webinar 20:15-22:15 blocks consulting slots 20:00-22:30 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- src/pages/Calendar.tsx | 6 +++- src/pages/ConsultingBooking.tsx | 50 ++++++++++++++++++++++++++++++--- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/pages/Calendar.tsx b/src/pages/Calendar.tsx index 3c148ee..9da7bb5 100644 --- a/src/pages/Calendar.tsx +++ b/src/pages/Calendar.tsx @@ -46,7 +46,7 @@ export default function Calendar() { // Fetch webinar events const { data: webinars } = await supabase .from('products') - .select('id, title, event_start, duration') + .select('id, title, event_start, duration_minutes') .eq('type', 'webinar') .eq('is_active', true) .gte('event_start', start) @@ -76,12 +76,16 @@ export default function Calendar() { webinars?.forEach(w => { if (w.event_start) { const eventDate = new Date(w.event_start); + const durationMs = (w.duration_minutes || 60) * 60 * 1000; + const endDate = new Date(eventDate.getTime() + durationMs); + allEvents.push({ id: w.id, title: w.title, type: 'webinar', date: format(eventDate, 'yyyy-MM-dd'), start_time: format(eventDate, 'HH:mm'), + end_time: format(endDate, 'HH:mm'), }); } }); diff --git a/src/pages/ConsultingBooking.tsx b/src/pages/ConsultingBooking.tsx index c893180..9522b7d 100644 --- a/src/pages/ConsultingBooking.tsx +++ b/src/pages/ConsultingBooking.tsx @@ -37,6 +37,13 @@ interface ConfirmedSlot { end_time: string; } +interface Webinar { + id: string; + title: string; + event_start: string; + duration_minutes: number | null; +} + interface TimeSlot { start: string; end: string; @@ -54,6 +61,7 @@ export default function ConsultingBooking() { const [settings, setSettings] = useState(null); const [workhours, setWorkhours] = useState([]); const [confirmedSlots, setConfirmedSlots] = useState([]); + const [webinars, setWebinars] = useState([]); const [loading, setLoading] = useState(true); const [profile, setProfile] = useState(null); @@ -78,6 +86,7 @@ export default function ConsultingBooking() { useEffect(() => { if (selectedDate) { fetchConfirmedSlots(selectedDate); + fetchWebinars(selectedDate); } }, [selectedDate]); @@ -101,10 +110,22 @@ export default function ConsultingBooking() { .select('date, start_time, end_time') .eq('date', dateStr) .in('status', ['pending_payment', 'confirmed']); - + if (data) setConfirmedSlots(data); }; + const fetchWebinars = async (date: Date) => { + const dateStr = format(date, 'yyyy-MM-dd'); + const { data } = await supabase + .from('products') + .select('id, title, event_start, duration_minutes') + .eq('type', 'webinar') + .eq('is_active', true) + .like('event_start', `${dateStr}%`); + + if (data) setWebinars(data); + }; + const categories = useMemo(() => { if (!settings?.consulting_categories) return []; return settings.consulting_categories.split(',').map(c => c.trim()).filter(Boolean); @@ -131,20 +152,36 @@ export default function ConsultingBooking() { const slotStart = format(current, 'HH:mm'); const slotEnd = format(addMinutes(current, duration), 'HH:mm'); - // Check if slot conflicts with confirmed/pending slots + // Check if slot conflicts with confirmed/pending consulting slots const isConflict = confirmedSlots.some(cs => { const csStart = cs.start_time.substring(0, 5); const csEnd = cs.end_time.substring(0, 5); return !(slotEnd <= csStart || slotStart >= csEnd); }); + // Check if slot conflicts with webinars + const webinarConflict = webinars.some(w => { + const webinarStart = new Date(w.event_start); + const webinarDurationMs = (w.duration_minutes || 60) * 60 * 1000; + const webinarEnd = new Date(webinarStart.getTime() + webinarDurationMs); + + const slotStartTime = new Date(selectedDate); + slotStartTime.setHours(parseInt(slotStart.split(':')[0]), parseInt(slotStart.split(':')[1]), 0); + + const slotEndTime = new Date(selectedDate); + slotEndTime.setHours(parseInt(slotEnd.split(':')[0]), parseInt(slotEnd.split(':')[1]), 0); + + // Block if slot overlaps with webinar time + return slotStartTime < webinarEnd && slotEndTime > webinarStart; + }); + // Check if slot is in the past for today const isPassed = isToday && isBefore(current, now); slots.push({ start: slotStart, end: slotEnd, - available: !isConflict && !isPassed, + available: !isConflict && !webinarConflict && !isPassed, }); current = addMinutes(current, duration); @@ -152,7 +189,7 @@ export default function ConsultingBooking() { } return slots; - }, [selectedDate, workhours, confirmedSlots, settings]); + }, [selectedDate, workhours, confirmedSlots, webinars, settings]); // Helper: Get all slots between start and end (inclusive) const getSlotsInRange = useMemo(() => { @@ -414,6 +451,11 @@ export default function ConsultingBooking() { Klik slot awal dan akhir untuk memilih rentang waktu. {settings.consulting_block_duration_minutes} menit per blok. + {webinars.length > 0 && ( + + ⚠️ {webinars.length} webinar terjadwal - beberapa slot mungkin tidak tersedia + + )}