- 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>
308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { supabase } from '@/integrations/supabase/client';
|
|
import { AppLayout } from '@/components/AppLayout';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Calendar as CalendarIcon, Video, BookOpen, Users, Clock, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth, addMonths, subMonths, parseISO } from 'date-fns';
|
|
import { id } from 'date-fns/locale';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface CalendarEvent {
|
|
id: string;
|
|
title: string;
|
|
type: 'bootcamp' | 'webinar' | 'consulting';
|
|
date: string;
|
|
start_time?: string;
|
|
end_time?: string;
|
|
description?: string;
|
|
}
|
|
|
|
export default function Calendar() {
|
|
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
|
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
|
|
|
useEffect(() => {
|
|
fetchEvents();
|
|
}, [currentMonth]);
|
|
|
|
const fetchEvents = async () => {
|
|
const start = format(startOfMonth(currentMonth), 'yyyy-MM-dd');
|
|
const end = format(endOfMonth(currentMonth), 'yyyy-MM-dd');
|
|
|
|
// Fetch bootcamp events
|
|
const { data: bootcamps } = await supabase
|
|
.from('products')
|
|
.select('id, title, event_start')
|
|
.eq('type', 'bootcamp')
|
|
.eq('is_active', true)
|
|
.gte('event_start', start)
|
|
.lte('event_start', end);
|
|
|
|
// Fetch webinar events
|
|
const { data: webinars } = await supabase
|
|
.from('products')
|
|
.select('id, title, event_start, duration_minutes')
|
|
.eq('type', 'webinar')
|
|
.eq('is_active', true)
|
|
.gte('event_start', start)
|
|
.lte('event_start', end);
|
|
|
|
// Fetch confirmed consulting slots
|
|
const { data: consultings } = await supabase
|
|
.from('consulting_slots')
|
|
.select('id, date, start_time, end_time, topic_category')
|
|
.eq('status', 'confirmed')
|
|
.gte('date', start)
|
|
.lte('date', end);
|
|
|
|
const allEvents: CalendarEvent[] = [];
|
|
|
|
bootcamps?.forEach(b => {
|
|
if (b.event_start) {
|
|
allEvents.push({
|
|
id: b.id,
|
|
title: b.title,
|
|
type: 'bootcamp',
|
|
date: b.event_start.split('T')[0],
|
|
});
|
|
}
|
|
});
|
|
|
|
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'),
|
|
});
|
|
}
|
|
});
|
|
|
|
consultings?.forEach(c => {
|
|
allEvents.push({
|
|
id: c.id,
|
|
title: `Konsultasi: ${c.topic_category}`,
|
|
type: 'consulting',
|
|
date: c.date,
|
|
start_time: c.start_time?.substring(0, 5),
|
|
end_time: c.end_time?.substring(0, 5),
|
|
});
|
|
});
|
|
|
|
setEvents(allEvents);
|
|
setLoading(false);
|
|
};
|
|
|
|
const days = eachDayOfInterval({
|
|
start: startOfMonth(currentMonth),
|
|
end: endOfMonth(currentMonth),
|
|
});
|
|
|
|
const getEventsForDate = (date: Date) => {
|
|
return events.filter(e => isSameDay(parseISO(e.date), date));
|
|
};
|
|
|
|
const getEventIcon = (type: string) => {
|
|
switch (type) {
|
|
case 'bootcamp': return <BookOpen className="w-3 h-3" />;
|
|
case 'webinar': return <Video className="w-3 h-3" />;
|
|
case 'consulting': return <Users className="w-3 h-3" />;
|
|
default: return <CalendarIcon className="w-3 h-3" />;
|
|
}
|
|
};
|
|
|
|
const getEventColor = (type: string) => {
|
|
switch (type) {
|
|
case 'bootcamp': return 'bg-primary text-primary-foreground';
|
|
case 'webinar': return 'bg-accent text-primary';
|
|
case 'consulting': return 'bg-secondary text-secondary-foreground';
|
|
default: return 'bg-muted';
|
|
}
|
|
};
|
|
|
|
const selectedDateEvents = selectedDate ? getEventsForDate(selectedDate) : [];
|
|
|
|
if (loading) {
|
|
return (
|
|
<AppLayout>
|
|
<div className="container mx-auto px-4 py-8">
|
|
<Skeleton className="h-10 w-1/3 mb-8" />
|
|
<Skeleton className="h-96 w-full" />
|
|
</div>
|
|
</AppLayout>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<AppLayout>
|
|
<div className="container mx-auto px-4 py-8">
|
|
<h1 className="text-4xl font-bold mb-2 flex items-center gap-3">
|
|
<CalendarIcon className="w-10 h-10" />
|
|
Kalender Event
|
|
</h1>
|
|
<p className="text-muted-foreground mb-8">
|
|
Lihat jadwal bootcamp, webinar, dan konsultasi
|
|
</p>
|
|
|
|
{/* Legend */}
|
|
<div className="flex flex-wrap gap-4 mb-6">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-4 h-4 bg-primary rounded" />
|
|
<span className="text-sm">Bootcamp</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-4 h-4 bg-accent rounded" />
|
|
<span className="text-sm">Webinar</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-4 h-4 bg-secondary rounded" />
|
|
<span className="text-sm">Konsultasi</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
{/* Calendar Grid */}
|
|
<Card className="border-2 border-border lg:col-span-2">
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<CardTitle>
|
|
{format(currentMonth, 'MMMM yyyy', { locale: id })}
|
|
</CardTitle>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" size="sm" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
|
<ChevronLeft className="w-4 h-4" />
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={() => setCurrentMonth(new Date())}>
|
|
Hari Ini
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
|
<ChevronRight className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{/* Day headers */}
|
|
<div className="grid grid-cols-7 gap-1 mb-2">
|
|
{['Min', 'Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab'].map(day => (
|
|
<div key={day} className="text-center text-sm font-medium text-muted-foreground py-2">
|
|
{day}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Calendar days */}
|
|
<div className="grid grid-cols-7 gap-1">
|
|
{/* Empty cells for days before month start */}
|
|
{Array.from({ length: startOfMonth(currentMonth).getDay() }).map((_, i) => (
|
|
<div key={`empty-${i}`} className="aspect-square p-1" />
|
|
))}
|
|
|
|
{days.map(day => {
|
|
const dayEvents = getEventsForDate(day);
|
|
const isSelected = selectedDate && isSameDay(day, selectedDate);
|
|
const isToday = isSameDay(day, new Date());
|
|
|
|
return (
|
|
<button
|
|
key={day.toISOString()}
|
|
onClick={() => setSelectedDate(day)}
|
|
className={cn(
|
|
"aspect-square p-1 text-sm rounded-md transition-colors relative",
|
|
isSelected && "bg-primary text-primary-foreground",
|
|
!isSelected && isToday && "bg-accent",
|
|
!isSelected && !isToday && "hover:bg-muted",
|
|
!isSameMonth(day, currentMonth) && "text-muted-foreground opacity-50"
|
|
)}
|
|
>
|
|
<span className="block">{format(day, 'd')}</span>
|
|
{dayEvents.length > 0 && (
|
|
<div className="flex gap-0.5 justify-center mt-1">
|
|
{dayEvents.slice(0, 3).map((e, i) => (
|
|
<div
|
|
key={i}
|
|
className={cn(
|
|
"w-1.5 h-1.5 rounded-full",
|
|
e.type === 'bootcamp' && "bg-primary",
|
|
e.type === 'webinar' && "bg-accent",
|
|
e.type === 'consulting' && "bg-secondary"
|
|
)}
|
|
/>
|
|
))}
|
|
{dayEvents.length > 3 && (
|
|
<span className="text-[8px]">+{dayEvents.length - 3}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Event Details */}
|
|
<Card className="border-2 border-border">
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">
|
|
{selectedDate
|
|
? format(selectedDate, 'd MMMM yyyy', { locale: id })
|
|
: 'Pilih Tanggal'
|
|
}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{!selectedDate ? (
|
|
<p className="text-muted-foreground text-sm">
|
|
Klik tanggal untuk melihat event
|
|
</p>
|
|
) : selectedDateEvents.length === 0 ? (
|
|
<p className="text-muted-foreground text-sm">
|
|
Tidak ada event di tanggal ini
|
|
</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{selectedDateEvents.map(event => (
|
|
<div
|
|
key={event.id}
|
|
className={cn(
|
|
"p-3 rounded-md",
|
|
getEventColor(event.type)
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
{getEventIcon(event.type)}
|
|
<Badge variant="outline" className="text-xs capitalize">
|
|
{event.type}
|
|
</Badge>
|
|
</div>
|
|
<p className="font-medium">{event.title}</p>
|
|
{event.start_time && (
|
|
<p className="text-sm flex items-center gap-1 mt-1">
|
|
<Clock className="w-3 h-3" />
|
|
{event.start_time}
|
|
{event.end_time && ` - ${event.end_time}`}
|
|
</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</AppLayout>
|
|
);
|
|
}
|