Integrate TimeSlotPickerModal and calendar event updates

Add availability checking and calendar sync to admin session editing:

**New Features:**
- Admin can now select time slots using visual picker with availability checking
- Time slot picker respects confirmed sessions and excludes current session from conflict check
- Calendar events are automatically updated when session time changes
- consulting_time_slots table is updated when time changes (old slots deleted, new slots created)

**New Component:**
- src/components/admin/TimeSlotPickerModal.tsx
  - Reusable modal for time slot selection
  - Shows visual grid of available time slots
  - Range selection for multi-slot sessions
  - Availability checking against consulting_sessions
  - Supports editing (excludes current session from conflicts)

**Enhanced AdminConsulting.tsx:**
- Replaced simple time inputs with TimeSlotPickerModal
- Added state: timeSlotPickerOpen, editTotalBlocks, editTotalDuration
- Added handleTimeSlotSelect callback
- Enhanced saveMeetLink to:
  - Update consulting_time_slots when time changes
  - Call update-calendar-event edge function
  - Update calendar event time via Google Calendar API
- Button shows selected time with duration and blocks count

**New Edge Function:**
- supabase/functions/update-calendar-event/index.ts
  - Updates existing Google Calendar events when session time changes
  - Uses PATCH method to update event (preserves event_id and history)
  - Handles OAuth token refresh with caching
  - Only updates start/end time (keeps title, description, meet link)

**Flow:**
1. Admin clicks "Edit" on session → Opens dialog
2. Admin clicks time button → Opens TimeSlotPickerModal
3. Admin selects new time → Only shows available slots
4. On save:
   - consulting_sessions updated with new time
   - Old consulting_time_slots deleted
   - New consulting_time_slots created
   - Google Calendar event updated (same event_id)
   - Meet link preserved

**Benefits:**
-  Prevents double-booking with availability checking
-  Visual time slot selection (same UX as booking page)
-  Calendar events stay in sync (no orphaned events)
-  Time slots table properly maintained
-  Meet link and event_id preserved during time changes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
dwindown
2025-12-28 16:02:00 +07:00
parent b1bd092eb8
commit 9bb922f5aa
3 changed files with 724 additions and 52 deletions

View File

@@ -0,0 +1,313 @@
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Clock, Calendar as CalendarIcon, Loader2 } from 'lucide-react';
import { format, addMinutes, parse, isAfter, isBefore, startOfDay } from 'date-fns';
import { id } from 'date-fns/locale';
import { supabase } from '@/integrations/supabase/client';
interface ConsultingSettings {
consulting_block_duration_minutes: number;
}
interface Workhour {
weekday: number;
start_time: string;
end_time: string;
}
interface ConfirmedSlot {
session_date: string;
start_time: string;
end_time: string;
}
interface TimeSlot {
start: string;
end: string;
available: boolean;
}
interface TimeSlotPickerModalProps {
open: boolean;
onClose: () => void;
selectedDate: Date;
initialStartTime?: string;
initialEndTime?: string;
onSave: (startTime: string, endTime: string, totalBlocks: number, totalDuration: number) => void;
sessionId?: string; // If editing, exclude this session from availability check
}
export function TimeSlotPickerModal({
open,
onClose,
selectedDate,
initialStartTime,
initialEndTime,
onSave,
sessionId
}: TimeSlotPickerModalProps) {
const [settings, setSettings] = useState<ConsultingSettings | null>(null);
const [workhours, setWorkhours] = useState<Workhour[]>([]);
const [confirmedSlots, setConfirmedSlots] = useState<ConfirmedSlot[]>([]);
const [loading, setLoading] = useState(true);
// Range selection state
const [selectedRange, setSelectedRange] = useState<{ start: string | null; end: string | null }>({
start: initialStartTime || null,
end: initialEndTime || null
});
const [pendingSlot, setPendingSlot] = useState<string | null>(null);
useEffect(() => {
if (open) {
fetchData();
}
}, [open, selectedDate]);
const fetchData = async () => {
setLoading(true);
const [settingsRes, workhoursRes] = await Promise.all([
supabase.from('consulting_settings').select('consulting_block_duration_minutes').single(),
supabase.from('workhours').select('*').order('weekday'),
]);
if (settingsRes.data) {
setSettings(settingsRes.data);
}
if (workhoursRes.data) {
setWorkhours(workhoursRes.data);
}
// Fetch confirmed sessions for availability check
const dateStr = format(selectedDate, 'yyyy-MM-dd');
const query = supabase
.from('consulting_sessions')
.select('session_date, start_time, end_time')
.eq('session_date', dateStr)
.in('status', ['pending_payment', 'confirmed']);
// If editing, exclude current session
if (sessionId) {
query.neq('id', sessionId);
}
const { data: sessions } = await query;
if (sessions) {
setConfirmedSlots(sessions);
}
setLoading(false);
};
const generateTimeSlots = (): TimeSlot[] => {
if (!settings || !workhours.length) return [];
const dayOfWeek = selectedDate.getDay();
const workhour = workhours.find(wh => wh.weekday === dayOfWeek);
if (!workhour) {
return [];
}
const slotDuration = settings.consulting_block_duration_minutes;
const slots: TimeSlot[] = [];
const startTime = parse(workhour.start_time, 'HH:mm:ss', new Date());
const endTime = parse(workhour.end_time, 'HH:mm:ss', new Date());
let currentTime = startTime;
while (true) {
const slotEnd = addMinutes(currentTime, slotDuration);
if (isAfter(slotEnd, endTime) || isBefore(slotEnd, currentTime)) {
break;
}
const timeString = format(currentTime, 'HH:mm');
// Check if this slot is available
const isAvailable = !confirmedSlots.some(slot => {
const slotStart = slot.start_time.substring(0, 5);
const slotEnd = slot.end_time.substring(0, 5);
return timeString >= slotStart && timeString < slotEnd;
});
slots.push({
start: timeString,
end: format(slotEnd, 'HH:mm'),
available: isAvailable
});
currentTime = slotEnd;
}
return slots;
};
const timeSlots = generateTimeSlots();
// Get slots in selected range
const getSlotsInRange = () => {
if (!selectedRange.start || !selectedRange.end) return [];
const startIndex = timeSlots.findIndex(s => s.start === selectedRange.start);
const endIndex = timeSlots.findIndex(s => s.start === selectedRange.end);
if (startIndex === -1 || endIndex === -1) return [];
return timeSlots.slice(startIndex, endIndex + 1);
};
const totalBlocks = getSlotsInRange().length;
const totalDuration = totalBlocks * (settings?.consulting_block_duration_minutes || 30);
const handleSlotClick = (slotStart: string) => {
if (!slot.available) return;
// No selection yet → Set as pending
if (!selectedRange.start) {
setPendingSlot(slotStart);
return;
}
// Have pending slot → Check if clicking same slot
if (pendingSlot) {
if (pendingSlot === slotStart) {
// Confirm pending slot as range start
setSelectedRange({ start: pendingSlot, end: pendingSlot });
setPendingSlot(null);
return;
}
// Different slot → Set as range end
setSelectedRange({ start: pendingSlot, end: slotStart });
setPendingSlot(null);
return;
}
// Already have range → Start new selection
setSelectedRange({ start: slotStart, end: slotStart });
setPendingSlot(null);
};
const handleReset = () => {
setSelectedRange({ start: null, end: null });
setPendingSlot(null);
};
const handleSave = () => {
if (selectedRange.start && selectedRange.end) {
onSave(selectedRange.start, selectedRange.end, totalBlocks, totalDuration);
}
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-2xl border-2 border-border max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Pilih Waktu Sesi</DialogTitle>
<DialogDescription>
{format(selectedDate, 'd MMMM yyyy', { locale: id })} Pilih slot waktu untuk sesi konsultasi
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="py-8 space-y-3">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : (
<div className="space-y-4">
{/* Info */}
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted p-3 rounded-lg">
<Clock className="w-4 h-4" />
<span>
Klik slot untuk memilih durasi. Setiap slot = {settings?.consulting_block_duration_minutes || 30} menit
</span>
</div>
{/* Time Slots Grid */}
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
{timeSlots.map((slot) => {
const isSelected = selectedRange.start && selectedRange.end &&
timeSlots.findIndex(s => s.start === selectedRange.start) <=
timeSlots.findIndex(s => s.start === slot.start) &&
timeSlots.findIndex(s => s.start === selectedRange.end) >=
timeSlots.findIndex(s => s.start === slot.start);
const isPending = pendingSlot === slot.start;
return (
<Button
key={slot.start}
variant={isSelected ? "default" : isPending ? "secondary" : "outline"}
className={`h-12 text-sm border-2 ${
!slot.available ? 'opacity-30 cursor-not-allowed' : ''
}`}
disabled={!slot.available}
onClick={() => handleSlotClick(slot.start)}
>
{slot.start}
</Button>
);
})}
</div>
{/* Selection Summary */}
{selectedRange.start && selectedRange.end && (
<div className="bg-primary/10 p-4 rounded-lg border-2 border-primary/20">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground">Mulai</p>
<p className="font-bold text-lg">{selectedRange.start}</p>
</div>
<div className="text-center">
<p className="text-2xl"></p>
<p className="text-xs text-muted-foreground">{totalBlocks} blok</p>
</div>
<div className="text-right">
<p className="text-xs text-muted-foreground">Selesai</p>
<p className="font-bold text-lg">
{format(addMinutes(parse(selectedRange.end, 'HH:mm', new Date()), settings?.consulting_block_duration_minutes || 30), 'HH:mm')}
</p>
</div>
</div>
<p className="text-center text-sm mt-2 text-primary font-medium">
Durasi: {totalDuration} menit
</p>
</div>
)}
{/* Pending Slot */}
{pendingSlot && (
<div className="bg-amber-500/10 p-3 rounded-lg border-2 border-amber-500/20">
<p className="text-center text-sm">
Klik lagi untuk konfirmasi slot: <strong>{pendingSlot}</strong>
</p>
</div>
)}
{/* Actions */}
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={handleReset} className="border-2">
Reset
</Button>
<Button
onClick={handleSave}
disabled={!selectedRange.start || !selectedRange.end}
className="shadow-sm"
>
Simpan Waktu
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -15,8 +15,9 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { toast } from '@/hooks/use-toast';
import { formatIDR } from '@/lib/format';
import { Video, Calendar, Clock, User, Link as LinkIcon, ExternalLink, CheckCircle, XCircle, Loader2, Search, X } from 'lucide-react';
import { format, parseISO, isToday, isTomorrow, isPast, parse, differenceInMinutes } from 'date-fns';
import { format, parseISO, isToday, isTomorrow, isPast, parse, differenceInMinutes, addMinutes } from 'date-fns';
import { id } from 'date-fns/locale';
import { TimeSlotPickerModal } from '@/components/admin/TimeSlotPickerModal';
interface ConsultingSession {
id: string;
@@ -70,6 +71,9 @@ export default function AdminConsulting() {
const [filterStatus, setFilterStatus] = useState<string>('all');
const [editStartTime, setEditStartTime] = useState('');
const [editEndTime, setEditEndTime] = useState('');
const [timeSlotPickerOpen, setTimeSlotPickerOpen] = useState(false);
const [editTotalBlocks, setEditTotalBlocks] = useState(0);
const [editTotalDuration, setEditTotalDuration] = useState(0);
useEffect(() => {
if (!authLoading) {
@@ -117,9 +121,19 @@ export default function AdminConsulting() {
setMeetLink(session.meet_link || '');
setEditStartTime(session.start_time.substring(0, 5));
setEditEndTime(session.end_time.substring(0, 5));
setEditTotalBlocks(session.total_blocks || 1);
setEditTotalDuration(session.total_duration_minutes || 30);
setDialogOpen(true);
};
const handleTimeSlotSelect = (startTime: string, endTime: string, totalBlocks: number, totalDuration: number) => {
setEditStartTime(startTime);
setEditEndTime(endTime);
setEditTotalBlocks(totalBlocks);
setEditTotalDuration(totalDuration);
setTimeSlotPickerOpen(false);
};
const deleteMeetLink = async () => {
if (!selectedSession) return;
@@ -157,38 +171,95 @@ export default function AdminConsulting() {
if (!selectedSession) return;
setSaving(true);
// Prepare update data
const updateData: any = {
meet_link: meetLink || null
};
try {
// Prepare update data
const updateData: any = {
meet_link: meetLink || null
};
// Check if time changed
const newStartTime = editStartTime + ':00';
const newEndTime = editEndTime + ':00';
if (newStartTime !== selectedSession.start_time || newEndTime !== selectedSession.end_time) {
updateData.start_time = newStartTime;
updateData.end_time = newEndTime;
// Check if time changed
const newStartTime = editStartTime + ':00';
const newEndTime = editEndTime + ':00';
const timeChanged = newStartTime !== selectedSession.start_time || newEndTime !== selectedSession.end_time;
// Recalculate duration
const start = parse(newStartTime, 'HH:mm', new Date());
const end = parse(newEndTime, 'HH:mm', new Date());
const durationMinutes = differenceInMinutes(end, start);
updateData.total_duration_minutes = durationMinutes;
if (timeChanged) {
updateData.start_time = newStartTime;
updateData.end_time = newEndTime;
// Recalculate duration
const start = parse(newStartTime, 'HH:mm', new Date());
const end = parse(newEndTime, 'HH:mm', new Date());
const durationMinutes = differenceInMinutes(end, start);
updateData.total_duration_minutes = durationMinutes;
// Update consulting_time_slots - delete old slots and create new ones
// First, delete old time slots
await supabase
.from('consulting_time_slots')
.delete()
.eq('session_id', selectedSession.id);
// Create new time slots for updated session
const slotDuration = 30; // TODO: Fetch from consulting_settings
const newSlots = [];
let currentSlotStart = start;
while (differenceInMinutes(end, currentSlotStart) > 0) {
const slotEnd = addMinutes(currentSlotStart, Math.min(slotDuration, differenceInMinutes(end, currentSlotStart)));
newSlots.push({
session_id: selectedSession.id,
slot_date: selectedSession.session_date,
start_time: format(currentSlotStart, 'HH:mm:ss'),
end_time: format(slotEnd, 'HH:mm:ss'),
is_available: false,
booked_at: new Date().toISOString()
});
currentSlotStart = slotEnd;
}
if (newSlots.length > 0) {
await supabase.from('consulting_time_slots').insert(newSlots);
}
// Update calendar event if exists
if (selectedSession.calendar_event_id && selectedSession.meet_link) {
try {
await supabase.functions.invoke('update-calendar-event', {
body: {
session_id: selectedSession.id,
date: selectedSession.session_date,
start_time: newStartTime,
end_time: newEndTime
}
});
toast({ title: 'Info', description: 'Event kalender diperbarui' });
} catch (err) {
console.log('Failed to update calendar event:', err);
toast({ title: 'Warning', description: 'Gagal memperbarui event kalender', variant: 'destructive' });
}
}
}
const { error } = await supabase
.from('consulting_sessions')
.update(updateData)
.eq('id', selectedSession.id);
if (error) {
toast({ title: 'Error', description: error.message, variant: 'destructive' });
} else {
toast({ title: 'Berhasil', description: 'Perubahan disimpan' });
setDialogOpen(false);
fetchSessions();
}
} catch (error: any) {
console.error('Error saving session:', error);
toast({ title: 'Error', description: error.message || 'Gagal menyimpan perubahan', variant: 'destructive' });
} finally {
setSaving(false);
}
const { error } = await supabase
.from('consulting_sessions')
.update(updateData)
.eq('id', selectedSession.id);
if (error) {
toast({ title: 'Error', description: error.message, variant: 'destructive' });
} else {
toast({ title: 'Berhasil', description: 'Perubahan disimpan' });
setDialogOpen(false);
fetchSessions();
}
setSaving(false);
};
const createMeetLink = async () => {
@@ -814,29 +885,26 @@ export default function AdminConsulting() {
{/* Time Editing */}
<div className="space-y-2">
<label className="text-sm font-medium">Waktu Sesi</label>
<div className="flex gap-2 items-center">
<div className="flex-1">
<Input
type="time"
value={editStartTime}
onChange={(e) => setEditStartTime(e.target.value)}
className="border-2"
/>
</div>
<span className="text-muted-foreground"></span>
<div className="flex-1">
<Input
type="time"
value={editEndTime}
onChange={(e) => setEditEndTime(e.target.value)}
className="border-2"
/>
</div>
</div>
<Button
type="button"
variant="outline"
className="w-full border-2"
onClick={() => setTimeSlotPickerOpen(true)}
>
<Clock className="w-4 h-4 mr-2" />
{editStartTime && editEndTime ? (
<>
{editStartTime} {editEndTime}
<span className="ml-2 text-muted-foreground">
({editTotalDuration} menit, {editTotalBlocks} blok)
</span>
</>
) : (
'Pilih Waktu'
)}
</Button>
<p className="text-xs text-muted-foreground">
Durasi: {editStartTime && editEndTime ?
`${differenceInMinutes(parse(editEndTime, 'HH:mm', new Date()), parse(editStartTime, 'HH:mm', new Date()))} menit` :
'-'}
Klik untuk memilih waktu yang tersedia
</p>
</div>
@@ -910,6 +978,19 @@ export default function AdminConsulting() {
</div>
</DialogContent>
</Dialog>
{/* Time Slot Picker Modal */}
{selectedSession && (
<TimeSlotPickerModal
open={timeSlotPickerOpen}
onClose={() => setTimeSlotPickerOpen(false)}
selectedDate={parseISO(selectedSession.session_date)}
initialStartTime={editStartTime}
initialEndTime={editEndTime}
onSave={handleTimeSlotSelect}
sessionId={selectedSession.id}
/>
)}
</div>
</AppLayout>
);