diff --git a/src/pages/ConsultingBooking.tsx b/src/pages/ConsultingBooking.tsx index ef0db90..6b1f9a5 100644 --- a/src/pages/ConsultingBooking.tsx +++ b/src/pages/ConsultingBooking.tsx @@ -67,12 +67,13 @@ export default function ConsultingBooking() { const [selectedDate, setSelectedDate] = useState(addDays(new Date(), 1)); - // NEW: Range selection instead of array + // Range selection with pending slot interface TimeRange { start: string | null; end: string | null; } const [selectedRange, setSelectedRange] = useState({ start: null, end: null }); + const [pendingSlot, setPendingSlot] = useState(null); const [selectedCategory, setSelectedCategory] = useState(''); const [notes, setNotes] = useState(''); @@ -192,7 +193,12 @@ export default function ConsultingBooking() { }, [selectedDate, workhours, confirmedSlots, webinars, settings]); // Helper: Get all slots between start and end (inclusive) + // Now supports single slot selection where start = end const getSlotsInRange = useMemo(() => { + // If there's a pending slot but no confirmed range, don't show any slots as selected + if (pendingSlot && !selectedRange.start) return []; + + // If only start is set (no end), don't show any slots as selected yet if (!selectedRange.start || !selectedRange.end) return []; const startIndex = availableSlots.findIndex(s => s.start === selectedRange.start); @@ -203,65 +209,74 @@ export default function ConsultingBooking() { return availableSlots .slice(startIndex, endIndex + 1) .map(s => s.start); - }, [selectedRange, availableSlots]); + }, [selectedRange, availableSlots, pendingSlot]); - // NEW: Range selection handler + // Range selection handler with pending slot UX const handleSlotClick = (slotStart: string) => { const slot = availableSlots.find(s => s.start === slotStart); if (!slot || !slot.available) return; - setSelectedRange(prev => { - // CASE 1: No selection yet → Set start time - if (!prev.start) { - return { start: slotStart, end: null }; - } - - // CASE 2: Only start selected → Set end time - if (!prev.end) { - if (slotStart === prev.start) { - // Clicked same slot → Clear selection - return { start: null, end: null }; - } - // Ensure end is after start - const startIndex = availableSlots.findIndex(s => s.start === prev.start); + // If there's a pending slot + if (pendingSlot) { + if (slotStart === pendingSlot) { + // Clicked same slot again → Confirm single slot selection + setSelectedRange({ start: slotStart, end: slotStart }); + setPendingSlot(null); + } else { + // Clicked different slot → First becomes start, second becomes end + const pendingIndex = availableSlots.findIndex(s => s.start === pendingSlot); const clickIndex = availableSlots.findIndex(s => s.start === slotStart); - if (clickIndex < startIndex) { - // Clicked before start → Make new start, old start becomes end - return { start: slotStart, end: prev.start }; + if (clickIndex < pendingIndex) { + // Clicked before pending → Make clicked slot start, pending becomes end + setSelectedRange({ start: slotStart, end: pendingSlot }); + } else { + // Clicked after pending → Pending is start, clicked is end + setSelectedRange({ start: pendingSlot, end: slotStart }); } - - return { start: prev.start, end: slotStart }; + setPendingSlot(null); } + return; + } - // CASE 3: Both selected (changing range) - const startIndex = availableSlots.findIndex(s => s.start === prev.start); - const endIndex = availableSlots.findIndex(s => s.start === prev.end); + // No pending slot - check if we're modifying existing selection + if (selectedRange.start && selectedRange.end) { + const startIndex = availableSlots.findIndex(s => s.start === selectedRange.start); + const endIndex = availableSlots.findIndex(s => s.start === selectedRange.end); const clickIndex = availableSlots.findIndex(s => s.start === slotStart); // Clicked start time → Clear all - if (slotStart === prev.start) { - return { start: null, end: null }; + if (slotStart === selectedRange.start) { + setSelectedRange({ start: null, end: null }); + return; } - // Clicked end time → Update end - if (slotStart === prev.end) { - return { start: prev.start, end: null }; + // Clicked end time → Remove end, keep start as pending + if (slotStart === selectedRange.end) { + setPendingSlot(selectedRange.start); + setSelectedRange({ start: null, end: null }); + return; } // Clicked before start → New start, old start becomes end if (clickIndex < startIndex) { - return { start: slotStart, end: prev.start }; + setSelectedRange({ start: slotStart, end: selectedRange.start }); + return; } // Clicked after end → New end if (clickIndex > endIndex) { - return { start: prev.start, end: slotStart }; + setSelectedRange({ start: selectedRange.start, end: slotStart }); + return; } // Clicked within range → Update end to clicked slot - return { start: prev.start, end: slotStart }; - }); + setSelectedRange({ start: selectedRange.start, end: slotStart }); + return; + } + + // No selection at all → Set as pending + setPendingSlot(slotStart); }; // Calculate total blocks from range @@ -447,7 +462,7 @@ export default function ConsultingBooking() { Slot Waktu - {format(selectedDate, 'EEEE, d MMMM yyyy', { locale: id })} - Klik slot awal dan akhir untuk memilih rentang waktu. {settings.consulting_block_duration_minutes} menit per blok. + Klik satu slot untuk memilih, klik lagi untuk konfirmasi. Atau klik dua slot berbeda untuk rentang waktu. {settings.consulting_block_duration_minutes} menit per blok. {webinars.length > 0 && ( ⚠️ {webinars.length} webinar terjadwal - beberapa slot mungkin tidak tersedia @@ -464,6 +479,7 @@ export default function ConsultingBooking() {
{availableSlots.map((slot, index) => { const isSelected = getSlotsInRange.includes(slot.start); + const isPending = slot.start === pendingSlot; const isStart = slot.start === selectedRange.start; const isEnd = slot.start === selectedRange.end; const isMiddle = isSelected && !isStart && !isEnd; @@ -475,6 +491,11 @@ export default function ConsultingBooking() { // Determine border radius for seamless connection let className = "border-2 h-10"; + // Add special styling for pending slot + if (isPending) { + className += " bg-amber-500 hover:bg-amber-600 text-white border-amber-600"; + } + if (isStart) { // First selected slot - right side should connect className += index < availableSlots.length - 1 && availableSlots[index + 1]?.start === getSlotsInRange[1] @@ -491,14 +512,15 @@ export default function ConsultingBooking() { return ( ); })} @@ -610,6 +632,21 @@ export default function ConsultingBooking() {
)} + {pendingSlot && !selectedRange.start && ( +
+

Slot dipilih:

+ + {/* Show pending slot */} +
+
+

Klik lagi untuk konfirmasi, atau pilih slot lain

+

{pendingSlot}

+

1 blok = {settings.consulting_block_duration_minutes} menit ({formatIDR(settings.consulting_block_price)})

+
+
+
+ )} +
Total diff --git a/src/pages/admin/AdminOrders.tsx b/src/pages/admin/AdminOrders.tsx index 6aa1f8d..cd5210b 100644 --- a/src/pages/admin/AdminOrders.tsx +++ b/src/pages/admin/AdminOrders.tsx @@ -46,6 +46,8 @@ interface ConsultingSlot { end_time: string; status: string; meet_link?: string; + topic_category?: string | null; + notes?: string | null; } export default function AdminOrders() { @@ -90,13 +92,17 @@ export default function AdminOrders() { setOrderItems((itemsData as unknown as OrderItem[]) || []); // Check if any item is a consulting product and fetch slots + // Also fetch slots if no order_items exist (consulting-only order) const hasConsulting = (itemsData as unknown as OrderItem[])?.some(item => item.product?.type === "consulting"); - if (hasConsulting) { + const hasNoItems = !itemsData || itemsData.length === 0; + + if (hasConsulting || hasNoItems) { const { data: slotsData } = await supabase .from("consulting_slots") .select("*") .eq("order_id", order.id) - .order("date", { ascending: true }); + .order("date", { ascending: true }) + .order("start_time", { ascending: true }); setConsultingSlots((slotsData as ConsultingSlot[]) || []); } else { setConsultingSlots([]); @@ -475,19 +481,32 @@ export default function AdminOrders() { Metode: {selectedOrder.payment_method || "-"}
-
-

Item:

- {orderItems.map((item) => ( -
- {item.product?.title} - {formatIDR(item.unit_price)} + {/* Order Items - only show if there are items */} + {orderItems.length > 0 && ( +
+

Item Pesanan:

+ {orderItems.map((item) => ( +
+ {item.product?.title} + {formatIDR(item.unit_price)} +
+ ))} +
+ Total + {formatIDR(selectedOrder.total_amount)}
- ))} -
- Total - {formatIDR(selectedOrder.total_amount)}
-
+ )} + + {/* Order Total for consulting-only orders */} + {orderItems.length === 0 && consultingSlots.length > 0 && ( +
+
+ Total + {formatIDR(selectedOrder.total_amount)} +
+
+ )} {/* Consulting Slots */} {consultingSlots.length > 0 && ( @@ -499,12 +518,17 @@ export default function AdminOrders() {
{consultingSlots.map((slot) => (
-
+
{slot.status === "confirmed" ? "Terkonfirmasi" : slot.status} + {slot.topic_category && ( + + {slot.topic_category} + + )} {/* Meet Link Status */} {slot.meet_link ? ( @@ -529,6 +553,11 @@ export default function AdminOrders() {

{slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)} WIB

+ {slot.notes && ( +

+ Catatan: {slot.notes} +

+ )}
{slot.meet_link && ( diff --git a/supabase/functions/create-google-meet-event/index.ts b/supabase/functions/create-google-meet-event/index.ts index 85a7757..3f4fef3 100644 --- a/supabase/functions/create-google-meet-event/index.ts +++ b/supabase/functions/create-google-meet-event/index.ts @@ -50,15 +50,16 @@ async function getGoogleAccessToken(oauthConfig: GoogleOAuthConfig): Promise<{ a body: new URLSearchParams(tokenRequest), }); - const responseText = await response.text(); console.log("Token response status:", response.status); - console.log("Token response body:", responseText); if (!response.ok) { - throw new Error(`Token exchange failed: ${responseText}`); + const errorText = await response.text(); + console.error("Token response error:", errorText); + throw new Error(`Token exchange failed: ${errorText}`); } const data = await response.json(); + console.log("Token response data:", JSON.stringify(data, null, 2)); if (!data.access_token) { throw new Error("No access token in response"); @@ -80,6 +81,12 @@ serve(async (req: Request): Promise => { 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")!; @@ -96,46 +103,60 @@ serve(async (req: Request): Promise => { }; try { + log("Starting to read request body..."); debugInfo.bodyReadAttempt = "Starting req.text()"; const bodyText = await req.text(); debugInfo.bodyLength = bodyText.length; debugInfo.bodyPreview = bodyText.substring(0, 200); - console.log("Raw body text:", bodyText.substring(0, 100) + "..."); + log(`Raw body text: ${bodyText.substring(0, 100)}...`); body = JSON.parse(bodyText); debugInfo.parsedBody = body; + log(`Parsed body: ${JSON.stringify(body)}`); } catch (bodyError) { debugInfo.readError = (bodyError as Error).message; - console.error("Error reading body:", bodyError); - console.error("Debug info:", JSON.stringify(debugInfo, null, 2)); + log(`Error reading body: ${(bodyError as Error).message}`); + log(`Debug info: ${JSON.stringify(debugInfo, null, 2)}`); return new Response( JSON.stringify({ success: false, message: "Invalid request body: " + (bodyError as Error).message, - debug: debugInfo + debug: debugInfo, + logs: logs }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } - console.log("Creating Google Meet event for slot:", body.slot_id); + log(`Creating Google Meet event for slot: ${body.slot_id}`); // 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) { - console.error("Error fetching settings:", settingsError); - throw settingsError; - } - - const calendarId = settings?.integration_google_calendar_id; - - if (!calendarId) { + log(`Error fetching settings: ${JSON.stringify(settingsError)}`); return new Response( JSON.stringify({ success: false, - message: "Google Calendar ID belum dikonfigurasi di Pengaturan > Integrasi" + message: "Error fetching settings: " + settingsError.message, + logs: logs + }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + const calendarId = settings?.integration_google_calendar_id; + log(`Calendar ID: ${calendarId}`); + + if (!calendarId) { + log("Calendar ID not configured"); + return new Response( + JSON.stringify({ + success: false, + message: "Google Calendar ID belum dikonfigurasi di Pengaturan > Integrasi", + logs: logs }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } ); @@ -145,10 +166,12 @@ serve(async (req: Request): Promise => { const oauthConfigJson = settings?.google_oauth_config; if (!oauthConfigJson) { + log("OAuth config not found"); return new Response( JSON.stringify({ success: false, - message: "Google OAuth Config belum dikonfigurasi. Tambahkan di Pengaturan > Integrasi. Format: {\"client_id\":\"...\",\"client_secret\":\"...\",\"refresh_token\":\"...\"}" + message: "Google OAuth Config belum dikonfigurasi. Tambahkan di Pengaturan > Integrasi. Format: {\"client_id\":\"...\",\"client_secret\":\"...\",\"refresh_token\":\"...\"}", + logs: logs }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } ); @@ -158,12 +181,14 @@ serve(async (req: Request): Promise => { let oauthConfig: GoogleOAuthConfig; try { oauthConfig = JSON.parse(oauthConfigJson); + log("OAuth config parsed successfully"); } catch (error: any) { - console.error("Failed to parse OAuth config JSON:", error); + log(`Failed to parse OAuth config: ${error.message}`); return new Response( JSON.stringify({ success: false, - message: "Format Google OAuth Config tidak valid: " + error.message + message: "Format Google OAuth Config tidak valid: " + error.message, + logs: logs }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } ); @@ -175,11 +200,11 @@ serve(async (req: Request): Promise => { if (oauthConfig.access_token && oauthConfig.expires_at && oauthConfig.expires_at > now + 60) { // Token is still valid (with 60 second buffer) - console.log("Using cached access_token (expires at:", new Date(oauthConfig.expires_at * 1000).toISOString(), ")"); + log(`Using cached access_token (expires at: ${new Date(oauthConfig.expires_at * 1000).toISOString()})`); accessToken = oauthConfig.access_token; } else { // Need to refresh the token - console.log("Access token expired or missing, refreshing..."); + log("Access token expired or missing, refreshing..."); const tokenData = await getGoogleAccessToken(oauthConfig); accessToken = tokenData.access_token; @@ -197,15 +222,17 @@ serve(async (req: Request): Promise => { .update({ google_oauth_config: JSON.stringify(updatedConfig) }) .eq("id", settings.id); - console.log("Updated cached access_token in database"); + log("Updated cached access_token in database"); } - console.log("Got access token"); + log("Got access token"); // Build event data // Include +07:00 timezone offset to ensure times are treated as Asia/Jakarta time const startDate = new Date(`${body.date}T${body.start_time}+07:00`); const endDate = new Date(`${body.date}T${body.end_time}+07:00`); + log(`Event time: ${startDate.toISOString()} to ${endDate.toISOString()}`); + const eventData = { summary: `Konsultasi: ${body.topic} - ${body.client_name}`, description: `Client: ${body.client_email}\n\nNotes: ${body.notes || '-'}\n\nSlot ID: ${body.slot_id}`, @@ -227,12 +254,13 @@ serve(async (req: Request): Promise => { }, }; - console.log("Creating event in calendar:", calendarId); - console.log("Event data:", JSON.stringify(eventData, null, 2)); + log(`Creating event in calendar: ${calendarId}`); + log(`Event data: ${JSON.stringify(eventData, null, 2)}`); // Create event via Google Calendar API with better error handling let calendarResponse: Response; try { + log("Calling Google Calendar API..."); calendarResponse = await fetch( `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?conferenceDataVersion=1`, { @@ -246,50 +274,73 @@ serve(async (req: Request): Promise => { } ); } catch (fetchError: any) { - console.error("Network error calling Google Calendar API:", fetchError); + log(`Network error calling Google Calendar API: ${fetchError.message}`); return new Response( JSON.stringify({ success: false, - message: "Network error calling Google Calendar API: " + fetchError.message + message: "Network error calling Google Calendar API: " + fetchError.message, + logs: logs }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } - console.log("Calendar API response status:", calendarResponse.status); + log(`Calendar API response status: ${calendarResponse.status}`); if (!calendarResponse.ok) { const errorText = await calendarResponse.text(); - console.error("Google Calendar API error:", errorText); + log(`Google Calendar API error: ${errorText}`); return new Response( JSON.stringify({ success: false, - message: "Gagal membuat event di Google Calendar: " + errorText + message: "Gagal membuat event di Google Calendar: " + errorText, + logs: logs }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } const eventDataResult = await calendarResponse.json(); - console.log("Event created:", eventDataResult.id); - console.log("Full event response:", JSON.stringify(eventDataResult, null, 2)); + log(`Event created with ID: ${eventDataResult.id}`); + log(`Full event response: ${JSON.stringify(eventDataResult, null, 2)}`); // Check if conference data was created if (eventDataResult.conferenceData && eventDataResult.conferenceData.entryPoints) { const meetLink = eventDataResult.conferenceData.entryPoints.find((ep: any) => ep.entryPointType === "video")?.uri; if (meetLink) { - await supabase - .from("consulting_slots") - .update({ meet_link: meetLink }) - .eq("id", body.slot_id); + log(`Meet link found: ${meetLink}`); + // If this is part of a multi-slot order, update all slots with the same order_id + // First, check if this slot has an order_id + const { data: slotData } = await supabase + .from("consulting_slots") + .select("order_id") + .eq("id", body.slot_id) + .single(); + + if (slotData?.order_id) { + log(`Updating all slots in order ${slotData.order_id} with meet_link`); + await supabase + .from("consulting_slots") + .update({ meet_link: meetLink }) + .eq("order_id", slotData.order_id); + } else { + log(`No order_id found, updating only slot ${body.slot_id}`); + await supabase + .from("consulting_slots") + .update({ meet_link: meetLink }) + .eq("id", body.slot_id); + } + + log("Successfully completed"); return new Response( JSON.stringify({ success: true, meet_link: meetLink, event_id: eventDataResult.id, html_link: eventDataResult.htmlLink, + logs: logs }), { headers: { ...corsHeaders, "Content-Type": "application/json" } } ); @@ -298,36 +349,60 @@ serve(async (req: Request): Promise => { // Fallback to hangoutLink for backwards compatibility if (eventDataResult.hangoutLink) { - await supabase - .from("consulting_slots") - .update({ meet_link: eventDataResult.hangoutLink }) - .eq("id", body.slot_id); + log(`Using hangoutLink: ${eventDataResult.hangoutLink}`); + // If this is part of a multi-slot order, update all slots with the same order_id + const { data: slotData } = await supabase + .from("consulting_slots") + .select("order_id") + .eq("id", body.slot_id) + .single(); + + if (slotData?.order_id) { + log(`Updating all slots in order ${slotData.order_id} with meet_link`); + await supabase + .from("consulting_slots") + .update({ meet_link: eventDataResult.hangoutLink }) + .eq("order_id", slotData.order_id); + } else { + log(`No order_id found, updating only slot ${body.slot_id}`); + await supabase + .from("consulting_slots") + .update({ meet_link: eventDataResult.hangoutLink }) + .eq("id", body.slot_id); + } + + log("Successfully completed"); return new Response( JSON.stringify({ success: true, meet_link: eventDataResult.hangoutLink, event_id: eventDataResult.id, html_link: eventDataResult.htmlLink, + logs: logs }), { headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } + log("Event created but no meet link found"); return new Response( JSON.stringify({ success: false, - message: "Event berhasil dibuat tapi tidak ada meet link" + message: "Event berhasil dibuat tapi tidak ada meet link", + logs: logs }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } catch (error: any) { - console.error("Error creating Google Meet event:", error); + log(`Error creating Google Meet event: ${error.message}`); + log(`Stack: ${error.stack}`); return new Response( JSON.stringify({ success: false, - message: error.message || "Unknown error occurred" + message: error.message || "Unknown error occurred", + logs: logs }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } );