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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,8 +15,9 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { formatIDR } from '@/lib/format';
|
import { formatIDR } from '@/lib/format';
|
||||||
import { Video, Calendar, Clock, User, Link as LinkIcon, ExternalLink, CheckCircle, XCircle, Loader2, Search, X } from 'lucide-react';
|
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 { id } from 'date-fns/locale';
|
||||||
|
import { TimeSlotPickerModal } from '@/components/admin/TimeSlotPickerModal';
|
||||||
|
|
||||||
interface ConsultingSession {
|
interface ConsultingSession {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -70,6 +71,9 @@ export default function AdminConsulting() {
|
|||||||
const [filterStatus, setFilterStatus] = useState<string>('all');
|
const [filterStatus, setFilterStatus] = useState<string>('all');
|
||||||
const [editStartTime, setEditStartTime] = useState('');
|
const [editStartTime, setEditStartTime] = useState('');
|
||||||
const [editEndTime, setEditEndTime] = useState('');
|
const [editEndTime, setEditEndTime] = useState('');
|
||||||
|
const [timeSlotPickerOpen, setTimeSlotPickerOpen] = useState(false);
|
||||||
|
const [editTotalBlocks, setEditTotalBlocks] = useState(0);
|
||||||
|
const [editTotalDuration, setEditTotalDuration] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading) {
|
if (!authLoading) {
|
||||||
@@ -117,9 +121,19 @@ export default function AdminConsulting() {
|
|||||||
setMeetLink(session.meet_link || '');
|
setMeetLink(session.meet_link || '');
|
||||||
setEditStartTime(session.start_time.substring(0, 5));
|
setEditStartTime(session.start_time.substring(0, 5));
|
||||||
setEditEndTime(session.end_time.substring(0, 5));
|
setEditEndTime(session.end_time.substring(0, 5));
|
||||||
|
setEditTotalBlocks(session.total_blocks || 1);
|
||||||
|
setEditTotalDuration(session.total_duration_minutes || 30);
|
||||||
setDialogOpen(true);
|
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 () => {
|
const deleteMeetLink = async () => {
|
||||||
if (!selectedSession) return;
|
if (!selectedSession) return;
|
||||||
|
|
||||||
@@ -157,6 +171,7 @@ export default function AdminConsulting() {
|
|||||||
if (!selectedSession) return;
|
if (!selectedSession) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
// Prepare update data
|
// Prepare update data
|
||||||
const updateData: any = {
|
const updateData: any = {
|
||||||
meet_link: meetLink || null
|
meet_link: meetLink || null
|
||||||
@@ -165,7 +180,9 @@ export default function AdminConsulting() {
|
|||||||
// Check if time changed
|
// Check if time changed
|
||||||
const newStartTime = editStartTime + ':00';
|
const newStartTime = editStartTime + ':00';
|
||||||
const newEndTime = editEndTime + ':00';
|
const newEndTime = editEndTime + ':00';
|
||||||
if (newStartTime !== selectedSession.start_time || newEndTime !== selectedSession.end_time) {
|
const timeChanged = newStartTime !== selectedSession.start_time || newEndTime !== selectedSession.end_time;
|
||||||
|
|
||||||
|
if (timeChanged) {
|
||||||
updateData.start_time = newStartTime;
|
updateData.start_time = newStartTime;
|
||||||
updateData.end_time = newEndTime;
|
updateData.end_time = newEndTime;
|
||||||
|
|
||||||
@@ -174,6 +191,55 @@ export default function AdminConsulting() {
|
|||||||
const end = parse(newEndTime, 'HH:mm', new Date());
|
const end = parse(newEndTime, 'HH:mm', new Date());
|
||||||
const durationMinutes = differenceInMinutes(end, start);
|
const durationMinutes = differenceInMinutes(end, start);
|
||||||
updateData.total_duration_minutes = durationMinutes;
|
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
|
const { error } = await supabase
|
||||||
@@ -188,7 +254,12 @@ export default function AdminConsulting() {
|
|||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
fetchSessions();
|
fetchSessions();
|
||||||
}
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error saving session:', error);
|
||||||
|
toast({ title: 'Error', description: error.message || 'Gagal menyimpan perubahan', variant: 'destructive' });
|
||||||
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const createMeetLink = async () => {
|
const createMeetLink = async () => {
|
||||||
@@ -814,29 +885,26 @@ export default function AdminConsulting() {
|
|||||||
{/* Time Editing */}
|
{/* Time Editing */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Waktu Sesi</label>
|
<label className="text-sm font-medium">Waktu Sesi</label>
|
||||||
<div className="flex gap-2 items-center">
|
<Button
|
||||||
<div className="flex-1">
|
type="button"
|
||||||
<Input
|
variant="outline"
|
||||||
type="time"
|
className="w-full border-2"
|
||||||
value={editStartTime}
|
onClick={() => setTimeSlotPickerOpen(true)}
|
||||||
onChange={(e) => setEditStartTime(e.target.value)}
|
>
|
||||||
className="border-2"
|
<Clock className="w-4 h-4 mr-2" />
|
||||||
/>
|
{editStartTime && editEndTime ? (
|
||||||
</div>
|
<>
|
||||||
<span className="text-muted-foreground">→</span>
|
{editStartTime} → {editEndTime}
|
||||||
<div className="flex-1">
|
<span className="ml-2 text-muted-foreground">
|
||||||
<Input
|
({editTotalDuration} menit, {editTotalBlocks} blok)
|
||||||
type="time"
|
</span>
|
||||||
value={editEndTime}
|
</>
|
||||||
onChange={(e) => setEditEndTime(e.target.value)}
|
) : (
|
||||||
className="border-2"
|
'Pilih Waktu'
|
||||||
/>
|
)}
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Durasi: {editStartTime && editEndTime ?
|
Klik untuk memilih waktu yang tersedia
|
||||||
`${differenceInMinutes(parse(editEndTime, 'HH:mm', new Date()), parse(editStartTime, 'HH:mm', new Date()))} menit` :
|
|
||||||
'-'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -910,6 +978,19 @@ export default function AdminConsulting() {
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
278
supabase/functions/update-calendar-event/index.ts
Normal file
278
supabase/functions/update-calendar-event/index.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GoogleOAuthConfig {
|
||||||
|
client_id: string;
|
||||||
|
client_secret: string;
|
||||||
|
refresh_token: string;
|
||||||
|
access_token?: string;
|
||||||
|
expires_at?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateEventRequest {
|
||||||
|
session_id: string;
|
||||||
|
date: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to get access token from refresh token (OAuth2)
|
||||||
|
async function getGoogleAccessToken(oauthConfig: GoogleOAuthConfig): Promise<{ access_token: string; expires_in: number }> {
|
||||||
|
try {
|
||||||
|
console.log("Attempting to exchange refresh token for access token...");
|
||||||
|
|
||||||
|
const tokenRequest = {
|
||||||
|
client_id: oauthConfig.client_id,
|
||||||
|
client_secret: oauthConfig.client_secret,
|
||||||
|
refresh_token: oauthConfig.refresh_token,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch("https://oauth2.googleapis.com/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams(tokenRequest),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error("Token response error:", errorText);
|
||||||
|
throw new Error(`Token exchange failed: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.access_token) {
|
||||||
|
throw new Error("No access token in response");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Successfully obtained access token");
|
||||||
|
return {
|
||||||
|
access_token: data.access_token,
|
||||||
|
expires_in: data.expires_in || 3600
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error getting Google access token:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serve(async (req: Request): Promise<Response> => {
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return new Response(null, { headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs: string[] = [];
|
||||||
|
const log = (msg: string) => {
|
||||||
|
console.log(msg);
|
||||||
|
logs.push(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||||
|
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||||
|
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||||
|
|
||||||
|
const body: UpdateEventRequest = await req.json();
|
||||||
|
const { session_id, date, start_time, end_time } = body;
|
||||||
|
|
||||||
|
log(`Updating calendar event for session: ${session_id}`);
|
||||||
|
log(`New time: ${date} ${start_time} - ${end_time}`);
|
||||||
|
|
||||||
|
// Get session details including calendar_event_id
|
||||||
|
const { data: session, error: sessionError } = await supabase
|
||||||
|
.from("consulting_sessions")
|
||||||
|
.select("id, calendar_event_id, topic_category, profiles(name, email), notes, meet_link")
|
||||||
|
.eq("id", session_id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (sessionError || !session) {
|
||||||
|
log(`Session not found: ${sessionError?.message}`);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
message: "Session not found",
|
||||||
|
logs: logs
|
||||||
|
}),
|
||||||
|
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.calendar_event_id) {
|
||||||
|
log("No calendar event ID found for this session");
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
message: "No calendar event linked to this session",
|
||||||
|
logs: logs
|
||||||
|
}),
|
||||||
|
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get platform settings
|
||||||
|
log("Fetching platform settings...");
|
||||||
|
const { data: settings, error: settingsError } = await supabase
|
||||||
|
.from("platform_settings")
|
||||||
|
.select("integration_google_calendar_id, google_oauth_config")
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (settingsError || !settings) {
|
||||||
|
log(`Error fetching settings: ${settingsError?.message}`);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
message: "Error fetching settings",
|
||||||
|
logs: logs
|
||||||
|
}),
|
||||||
|
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendarId = settings.integration_google_calendar_id;
|
||||||
|
if (!calendarId) {
|
||||||
|
log("Calendar ID not configured");
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
message: "Google Calendar ID not configured",
|
||||||
|
logs: logs
|
||||||
|
}),
|
||||||
|
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get OAuth config
|
||||||
|
const oauthConfigJson = settings.google_oauth_config;
|
||||||
|
if (!oauthConfigJson) {
|
||||||
|
log("OAuth config not found");
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
message: "Google OAuth Config not configured",
|
||||||
|
logs: logs
|
||||||
|
}),
|
||||||
|
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let oauthConfig: GoogleOAuthConfig;
|
||||||
|
try {
|
||||||
|
oauthConfig = JSON.parse(oauthConfigJson);
|
||||||
|
} catch (error: any) {
|
||||||
|
log(`Failed to parse OAuth config: ${error.message}`);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
message: "Invalid OAuth config format",
|
||||||
|
logs: logs
|
||||||
|
}),
|
||||||
|
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get access token
|
||||||
|
let accessToken: string;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
if (oauthConfig.access_token && oauthConfig.expires_at && oauthConfig.expires_at > now + 60) {
|
||||||
|
log(`Using cached access_token`);
|
||||||
|
accessToken = oauthConfig.access_token;
|
||||||
|
} else {
|
||||||
|
log("Refreshing access token...");
|
||||||
|
const tokenData = await getGoogleAccessToken(oauthConfig);
|
||||||
|
accessToken = tokenData.access_token;
|
||||||
|
|
||||||
|
const newExpiresAt = now + tokenData.expires_in;
|
||||||
|
const updatedConfig = {
|
||||||
|
...oauthConfig,
|
||||||
|
access_token: accessToken,
|
||||||
|
expires_at: newExpiresAt
|
||||||
|
};
|
||||||
|
|
||||||
|
await supabase
|
||||||
|
.from("platform_settings")
|
||||||
|
.update({ google_oauth_config: JSON.stringify(updatedConfig) })
|
||||||
|
.eq("id", settings.id);
|
||||||
|
|
||||||
|
log("Updated cached access_token in database");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build event data for update
|
||||||
|
const startDate = new Date(`${date}T${start_time}+07:00`);
|
||||||
|
const endDate = new Date(`${date}T${end_time}+07:00`);
|
||||||
|
|
||||||
|
log(`Event time: ${startDate.toISOString()} to ${endDate.toISOString()}`);
|
||||||
|
|
||||||
|
const eventData = {
|
||||||
|
start: {
|
||||||
|
dateTime: startDate.toISOString(),
|
||||||
|
timeZone: "Asia/Jakarta",
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
dateTime: endDate.toISOString(),
|
||||||
|
timeZone: "Asia/Jakarta",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
log(`Updating event ${session.calendar_event_id} in calendar ${calendarId}`);
|
||||||
|
|
||||||
|
// Update event via Google Calendar API
|
||||||
|
const calendarResponse = await fetch(
|
||||||
|
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(session.calendar_event_id)}`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(eventData),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
log(`Calendar API response status: ${calendarResponse.status}`);
|
||||||
|
|
||||||
|
if (!calendarResponse.ok) {
|
||||||
|
const errorText = await calendarResponse.text();
|
||||||
|
log(`Google Calendar API error: ${errorText}`);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to update event in Google Calendar: " + errorText,
|
||||||
|
logs: logs
|
||||||
|
}),
|
||||||
|
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventDataResult = await calendarResponse.json();
|
||||||
|
log(`Event updated successfully: ${eventDataResult.id}`);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
event_id: eventDataResult.id,
|
||||||
|
html_link: eventDataResult.htmlLink,
|
||||||
|
logs: logs
|
||||||
|
}),
|
||||||
|
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
log(`Error updating calendar event: ${error.message}`);
|
||||||
|
log(`Stack: ${error.stack}`);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "Internal server error",
|
||||||
|
logs: logs
|
||||||
|
}),
|
||||||
|
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user