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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user