Continue remaining tasks
Implement frontend features for LMS gaps, branding, and admin consulting, plus enhancements to rich text and member order detail. This includes Google Meet webhook frontend stub, ElasticEmail adapter, HTML in descriptions, improved navigation, and improved UI/UX for consulting management and notifications. X-Lovable-Edit-ID: edt-5031906b-9c9f-4b2f-8526-fe8a75340c65
This commit is contained in:
@@ -5,6 +5,7 @@ import { useCart } from '@/contexts/CartContext';
|
|||||||
import { useBranding } from '@/hooks/useBranding';
|
import { useBranding } from '@/hooks/useBranding';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||||
|
import { Footer } from '@/components/Footer';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -101,7 +102,7 @@ export function AppLayout({ children }: AppLayoutProps) {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
// Public layout for non-authenticated pages
|
// Public layout for non-authenticated pages
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background flex flex-col">
|
||||||
<header className="border-b-2 border-border bg-background sticky top-0 z-50">
|
<header className="border-b-2 border-border bg-background sticky top-0 z-50">
|
||||||
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
|
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
|
||||||
<Link to="/" className="text-2xl font-bold flex items-center gap-2">
|
<Link to="/" className="text-2xl font-bold flex items-center gap-2">
|
||||||
@@ -113,7 +114,7 @@ export function AppLayout({ children }: AppLayoutProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
<nav className="flex items-center gap-4">
|
<nav className="flex items-center gap-4">
|
||||||
<Link to="/products" className="hover:underline font-medium">Produk</Link>
|
<Link to="/products" className="hover:underline font-medium">Produk</Link>
|
||||||
<Link to="/events" className="hover:underline font-medium">Kalender</Link>
|
<Link to="/calendar" className="hover:underline font-medium">Kalender</Link>
|
||||||
<Link to="/auth">
|
<Link to="/auth">
|
||||||
<Button variant="outline" size="sm" className="border-2">
|
<Button variant="outline" size="sm" className="border-2">
|
||||||
<User className="w-4 h-4 mr-2" />
|
<User className="w-4 h-4 mr-2" />
|
||||||
@@ -133,7 +134,8 @@ export function AppLayout({ children }: AppLayoutProps) {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main>{children}</main>
|
<main className="flex-1">{children}</main>
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
67
src/components/Footer.tsx
Normal file
67
src/components/Footer.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useBranding } from '@/hooks/useBranding';
|
||||||
|
|
||||||
|
export function Footer() {
|
||||||
|
const branding = useBranding();
|
||||||
|
const brandName = branding.brand_name || 'LearnHub';
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="border-t-2 border-border bg-muted/50 mt-auto">
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||||
|
{/* Brand */}
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<h3 className="font-bold text-lg mb-2">{brandName}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-md">
|
||||||
|
{branding.brand_tagline || 'Platform pembelajaran online untuk mengembangkan skill dan karir Anda.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Links */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold mb-3">Tautan</h4>
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
<li>
|
||||||
|
<Link to="/products" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
Produk
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/calendar" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
Kalender Event
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/consulting" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
Konsultasi
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legal */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold mb-3">Legal</h4>
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
<li>
|
||||||
|
<Link to="/privacy" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
Kebijakan Privasi
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/terms" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
Syarat & Ketentuan
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border mt-8 pt-6 text-center text-sm text-muted-foreground">
|
||||||
|
© {currentYear} {brandName}. All rights reserved.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -137,7 +137,7 @@ export default function Bootcamp() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const markAsCompleted = async () => {
|
const markAsCompleted = async () => {
|
||||||
if (!selectedLesson || !user) return;
|
if (!selectedLesson || !user || !product) return;
|
||||||
|
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('lesson_progress')
|
.from('lesson_progress')
|
||||||
@@ -152,7 +152,34 @@ export default function Bootcamp() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setProgress([...progress, { lesson_id: selectedLesson.id, completed_at: new Date().toISOString() }]);
|
const newProgress = [...progress, { lesson_id: selectedLesson.id, completed_at: new Date().toISOString() }];
|
||||||
|
setProgress(newProgress);
|
||||||
|
|
||||||
|
// Calculate completion percentage for notification
|
||||||
|
const completedCount = newProgress.length;
|
||||||
|
const completionPercent = Math.round((completedCount / totalLessons) * 100);
|
||||||
|
|
||||||
|
// Trigger progress notification at milestones
|
||||||
|
if (completionPercent === 25 || completionPercent === 50 || completionPercent === 75 || completionPercent === 100) {
|
||||||
|
try {
|
||||||
|
await supabase.functions.invoke('send-notification', {
|
||||||
|
body: {
|
||||||
|
template_key: 'bootcamp_progress',
|
||||||
|
recipient_email: user.email,
|
||||||
|
recipient_name: user.user_metadata?.name || 'Peserta',
|
||||||
|
variables: {
|
||||||
|
bootcamp_title: product.title,
|
||||||
|
progress_percent: completionPercent.toString(),
|
||||||
|
completed_lessons: completedCount.toString(),
|
||||||
|
total_lessons: totalLessons.toString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.log('Progress notification skipped:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
toast({ title: 'Selesai!', description: 'Pelajaran ditandai selesai' });
|
toast({ title: 'Selesai!', description: 'Pelajaran ditandai selesai' });
|
||||||
goToNextLesson();
|
goToNextLesson();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
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 { toast } from '@/hooks/use-toast';
|
||||||
import { formatIDR } from '@/lib/format';
|
import { formatIDR } from '@/lib/format';
|
||||||
import { Video, Calendar, Clock, User, Link as LinkIcon, ExternalLink } from 'lucide-react';
|
import { Video, Calendar, Clock, User, Link as LinkIcon, ExternalLink, CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||||
import { format, parseISO } from 'date-fns';
|
import { format, parseISO, isToday, isTomorrow, isPast } from 'date-fns';
|
||||||
import { id } from 'date-fns/locale';
|
import { id } from 'date-fns/locale';
|
||||||
|
|
||||||
interface ConsultingSlot {
|
interface ConsultingSlot {
|
||||||
@@ -34,6 +36,11 @@ interface ConsultingSlot {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PlatformSettings {
|
||||||
|
integration_n8n_base_url?: string;
|
||||||
|
integration_google_calendar_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const statusLabels: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
const statusLabels: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
||||||
pending_payment: { label: 'Menunggu Pembayaran', variant: 'secondary' },
|
pending_payment: { label: 'Menunggu Pembayaran', variant: 'secondary' },
|
||||||
confirmed: { label: 'Dikonfirmasi', variant: 'default' },
|
confirmed: { label: 'Dikonfirmasi', variant: 'default' },
|
||||||
@@ -46,17 +53,23 @@ export default function AdminConsulting() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [slots, setSlots] = useState<ConsultingSlot[]>([]);
|
const [slots, setSlots] = useState<ConsultingSlot[]>([]);
|
||||||
|
const [settings, setSettings] = useState<PlatformSettings>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedSlot, setSelectedSlot] = useState<ConsultingSlot | null>(null);
|
const [selectedSlot, setSelectedSlot] = useState<ConsultingSlot | null>(null);
|
||||||
const [meetLink, setMeetLink] = useState('');
|
const [meetLink, setMeetLink] = useState('');
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [creatingMeet, setCreatingMeet] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState('upcoming');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading) {
|
if (!authLoading) {
|
||||||
if (!user) navigate('/auth');
|
if (!user) navigate('/auth');
|
||||||
else if (!isAdmin) navigate('/dashboard');
|
else if (!isAdmin) navigate('/dashboard');
|
||||||
else fetchSlots();
|
else {
|
||||||
|
fetchSlots();
|
||||||
|
fetchSettings();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [user, isAdmin, authLoading]);
|
}, [user, isAdmin, authLoading]);
|
||||||
|
|
||||||
@@ -74,6 +87,15 @@ export default function AdminConsulting() {
|
|||||||
setLoading(false);
|
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) => {
|
const openMeetDialog = (slot: ConsultingSlot) => {
|
||||||
setSelectedSlot(slot);
|
setSelectedSlot(slot);
|
||||||
setMeetLink(slot.meet_link || '');
|
setMeetLink(slot.meet_link || '');
|
||||||
@@ -96,31 +118,98 @@ export default function AdminConsulting() {
|
|||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
fetchSlots();
|
fetchSlots();
|
||||||
|
|
||||||
// TODO: Trigger notification with meet link
|
// Send notification to client with meet link
|
||||||
console.log('Would trigger consulting_scheduled notification with meet_link:', meetLink);
|
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.full_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);
|
setSaving(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createMeetLink = async () => {
|
const createMeetLink = async () => {
|
||||||
// Placeholder for Google Calendar API integration
|
if (!selectedSlot) return;
|
||||||
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
|
|
||||||
|
|
||||||
if (!GOOGLE_CLIENT_ID) {
|
// Check if n8n webhook is configured
|
||||||
|
const webhookUrl = settings.integration_n8n_base_url;
|
||||||
|
if (!webhookUrl) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Info',
|
title: 'Info',
|
||||||
description: 'VITE_GOOGLE_CLIENT_ID belum dikonfigurasi. Masukkan link Meet secara manual.',
|
description: 'Webhook URL belum dikonfigurasi di Pengaturan Integrasi. Masukkan link Meet secara manual.',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement actual Google Calendar API call
|
setCreatingMeet(true);
|
||||||
// For now, log what would happen
|
|
||||||
console.log('Would call Google Calendar API to create Meet link for slot:', selectedSlot);
|
try {
|
||||||
toast({
|
// Call the webhook to create Google Meet link
|
||||||
title: 'Info',
|
const response = await fetch(`${webhookUrl}/create-meet`, {
|
||||||
description: 'Integrasi Google Calendar akan tersedia setelah konfigurasi OAuth selesai.',
|
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?.full_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) {
|
if (authLoading || loading) {
|
||||||
@@ -134,8 +223,10 @@ export default function AdminConsulting() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmedSlots = slots.filter(s => s.status === 'confirmed');
|
const today = new Date().toISOString().split('T')[0];
|
||||||
const pendingSlots = slots.filter(s => s.status === 'pending_payment');
|
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 (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
@@ -148,113 +239,214 @@ export default function AdminConsulting() {
|
|||||||
Kelola jadwal dan link Google Meet untuk sesi konsultasi
|
Kelola jadwal dan link Google Meet untuk sesi konsultasi
|
||||||
</p>
|
</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?.full_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 */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
<div className="grid grid-cols-1 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">{confirmedSlots.length}</div>
|
<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>
|
<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">{pendingSlots.length}</div>
|
<div className="text-2xl font-bold">{upcomingSlots.filter(s => !s.meet_link && s.status === 'confirmed').length}</div>
|
||||||
<p className="text-sm text-muted-foreground">Menunggu Pembayaran</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">
|
<div className="text-2xl font-bold">{pastSlots.filter(s => s.status === 'completed').length}</div>
|
||||||
{confirmedSlots.filter(s => !s.meet_link).length}
|
<p className="text-sm text-muted-foreground">Selesai</p>
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">Perlu Link Meet</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Slots Table */}
|
{/* Tabs */}
|
||||||
<Card className="border-2 border-border">
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<CardHeader>
|
<TabsList className="mb-4">
|
||||||
<CardTitle>Jadwal Konsultasi</CardTitle>
|
<TabsTrigger value="upcoming">Mendatang ({upcomingSlots.length})</TabsTrigger>
|
||||||
<CardDescription>Daftar semua booking konsultasi</CardDescription>
|
<TabsTrigger value="past">Riwayat ({pastSlots.length})</TabsTrigger>
|
||||||
</CardHeader>
|
</TabsList>
|
||||||
<CardContent className="p-0">
|
|
||||||
<Table>
|
<TabsContent value="upcoming">
|
||||||
<TableHeader>
|
<Card className="border-2 border-border">
|
||||||
<TableRow>
|
<CardContent className="p-0">
|
||||||
<TableHead>Tanggal</TableHead>
|
<Table>
|
||||||
<TableHead>Waktu</TableHead>
|
<TableHeader>
|
||||||
<TableHead>Klien</TableHead>
|
<TableRow>
|
||||||
<TableHead>Kategori</TableHead>
|
<TableHead>Tanggal</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Waktu</TableHead>
|
||||||
<TableHead>Link Meet</TableHead>
|
<TableHead>Klien</TableHead>
|
||||||
<TableHead className="text-right">Aksi</TableHead>
|
<TableHead>Kategori</TableHead>
|
||||||
</TableRow>
|
<TableHead>Status</TableHead>
|
||||||
</TableHeader>
|
<TableHead>Link Meet</TableHead>
|
||||||
<TableBody>
|
<TableHead className="text-right">Aksi</TableHead>
|
||||||
{slots.map((slot) => (
|
</TableRow>
|
||||||
<TableRow key={slot.id}>
|
</TableHeader>
|
||||||
<TableCell className="font-medium">
|
<TableBody>
|
||||||
{format(parseISO(slot.date), 'd MMM yyyy', { locale: id })}
|
{upcomingSlots.map((slot) => (
|
||||||
</TableCell>
|
<TableRow key={slot.id}>
|
||||||
<TableCell>
|
<TableCell className="font-medium">
|
||||||
{slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)}
|
<div>
|
||||||
</TableCell>
|
{format(parseISO(slot.date), 'd MMM yyyy', { locale: id })}
|
||||||
<TableCell>
|
{isToday(parseISO(slot.date)) && <Badge className="ml-2 bg-primary">Hari Ini</Badge>}
|
||||||
<div>
|
{isTomorrow(parseISO(slot.date)) && <Badge className="ml-2 bg-accent">Besok</Badge>}
|
||||||
<p className="font-medium">{slot.profiles?.full_name || '-'}</p>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{slot.profiles?.email}</p>
|
</TableCell>
|
||||||
</div>
|
<TableCell>
|
||||||
</TableCell>
|
{slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)}
|
||||||
<TableCell>
|
</TableCell>
|
||||||
<Badge variant="outline">{slot.topic_category}</Badge>
|
<TableCell>
|
||||||
</TableCell>
|
<div>
|
||||||
<TableCell>
|
<p className="font-medium">{slot.profiles?.full_name || '-'}</p>
|
||||||
<Badge variant={statusLabels[slot.status]?.variant || 'secondary'}>
|
<p className="text-sm text-muted-foreground">{slot.profiles?.email}</p>
|
||||||
{statusLabels[slot.status]?.label || slot.status}
|
</div>
|
||||||
</Badge>
|
</TableCell>
|
||||||
</TableCell>
|
<TableCell>
|
||||||
<TableCell>
|
<Badge variant="outline">{slot.topic_category}</Badge>
|
||||||
{slot.meet_link ? (
|
</TableCell>
|
||||||
<a
|
<TableCell>
|
||||||
href={slot.meet_link}
|
<Badge variant={statusLabels[slot.status]?.variant || 'secondary'}>
|
||||||
target="_blank"
|
{statusLabels[slot.status]?.label || slot.status}
|
||||||
rel="noopener noreferrer"
|
</Badge>
|
||||||
className="text-primary hover:underline flex items-center gap-1"
|
</TableCell>
|
||||||
>
|
<TableCell>
|
||||||
<ExternalLink className="w-4 h-4" />
|
{slot.meet_link ? (
|
||||||
Buka
|
<a
|
||||||
</a>
|
href={slot.meet_link}
|
||||||
) : (
|
target="_blank"
|
||||||
<span className="text-muted-foreground">-</span>
|
rel="noopener noreferrer"
|
||||||
)}
|
className="text-primary hover:underline flex items-center gap-1"
|
||||||
</TableCell>
|
>
|
||||||
<TableCell className="text-right">
|
<ExternalLink className="w-4 h-4" />
|
||||||
{slot.status === 'confirmed' && (
|
Buka
|
||||||
<Button
|
</a>
|
||||||
variant="outline"
|
) : (
|
||||||
size="sm"
|
<span className="text-muted-foreground">-</span>
|
||||||
onClick={() => openMeetDialog(slot)}
|
)}
|
||||||
className="border-2"
|
</TableCell>
|
||||||
>
|
<TableCell className="text-right space-x-2">
|
||||||
<LinkIcon className="w-4 h-4 mr-2" />
|
{slot.status === 'confirmed' && (
|
||||||
{slot.meet_link ? 'Edit Link' : 'Tambah Link'}
|
<>
|
||||||
</Button>
|
<Button
|
||||||
)}
|
variant="outline"
|
||||||
</TableCell>
|
size="sm"
|
||||||
</TableRow>
|
onClick={() => openMeetDialog(slot)}
|
||||||
))}
|
className="border-2"
|
||||||
{slots.length === 0 && (
|
>
|
||||||
<TableRow>
|
<LinkIcon className="w-4 h-4 mr-1" />
|
||||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
{slot.meet_link ? 'Edit' : 'Link'}
|
||||||
Belum ada booking konsultasi
|
</Button>
|
||||||
</TableCell>
|
<Button
|
||||||
</TableRow>
|
variant="outline"
|
||||||
)}
|
size="sm"
|
||||||
</TableBody>
|
onClick={() => updateSlotStatus(slot.id, 'completed')}
|
||||||
</Table>
|
className="border-2 text-green-600"
|
||||||
</CardContent>
|
>
|
||||||
</Card>
|
<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>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="past">
|
||||||
|
<Card className="border-2 border-border">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Tanggal</TableHead>
|
||||||
|
<TableHead>Waktu</TableHead>
|
||||||
|
<TableHead>Klien</TableHead>
|
||||||
|
<TableHead>Kategori</TableHead>
|
||||||
|
<TableHead>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?.full_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>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
{/* Meet Link Dialog */}
|
{/* Meet Link Dialog */}
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
@@ -286,13 +478,31 @@ export default function AdminConsulting() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button onClick={createMeetLink} variant="outline" className="flex-1 border-2">
|
<Button
|
||||||
Buat Link Meet
|
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>
|
||||||
<Button onClick={saveMeetLink} disabled={saving} className="flex-1 shadow-sm">
|
<Button onClick={saveMeetLink} disabled={saving} className="flex-1 shadow-sm">
|
||||||
{saving ? 'Menyimpan...' : 'Simpan'}
|
{saving ? 'Menyimpan...' : 'Simpan'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
Reference in New Issue
Block a user