AdminMembers.tsx: - Wrap table in overflow-x-auto div for horizontal scrolling - Add whitespace-nowrap to TableHead cells AdminConsulting.tsx: - Wrap both tables (upcoming and past) in overflow-x-auto div - Add whitespace-nowrap to all TableHead cells - Change stats grid from grid-cols-1 md:grid-cols-4 to grid-cols-2 md:grid-cols-4 for better mobile layout AdminEvents.tsx: - Wrap both tables (events and availability) in overflow-x-auto div - Add whitespace-nowrap to all TableHead cells - Change dialog form grids from grid-cols-2 to grid-cols-1 md:grid-cols-2 CurriculumEditor.tsx: - Make curriculum header responsive (flex-col sm:flex-row) - Make module card headers responsive (stack title and buttons on mobile) - Make lesson items responsive (stack title and buttons on mobile) All admin pages are now fully responsive with proper horizontal scrolling for tables on mobile and stacked layouts for forms and button groups.
517 lines
21 KiB
TypeScript
517 lines
21 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { supabase } from '@/integrations/supabase/client';
|
|
import { useAuth } from '@/hooks/useAuth';
|
|
import { AppLayout } from '@/components/AppLayout';
|
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
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 } from 'lucide-react';
|
|
import { format, parseISO, isToday, isTomorrow, isPast } from 'date-fns';
|
|
import { id } from 'date-fns/locale';
|
|
|
|
interface ConsultingSlot {
|
|
id: string;
|
|
user_id: string;
|
|
order_id: string;
|
|
date: string;
|
|
start_time: string;
|
|
end_time: string;
|
|
status: string;
|
|
topic_category: string;
|
|
notes: string;
|
|
meet_link: string | null;
|
|
created_at: string;
|
|
profiles?: {
|
|
name: string;
|
|
email: string;
|
|
};
|
|
}
|
|
|
|
interface PlatformSettings {
|
|
integration_n8n_base_url?: string;
|
|
integration_google_calendar_id?: string;
|
|
}
|
|
|
|
const statusLabels: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
|
pending_payment: { label: 'Menunggu Pembayaran', variant: 'secondary' },
|
|
confirmed: { label: 'Dikonfirmasi', variant: 'default' },
|
|
completed: { label: 'Selesai', variant: 'outline' },
|
|
cancelled: { label: 'Dibatalkan', variant: 'destructive' },
|
|
};
|
|
|
|
export default function AdminConsulting() {
|
|
const { user, isAdmin, loading: authLoading } = useAuth();
|
|
const navigate = useNavigate();
|
|
|
|
const [slots, setSlots] = useState<ConsultingSlot[]>([]);
|
|
const [settings, setSettings] = useState<PlatformSettings>({});
|
|
const [loading, setLoading] = useState(true);
|
|
const [selectedSlot, setSelectedSlot] = useState<ConsultingSlot | null>(null);
|
|
const [meetLink, setMeetLink] = useState('');
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [creatingMeet, setCreatingMeet] = useState(false);
|
|
const [activeTab, setActiveTab] = useState('upcoming');
|
|
|
|
useEffect(() => {
|
|
if (!authLoading) {
|
|
if (!user) navigate('/auth');
|
|
else if (!isAdmin) navigate('/dashboard');
|
|
else {
|
|
fetchSlots();
|
|
fetchSettings();
|
|
}
|
|
}
|
|
}, [user, isAdmin, authLoading]);
|
|
|
|
const fetchSlots = async () => {
|
|
const { data, error } = await supabase
|
|
.from('consulting_slots')
|
|
.select(`
|
|
*,
|
|
profiles:user_id (name, email)
|
|
`)
|
|
.order('date', { ascending: false })
|
|
.order('start_time', { ascending: true });
|
|
|
|
if (!error && data) setSlots(data);
|
|
setLoading(false);
|
|
};
|
|
|
|
const fetchSettings = async () => {
|
|
const { data } = await supabase
|
|
.from('platform_settings')
|
|
.select('integration_n8n_base_url, integration_google_calendar_id')
|
|
.single();
|
|
|
|
if (data) setSettings(data);
|
|
};
|
|
|
|
const openMeetDialog = (slot: ConsultingSlot) => {
|
|
setSelectedSlot(slot);
|
|
setMeetLink(slot.meet_link || '');
|
|
setDialogOpen(true);
|
|
};
|
|
|
|
const saveMeetLink = async () => {
|
|
if (!selectedSlot) return;
|
|
setSaving(true);
|
|
|
|
const { error } = await supabase
|
|
.from('consulting_slots')
|
|
.update({ meet_link: meetLink })
|
|
.eq('id', selectedSlot.id);
|
|
|
|
if (error) {
|
|
toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
|
} else {
|
|
toast({ title: 'Berhasil', description: 'Link Google Meet disimpan' });
|
|
setDialogOpen(false);
|
|
fetchSlots();
|
|
|
|
// Send notification to client with meet link
|
|
if (meetLink && selectedSlot.profiles?.email) {
|
|
try {
|
|
await supabase.functions.invoke('send-notification', {
|
|
body: {
|
|
template_key: 'consulting_scheduled',
|
|
recipient_email: selectedSlot.profiles.email,
|
|
recipient_name: selectedSlot.profiles.name,
|
|
variables: {
|
|
consultation_date: format(parseISO(selectedSlot.date), 'd MMMM yyyy', { locale: id }),
|
|
consultation_time: `${selectedSlot.start_time.substring(0, 5)} - ${selectedSlot.end_time.substring(0, 5)}`,
|
|
meet_link: meetLink,
|
|
topic_category: selectedSlot.topic_category,
|
|
},
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.log('Notification skipped:', err);
|
|
}
|
|
}
|
|
}
|
|
setSaving(false);
|
|
};
|
|
|
|
const createMeetLink = async () => {
|
|
if (!selectedSlot) return;
|
|
|
|
// Check if n8n webhook is configured
|
|
const webhookUrl = settings.integration_n8n_base_url;
|
|
if (!webhookUrl) {
|
|
toast({
|
|
title: 'Info',
|
|
description: 'Webhook URL belum dikonfigurasi di Pengaturan Integrasi. Masukkan link Meet secara manual.',
|
|
});
|
|
return;
|
|
}
|
|
|
|
setCreatingMeet(true);
|
|
|
|
try {
|
|
// Call the webhook to create Google Meet link
|
|
const response = await fetch(`${webhookUrl}/create-meet`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
slot_id: selectedSlot.id,
|
|
date: selectedSlot.date,
|
|
start_time: selectedSlot.start_time,
|
|
end_time: selectedSlot.end_time,
|
|
topic: selectedSlot.topic_category,
|
|
client_name: selectedSlot.profiles?.name || 'Client',
|
|
client_email: selectedSlot.profiles?.email,
|
|
calendar_id: settings.integration_google_calendar_id,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Webhook request failed');
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.meet_link) {
|
|
setMeetLink(data.meet_link);
|
|
toast({ title: 'Berhasil', description: 'Link Google Meet dibuat' });
|
|
} else {
|
|
throw new Error('No meet_link in response');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error creating meet link:', error);
|
|
toast({
|
|
title: 'Gagal',
|
|
description: 'Gagal membuat link Meet. Pastikan webhook sudah dikonfigurasi dengan benar.',
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setCreatingMeet(false);
|
|
}
|
|
};
|
|
|
|
const updateSlotStatus = async (slotId: string, newStatus: string) => {
|
|
const { error } = await supabase
|
|
.from('consulting_slots')
|
|
.update({ status: newStatus })
|
|
.eq('id', slotId);
|
|
|
|
if (error) {
|
|
toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
|
} else {
|
|
toast({ title: 'Berhasil', description: `Status diubah ke ${statusLabels[newStatus]?.label || newStatus}` });
|
|
fetchSlots();
|
|
}
|
|
};
|
|
|
|
if (authLoading || loading) {
|
|
return (
|
|
<AppLayout>
|
|
<div className="container mx-auto px-4 py-8">
|
|
<Skeleton className="h-10 w-1/3 mb-8" />
|
|
<Skeleton className="h-96 w-full" />
|
|
</div>
|
|
</AppLayout>
|
|
);
|
|
}
|
|
|
|
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');
|
|
|
|
return (
|
|
<AppLayout>
|
|
<div className="container mx-auto px-4 py-8">
|
|
<h1 className="text-4xl font-bold mb-2 flex items-center gap-3">
|
|
<Video className="w-10 h-10" />
|
|
Manajemen Konsultasi
|
|
</h1>
|
|
<p className="text-muted-foreground mb-8">
|
|
Kelola jadwal dan link Google Meet untuk sesi konsultasi
|
|
</p>
|
|
|
|
{/* Today's Sessions Alert */}
|
|
{todaySlots.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})
|
|
</h3>
|
|
<div className="mt-2 space-y-2">
|
|
{todaySlots.map(slot => (
|
|
<div key={slot.id} className="flex items-center justify-between text-sm">
|
|
<span>
|
|
{slot.start_time.substring(0, 5)} - {slot.profiles?.name || 'N/A'} ({slot.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">
|
|
<ExternalLink className="w-3 h-3" /> Join
|
|
</a>
|
|
) : (
|
|
<Button size="sm" variant="outline" onClick={() => openMeetDialog(slot)}>
|
|
Tambah Link
|
|
</Button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Stats */}
|
|
<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>
|
|
<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>
|
|
<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>
|
|
<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>
|
|
<p className="text-sm text-muted-foreground">Selesai</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
<TabsList className="mb-4">
|
|
<TabsTrigger value="upcoming">Mendatang ({upcomingSlots.length})</TabsTrigger>
|
|
<TabsTrigger value="past">Riwayat ({pastSlots.length})</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="upcoming">
|
|
<Card className="border-2 border-border">
|
|
<CardContent className="p-0">
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="whitespace-nowrap">Tanggal</TableHead>
|
|
<TableHead className="whitespace-nowrap">Waktu</TableHead>
|
|
<TableHead className="whitespace-nowrap">Klien</TableHead>
|
|
<TableHead className="whitespace-nowrap">Kategori</TableHead>
|
|
<TableHead className="whitespace-nowrap">Status</TableHead>
|
|
<TableHead className="whitespace-nowrap">Link Meet</TableHead>
|
|
<TableHead className="text-right whitespace-nowrap">Aksi</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{upcomingSlots.map((slot) => (
|
|
<TableRow key={slot.id}>
|
|
<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>}
|
|
</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>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant="outline">{slot.topic_category}</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant={statusLabels[slot.status]?.variant || 'secondary'}>
|
|
{statusLabels[slot.status]?.label || slot.status}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
{slot.meet_link ? (
|
|
<a
|
|
href={slot.meet_link}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-primary hover:underline flex items-center gap-1"
|
|
>
|
|
<ExternalLink className="w-4 h-4" />
|
|
Buka
|
|
</a>
|
|
) : (
|
|
<span className="text-muted-foreground">-</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-right space-x-2">
|
|
{slot.status === 'confirmed' && (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => openMeetDialog(slot)}
|
|
className="border-2"
|
|
>
|
|
<LinkIcon className="w-4 h-4 mr-1" />
|
|
{slot.meet_link ? 'Edit' : 'Link'}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => updateSlotStatus(slot.id, 'completed')}
|
|
className="border-2 text-green-600"
|
|
>
|
|
<CheckCircle className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => updateSlotStatus(slot.id, 'cancelled')}
|
|
className="border-2 text-destructive"
|
|
>
|
|
<XCircle className="w-4 h-4" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
{upcomingSlots.length === 0 && (
|
|
<TableRow>
|
|
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
|
Tidak ada jadwal mendatang
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="past">
|
|
<Card className="border-2 border-border">
|
|
<CardContent className="p-0">
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="whitespace-nowrap">Tanggal</TableHead>
|
|
<TableHead className="whitespace-nowrap">Waktu</TableHead>
|
|
<TableHead className="whitespace-nowrap">Klien</TableHead>
|
|
<TableHead className="whitespace-nowrap">Kategori</TableHead>
|
|
<TableHead className="whitespace-nowrap">Status</TableHead>
|
|
</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>
|
|
<TableCell>
|
|
<Badge variant={statusLabels[slot.status]?.variant || 'secondary'}>
|
|
{statusLabels[slot.status]?.label || slot.status}
|
|
</Badge>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
{pastSlots.length === 0 && (
|
|
<TableRow>
|
|
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
|
Belum ada riwayat konsultasi
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
{/* Meet Link Dialog */}
|
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
<DialogContent className="max-w-md border-2 border-border">
|
|
<DialogHeader>
|
|
<DialogTitle>Link Google Meet</DialogTitle>
|
|
<DialogDescription>
|
|
Tambahkan atau edit link Google Meet untuk sesi ini
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
{selectedSlot && (
|
|
<div className="p-3 bg-muted rounded-lg text-sm space-y-1">
|
|
<p><strong>Tanggal:</strong> {format(parseISO(selectedSlot.date), 'd MMMM yyyy', { locale: id })}</p>
|
|
<p><strong>Waktu:</strong> {selectedSlot.start_time.substring(0, 5)} - {selectedSlot.end_time.substring(0, 5)}</p>
|
|
<p><strong>Klien:</strong> {selectedSlot.profiles?.name}</p>
|
|
<p><strong>Topik:</strong> {selectedSlot.topic_category}</p>
|
|
{selectedSlot.notes && <p><strong>Catatan:</strong> {selectedSlot.notes}</p>}
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<Input
|
|
value={meetLink}
|
|
onChange={(e) => setMeetLink(e.target.value)}
|
|
placeholder="https://meet.google.com/xxx-xxxx-xxx"
|
|
className="border-2"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button
|
|
onClick={createMeetLink}
|
|
variant="outline"
|
|
className="flex-1 border-2"
|
|
disabled={creatingMeet}
|
|
>
|
|
{creatingMeet ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
Membuat...
|
|
</>
|
|
) : (
|
|
'Buat Link Meet'
|
|
)}
|
|
</Button>
|
|
<Button onClick={saveMeetLink} disabled={saving} className="flex-1 shadow-sm">
|
|
{saving ? 'Menyimpan...' : 'Simpan'}
|
|
</Button>
|
|
</div>
|
|
|
|
{!settings.integration_n8n_base_url && (
|
|
<p className="text-xs text-muted-foreground text-center">
|
|
Tip: Konfigurasi webhook di Pengaturan → Integrasi untuk pembuatan otomatis
|
|
</p>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
</AppLayout>
|
|
);
|
|
}
|