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:
313
src/components/admin/TimeSlotPickerModal.tsx
Normal file
313
src/components/admin/TimeSlotPickerModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user