Add webinar calendar integration and consulting slot blocking
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -46,7 +46,7 @@ export default function Calendar() {
|
|||||||
// Fetch webinar events
|
// Fetch webinar events
|
||||||
const { data: webinars } = await supabase
|
const { data: webinars } = await supabase
|
||||||
.from('products')
|
.from('products')
|
||||||
.select('id, title, event_start, duration')
|
.select('id, title, event_start, duration_minutes')
|
||||||
.eq('type', 'webinar')
|
.eq('type', 'webinar')
|
||||||
.eq('is_active', true)
|
.eq('is_active', true)
|
||||||
.gte('event_start', start)
|
.gte('event_start', start)
|
||||||
@@ -76,12 +76,16 @@ export default function Calendar() {
|
|||||||
webinars?.forEach(w => {
|
webinars?.forEach(w => {
|
||||||
if (w.event_start) {
|
if (w.event_start) {
|
||||||
const eventDate = new Date(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({
|
allEvents.push({
|
||||||
id: w.id,
|
id: w.id,
|
||||||
title: w.title,
|
title: w.title,
|
||||||
type: 'webinar',
|
type: 'webinar',
|
||||||
date: format(eventDate, 'yyyy-MM-dd'),
|
date: format(eventDate, 'yyyy-MM-dd'),
|
||||||
start_time: format(eventDate, 'HH:mm'),
|
start_time: format(eventDate, 'HH:mm'),
|
||||||
|
end_time: format(endDate, 'HH:mm'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ interface ConfirmedSlot {
|
|||||||
end_time: string;
|
end_time: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Webinar {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
event_start: string;
|
||||||
|
duration_minutes: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface TimeSlot {
|
interface TimeSlot {
|
||||||
start: string;
|
start: string;
|
||||||
end: string;
|
end: string;
|
||||||
@@ -54,6 +61,7 @@ export default function ConsultingBooking() {
|
|||||||
const [settings, setSettings] = useState<ConsultingSettings | null>(null);
|
const [settings, setSettings] = useState<ConsultingSettings | null>(null);
|
||||||
const [workhours, setWorkhours] = useState<Workhour[]>([]);
|
const [workhours, setWorkhours] = useState<Workhour[]>([]);
|
||||||
const [confirmedSlots, setConfirmedSlots] = useState<ConfirmedSlot[]>([]);
|
const [confirmedSlots, setConfirmedSlots] = useState<ConfirmedSlot[]>([]);
|
||||||
|
const [webinars, setWebinars] = useState<Webinar[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [profile, setProfile] = useState<Profile | null>(null);
|
const [profile, setProfile] = useState<Profile | null>(null);
|
||||||
|
|
||||||
@@ -78,6 +86,7 @@ export default function ConsultingBooking() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedDate) {
|
if (selectedDate) {
|
||||||
fetchConfirmedSlots(selectedDate);
|
fetchConfirmedSlots(selectedDate);
|
||||||
|
fetchWebinars(selectedDate);
|
||||||
}
|
}
|
||||||
}, [selectedDate]);
|
}, [selectedDate]);
|
||||||
|
|
||||||
@@ -105,6 +114,18 @@ export default function ConsultingBooking() {
|
|||||||
if (data) setConfirmedSlots(data);
|
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(() => {
|
const categories = useMemo(() => {
|
||||||
if (!settings?.consulting_categories) return [];
|
if (!settings?.consulting_categories) return [];
|
||||||
return settings.consulting_categories.split(',').map(c => c.trim()).filter(Boolean);
|
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 slotStart = format(current, 'HH:mm');
|
||||||
const slotEnd = format(addMinutes(current, duration), '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 isConflict = confirmedSlots.some(cs => {
|
||||||
const csStart = cs.start_time.substring(0, 5);
|
const csStart = cs.start_time.substring(0, 5);
|
||||||
const csEnd = cs.end_time.substring(0, 5);
|
const csEnd = cs.end_time.substring(0, 5);
|
||||||
return !(slotEnd <= csStart || slotStart >= csEnd);
|
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
|
// Check if slot is in the past for today
|
||||||
const isPassed = isToday && isBefore(current, now);
|
const isPassed = isToday && isBefore(current, now);
|
||||||
|
|
||||||
slots.push({
|
slots.push({
|
||||||
start: slotStart,
|
start: slotStart,
|
||||||
end: slotEnd,
|
end: slotEnd,
|
||||||
available: !isConflict && !isPassed,
|
available: !isConflict && !webinarConflict && !isPassed,
|
||||||
});
|
});
|
||||||
|
|
||||||
current = addMinutes(current, duration);
|
current = addMinutes(current, duration);
|
||||||
@@ -152,7 +189,7 @@ export default function ConsultingBooking() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return slots;
|
return slots;
|
||||||
}, [selectedDate, workhours, confirmedSlots, settings]);
|
}, [selectedDate, workhours, confirmedSlots, webinars, settings]);
|
||||||
|
|
||||||
// Helper: Get all slots between start and end (inclusive)
|
// Helper: Get all slots between start and end (inclusive)
|
||||||
const getSlotsInRange = useMemo(() => {
|
const getSlotsInRange = useMemo(() => {
|
||||||
@@ -414,6 +451,11 @@ export default function ConsultingBooking() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Klik slot awal dan akhir untuk memilih rentang waktu. {settings.consulting_block_duration_minutes} menit per blok.
|
Klik slot awal dan akhir untuk memilih rentang waktu. {settings.consulting_block_duration_minutes} menit per blok.
|
||||||
|
{webinars.length > 0 && (
|
||||||
|
<span className="block mt-1 text-amber-600 dark:text-amber-400">
|
||||||
|
⚠️ {webinars.length} webinar terjadwal - beberapa slot mungkin tidak tersedia
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user