From 81bbafcff00c9972215bc332ee48c192303b7307 Mon Sep 17 00:00:00 2001 From: dwindown Date: Fri, 26 Dec 2025 17:05:25 +0700 Subject: [PATCH] Add refund system and meet link management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refund System: - Add refund processing with amount and reason tracking - Auto-revoke product access on refund - Support full and partial refunds - Add database fields for refund tracking Meet Link Management: - Show meet link status badge (Ready/Not Ready) - Add manual meet link creation/update form - Allow admin to create meet links if auto-creation fails Database Migration: - Add refund_amount, refund_reason, refunded_at, refunded_by to orders - Add cancellation_reason to orders and consulting_slots 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- src/pages/admin/AdminOrders.tsx | 294 +++++++++++++++++- .../20250126000000_add_refund_fields.sql | 28 ++ 2 files changed, 305 insertions(+), 17 deletions(-) create mode 100644 supabase/migrations/20250126000000_add_refund_fields.sql diff --git a/src/pages/admin/AdminOrders.tsx b/src/pages/admin/AdminOrders.tsx index e2d0e37..8d2c64c 100644 --- a/src/pages/admin/AdminOrders.tsx +++ b/src/pages/admin/AdminOrders.tsx @@ -7,10 +7,13 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Skeleton } from "@/components/ui/skeleton"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; import { formatIDR, formatDateTime } from "@/lib/format"; -import { Eye, CheckCircle, XCircle, Video, ExternalLink, Trash2, AlertTriangle } from "lucide-react"; +import { Eye, CheckCircle, XCircle, Video, ExternalLink, Trash2, AlertTriangle, RefreshCw, Link as LinkIcon } from "lucide-react"; import { toast } from "@/hooks/use-toast"; interface Order { @@ -22,6 +25,8 @@ interface Order { payment_method: string | null; payment_reference: string | null; created_at: string; + refunded_amount?: number | null; + refunded_at?: string | null; profile?: { email: string } | null; } @@ -50,6 +55,14 @@ export default function AdminOrders() { const [orderItems, setOrderItems] = useState([]); const [consultingSlots, setConsultingSlots] = useState([]); const [dialogOpen, setDialogOpen] = useState(false); + const [refundDialogOpen, setRefundDialogOpen] = useState(false); + const [refundAmount, setRefundAmount] = useState(""); + const [refundReason, setRefundReason] = useState(""); + const [processingRefund, setProcessingRefund] = useState(false); + const [meetLinkDialogOpen, setMeetLinkDialogOpen] = useState(false); + const [selectedSlotId, setSelectedSlotId] = useState(null); + const [newMeetLink, setNewMeetLink] = useState(""); + const [creatingMeetLink, setCreatingMeetLink] = useState(false); useEffect(() => { if (!authLoading) { @@ -146,10 +159,126 @@ export default function AdminOrders() { } }; + const processRefund = async () => { + if (!selectedOrder || !refundAmount || !refundReason) { + toast({ title: "Error", description: "Mohon lengkapi jumlah dan alasan refund", variant: "destructive" }); + return; + } + + const refundAmountCents = parseInt(refundAmount); + if (isNaN(refundAmountCents) || refundAmountCents <= 0 || refundAmountCents > selectedOrder.total_amount) { + toast({ title: "Error", description: "Jumlah refund tidak valid", variant: "destructive" }); + return; + } + + setProcessingRefund(true); + try { + // Update order with refund info + const { error: updateError } = await supabase + .from("orders") + .update({ + refunded_amount: refundAmountCents, + refund_reason: refundReason, + refunded_at: new Date().toISOString(), + refunded_by: user?.id, + payment_status: refundAmountCents >= selectedOrder.total_amount ? "refunded" : "partially_refunded", + }) + .eq("id", selectedOrder.id); + + if (updateError) throw updateError; + + // Revoke access for all products in this order + const { data: itemsData } = await supabase + .from("order_items") + .select("product_id") + .eq("order_id", selectedOrder.id); + + if (itemsData) { + for (const item of itemsData) { + await supabase + .from("user_access") + .delete() + .eq("user_id", selectedOrder.user_id) + .eq("product_id", item.product_id); + } + } + + toast({ title: "Berhasil", description: "Refund berhasil diproses dan akses produk dicabut" }); + setRefundDialogOpen(false); + setRefundAmount(""); + setRefundReason(""); + fetchOrders(); + setDialogOpen(false); + } catch (error: any) { + toast({ + title: "Error", + description: error.message || "Gagal memproses refund", + variant: "destructive", + }); + } finally { + setProcessingRefund(false); + } + }; + + const openRefundDialog = () => { + setRefundAmount(selectedOrder?.total_amount.toString() || ""); + setRefundDialogOpen(true); + }; + + const openMeetLinkDialog = (slotId: string, currentLink?: string) => { + setSelectedSlotId(slotId); + setNewMeetLink(currentLink || ""); + setMeetLinkDialogOpen(true); + }; + + const updateMeetLink = async () => { + if (!selectedSlotId || !newMeetLink) { + toast({ title: "Error", description: "Mohon masukkan Meet link", variant: "destructive" }); + return; + } + + setCreatingMeetLink(true); + try { + const { error } = await supabase + .from("consulting_slots") + .update({ meet_link: newMeetLink }) + .eq("id", selectedSlotId); + + if (error) throw error; + + // Refresh consulting slots + if (selectedOrder) { + const { data: slotsData } = await supabase + .from("consulting_slots") + .select("*") + .eq("order_id", selectedOrder.id) + .order("date", { ascending: true }); + setConsultingSlots((slotsData as ConsultingSlot[]) || []); + } + + toast({ title: "Berhasil", description: "Meet link berhasil diperbarui" }); + setMeetLinkDialogOpen(false); + setNewMeetLink(""); + setSelectedSlotId(null); + } catch (error: any) { + toast({ + title: "Error", + description: error.message || "Gagal memperbarui Meet link", + variant: "destructive", + }); + } finally { + setCreatingMeetLink(false); + } + }; + const getStatusBadge = (status: string | null) => { switch (status) { case "paid": return Lunas; + case "refunded": + return Refund; + case "partially_refunded": + return Refund Sebagian; case "pending": return Pending; case "cancelled": @@ -309,10 +438,22 @@ export default function AdminOrders() {
-
+
{slot.status === "confirmed" ? "Terkonfirmasi" : slot.status} + {/* Meet Link Status */} + {slot.meet_link ? ( + + + Meet Link Ready + + ) : ( + + + Belum ada Meet Link + + )}

{new Date(slot.date).toLocaleDateString("id-ID", { @@ -326,19 +467,30 @@ export default function AdminOrders() { {slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)} WIB

- {slot.meet_link && ( - + )} + - )} +
))} @@ -347,13 +499,23 @@ export default function AdminOrders() { )}
- {selectedOrder.payment_status !== "paid" && ( + {selectedOrder.payment_status === "paid" && !selectedOrder.refunded_at && ( + + )} + {selectedOrder.payment_status !== "paid" && !selectedOrder.refunded_at && ( )} - {selectedOrder.payment_status !== "cancelled" && ( + {selectedOrder.payment_status !== "cancelled" && !selectedOrder.refunded_at && (