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:
@@ -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 {
|
interface PlatformSettings {
|
||||||
integration_n8n_base_url?: string;
|
integration_n8n_base_url?: string;
|
||||||
integration_google_calendar_id?: 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 today = new Date().toISOString().split('T')[0];
|
||||||
const upcomingSlots = slots.filter(s => s.date >= today && (s.status === 'confirmed' || s.status === 'pending_payment'));
|
const upcomingOrders = groupedOrders.filter(o => o.firstDate >= today && o.slots.some(s => s.status === 'confirmed' || s.status === 'pending_payment'));
|
||||||
const pastSlots = slots.filter(s => s.date < today || s.status === 'completed' || s.status === 'cancelled');
|
const pastOrders = groupedOrders.filter(o => o.firstDate < today || o.slots.every(s => s.status === 'completed' || s.status === 'cancelled'));
|
||||||
const todaySlots = slots.filter(s => isToday(parseISO(s.date)) && s.status === 'confirmed');
|
const todayOrders = groupedOrders.filter(o => isToday(parseISO(o.firstDate)) && o.slots.some(s => s.status === 'confirmed'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
@@ -262,30 +294,34 @@ export default function AdminConsulting() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Today's Sessions Alert */}
|
{/* Today's Sessions Alert */}
|
||||||
{todaySlots.length > 0 && (
|
{todayOrders.length > 0 && (
|
||||||
<Card className="border-2 border-primary bg-primary/5 mb-6">
|
<Card className="border-2 border-primary bg-primary/5 mb-6">
|
||||||
<CardContent className="py-4">
|
<CardContent className="py-4">
|
||||||
<h3 className="font-bold flex items-center gap-2">
|
<h3 className="font-bold flex items-center gap-2">
|
||||||
<Calendar className="w-5 h-5" />
|
<Calendar className="w-5 h-5" />
|
||||||
Sesi Hari Ini ({todaySlots.length})
|
Sesi Hari Ini ({todayOrders.length})
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mt-2 space-y-2">
|
<div className="mt-2 space-y-2">
|
||||||
{todaySlots.map(slot => (
|
{todayOrders.map((order) => {
|
||||||
<div key={slot.id} className="flex items-center justify-between text-sm">
|
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>
|
<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>
|
</span>
|
||||||
{slot.meet_link ? (
|
{order.meetLink ? (
|
||||||
<a href={slot.meet_link} target="_blank" rel="noopener noreferrer" className="text-primary underline flex items-center gap-1">
|
<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
|
<ExternalLink className="w-3 h-3" /> Join
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<Button size="sm" variant="outline" onClick={() => openMeetDialog(slot)}>
|
<Button size="sm" variant="outline" onClick={() => openMeetDialog(firstSlot)}>
|
||||||
Tambah Link
|
Tambah Link
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -295,25 +331,25 @@ export default function AdminConsulting() {
|
|||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
<Card className="border-2 border-border">
|
<Card className="border-2 border-border">
|
||||||
<CardContent className="pt-6">
|
<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>
|
<p className="text-sm text-muted-foreground">Hari Ini</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="border-2 border-border">
|
<Card className="border-2 border-border">
|
||||||
<CardContent className="pt-6">
|
<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>
|
<p className="text-sm text-muted-foreground">Dikonfirmasi</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="border-2 border-border">
|
<Card className="border-2 border-border">
|
||||||
<CardContent className="pt-6">
|
<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>
|
<p className="text-sm text-muted-foreground">Perlu Link Meet</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="border-2 border-border">
|
<Card className="border-2 border-border">
|
||||||
<CardContent className="pt-6">
|
<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>
|
<p className="text-sm text-muted-foreground">Selesai</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -322,8 +358,8 @@ export default function AdminConsulting() {
|
|||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<TabsList className="mb-4">
|
<TabsList className="mb-4">
|
||||||
<TabsTrigger value="upcoming">Mendatang ({upcomingSlots.length})</TabsTrigger>
|
<TabsTrigger value="upcoming">Mendatang ({upcomingOrders.length})</TabsTrigger>
|
||||||
<TabsTrigger value="past">Riwayat ({pastSlots.length})</TabsTrigger>
|
<TabsTrigger value="past">Riwayat ({pastOrders.length})</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="upcoming">
|
<TabsContent value="upcoming">
|
||||||
@@ -344,36 +380,45 @@ export default function AdminConsulting() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{upcomingSlots.map((slot) => (
|
{upcomingOrders.map((order) => {
|
||||||
<TableRow key={slot.id}>
|
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">
|
<TableCell className="font-medium">
|
||||||
<div>
|
<div>
|
||||||
{format(parseISO(slot.date), 'd MMM yyyy', { locale: id })}
|
{format(parseISO(firstSlot.date), 'd MMM yyyy', { locale: id })}
|
||||||
{isToday(parseISO(slot.date)) && <Badge className="ml-2 bg-primary">Hari Ini</Badge>}
|
{isToday(parseISO(firstSlot.date)) && <Badge className="ml-2 bg-primary">Hari Ini</Badge>}
|
||||||
{isTomorrow(parseISO(slot.date)) && <Badge className="ml-2 bg-accent">Besok</Badge>}
|
{isTomorrow(parseISO(firstSlot.date)) && <Badge className="ml-2 bg-accent">Besok</Badge>}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
|
||||||
{slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{slot.profiles?.name || '-'}</p>
|
<div>{firstSlot.start_time.substring(0, 5)} - {lastSlot.end_time.substring(0, 5)}</div>
|
||||||
<p className="text-sm text-muted-foreground">{slot.profiles?.email}</p>
|
{sessionCount > 1 && (
|
||||||
|
<div className="text-xs text-muted-foreground">{sessionCount} sesi</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<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>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={statusLabels[slot.status]?.variant || 'secondary'}>
|
<Badge variant="outline">{firstSlot.topic_category}</Badge>
|
||||||
{statusLabels[slot.status]?.label || slot.status}
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusLabels[firstSlot.status]?.variant || 'secondary'}>
|
||||||
|
{statusLabels[firstSlot.status]?.label || firstSlot.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{slot.meet_link ? (
|
{order.meetLink ? (
|
||||||
<a
|
<a
|
||||||
href={slot.meet_link}
|
href={order.meetLink}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-primary hover:underline flex items-center gap-1"
|
className="text-primary hover:underline flex items-center gap-1"
|
||||||
@@ -386,21 +431,21 @@ export default function AdminConsulting() {
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right space-x-2">
|
<TableCell className="text-right space-x-2">
|
||||||
{slot.status === 'confirmed' && (
|
{firstSlot.status === 'confirmed' && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => openMeetDialog(slot)}
|
onClick={() => openMeetDialog(firstSlot)}
|
||||||
className="border-2"
|
className="border-2"
|
||||||
>
|
>
|
||||||
<LinkIcon className="w-4 h-4 mr-1" />
|
<LinkIcon className="w-4 h-4 mr-1" />
|
||||||
{slot.meet_link ? 'Edit' : 'Link'}
|
{order.meetLink ? 'Edit' : 'Link'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => updateSlotStatus(slot.id, 'completed')}
|
onClick={() => updateSlotStatus(firstSlot.id, 'completed')}
|
||||||
className="border-2 text-green-600"
|
className="border-2 text-green-600"
|
||||||
>
|
>
|
||||||
<CheckCircle className="w-4 h-4" />
|
<CheckCircle className="w-4 h-4" />
|
||||||
@@ -408,7 +453,7 @@ export default function AdminConsulting() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => updateSlotStatus(slot.id, 'cancelled')}
|
onClick={() => updateSlotStatus(firstSlot.id, 'cancelled')}
|
||||||
className="border-2 text-destructive"
|
className="border-2 text-destructive"
|
||||||
>
|
>
|
||||||
<XCircle className="w-4 h-4" />
|
<XCircle className="w-4 h-4" />
|
||||||
@@ -417,8 +462,9 @@ export default function AdminConsulting() {
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
);
|
||||||
{upcomingSlots.length === 0 && (
|
})}
|
||||||
|
{upcomingOrders.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||||
Tidak ada jadwal mendatang
|
Tidak ada jadwal mendatang
|
||||||
@@ -433,21 +479,28 @@ export default function AdminConsulting() {
|
|||||||
|
|
||||||
{/* Mobile Card Layout */}
|
{/* Mobile Card Layout */}
|
||||||
<div className="md:hidden space-y-3">
|
<div className="md:hidden space-y-3">
|
||||||
{upcomingSlots.map((slot) => (
|
{upcomingOrders.map((order) => {
|
||||||
<div key={slot.id} className="border-2 border-border rounded-lg p-4 space-y-3 bg-card shadow-sm">
|
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>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||||
<h3 className="font-semibold text-sm">
|
<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>
|
</h3>
|
||||||
<Badge variant={statusLabels[slot.status]?.variant || 'secondary'}>
|
<Badge variant={statusLabels[firstSlot.status]?.variant || 'secondary'}>
|
||||||
{statusLabels[slot.status]?.label || slot.status}
|
{statusLabels[firstSlot.status]?.label || firstSlot.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -455,18 +508,18 @@ export default function AdminConsulting() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">Klien:</span>
|
<span className="text-sm text-muted-foreground">Klien:</span>
|
||||||
<div className="text-right">
|
<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>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">Kategori:</span>
|
<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>
|
||||||
{slot.meet_link && (
|
{order.meetLink && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">Meet:</span>
|
<span className="text-sm text-muted-foreground">Meet:</span>
|
||||||
<a
|
<a
|
||||||
href={slot.meet_link}
|
href={order.meetLink}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-primary hover:underline text-sm flex items-center gap-1"
|
className="text-primary hover:underline text-sm flex items-center gap-1"
|
||||||
@@ -477,21 +530,21 @@ export default function AdminConsulting() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{slot.status === 'confirmed' && (
|
{firstSlot.status === 'confirmed' && (
|
||||||
<div className="flex gap-2 pt-2 border-t border-border">
|
<div className="flex gap-2 pt-2 border-t border-border">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => openMeetDialog(slot)}
|
onClick={() => openMeetDialog(firstSlot)}
|
||||||
className="flex-1 border-2 text-xs"
|
className="flex-1 border-2 text-xs"
|
||||||
>
|
>
|
||||||
<LinkIcon className="w-3 h-3 mr-1" />
|
<LinkIcon className="w-3 h-3 mr-1" />
|
||||||
{slot.meet_link ? 'Edit' : 'Link'}
|
{order.meetLink ? 'Edit' : 'Link'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => updateSlotStatus(slot.id, 'completed')}
|
onClick={() => updateSlotStatus(firstSlot.id, 'completed')}
|
||||||
className="flex-1 border-2 text-green-600 text-xs"
|
className="flex-1 border-2 text-green-600 text-xs"
|
||||||
>
|
>
|
||||||
<CheckCircle className="w-3 h-3 mr-1" />
|
<CheckCircle className="w-3 h-3 mr-1" />
|
||||||
@@ -500,7 +553,7 @@ export default function AdminConsulting() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => updateSlotStatus(slot.id, 'cancelled')}
|
onClick={() => updateSlotStatus(firstSlot.id, 'cancelled')}
|
||||||
className="flex-1 border-2 text-destructive text-xs"
|
className="flex-1 border-2 text-destructive text-xs"
|
||||||
>
|
>
|
||||||
<XCircle className="w-3 h-3 mr-1" />
|
<XCircle className="w-3 h-3 mr-1" />
|
||||||
@@ -510,8 +563,9 @@ export default function AdminConsulting() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
{upcomingSlots.length === 0 && (
|
})}
|
||||||
|
{upcomingOrders.length === 0 && (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
Tidak ada jadwal mendatang
|
Tidak ada jadwal mendatang
|
||||||
</div>
|
</div>
|
||||||
@@ -535,20 +589,32 @@ export default function AdminConsulting() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{pastSlots.slice(0, 20).map((slot) => (
|
{pastOrders.slice(0, 20).map((order) => {
|
||||||
<TableRow key={slot.id}>
|
const firstSlot = order.slots[0];
|
||||||
<TableCell>{format(parseISO(slot.date), 'd MMM yyyy', { locale: id })}</TableCell>
|
const lastSlot = order.slots[order.slots.length - 1];
|
||||||
<TableCell>{slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)}</TableCell>
|
const sessionCount = order.slots.length;
|
||||||
<TableCell>{slot.profiles?.name || '-'}</TableCell>
|
return (
|
||||||
<TableCell><Badge variant="outline">{slot.topic_category}</Badge></TableCell>
|
<TableRow key={order.orderId || 'no-order'}>
|
||||||
|
<TableCell>{format(parseISO(firstSlot.date), 'd MMM yyyy', { locale: id })}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={statusLabels[slot.status]?.variant || 'secondary'}>
|
<div>
|
||||||
{statusLabels[slot.status]?.label || slot.status}
|
<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>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
);
|
||||||
{pastSlots.length === 0 && (
|
})}
|
||||||
|
{pastOrders.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||||||
Belum ada riwayat konsultasi
|
Belum ada riwayat konsultasi
|
||||||
@@ -563,36 +629,44 @@ export default function AdminConsulting() {
|
|||||||
|
|
||||||
{/* Mobile Card Layout */}
|
{/* Mobile Card Layout */}
|
||||||
<div className="md:hidden space-y-3">
|
<div className="md:hidden space-y-3">
|
||||||
{pastSlots.slice(0, 20).map((slot) => (
|
{pastOrders.slice(0, 20).map((order) => {
|
||||||
<div key={slot.id} className="border-2 border-border rounded-lg p-4 space-y-3 bg-card shadow-sm">
|
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>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-semibold text-sm">
|
<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>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={statusLabels[slot.status]?.variant || 'secondary'}>
|
<Badge variant={statusLabels[firstSlot.status]?.variant || 'secondary'}>
|
||||||
{statusLabels[slot.status]?.label || slot.status}
|
{statusLabels[firstSlot.status]?.label || firstSlot.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">Klien:</span>
|
<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>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">Kategori:</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
{pastSlots.length === 0 && (
|
})}
|
||||||
|
{pastOrders.length === 0 && (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
Belum ada riwayat konsultasi
|
Belum ada riwayat konsultasi
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -202,8 +202,9 @@ serve(async (req: Request): Promise<Response> => {
|
|||||||
console.log("Got access token");
|
console.log("Got access token");
|
||||||
|
|
||||||
// Build event data
|
// Build event data
|
||||||
const startDate = new Date(`${body.date}T${body.start_time}`);
|
// Include +07:00 timezone offset to ensure times are treated as Asia/Jakarta time
|
||||||
const endDate = new Date(`${body.date}T${body.end_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 = {
|
const eventData = {
|
||||||
summary: `Konsultasi: ${body.topic} - ${body.client_name}`,
|
summary: `Konsultasi: ${body.topic} - ${body.client_name}`,
|
||||||
|
|||||||
@@ -113,10 +113,12 @@ serve(async (req: Request): Promise<Response> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (consultingSlots && consultingSlots.length > 0) {
|
if (consultingSlots && consultingSlots.length > 0) {
|
||||||
for (const slot of consultingSlots) {
|
|
||||||
try {
|
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 topic = "Konsultasi 1-on-1";
|
||||||
|
|
||||||
const meetResponse = await fetch(
|
const meetResponse = await fetch(
|
||||||
@@ -128,13 +130,14 @@ serve(async (req: Request): Promise<Response> => {
|
|||||||
"Authorization": `Bearer ${Deno.env.get("SUPABASE_ANON_KEY")}`,
|
"Authorization": `Bearer ${Deno.env.get("SUPABASE_ANON_KEY")}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
slot_id: slot.id,
|
slot_id: firstSlot.id, // Use first slot ID
|
||||||
date: slot.date,
|
date: firstSlot.date,
|
||||||
start_time: slot.start_time,
|
start_time: firstSlot.start_time,
|
||||||
end_time: slot.end_time,
|
end_time: lastSlot.end_time, // Use last slot's end time for continuous block
|
||||||
client_name: userName,
|
client_name: userName,
|
||||||
client_email: userEmail,
|
client_email: userEmail,
|
||||||
topic: topic,
|
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();
|
const meetData = await meetResponse.json();
|
||||||
if (meetData.success) {
|
if (meetData.success) {
|
||||||
console.log("[HANDLE-PAID] Meet created:", meetData.meet_link);
|
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) {
|
} catch (error) {
|
||||||
@@ -150,7 +159,6 @@ serve(async (req: Request): Promise<Response> => {
|
|||||||
// Don't fail the entire process
|
// Don't fail the entire process
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Send consulting notification with the consultingSlots data
|
// Send consulting notification with the consultingSlots data
|
||||||
await sendNotification(supabase, "consulting_scheduled", {
|
await sendNotification(supabase, "consulting_scheduled", {
|
||||||
|
|||||||
Reference in New Issue
Block a user