Files
setrip/features/refund/components/refund-review-card.tsx
T

393 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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";
type RefundStatus =
| "PENDING"
| "APPROVED"
| "REJECTED"
| "PROCESSING"
| "SUCCEEDED"
| "FAILED";
type Decision = "APPROVE" | "REJECT" | "SUCCEEDED" | "FAILED";
export type RefundCardData = {
id: string;
amount: number;
currency: string;
reason: string;
reportedBy: "PARTICIPANT" | "ORGANIZER";
reportNote: string;
initiatedBy: string;
status: RefundStatus;
adminNote: string | null;
createdAt: Date;
reviewedAt: Date | null;
succeededAt: Date | null;
failedAt: Date | null;
reviewedBy: { id: string; name: string; email: string } | null;
booking: {
id: string;
amount: number;
status: string;
trip: { id: string; title: string; date: Date };
user: { id: string; name: string; email: string };
payments: {
id: string;
provider: string;
method: string | null;
amount: number;
status: string;
paidAt: Date | null;
}[];
};
};
function formatDate(d: Date): string {
return new Date(d).toLocaleString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
const REASON_LABEL: Record<string, string> = {
USER_CANCELLATION: "Peserta cancel",
ORGANIZER_CANCELLED: "Organizer batalkan",
TRIP_ISSUE: "Masalah trip",
ADMIN_ADJUSTMENT: "Penyesuaian admin",
DISPUTE_RESOLVED: "Dispute resolved",
OTHER: "Lain-lain",
};
export function RefundReviewCard({ refund }: { refund: RefundCardData }) {
const router = useRouter();
const [openAction, setOpenAction] = useState<Decision | null>(null);
const [note, setNote] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const paidPayment = refund.booking.payments.find(
(p) => p.status === "PAID" || p.status === "REFUNDED"
);
async function submit(decision: Decision) {
setError("");
setLoading(true);
const fd = new FormData();
fd.set("refundId", refund.id);
fd.set("decision", decision);
if (note.trim()) fd.set("adminNote", note);
const result = await decideRefundAction(fd);
setLoading(false);
if (result.error) {
setError(result.error);
return;
}
setOpenAction(null);
setNote("");
router.refresh();
}
return (
<article className="rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
<header className="flex flex-wrap items-start justify-between gap-3 border-b border-neutral-100 pb-4">
<div className="min-w-0">
<h3 className="truncate text-base font-bold text-neutral-900">
{refund.booking.trip.title}
</h3>
<p className="mt-0.5 text-xs text-neutral-500">
Dilaporkan {formatDate(refund.createdAt)} oleh{" "}
<span className="font-semibold">
{refund.reportedBy === "PARTICIPANT" ? "Peserta" : "Organizer"}
</span>
{" · "}
<span className="font-mono">{refund.id.slice(0, 8)}</span>
</p>
</div>
<StatusPill status={refund.status} />
</header>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<Field
label="Peserta booking"
value={`${refund.booking.user.name} · ${refund.booking.user.email}`}
/>
<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)}
/>
<Field
label="Alasan"
value={REASON_LABEL[refund.reason] ?? refund.reason}
/>
<Field
label="Nominal refund"
value={`${formatRupiah(refund.amount)} ${refund.currency !== "IDR" ? `(${refund.currency})` : ""}`}
highlight
/>
<Field
label="Total dibayar"
value={
paidPayment
? `${formatRupiah(paidPayment.amount)} · ${paidPayment.provider} ${paidPayment.method ?? ""}`
: "—"
}
/>
</div>
<div className="mt-4 rounded-xl bg-neutral-50 p-3 text-sm text-neutral-700">
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-neutral-500">
Isi laporan
</p>
<p className="whitespace-pre-wrap">{refund.reportNote}</p>
</div>
{refund.adminNote && (
<div className="mt-3 rounded-xl bg-blue-50 p-3 text-sm text-blue-800">
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-blue-600">
Catatan admin
</p>
<p className="whitespace-pre-wrap">{refund.adminNote}</p>
</div>
)}
{refund.reviewedBy && refund.reviewedAt && (
<p className="mt-3 text-xs text-neutral-500">
Diproses oleh {refund.reviewedBy.name} pada{" "}
{formatDate(refund.reviewedAt)}
{refund.succeededAt && ` · uang keluar ${formatDate(refund.succeededAt)}`}
{refund.failedAt && ` · gagal ${formatDate(refund.failedAt)}`}
</p>
)}
{(refund.status === "PENDING" || refund.status === "APPROVED") && (
<div className="mt-5 border-t border-neutral-100 pt-4">
{error && (
<div className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-xs text-red-600">
{error}
</div>
)}
{openAction ? (
<ActionForm
decision={openAction}
note={note}
setNote={setNote}
loading={loading}
onCancel={() => {
setOpenAction(null);
setNote("");
setError("");
}}
onConfirm={() => submit(openAction)}
/>
) : (
<div className="flex flex-wrap gap-2">
{refund.status === "PENDING" && (
<>
<button
type="button"
onClick={() => setOpenAction("APPROVE")}
disabled={loading}
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
>
Setujui
</button>
<button
type="button"
onClick={() => setOpenAction("REJECT")}
disabled={loading}
className="rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50"
>
Tolak
</button>
</>
)}
{refund.status === "APPROVED" && (
<>
<button
type="button"
onClick={() => setOpenAction("SUCCEEDED")}
disabled={loading}
className="rounded-xl bg-primary-600 px-4 py-2 text-sm font-bold text-white hover:bg-primary-700 disabled:opacity-50"
>
💸 Tandai sudah ditransfer
</button>
<button
type="button"
onClick={() => setOpenAction("FAILED")}
disabled={loading}
className="rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 disabled:opacity-50"
>
Tandai gagal
</button>
</>
)}
</div>
)}
</div>
)}
</article>
);
}
function ActionForm({
decision,
note,
setNote,
loading,
onCancel,
onConfirm,
}: {
decision: Decision;
note: string;
setNote: (v: string) => void;
loading: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
const cfg = {
APPROVE: {
label: "Setujui Refund",
placeholder: "Catatan untuk approval (opsional)",
required: false,
btnLabel: "Konfirmasi Setuju",
btnClass: "bg-primary-600 hover:bg-primary-700",
},
REJECT: {
label: "Tolak Refund",
placeholder: "Alasan penolakan (wajib)",
required: true,
btnLabel: "Konfirmasi Tolak",
btnClass: "bg-red-600 hover:bg-red-700",
},
SUCCEEDED: {
label: "Tandai Sudah Transfer",
placeholder: "Referensi transfer / nomor mutasi bank (wajib)",
required: true,
btnLabel: "Tandai SUCCEEDED",
btnClass: "bg-primary-600 hover:bg-primary-700",
},
FAILED: {
label: "Tandai Gagal",
placeholder: "Alasan gagal (wajib)",
required: true,
btnLabel: "Tandai FAILED",
btnClass: "bg-red-600 hover:bg-red-700",
},
}[decision];
return (
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-600">
{cfg.label}
</p>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
rows={2}
placeholder={cfg.placeholder}
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-3 py-2 text-sm focus:bg-white"
/>
<div className="flex gap-2">
<button
type="button"
onClick={onConfirm}
disabled={loading || (cfg.required && !note.trim())}
className={`rounded-xl px-4 py-2 text-sm font-bold text-white disabled:opacity-50 ${cfg.btnClass}`}
>
{loading ? "Memproses…" : cfg.btnLabel}
</button>
<button
type="button"
onClick={onCancel}
disabled={loading}
className="rounded-xl border border-neutral-200 bg-white px-4 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-50 disabled:opacity-50"
>
Batal
</button>
</div>
</div>
);
}
function Field({
label,
value,
mono,
highlight,
}: {
label: string;
value: string;
mono?: boolean;
highlight?: boolean;
}) {
return (
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
{label}
</p>
<p
className={`mt-0.5 text-sm ${mono ? "font-mono" : ""} ${
highlight ? "font-bold text-primary-700" : "text-neutral-800"
}`}
>
{value}
</p>
</div>
);
}
function StatusPill({ status }: { status: RefundStatus }) {
const cfg: Record<RefundStatus, { label: string; cls: string }> = {
PENDING: {
label: "Pending Review",
cls: "bg-amber-50 text-amber-700 ring-amber-200",
},
APPROVED: {
label: "Disetujui",
cls: "bg-blue-50 text-blue-700 ring-blue-200",
},
REJECTED: { label: "Ditolak", cls: "bg-red-50 text-red-700 ring-red-200" },
PROCESSING: {
label: "Diproses",
cls: "bg-violet-50 text-violet-700 ring-violet-200",
},
SUCCEEDED: {
label: "Selesai",
cls: "bg-primary-50 text-primary-700 ring-primary-200",
},
FAILED: { label: "Gagal", cls: "bg-red-50 text-red-700 ring-red-200" },
};
const c = cfg[status];
return (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ring-1 ${c.cls}`}
>
{c.label}
</span>
);
}