Fix calendar timezone and group consulting slots by order

Calendar Timezone Fix:
- Add +07:00 timezone offset to date strings in create-google-meet-event
- Fixes 13:00 appearing as 20:00 in Google Calendar
- Now treats times as Asia/Jakarta time explicitly

Single Calendar Event per Order:
- handle-order-paid now creates ONE event for all slots in an order
- Uses first slot's start time and last slot's end time
- Updates all slots with the same meet_link
- Prevents duplicate calendar events for multi-slot orders

Admin Consulting Page Improvements:
- Group consulting slots by order_id
- Display as single row with continuous time range (start-end)
- Show session count when multiple slots (e.g., "2 sesi")
- Consistent with member-facing ConsultingHistory component
- Updated both desktop table and mobile card layouts
- Updated both upcoming and past tabs

🤖 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-27 01:34:40 +07:00
parent 3f0acca658
commit 42d6bd98e2
3 changed files with 337 additions and 254 deletions

View File

@@ -36,6 +36,17 @@ interface ConsultingSlot {
};
}
interface GroupedOrder {
orderId: string | null;
slots: ConsultingSlot[];
firstDate: string;
meetLink: string | null;
profile: {
name: string;
email: string;
} | null;
}
interface PlatformSettings {
integration_n8n_base_url?: string;
integration_google_calendar_id?: string;
@@ -245,10 +256,31 @@ export default function AdminConsulting() {
);
}
// Group slots by order_id
const groupedOrders: GroupedOrder[] = (() => {
const groups = new Map<string | null, ConsultingSlot[]>();
slots.forEach(slot => {
const orderId = slot.order_id || 'no-order';
if (!groups.has(orderId)) {
groups.set(orderId, []);
}
groups.get(orderId)!.push(slot);
});
return Array.from(groups.entries()).map(([orderId, slots]) => ({
orderId: orderId === 'no-order' ? null : orderId,
slots,
firstDate: slots[0].date,
meetLink: slots[0].meet_link,
profile: slots[0].profiles || null,
})).sort((a, b) => new Date(a.firstDate).getTime() - new Date(b.firstDate).getTime());
})();
const today = new Date().toISOString().split('T')[0];
const upcomingSlots = slots.filter(s => s.date >= today && (s.status === 'confirmed' || s.status === 'pending_payment'));
const pastSlots = slots.filter(s => s.date < today || s.status === 'completed' || s.status === 'cancelled');
const todaySlots = slots.filter(s => isToday(parseISO(s.date)) && s.status === 'confirmed');
const upcomingOrders = groupedOrders.filter(o => o.firstDate >= today && o.slots.some(s => s.status === 'confirmed' || s.status === 'pending_payment'));
const pastOrders = groupedOrders.filter(o => o.firstDate < today || o.slots.every(s => s.status === 'completed' || s.status === 'cancelled'));
const todayOrders = groupedOrders.filter(o => isToday(parseISO(o.firstDate)) && o.slots.some(s => s.status === 'confirmed'));
return (
<AppLayout>
@@ -262,30 +294,34 @@ export default function AdminConsulting() {
</p>
{/* Today's Sessions Alert */}
{todaySlots.length > 0 && (
{todayOrders.length > 0 && (
<Card className="border-2 border-primary bg-primary/5 mb-6">
<CardContent className="py-4">
<h3 className="font-bold flex items-center gap-2">
<Calendar className="w-5 h-5" />
Sesi Hari Ini ({todaySlots.length})
Sesi Hari Ini ({todayOrders.length})
</h3>
<div className="mt-2 space-y-2">
{todaySlots.map(slot => (
<div key={slot.id} className="flex items-center justify-between text-sm">
{todayOrders.map((order) => {
const firstSlot = order.slots[0];
const lastSlot = order.slots[order.slots.length - 1];
return (
<div key={order.orderId || 'no-order'} className="flex items-center justify-between text-sm">
<span>
{slot.start_time.substring(0, 5)} - {slot.profiles?.name || 'N/A'} ({slot.topic_category})
{firstSlot.start_time.substring(0, 5)} - {lastSlot.end_time.substring(0, 5)} {order.profile?.name || 'N/A'} ({firstSlot.topic_category})
</span>
{slot.meet_link ? (
<a href={slot.meet_link} target="_blank" rel="noopener noreferrer" className="text-primary underline flex items-center gap-1">
{order.meetLink ? (
<a href={order.meetLink} target="_blank" rel="noopener noreferrer" className="text-primary underline flex items-center gap-1">
<ExternalLink className="w-3 h-3" /> Join
</a>
) : (
<Button size="sm" variant="outline" onClick={() => openMeetDialog(slot)}>
<Button size="sm" variant="outline" onClick={() => openMeetDialog(firstSlot)}>
Tambah Link
</Button>
)}
</div>
))}
);
})}
</div>
</CardContent>
</Card>
@@ -295,25 +331,25 @@ export default function AdminConsulting() {
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<Card className="border-2 border-border">
<CardContent className="pt-6">
<div className="text-2xl font-bold">{todaySlots.length}</div>
<div className="text-2xl font-bold">{todayOrders.length}</div>
<p className="text-sm text-muted-foreground">Hari Ini</p>
</CardContent>
</Card>
<Card className="border-2 border-border">
<CardContent className="pt-6">
<div className="text-2xl font-bold">{upcomingSlots.filter(s => s.status === 'confirmed').length}</div>
<div className="text-2xl font-bold">{upcomingOrders.filter(o => o.slots.some(s => s.status === 'confirmed')).length}</div>
<p className="text-sm text-muted-foreground">Dikonfirmasi</p>
</CardContent>
</Card>
<Card className="border-2 border-border">
<CardContent className="pt-6">
<div className="text-2xl font-bold">{upcomingSlots.filter(s => !s.meet_link && s.status === 'confirmed').length}</div>
<div className="text-2xl font-bold">{upcomingOrders.filter(o => !o.meetLink && o.slots.some(s => s.status === 'confirmed')).length}</div>
<p className="text-sm text-muted-foreground">Perlu Link Meet</p>
</CardContent>
</Card>
<Card className="border-2 border-border">
<CardContent className="pt-6">
<div className="text-2xl font-bold">{pastSlots.filter(s => s.status === 'completed').length}</div>
<div className="text-2xl font-bold">{pastOrders.filter(o => o.slots.every(s => s.status === 'completed')).length}</div>
<p className="text-sm text-muted-foreground">Selesai</p>
</CardContent>
</Card>
@@ -322,8 +358,8 @@ export default function AdminConsulting() {
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="mb-4">
<TabsTrigger value="upcoming">Mendatang ({upcomingSlots.length})</TabsTrigger>
<TabsTrigger value="past">Riwayat ({pastSlots.length})</TabsTrigger>
<TabsTrigger value="upcoming">Mendatang ({upcomingOrders.length})</TabsTrigger>
<TabsTrigger value="past">Riwayat ({pastOrders.length})</TabsTrigger>
</TabsList>
<TabsContent value="upcoming">
@@ -344,36 +380,45 @@ export default function AdminConsulting() {
</TableRow>
</TableHeader>
<TableBody>
{upcomingSlots.map((slot) => (
<TableRow key={slot.id}>
{upcomingOrders.map((order) => {
const firstSlot = order.slots[0];
const lastSlot = order.slots[order.slots.length - 1];
const sessionCount = order.slots.length;
return (
<TableRow key={order.orderId || 'no-order'}>
<TableCell className="font-medium">
<div>
{format(parseISO(slot.date), 'd MMM yyyy', { locale: id })}
{isToday(parseISO(slot.date)) && <Badge className="ml-2 bg-primary">Hari Ini</Badge>}
{isTomorrow(parseISO(slot.date)) && <Badge className="ml-2 bg-accent">Besok</Badge>}
{format(parseISO(firstSlot.date), 'd MMM yyyy', { locale: id })}
{isToday(parseISO(firstSlot.date)) && <Badge className="ml-2 bg-primary">Hari Ini</Badge>}
{isTomorrow(parseISO(firstSlot.date)) && <Badge className="ml-2 bg-accent">Besok</Badge>}
</div>
</TableCell>
<TableCell>
{slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)}
</TableCell>
<TableCell>
<div>
<p className="font-medium">{slot.profiles?.name || '-'}</p>
<p className="text-sm text-muted-foreground">{slot.profiles?.email}</p>
<div>{firstSlot.start_time.substring(0, 5)} - {lastSlot.end_time.substring(0, 5)}</div>
{sessionCount > 1 && (
<div className="text-xs text-muted-foreground">{sessionCount} sesi</div>
)}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{slot.topic_category}</Badge>
<div>
<p className="font-medium">{order.profile?.name || '-'}</p>
<p className="text-sm text-muted-foreground">{order.profile?.email}</p>
</div>
</TableCell>
<TableCell>
<Badge variant={statusLabels[slot.status]?.variant || 'secondary'}>
{statusLabels[slot.status]?.label || slot.status}
<Badge variant="outline">{firstSlot.topic_category}</Badge>
</TableCell>
<TableCell>
<Badge variant={statusLabels[firstSlot.status]?.variant || 'secondary'}>
{statusLabels[firstSlot.status]?.label || firstSlot.status}
</Badge>
</TableCell>
<TableCell>
{slot.meet_link ? (
{order.meetLink ? (
<a
href={slot.meet_link}
href={order.meetLink}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline flex items-center gap-1"
@@ -386,21 +431,21 @@ export default function AdminConsulting() {
)}
</TableCell>
<TableCell className="text-right space-x-2">
{slot.status === 'confirmed' && (
{firstSlot.status === 'confirmed' && (
<>
<Button
variant="outline"
size="sm"
onClick={() => openMeetDialog(slot)}
onClick={() => openMeetDialog(firstSlot)}
className="border-2"
>
<LinkIcon className="w-4 h-4 mr-1" />
{slot.meet_link ? 'Edit' : 'Link'}
{order.meetLink ? 'Edit' : 'Link'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => updateSlotStatus(slot.id, 'completed')}
onClick={() => updateSlotStatus(firstSlot.id, 'completed')}
className="border-2 text-green-600"
>
<CheckCircle className="w-4 h-4" />
@@ -408,7 +453,7 @@ export default function AdminConsulting() {
<Button
variant="outline"
size="sm"
onClick={() => updateSlotStatus(slot.id, 'cancelled')}
onClick={() => updateSlotStatus(firstSlot.id, 'cancelled')}
className="border-2 text-destructive"
>
<XCircle className="w-4 h-4" />
@@ -417,8 +462,9 @@ export default function AdminConsulting() {
)}
</TableCell>
</TableRow>
))}
{upcomingSlots.length === 0 && (
);
})}
{upcomingOrders.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
Tidak ada jadwal mendatang
@@ -433,21 +479,28 @@ export default function AdminConsulting() {
{/* Mobile Card Layout */}
<div className="md:hidden space-y-3">
{upcomingSlots.map((slot) => (
<div key={slot.id} className="border-2 border-border rounded-lg p-4 space-y-3 bg-card shadow-sm">
{upcomingOrders.map((order) => {
const firstSlot = order.slots[0];
const lastSlot = order.slots[order.slots.length - 1];
const sessionCount = order.slots.length;
return (
<div key={order.orderId || 'no-order'} className="border-2 border-border rounded-lg p-4 space-y-3 bg-card shadow-sm">
<div>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<h3 className="font-semibold text-sm">
{format(parseISO(slot.date), 'd MMM yyyy', { locale: id })}
{format(parseISO(firstSlot.date), 'd MMM yyyy', { locale: id })}
</h3>
<Badge variant={statusLabels[slot.status]?.variant || 'secondary'}>
{statusLabels[slot.status]?.label || slot.status}
<Badge variant={statusLabels[firstSlot.status]?.variant || 'secondary'}>
{statusLabels[firstSlot.status]?.label || firstSlot.status}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)}
{firstSlot.start_time.substring(0, 5)} - {lastSlot.end_time.substring(0, 5)}
{sessionCount > 1 && (
<span className="ml-2 text-xs">({sessionCount} sesi)</span>
)}
</p>
</div>
</div>
@@ -455,18 +508,18 @@ export default function AdminConsulting() {
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Klien:</span>
<div className="text-right">
<p className="text-sm font-medium">{slot.profiles?.name || '-'}</p>
<p className="text-sm font-medium">{order.profile?.name || '-'}</p>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Kategori:</span>
<Badge variant="outline" className="text-xs">{slot.topic_category}</Badge>
<Badge variant="outline" className="text-xs">{firstSlot.topic_category}</Badge>
</div>
{slot.meet_link && (
{order.meetLink && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Meet:</span>
<a
href={slot.meet_link}
href={order.meetLink}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline text-sm flex items-center gap-1"
@@ -477,21 +530,21 @@ export default function AdminConsulting() {
</div>
)}
</div>
{slot.status === 'confirmed' && (
{firstSlot.status === 'confirmed' && (
<div className="flex gap-2 pt-2 border-t border-border">
<Button
variant="outline"
size="sm"
onClick={() => openMeetDialog(slot)}
onClick={() => openMeetDialog(firstSlot)}
className="flex-1 border-2 text-xs"
>
<LinkIcon className="w-3 h-3 mr-1" />
{slot.meet_link ? 'Edit' : 'Link'}
{order.meetLink ? 'Edit' : 'Link'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => updateSlotStatus(slot.id, 'completed')}
onClick={() => updateSlotStatus(firstSlot.id, 'completed')}
className="flex-1 border-2 text-green-600 text-xs"
>
<CheckCircle className="w-3 h-3 mr-1" />
@@ -500,7 +553,7 @@ export default function AdminConsulting() {
<Button
variant="outline"
size="sm"
onClick={() => updateSlotStatus(slot.id, 'cancelled')}
onClick={() => updateSlotStatus(firstSlot.id, 'cancelled')}
className="flex-1 border-2 text-destructive text-xs"
>
<XCircle className="w-3 h-3 mr-1" />
@@ -510,8 +563,9 @@ export default function AdminConsulting() {
)}
</div>
</div>
))}
{upcomingSlots.length === 0 && (
);
})}
{upcomingOrders.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
Tidak ada jadwal mendatang
</div>
@@ -535,20 +589,32 @@ export default function AdminConsulting() {
</TableRow>
</TableHeader>
<TableBody>
{pastSlots.slice(0, 20).map((slot) => (
<TableRow key={slot.id}>
<TableCell>{format(parseISO(slot.date), 'd MMM yyyy', { locale: id })}</TableCell>
<TableCell>{slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)}</TableCell>
<TableCell>{slot.profiles?.name || '-'}</TableCell>
<TableCell><Badge variant="outline">{slot.topic_category}</Badge></TableCell>
{pastOrders.slice(0, 20).map((order) => {
const firstSlot = order.slots[0];
const lastSlot = order.slots[order.slots.length - 1];
const sessionCount = order.slots.length;
return (
<TableRow key={order.orderId || 'no-order'}>
<TableCell>{format(parseISO(firstSlot.date), 'd MMM yyyy', { locale: id })}</TableCell>
<TableCell>
<Badge variant={statusLabels[slot.status]?.variant || 'secondary'}>
{statusLabels[slot.status]?.label || slot.status}
<div>
<div>{firstSlot.start_time.substring(0, 5)} - {lastSlot.end_time.substring(0, 5)}</div>
{sessionCount > 1 && (
<div className="text-xs text-muted-foreground">{sessionCount} sesi</div>
)}
</div>
</TableCell>
<TableCell>{order.profile?.name || '-'}</TableCell>
<TableCell><Badge variant="outline">{firstSlot.topic_category}</Badge></TableCell>
<TableCell>
<Badge variant={statusLabels[firstSlot.status]?.variant || 'secondary'}>
{statusLabels[firstSlot.status]?.label || firstSlot.status}
</Badge>
</TableCell>
</TableRow>
))}
{pastSlots.length === 0 && (
);
})}
{pastOrders.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
Belum ada riwayat konsultasi
@@ -563,36 +629,44 @@ export default function AdminConsulting() {
{/* Mobile Card Layout */}
<div className="md:hidden space-y-3">
{pastSlots.slice(0, 20).map((slot) => (
<div key={slot.id} className="border-2 border-border rounded-lg p-4 space-y-3 bg-card shadow-sm">
{pastOrders.slice(0, 20).map((order) => {
const firstSlot = order.slots[0];
const lastSlot = order.slots[order.slots.length - 1];
const sessionCount = order.slots.length;
return (
<div key={order.orderId || 'no-order'} className="border-2 border-border rounded-lg p-4 space-y-3 bg-card shadow-sm">
<div>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-sm">
{format(parseISO(slot.date), 'd MMM yyyy', { locale: id })}
{format(parseISO(firstSlot.date), 'd MMM yyyy', { locale: id })}
</h3>
<p className="text-sm text-muted-foreground">
{slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)}
{firstSlot.start_time.substring(0, 5)} - {lastSlot.end_time.substring(0, 5)}
{sessionCount > 1 && (
<span className="ml-2 text-xs">({sessionCount} sesi)</span>
)}
</p>
</div>
<Badge variant={statusLabels[slot.status]?.variant || 'secondary'}>
{statusLabels[slot.status]?.label || slot.status}
<Badge variant={statusLabels[firstSlot.status]?.variant || 'secondary'}>
{statusLabels[firstSlot.status]?.label || firstSlot.status}
</Badge>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Klien:</span>
<span className="text-sm">{slot.profiles?.name || '-'}</span>
<span className="text-sm">{order.profile?.name || '-'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Kategori:</span>
<Badge variant="outline" className="text-xs">{slot.topic_category}</Badge>
<Badge variant="outline" className="text-xs">{firstSlot.topic_category}</Badge>
</div>
</div>
</div>
</div>
))}
{pastSlots.length === 0 && (
);
})}
{pastOrders.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
Belum ada riwayat konsultasi
</div>

View File

@@ -202,8 +202,9 @@ serve(async (req: Request): Promise<Response> => {
console.log("Got access token");
// Build event data
const startDate = new Date(`${body.date}T${body.start_time}`);
const endDate = new Date(`${body.date}T${body.end_time}`);
// 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`);
const eventData = {
summary: `Konsultasi: ${body.topic} - ${body.client_name}`,

View File

@@ -113,10 +113,12 @@ serve(async (req: Request): Promise<Response> => {
}
if (consultingSlots && consultingSlots.length > 0) {
for (const slot of consultingSlots) {
try {
console.log("[HANDLE-PAID] Creating Google Meet for slot:", slot.id);
console.log("[HANDLE-PAID] Creating Google Meet for order:", order_id);
// Group slots by order - use first slot's start time and last slot's end time
const firstSlot = consultingSlots[0];
const lastSlot = consultingSlots[consultingSlots.length - 1];
const topic = "Konsultasi 1-on-1";
const meetResponse = await fetch(
@@ -128,13 +130,14 @@ serve(async (req: Request): Promise<Response> => {
"Authorization": `Bearer ${Deno.env.get("SUPABASE_ANON_KEY")}`,
},
body: JSON.stringify({
slot_id: slot.id,
date: slot.date,
start_time: slot.start_time,
end_time: slot.end_time,
slot_id: firstSlot.id, // Use first slot ID
date: firstSlot.date,
start_time: firstSlot.start_time,
end_time: lastSlot.end_time, // Use last slot's end time for continuous block
client_name: userName,
client_email: userEmail,
topic: topic,
notes: `${consultingSlots.length} sesi: ${consultingSlots.map(s => s.start_time.substring(0, 5)).join(', ')}`,
}),
}
);
@@ -143,6 +146,12 @@ serve(async (req: Request): Promise<Response> => {
const meetData = await meetResponse.json();
if (meetData.success) {
console.log("[HANDLE-PAID] Meet created:", meetData.meet_link);
// Update all slots with the same meet link
await supabase
.from("consulting_slots")
.update({ meet_link: meetData.meet_link })
.eq("order_id", order_id);
}
}
} catch (error) {
@@ -150,7 +159,6 @@ serve(async (req: Request): Promise<Response> => {
// Don't fail the entire process
}
}
}
// Send consulting notification with the consultingSlots data
await sendNotification(supabase, "consulting_scheduled", {