Add consulting slots display with Join Meet button
- Member OrderDetail page: Shows consulting slots with date/time and Join Meet button - Admin Orders dialog: Shows consulting slots with meet link access - Meet button only visible when payment_status is 'paid' - Both pages show slot status (confirmed/pending) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { formatIDR, formatDateTime } from "@/lib/format";
|
import { formatIDR, formatDateTime } from "@/lib/format";
|
||||||
import { Eye, CheckCircle, XCircle } from "lucide-react";
|
import { Eye, CheckCircle, XCircle, Video, ExternalLink } from "lucide-react";
|
||||||
import { toast } from "@/hooks/use-toast";
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
interface Order {
|
interface Order {
|
||||||
@@ -27,11 +27,20 @@ interface Order {
|
|||||||
|
|
||||||
interface OrderItem {
|
interface OrderItem {
|
||||||
id: string;
|
id: string;
|
||||||
product: { title: string };
|
product: { title: string; type?: string };
|
||||||
unit_price: number;
|
unit_price: number;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ConsultingSlot {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
status: string;
|
||||||
|
meet_link?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminOrders() {
|
export default function AdminOrders() {
|
||||||
const { user, isAdmin, loading: authLoading } = useAuth();
|
const { user, isAdmin, loading: authLoading } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -39,6 +48,7 @@ export default function AdminOrders() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedOrder, setSelectedOrder] = useState<Order | null>(null);
|
const [selectedOrder, setSelectedOrder] = useState<Order | null>(null);
|
||||||
const [orderItems, setOrderItems] = useState<OrderItem[]>([]);
|
const [orderItems, setOrderItems] = useState<OrderItem[]>([]);
|
||||||
|
const [consultingSlots, setConsultingSlots] = useState<ConsultingSlot[]>([]);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -60,8 +70,22 @@ export default function AdminOrders() {
|
|||||||
|
|
||||||
const viewOrderDetails = async (order: Order) => {
|
const viewOrderDetails = async (order: Order) => {
|
||||||
setSelectedOrder(order);
|
setSelectedOrder(order);
|
||||||
const { data } = await supabase.from("order_items").select("*, product:products(title)").eq("order_id", order.id);
|
const { data: itemsData } = await supabase.from("order_items").select("*, product:products(title, type)").eq("order_id", order.id);
|
||||||
setOrderItems((data as unknown as OrderItem[]) || []);
|
setOrderItems((itemsData as unknown as OrderItem[]) || []);
|
||||||
|
|
||||||
|
// Check if any item is a consulting product and fetch slots
|
||||||
|
const hasConsulting = (itemsData as unknown as OrderItem[])?.some(item => item.product?.type === "consulting");
|
||||||
|
if (hasConsulting) {
|
||||||
|
const { data: slotsData } = await supabase
|
||||||
|
.from("consulting_slots")
|
||||||
|
.select("*")
|
||||||
|
.eq("order_id", order.id)
|
||||||
|
.order("date", { ascending: true });
|
||||||
|
setConsultingSlots((slotsData as ConsultingSlot[]) || []);
|
||||||
|
} else {
|
||||||
|
setConsultingSlots([]);
|
||||||
|
}
|
||||||
|
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -199,6 +223,56 @@ export default function AdminOrders() {
|
|||||||
<span className="font-bold">{formatIDR(selectedOrder.total_amount)}</span>
|
<span className="font-bold">{formatIDR(selectedOrder.total_amount)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Consulting Slots */}
|
||||||
|
{consultingSlots.length > 0 && (
|
||||||
|
<div className="border-t border-border pt-4">
|
||||||
|
<p className="font-medium mb-3 flex items-center gap-2">
|
||||||
|
<Video className="w-4 h-4" />
|
||||||
|
Jadwal Konsultasi
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{consultingSlots.map((slot) => (
|
||||||
|
<div key={slot.id} className="border-2 border-border rounded-lg p-3 bg-background">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Badge variant={slot.status === "confirmed" ? "default" : "secondary"} className="text-xs">
|
||||||
|
{slot.status === "confirmed" ? "Terkonfirmasi" : slot.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{new Date(slot.date).toLocaleDateString("id-ID", {
|
||||||
|
weekday: "short",
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric"
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)} WIB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{slot.meet_link && (
|
||||||
|
<Button asChild variant="outline" size="sm" className="gap-1">
|
||||||
|
<a
|
||||||
|
href={slot.meet_link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Video className="w-3 h-3" />
|
||||||
|
Meet
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2 pt-4">
|
<div className="flex gap-2 pt-4">
|
||||||
{selectedOrder.payment_status !== "paid" && (
|
{selectedOrder.payment_status !== "paid" && (
|
||||||
<Button onClick={() => updateOrderStatus(selectedOrder.id, "paid")} className="flex-1">
|
<Button onClick={() => updateOrderStatus(selectedOrder.id, "paid")} className="flex-1">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { formatIDR, formatDate } from "@/lib/format";
|
import { formatIDR, formatDate } from "@/lib/format";
|
||||||
import { ArrowLeft, Package, CreditCard, Calendar, AlertCircle } from "lucide-react";
|
import { ArrowLeft, Package, CreditCard, Calendar, AlertCircle, Video } from "lucide-react";
|
||||||
|
|
||||||
interface OrderItem {
|
interface OrderItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -37,11 +37,21 @@ interface Order {
|
|||||||
order_items: OrderItem[];
|
order_items: OrderItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ConsultingSlot {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
status: string;
|
||||||
|
meet_link?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function OrderDetail() {
|
export default function OrderDetail() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { user, loading: authLoading } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [order, setOrder] = useState<Order | null>(null);
|
const [order, setOrder] = useState<Order | null>(null);
|
||||||
|
const [consultingSlots, setConsultingSlots] = useState<ConsultingSlot[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -94,6 +104,23 @@ export default function OrderDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setOrder(data as Order);
|
setOrder(data as Order);
|
||||||
|
|
||||||
|
// Fetch consulting slots if this is a consulting order
|
||||||
|
const hasConsultingProduct = data.order_items.some(
|
||||||
|
(item: OrderItem) => item.products.type === "consulting"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasConsultingProduct) {
|
||||||
|
const { data: slots } = await supabase
|
||||||
|
.from("consulting_slots")
|
||||||
|
.select("*")
|
||||||
|
.eq("order_id", id)
|
||||||
|
.order("date", { ascending: true });
|
||||||
|
|
||||||
|
if (slots) {
|
||||||
|
setConsultingSlots(slots as ConsultingSlot[]);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Unexpected error:", err);
|
console.error("Unexpected error:", err);
|
||||||
setError("Terjadi kesalahan");
|
setError("Terjadi kesalahan");
|
||||||
@@ -293,6 +320,68 @@ export default function OrderDetail() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Consulting Slots */}
|
||||||
|
{consultingSlots.length > 0 && (
|
||||||
|
<Card className="border-2 border-primary bg-primary/5">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Video className="w-5 h-5" />
|
||||||
|
Jadwal Konsultasi
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{consultingSlots.map((slot) => (
|
||||||
|
<div key={slot.id} className="border-2 border-border rounded-lg p-4 bg-background">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Badge variant={slot.status === "confirmed" ? "default" : "secondary"}>
|
||||||
|
{slot.status === "confirmed" ? "Terkonfirmasi" : slot.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{new Date(slot.date).toLocaleDateString("id-ID", {
|
||||||
|
weekday: "long",
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric"
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)} WIB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{slot.meet_link && order.payment_status === "paid" && (
|
||||||
|
<Button asChild className="shadow-sm">
|
||||||
|
<a
|
||||||
|
href={slot.meet_link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Video className="w-4 h-4 mr-2" />
|
||||||
|
Join Meet
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{slot.meet_link && order.payment_status !== "paid" && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Link tersedia setelah pembayaran
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!slot.meet_link && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Link akan dikirim via email
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Access Info */}
|
{/* Access Info */}
|
||||||
{order.payment_status === "paid" && (
|
{order.payment_status === "paid" && (
|
||||||
<Card className="border-2 border-primary bg-primary/5">
|
<Card className="border-2 border-primary bg-primary/5">
|
||||||
|
|||||||
@@ -229,18 +229,31 @@ serve(async (req: Request): Promise<Response> => {
|
|||||||
console.log("Creating event in calendar:", calendarId);
|
console.log("Creating event in calendar:", calendarId);
|
||||||
console.log("Event data:", JSON.stringify(eventData, null, 2));
|
console.log("Event data:", JSON.stringify(eventData, null, 2));
|
||||||
|
|
||||||
// Create event via Google Calendar API
|
// Create event via Google Calendar API with better error handling
|
||||||
const calendarResponse = await fetch(
|
let calendarResponse: Response;
|
||||||
|
try {
|
||||||
|
calendarResponse = await fetch(
|
||||||
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?conferenceDataVersion=1`,
|
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?conferenceDataVersion=1`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": `Bearer ${accessToken}`,
|
"Authorization": `Bearer ${accessToken}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "Deno/1.0 (Supabase Edge Function)",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(eventData),
|
body: JSON.stringify(eventData),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
} catch (fetchError: any) {
|
||||||
|
console.error("Network error calling Google Calendar API:", fetchError);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
message: "Network error calling Google Calendar API: " + fetchError.message
|
||||||
|
}),
|
||||||
|
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.log("Calendar API response status:", calendarResponse.status);
|
console.log("Calendar API response status:", calendarResponse.status);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user