feat(user): add admin topup functionality in user details page
All checks were successful
Build and Push Docker Images / Build and Push Docker Images (push) Successful in 6m16s

This commit is contained in:
2025-07-27 12:34:59 +05:00
parent 3943c0d6c8
commit 644e4f730f
11 changed files with 447 additions and 287 deletions

View File

@ -0,0 +1,112 @@
"use client";
import { Loader2, PlusCircle } from "lucide-react";
import { useRouter } from "next/navigation";
import { useActionState, useEffect, useState } from "react"; // Import useActionState
import { toast } from "sonner";
import { adminUserTopup } from "@/actions/user-actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export type AddTopupFormState = {
status: boolean;
message: string;
fieldErrors?: {
amount?: string[];
};
payload?: FormData;
};
export const initialState: AddTopupFormState = {
message: "",
fieldErrors: {},
status: false,
};
export default function AddTopupDialogForm({ user_id }: { user_id?: string }) {
const [open, setOpen] = useState(false);
const router = useRouter();
const [state, formAction, pending] = useActionState(
adminUserTopup,
initialState,
);
useEffect(() => {
if (state.message && state !== initialState) {
if (state.fieldErrors && Object.keys(state.fieldErrors).length > 0) {
toast.error(state.message);
} else if (state.status) {
setOpen(false);
toast.success(state.message);
router.push("/user-topups?page=1");
} else {
toast.error(state.message);
}
}
}, [state, router]);
if (!user_id) {
return null;
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
className="gap-2 items-center"
disabled={pending}
variant="default"
>
Add cash topup
<PlusCircle size={16} />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>New Manual Topup</DialogTitle>
<DialogDescription>
To add a new manual topup, enter the amount below. Click save when
you are done.
</DialogDescription>
</DialogHeader>
<form action={formAction}>
<div className="grid gap-4 py-4">
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-3">
<Label htmlFor="device_name">Topup Amount</Label>
<input type="hidden" name="user_id" value={user_id} />
<Input
type="number"
defaultValue={(state?.payload?.get("amount") || "") as string}
name="amount"
id="topup_amount"
className="col-span-3"
/>
{state.fieldErrors?.amount && (
<span className="text-red-500 text-sm">
{state.fieldErrors.amount[0]}
</span>
)}
</div>
</div>
</div>
<DialogFooter>
<Button disabled={pending} type="submit">
{pending ? <Loader2 className="animate-spin" /> : "Save"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -40,17 +40,17 @@ type Categories = {
id: string;
children: (
| {
title: string;
link: string;
perm_identifier: string;
icon: React.JSX.Element;
}
title: string;
link: string;
perm_identifier: string;
icon: React.JSX.Element;
}
| {
title: string;
link: string;
icon: React.JSX.Element;
perm_identifier?: undefined;
}
title: string;
link: string;
icon: React.JSX.Element;
perm_identifier?: undefined;
}
)[];
}[];

View File

@ -127,7 +127,7 @@ export async function UsersTable({
<TableCell>{user.mobile}</TableCell>
<TableCell>
<Link href={`/users/${user.id}/verify`}>
<Link href={`/users/${user.id}/details`}>
<Button>Details</Button>
</Link>
</TableCell>

View File

@ -2,14 +2,14 @@ import { Calendar } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { WalletTransaction } from "@/lib/backend-types";
import { cn } from "@/lib/utils";
@ -20,232 +20,224 @@ import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
export async function WalletTransactionsTable({
searchParams,
searchParams,
}: {
searchParams: Promise<{
[key: string]: unknown;
}>;
searchParams: Promise<{
[key: string]: unknown;
}>;
}) {
const resolvedParams = await searchParams;
const page = Number.parseInt(resolvedParams.page as string) || 1;
const limit = 10;
const offset = (page - 1) * limit;
// Build params object
const apiParams: Record<string, string | number | undefined> = {};
for (const [key, value] of Object.entries(resolvedParams)) {
if (value !== undefined && value !== "") {
apiParams[key] = typeof value === "number" ? value : String(value);
}
}
apiParams.limit = limit;
apiParams.offset = offset;
const [error, transactions] = await tryCatch(
getWaleltTransactions(apiParams),
);
const resolvedParams = await searchParams;
const page = Number.parseInt(resolvedParams.page as string) || 1;
const limit = 10;
const offset = (page - 1) * limit;
// Build params object
const apiParams: Record<string, string | number | undefined> = {};
for (const [key, value] of Object.entries(resolvedParams)) {
if (value !== undefined && value !== "") {
apiParams[key] = typeof value === "number" ? value : String(value);
}
}
apiParams.limit = limit;
apiParams.offset = offset;
const [error, transactions] = await tryCatch(
getWaleltTransactions(apiParams),
);
if (error) {
if (error.message.includes("Unauthorized")) {
redirect("/auth/signin");
} else {
return <pre>{JSON.stringify(error, null, 2)}</pre>;
}
}
const { data, meta } = transactions;
const totalDebit = data.reduce(
(acc, trx) =>
acc + (trx.transaction_type === "DEBIT" ? trx.amount : 0),
0,
);
const totalCredit = data.reduce(
(acc, trx) =>
acc + (trx.transaction_type === "TOPUP" ? trx.amount : 0),
0,
);
return (
<div>
{data?.length === 0 ? (
<div className="h-[calc(100svh-400px)] flex flex-col items-center justify-center my-4">
<h3>No transactions yet.</h3>
</div>
) : (
<div>
<div className="flex gap-4 mb-4 w-full">
<div className="bg-red-400 w-full sm:w-fit dark:bg-red-950 dark:text-red-400 text-red-900 p-2 px-4 rounded-md mb-2">
<h5 className="text-lg font-semibold">
Total Debit
</h5>
<p>{totalDebit.toFixed(2)} MVR</p>
</div>
<div className="bg-green-400 w-full sm:w-fit dark:bg-green-950 dark:text-green-400 text-green-900 p-2 px-4 rounded-md mb-2">
<h5 className="text-lg font-semibold">
Total Credit
</h5>
<p>{totalCredit.toFixed(2)} MVR</p>
</div>
</div>
<div className="hidden sm:block">
if (error) {
if (error.message.includes("Unauthorized")) {
redirect("/auth/signin");
} else {
return <pre>{JSON.stringify(error, null, 2)}</pre>;
}
}
const { data, meta } = transactions;
const totalDebit = data.reduce(
(acc, trx) => acc + (trx.transaction_type === "DEBIT" ? trx.amount : 0),
0,
);
const totalCredit = data.reduce(
(acc, trx) => acc + (trx.transaction_type === "TOPUP" ? trx.amount : 0),
0,
);
return (
<div>
{data?.length === 0 ? (
<div className="h-[calc(100svh-400px)] flex flex-col items-center justify-center my-4">
<h3>No transactions yet.</h3>
</div>
) : (
<div>
<div className="flex gap-4 mb-4 w-full">
<div className="bg-red-400 w-full sm:w-fit dark:bg-red-950 dark:text-red-400 text-red-900 p-2 px-4 rounded-md mb-2">
<h5 className="text-lg font-semibold">Total Debit</h5>
<p>{totalDebit.toFixed(2)} MVR</p>
</div>
<div className="bg-green-400 w-full sm:w-fit dark:bg-green-950 dark:text-green-400 text-green-900 p-2 px-4 rounded-md mb-2">
<h5 className="text-lg font-semibold">Total Credit</h5>
<p>{totalCredit.toFixed(2)} MVR</p>
</div>
</div>
<div className="hidden sm:block">
<Table className="overflow-scroll">
<TableCaption>Table of all transactions.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Description</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Transaction Type</TableHead>
<TableHead>View Details</TableHead>
<TableHead>Created at</TableHead>
</TableRow>
</TableHeader>
<TableBody className="overflow-scroll">
{transactions?.data?.map((trx) => (
<TableRow
className={cn(
"items-start border rounded p-2",
trx?.transaction_type === "TOPUP"
? "credit-bg"
: "debit-bg",
)}
key={trx.id}
>
<TableCell>
<span className="text-muted-foreground">
{trx.description}
</span>
</TableCell>
<TableCell>{trx.amount.toFixed(2)} MVR</TableCell>
<Table className="overflow-scroll">
<TableCaption>Table of all transactions.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Description</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Transaction Type</TableHead>
<TableHead>View Details</TableHead>
<TableHead>Created at</TableHead>
</TableRow>
</TableHeader>
<TableBody className="overflow-scroll">
{transactions?.data?.map((trx) => (
<TableRow
className={cn(
"items-start border rounded p-2",
trx?.transaction_type === "TOPUP"
? "credit-bg"
: "debit-bg",
)}
key={trx.id}
>
<TableCell>
<span className="text-muted-foreground">
{trx.description}
</span>
</TableCell>
<TableCell>{trx.amount.toFixed(2)} MVR</TableCell>
<TableCell>
<span className="font-semibold pr-2">
{trx.transaction_type === "TOPUP" ? (
<Badge className="bg-green-100 dark:bg-green-700">
{trx.transaction_type}
</Badge>
) : (
<Badge className="bg-red-500 dark:bg-red-700">
{trx.transaction_type}
</Badge>
)}
</span>
</TableCell>
<TableCell>
<span className="font-semibold pr-2">
{trx.transaction_type === "TOPUP" ? (
<Badge className="bg-green-100 dark:bg-green-700">
{trx.transaction_type}
</Badge>
) : (
<Badge className="bg-red-500 dark:bg-red-700">
{trx.transaction_type}
</Badge>
)}
</span>
</TableCell>
<TableCell>
<span className="">
{new Date(trx.created_at).toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
minute: "2-digit",
hour: "2-digit",
})}
</span>
</TableCell>
<TableCell>
<Button>
<Link
className="font-medium "
href={
trx.transaction_type === "TOPUP"
? `/top-ups/${trx.reference_id}`
: `/payments/${trx.reference_id}`
}
>
View Details
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={5} className="text-muted-foreground">
{meta?.total === 1 ? (
<p className="text-center">
Total {meta?.total} transaction.
</p>
) : (
<p className="text-center">
Total {meta?.total} transactions.
</p>
)}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
<div className="sm:hidden block">
{data.map((trx) => (
<MobileTransactionDetails key={trx.id} trx={trx} />
))}
</div>
<Pagination
totalPages={meta?.last_page}
currentPage={meta?.current_page}
/>
</div>
)
}
</div >
);
<TableCell>
<span className="">
{new Date(trx.created_at).toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
minute: "2-digit",
hour: "2-digit",
})}
</span>
</TableCell>
<TableCell>
<Button>
<Link
className="font-medium "
href={
trx.transaction_type === "TOPUP"
? `/top-ups/${trx.reference_id}`
: `/payments/${trx.reference_id}`
}
>
View Details
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={5} className="text-muted-foreground">
{meta?.total === 1 ? (
<p className="text-center">
Total {meta?.total} transaction.
</p>
) : (
<p className="text-center">
Total {meta?.total} transactions.
</p>
)}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
<div className="sm:hidden block">
{data.map((trx) => (
<MobileTransactionDetails key={trx.id} trx={trx} />
))}
</div>
<Pagination
totalPages={meta?.last_page}
currentPage={meta?.current_page}
/>
</div>
)}
</div>
);
}
function MobileTransactionDetails({ trx }: { trx: WalletTransaction }) {
return (
<div
className={cn(
"flex flex-col items-start border rounded p-2 my-2",
trx?.transaction_type === "TOPUP" ? "credit-bg" : "debit-bg",
)}
>
<div className="bg-white shadow dark:bg-black p-2 rounded w-full">
<div className="flex items-center gap-2">
<Calendar size={16} opacity={0.5} />
<span className="text-muted-foreground text-sm">
{new Date(trx.created_at).toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
minute: "2-digit",
hour: "2-digit",
})}
</span>
</div>
<p className="text-sm text-muted-foreground py-4">{trx.description}</p>
</div>
return (
<div
className={cn(
"flex flex-col items-start border rounded p-2 my-2",
trx?.transaction_type === "TOPUP" ? "credit-bg" : "debit-bg",
)}
>
<div className="bg-white shadow dark:bg-black p-2 rounded w-full">
<div className="flex items-center gap-2">
<Calendar size={16} opacity={0.5} />
<span className="text-muted-foreground text-sm">
{new Date(trx.created_at).toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
minute: "2-digit",
hour: "2-digit",
})}
</span>
</div>
<p className="text-sm text-muted-foreground py-4">{trx.description}</p>
</div>
<div className="bg-white dark:bg-black p-2 rounded mt-2 w-full border flex justify-between items-center">
<div className="block sm:hidden">
<h3 className="text-sm font-medium">Amount</h3>
<span className="text-sm text-muted-foreground">
{trx.amount.toFixed(2)} MVR
</span>
</div>
<span className="font-semibold pr-2">
{trx.transaction_type === "TOPUP" ? (
<Badge className="bg-green-100 dark:bg-green-700">
{trx.transaction_type}
</Badge>
) : (
<Badge className="bg-red-500 dark:bg-red-700">
{trx.transaction_type}
</Badge>
)}
</span>
</div>
<div className="flex items-center gap-2 mt-2 w-full">
<Link
className="font-medium hover:underline"
href={
trx.transaction_type === "TOPUP"
? `/top-ups/${trx.reference_id}`
: `/payments/${trx.reference_id}`
}
>
<Button size={"sm"} className="w-full">
View Details
</Button>
</Link>
</div>
</div>
);
<div className="bg-white dark:bg-black p-2 rounded mt-2 w-full border flex justify-between items-center">
<div className="block sm:hidden">
<h3 className="text-sm font-medium">Amount</h3>
<span className="text-sm text-muted-foreground">
{trx.amount.toFixed(2)} MVR
</span>
</div>
<span className="font-semibold pr-2">
{trx.transaction_type === "TOPUP" ? (
<Badge className="bg-green-100 dark:bg-green-700">
{trx.transaction_type}
</Badge>
) : (
<Badge className="bg-red-500 dark:bg-red-700">
{trx.transaction_type}
</Badge>
)}
</span>
</div>
<div className="flex items-center gap-2 mt-2 w-full">
<Link
className="font-medium hover:underline"
href={
trx.transaction_type === "TOPUP"
? `/top-ups/${trx.reference_id}`
: `/payments/${trx.reference_id}`
}
>
<Button size={"sm"} className="w-full">
View Details
</Button>
</Link>
</div>
</div>
);
}