Add reschedule functionality for consulting sessions

Problem: Admins need to reschedule sessions when members can't make it,
either before or after the scheduled time. Previously had to edit
manually without clear indication of rescheduling vs simple edits.

Solution:
1. Add "Reschedule" button (blue Calendar icon) for confirmed sessions:
   - In desktop table action buttons
   - In mobile card layout
   - In passed sessions alert card

2. Enhanced session editing with reschedule mode:
   - openMeetDialog(session, rescheduleMode = true/false)
   - Tracks isRescheduling state to show appropriate UI
   - Dynamic dialog title: "Reschedule Sesi" vs "Edit Sesi"
   - Dynamic description based on mode

3. Enhanced saveMeetLink function:
   - Detects date and time changes separately
   - Updates session_date when date changed
   - Recalculates duration when time changes
   - Updates consulting_time_slots for new schedule
   - Updates calendar event if exists
   - Shows success message: "Berhasil Reschedule" with new date/time

4. Session info display improvements:
   - Show current time in session info card
   - Better context for rescheduling decisions

Reschedule use cases:
- Member can't make it BEFORE session → Admin clicks Reschedule, picks new slot
- Member misses session, tells admin AFTER → Admin clicks Reschedule in passed alert
- Emergency reschedule → Quick date/time change with calendar auto-update

Calendar integration:
- Existing calendar events automatically updated/moved to new time
- Time slots properly released (old) and booked (new)

UI placement:
- Passed sessions alert: First button (blue) for quick reschedule access
- Upcoming table: Between Edit and Complete buttons
- Mobile: Between Link and Complete buttons

🤖 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-31 13:19:45 +07:00
parent 0be27ccf99
commit f68c8ee1c4

View File

@@ -71,9 +71,12 @@ 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 [editSessionDate, setEditSessionDate] = useState('');
const [timeSlotPickerOpen, setTimeSlotPickerOpen] = useState(false); const [timeSlotPickerOpen, setTimeSlotPickerOpen] = useState(false);
const [editTotalBlocks, setEditTotalBlocks] = useState(0); const [editTotalBlocks, setEditTotalBlocks] = useState(0);
const [editTotalDuration, setEditTotalDuration] = useState(0); const [editTotalDuration, setEditTotalDuration] = useState(0);
const [isRescheduling, setIsRescheduling] = useState(false);
const [notifyMember, setNotifyMember] = useState(true);
useEffect(() => { useEffect(() => {
if (!authLoading) { if (!authLoading) {
@@ -116,13 +119,16 @@ export default function AdminConsulting() {
if (data) setSettings(data); if (data) setSettings(data);
}; };
const openMeetDialog = (session: ConsultingSession) => { const openMeetDialog = (session: ConsultingSession, rescheduleMode: boolean = false) => {
setSelectedSession(session); setSelectedSession(session);
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));
setEditSessionDate(session.session_date);
setEditTotalBlocks(session.total_blocks || 1); setEditTotalBlocks(session.total_blocks || 1);
setEditTotalDuration(session.total_duration_minutes || 30); setEditTotalDuration(session.total_duration_minutes || 30);
setIsRescheduling(rescheduleMode);
setNotifyMember(rescheduleMode); // Auto-enable notification when rescheduling
setDialogOpen(true); setDialogOpen(true);
}; };
@@ -177,14 +183,17 @@ export default function AdminConsulting() {
meet_link: meetLink || null meet_link: meetLink || null
}; };
// Check if time changed // Check if date or time changed
const newStartTime = editStartTime + ':00'; const newStartTime = editStartTime + ':00';
const newEndTime = editEndTime + ':00'; const newEndTime = editEndTime + ':00';
const dateChanged = editSessionDate !== selectedSession.session_date;
const timeChanged = newStartTime !== selectedSession.start_time || newEndTime !== selectedSession.end_time; const timeChanged = newStartTime !== selectedSession.start_time || newEndTime !== selectedSession.end_time;
const scheduleChanged = dateChanged || timeChanged;
if (timeChanged) { if (scheduleChanged) {
updateData.start_time = newStartTime; updateData.start_time = newStartTime;
updateData.end_time = newEndTime; updateData.end_time = newEndTime;
updateData.session_date = editSessionDate;
// Recalculate duration // Recalculate duration
const start = parse(newStartTime, 'HH:mm', new Date()); const start = parse(newStartTime, 'HH:mm', new Date());
@@ -209,7 +218,7 @@ export default function AdminConsulting() {
newSlots.push({ newSlots.push({
session_id: selectedSession.id, session_id: selectedSession.id,
slot_date: selectedSession.session_date, slot_date: editSessionDate,
start_time: format(currentSlotStart, 'HH:mm:ss'), start_time: format(currentSlotStart, 'HH:mm:ss'),
end_time: format(slotEnd, 'HH:mm:ss'), end_time: format(slotEnd, 'HH:mm:ss'),
is_available: false, is_available: false,
@@ -229,7 +238,7 @@ export default function AdminConsulting() {
await supabase.functions.invoke('update-calendar-event', { await supabase.functions.invoke('update-calendar-event', {
body: { body: {
session_id: selectedSession.id, session_id: selectedSession.id,
date: selectedSession.session_date, date: editSessionDate,
start_time: newStartTime, start_time: newStartTime,
end_time: newEndTime end_time: newEndTime
} }
@@ -250,7 +259,18 @@ export default function AdminConsulting() {
if (error) { if (error) {
toast({ title: 'Error', description: error.message, variant: 'destructive' }); toast({ title: 'Error', description: error.message, variant: 'destructive' });
} else { } else {
toast({ title: 'Berhasil', description: 'Perubahan disimpan' }); // Show different success message based on what changed
if (isRescheduling && scheduleChanged) {
toast({
title: 'Berhasil Reschedule',
description: `Jadwal diubah ke ${format(parseISO(editSessionDate), 'd MMM yyyy', { locale: id })} pukul ${editStartTime}-${editEndTime}`
});
} else if (scheduleChanged) {
toast({ title: 'Berhasil', description: 'Waktu sesi diperbarui' });
} else {
toast({ title: 'Berhasil', description: 'Perubahan disimpan' });
}
setDialogOpen(false); setDialogOpen(false);
fetchSessions(); fetchSessions();
} }
@@ -417,20 +437,31 @@ export default function AdminConsulting() {
Sesi Terlewat ({passedConfirmedSessions.length}) Sesi Terlewat ({passedConfirmedSessions.length})
</h3> </h3>
<p className="text-sm text-orange-800 dark:text-orange-200 mt-1"> <p className="text-sm text-orange-800 dark:text-orange-200 mt-1">
Sesi berikut telah berakhir namun masih berstatus "Dikonfirmasi". Silakan update statusnya. Sesi berikut telah berakhir namun masih berstatus "Dikonfirmasi". Reschedule atau update statusnya.
</p> </p>
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
{passedConfirmedSessions.map((session) => ( {passedConfirmedSessions.map((session) => (
<div key={session.id} className="flex items-center justify-between text-sm p-2 bg-white dark:bg-gray-900 rounded border border-orange-200 dark:border-orange-800"> <div key={session.id} className="flex items-center justify-between text-sm p-2 bg-white dark:bg-gray-900 rounded border border-orange-200 dark:border-orange-800">
<span> <span className="flex-1 mr-2">
{format(parseISO(session.session_date), 'd MMM yyyy', { locale: id })} {session.start_time.substring(0, 5)} - {session.end_time.substring(0, 5)} {session.profiles?.name || 'N/A'} ({session.topic_category}) {format(parseISO(session.session_date), 'd MMM yyyy', { locale: id })} {session.start_time.substring(0, 5)} - {session.end_time.substring(0, 5)} {session.profiles?.name || 'N/A'} ({session.topic_category})
</span> </span>
<div className="flex gap-1"> <div className="flex gap-1 shrink-0">
<Button
size="sm"
variant="outline"
onClick={() => openMeetDialog(session, true)}
className="border-blue-600 text-blue-600 hover:bg-blue-50"
title="Reschedule session"
>
<Calendar className="w-3 h-3 mr-1" />
Reschedule
</Button>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => updateSessionStatus(session.id, 'completed')} onClick={() => updateSessionStatus(session.id, 'completed')}
className="border-green-600 text-green-600 hover:bg-green-50" className="border-green-600 text-green-600 hover:bg-green-50"
title="Mark as completed"
> >
<CheckCircle className="w-3 h-3 mr-1" /> <CheckCircle className="w-3 h-3 mr-1" />
Selesai Selesai
@@ -440,6 +471,7 @@ export default function AdminConsulting() {
variant="outline" variant="outline"
onClick={() => updateSessionStatus(session.id, 'cancelled')} onClick={() => updateSessionStatus(session.id, 'cancelled')}
className="border-red-600 text-red-600 hover:bg-red-50" className="border-red-600 text-red-600 hover:bg-red-50"
title="Cancel session"
> >
<XCircle className="w-3 h-3 mr-1" /> <XCircle className="w-3 h-3 mr-1" />
Batal Batal
@@ -681,6 +713,15 @@ export default function AdminConsulting() {
<LinkIcon className="w-4 h-4 mr-1" /> <LinkIcon className="w-4 h-4 mr-1" />
{session.meet_link ? 'Edit' : 'Link'} {session.meet_link ? 'Edit' : 'Link'}
</Button> </Button>
<Button
variant="outline"
size="sm"
onClick={() => openMeetDialog(session, true)}
className="border-2 text-blue-600"
>
<Calendar className="w-4 h-4 mr-1" />
Reschedule
</Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -777,6 +818,15 @@ export default function AdminConsulting() {
<LinkIcon className="w-3 h-3 mr-1" /> <LinkIcon className="w-3 h-3 mr-1" />
{session.meet_link ? 'Edit' : 'Link'} {session.meet_link ? 'Edit' : 'Link'}
</Button> </Button>
<Button
variant="outline"
size="sm"
onClick={() => openMeetDialog(session, true)}
className="flex-1 border-2 text-blue-600 text-xs"
>
<Calendar className="w-3 h-3 mr-1" />
Reschedule
</Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -915,15 +965,19 @@ export default function AdminConsulting() {
}}> }}>
<DialogContent className="max-w-md border-2 border-border"> <DialogContent className="max-w-md border-2 border-border">
<DialogHeader> <DialogHeader>
<DialogTitle>Edit Sesi Konsultasi</DialogTitle> <DialogTitle>{isRescheduling ? 'Reschedule Sesi Konsultasi' : 'Edit Sesi Konsultasi'}</DialogTitle>
<DialogDescription> <DialogDescription>
Edit waktu, link Google Meet, dan kelola event kalender {isRescheduling
? 'Pilih tanggal dan waktu baru untuk sesi konsultasi'
: 'Edit waktu, link Google Meet, dan kelola event kalender'
}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
{selectedSession && ( {selectedSession && (
<div className="p-3 bg-muted rounded-lg text-sm space-y-1"> <div className="p-3 bg-muted rounded-lg text-sm space-y-1">
<p><strong>Tanggal:</strong> {format(parseISO(selectedSession.session_date), 'd MMMM yyyy', { locale: id })}</p> <p><strong>Tanggal:</strong> {format(parseISO(selectedSession.session_date), 'd MMMM yyyy', { locale: id })}</p>
<p><strong>Waktu:</strong> {selectedSession.start_time.substring(0, 5)} - {selectedSession.end_time.substring(0, 5)}</p>
<p><strong>Klien:</strong> {selectedSession.profiles?.name}</p> <p><strong>Klien:</strong> {selectedSession.profiles?.name}</p>
<p><strong>Topik:</strong> {selectedSession.topic_category}</p> <p><strong>Topik:</strong> {selectedSession.topic_category}</p>
{selectedSession.notes && <p><strong>Catatan:</strong> {selectedSession.notes}</p>} {selectedSession.notes && <p><strong>Catatan:</strong> {selectedSession.notes}</p>}