admin roadmap trips ops and payment ops
This commit is contained in:
@@ -56,6 +56,37 @@ export async function startMidtransPaymentAction(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin variant reconcile — skip ownership check, dipakai dari panel admin
|
||||
* `/admin/bookings/[id]` saat investigasi.
|
||||
*/
|
||||
export async function adminReconcileMidtransAction(orderId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
return { error: "Kamu harus login terlebih dahulu" };
|
||||
}
|
||||
const { isAdminEmail } = await import("@/lib/admin");
|
||||
if (!isAdminEmail(session.user.email)) {
|
||||
return { error: "Hanya admin yang bisa melakukan aksi ini" };
|
||||
}
|
||||
if (!orderId || typeof orderId !== "string") {
|
||||
return { error: "order_id tidak valid" };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await paymentService.adminReconcile(orderId);
|
||||
if (!result.ok) {
|
||||
if (result.reason === "not_found") {
|
||||
return { error: "Order tidak ditemukan di sistem" };
|
||||
}
|
||||
return { error: "Status pembayaran tidak cocok dengan tagihan" };
|
||||
}
|
||||
return { success: true as const, status: result.status };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tarik status terkini dari Midtrans untuk satu order, lalu sinkron ke DB.
|
||||
* Dipakai oleh payment page saat user kembali dari Snap (redirect bawa
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { adminReconcileMidtransAction } from "@/features/booking/actions";
|
||||
|
||||
interface AdminReconcileButtonProps {
|
||||
orderId: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function AdminReconcileButton({
|
||||
orderId,
|
||||
disabled,
|
||||
}: AdminReconcileButtonProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleClick() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setStatus(null);
|
||||
const res = await adminReconcileMidtransAction(orderId);
|
||||
setLoading(false);
|
||||
if ("error" in res && res.error) {
|
||||
setError(res.error);
|
||||
return;
|
||||
}
|
||||
if ("status" in res && res.status) {
|
||||
setStatus(res.status);
|
||||
}
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="inline-flex flex-col items-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={loading || disabled}
|
||||
className="rounded-lg bg-secondary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-secondary-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Reconciling..." : "Reconcile Midtrans"}
|
||||
</button>
|
||||
{status && (
|
||||
<span className="text-[11px] font-medium text-emerald-700">
|
||||
✓ {reconcileOutcomeLabel(status)}
|
||||
</span>
|
||||
)}
|
||||
{error && (
|
||||
<span className="text-[11px] font-medium text-red-600">{error}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function reconcileOutcomeLabel(status: string): string {
|
||||
switch (status) {
|
||||
case "updated":
|
||||
return "Status di-update";
|
||||
case "skipped":
|
||||
return "Sudah final (tidak ada perubahan)";
|
||||
case "ignored":
|
||||
return "Tidak dikenali (mungkin sudah dihapus)";
|
||||
case "booking_conflict":
|
||||
return "Gateway PAID tapi booking di state konflik — perlu review manual";
|
||||
case "not_found":
|
||||
return "Order tidak ditemukan di Midtrans";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
interface RawCallbackViewerProps {
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
export function RawCallbackViewer({ payload }: RawCallbackViewerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (payload == null) {
|
||||
return (
|
||||
<p className="text-[11px] italic text-neutral-400">
|
||||
Belum ada callback dari gateway.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="text-[11px] font-semibold text-secondary-700 hover:text-secondary-900"
|
||||
>
|
||||
{open ? "▼ Sembunyikan raw callback" : "▶ Lihat raw callback JSON"}
|
||||
</button>
|
||||
{open && (
|
||||
<pre className="mt-2 max-h-96 overflow-auto rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-relaxed text-neutral-700">
|
||||
{JSON.stringify(payload, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { markPayoutPaidAction } from "@/features/payout/actions";
|
||||
import { formatRupiah } from "@/lib/utils";
|
||||
@@ -92,6 +93,12 @@ export function PayoutReviewCard({ payout }: { payout: PayoutCardData }) {
|
||||
{" · "}
|
||||
<span className="font-mono">{payout.id.slice(0, 8)}…</span>
|
||||
</p>
|
||||
<Link
|
||||
href={`/admin/bookings/${payout.booking.id}`}
|
||||
className="mt-1 inline-block text-[11px] font-semibold text-secondary-700 hover:text-secondary-900"
|
||||
>
|
||||
→ Lihat timeline booking
|
||||
</Link>
|
||||
</div>
|
||||
<StatusPill status={payout.status} />
|
||||
</header>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { decideRefundAction } from "@/features/refund/actions";
|
||||
import { formatRupiah } from "@/lib/utils";
|
||||
@@ -119,7 +120,20 @@ export function RefundReviewCard({ refund }: { refund: RefundCardData }) {
|
||||
label="Peserta booking"
|
||||
value={`${refund.booking.user.name} · ${refund.booking.user.email}`}
|
||||
/>
|
||||
<Field label="Booking ID" value={refund.booking.id} mono />
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||||
Booking ID
|
||||
</p>
|
||||
<p className="mt-0.5 font-mono text-xs text-neutral-700">
|
||||
{refund.booking.id}
|
||||
</p>
|
||||
<Link
|
||||
href={`/admin/bookings/${refund.booking.id}`}
|
||||
className="mt-1 inline-block text-[11px] font-semibold text-secondary-700 hover:text-secondary-900"
|
||||
>
|
||||
→ Lihat timeline payment & refund
|
||||
</Link>
|
||||
</div>
|
||||
<Field
|
||||
label="Tanggal trip"
|
||||
value={formatDate(refund.booking.trip.date)}
|
||||
|
||||
@@ -206,7 +206,10 @@ export async function cancelTripAction(tripId: string) {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await tripService.closeTrip(tripId, session.user.id);
|
||||
const result = await tripService.closeTrip(tripId, {
|
||||
type: "ORGANIZER",
|
||||
userId: session.user.id,
|
||||
});
|
||||
revalidatePath(`/trips/${tripId}`);
|
||||
revalidatePath("/trips");
|
||||
revalidatePath("/");
|
||||
@@ -222,3 +225,49 @@ export async function cancelTripAction(tripId: string) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin force-cancel trip dari panel admin (intervensi saat organizer
|
||||
* unreachable / safety issue / dispute). Pakai actor ADMIN — bypass cek
|
||||
* organizer match, record `cancelledByAdminId` + `cancelledReason`.
|
||||
*/
|
||||
export async function adminCancelTripAction(tripId: string, reason: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
return { error: "Kamu harus login terlebih dahulu" };
|
||||
}
|
||||
const { isAdminEmail } = await import("@/lib/admin");
|
||||
if (!isAdminEmail(session.user.email)) {
|
||||
return { error: "Hanya admin yang bisa melakukan aksi ini" };
|
||||
}
|
||||
const trimmedReason = reason.trim();
|
||||
if (trimmedReason.length < 10) {
|
||||
return {
|
||||
error: "Alasan cancel wajib diisi (minimal 10 karakter untuk audit)",
|
||||
};
|
||||
}
|
||||
if (trimmedReason.length > 500) {
|
||||
return { error: "Alasan cancel maksimal 500 karakter" };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await tripService.closeTrip(tripId, {
|
||||
type: "ADMIN",
|
||||
adminId: session.user.id,
|
||||
reason: trimmedReason,
|
||||
});
|
||||
revalidatePath(`/trips/${tripId}`);
|
||||
revalidatePath(`/admin/trips/${tripId}`);
|
||||
revalidatePath("/admin/trips");
|
||||
revalidatePath("/admin/refunds");
|
||||
revalidatePath("/trips");
|
||||
return {
|
||||
success: true as const,
|
||||
refundCount: result.refundsCreated.length,
|
||||
cancelledCount: result.cancelledBookings.length,
|
||||
skippedCount: result.skippedBookings.length,
|
||||
};
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { adminCancelTripAction } from "@/features/trip/actions";
|
||||
|
||||
interface AdminCancelTripButtonProps {
|
||||
tripId: string;
|
||||
}
|
||||
|
||||
export function AdminCancelTripButton({ tripId }: AdminCancelTripButtonProps) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [result, setResult] = useState<{
|
||||
refundCount: number;
|
||||
cancelledCount: number;
|
||||
skippedCount: number;
|
||||
} | null>(null);
|
||||
|
||||
async function handleConfirm() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const res = await adminCancelTripAction(tripId, reason);
|
||||
setLoading(false);
|
||||
if ("error" in res && res.error) {
|
||||
setError(res.error);
|
||||
return;
|
||||
}
|
||||
if ("success" in res && res.success) {
|
||||
setResult({
|
||||
refundCount: res.refundCount,
|
||||
cancelledCount: res.cancelledCount,
|
||||
skippedCount: res.skippedCount,
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
if (result) {
|
||||
return (
|
||||
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-900">
|
||||
<p className="font-bold">✅ Trip berhasil dibatalkan.</p>
|
||||
<ul className="mt-2 space-y-0.5 text-xs">
|
||||
<li>• {result.refundCount} booking PAID → refund auto-dibuat</li>
|
||||
<li>
|
||||
• {result.cancelledCount} booking PENDING/AWAITING_PAY → CANCELLED
|
||||
</li>
|
||||
{result.skippedCount > 0 && (
|
||||
<li>
|
||||
• {result.skippedCount} booking di-skip (sudah ada refund aktif —
|
||||
cek manual)
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white shadow-sm hover:bg-red-700"
|
||||
>
|
||||
Cancel Trip (Admin)
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="cancel-reason"
|
||||
className="mb-1 block text-xs font-semibold text-red-900"
|
||||
>
|
||||
Alasan cancel (wajib, min 10 karakter — untuk audit)
|
||||
</label>
|
||||
<textarea
|
||||
id="cancel-reason"
|
||||
rows={3}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
maxLength={500}
|
||||
placeholder="contoh: Organizer tidak responsif sejak H-7, peserta lapor via WA. Safety issue tidak terjawab."
|
||||
className="w-full rounded-xl border border-red-200 bg-white px-3 py-2 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-red-400"
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-red-900/70">
|
||||
{reason.trim().length}/500 karakter
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-100 px-3 py-2 text-xs font-medium text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={loading || reason.trim().length < 10}
|
||||
className="rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white shadow-sm hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Membatalkan..." : "Konfirmasi Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setReason("");
|
||||
setError("");
|
||||
}}
|
||||
disabled={loading}
|
||||
className="rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user