mirror of
https://github.com/i701/sarlink-portal.git
synced 2025-08-01 20:00:24 +00:00
feat(wallet): implement wallet transactions table and filtering options ✨
All checks were successful
Build and Push Docker Images / Build and Push Docker Images (push) Successful in 7m19s
All checks were successful
Build and Push Docker Images / Build and Push Docker Images (push) Successful in 7m19s
This commit is contained in:
@ -24,8 +24,7 @@ export async function createPayment(data: NewPayment) {
|
|||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
console.log("data", data);
|
console.log("data", data);
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${
|
`${process.env.SARLINK_API_BASE_URL // });
|
||||||
process.env.SARLINK_API_BASE_URL // });
|
|
||||||
}/api/billing/payment/`,
|
}/api/billing/payment/`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
@ -1,11 +1,63 @@
|
|||||||
import React from "react";
|
import { Suspense } from "react";
|
||||||
|
import DynamicFilter from "@/components/generic-filter";
|
||||||
|
import { WalletTransactionsTable } from "@/components/wallet-transactions-table";
|
||||||
|
|
||||||
|
export default async function Wallet({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{
|
||||||
|
query: string;
|
||||||
|
page: number;
|
||||||
|
sortBy: string;
|
||||||
|
status: string;
|
||||||
|
}>;
|
||||||
|
}) {
|
||||||
|
const query = (await searchParams)?.query || "";
|
||||||
|
|
||||||
export default function UserWallet() {
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center border rounded-md border-dashed font-bold title-bg py-4 px-2 mb-4">
|
<div className="flex justify-between items-center border rounded-md border-dashed font-bold title-bg py-4 px-2 mb-4">
|
||||||
<h3 className="text-sarLinkOrange text-2xl">My Wallet</h3>
|
<h3 className="text-sarLinkOrange text-2xl">My Wallet</h3>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
id="wallet-filters"
|
||||||
|
className=" pb-4 gap-4 flex sm:flex-row flex-col items-start justify-start"
|
||||||
|
>
|
||||||
|
<DynamicFilter
|
||||||
|
inputs={[
|
||||||
|
{
|
||||||
|
label: "Type",
|
||||||
|
name: "transaction_type",
|
||||||
|
type: "radio-group",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: "All",
|
||||||
|
value: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Debit",
|
||||||
|
value: "debit",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Credit",
|
||||||
|
value: "credit",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Topup Amount",
|
||||||
|
name: "amount",
|
||||||
|
type: "dual-range-slider",
|
||||||
|
min: 0,
|
||||||
|
max: 1000,
|
||||||
|
step: 10,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Suspense key={query} fallback={"loading...."}>
|
||||||
|
<WalletTransactionsTable searchParams={searchParams} />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -76,6 +76,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.credit-bg {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg width='6' height='6' viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%2340b02f' fill-opacity='0.29' fill-rule='evenodd'%3E%3Cpath d='M5 0h1L0 6V5zM6 5v1H5z'/%3E%3C/g%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
.debit-bg {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg width='6' height='6' viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23d35c5c' fill-opacity='0.2' fill-rule='evenodd'%3E%3Cpath d='M5 0h1L0 6V5zM6 5v1H5z'/%3E%3C/g%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
.error-bg {
|
.error-bg {
|
||||||
background-image: url("data:image/svg+xml,%3Csvg width='6' height='6' viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23e06f10' fill-opacity='0.35' fill-rule='evenodd'%3E%3Cpath d='M5 0h1L0 6V5zM6 5v1H5z'/%3E%3C/g%3E%3C/svg%3E");
|
background-image: url("data:image/svg+xml,%3Csvg width='6' height='6' viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23e06f10' fill-opacity='0.35' fill-rule='evenodd'%3E%3Cpath d='M5 0h1L0 6V5zM6 5v1H5z'/%3E%3C/g%3E%3C/svg%3E");
|
||||||
}
|
}
|
||||||
|
@ -96,7 +96,7 @@ export async function AppSidebar({
|
|||||||
title: "Wallet",
|
title: "Wallet",
|
||||||
link: "/wallet",
|
link: "/wallet",
|
||||||
icon: <Wallet2Icon size={16} />,
|
icon: <Wallet2Icon size={16} />,
|
||||||
perm_identifier: "wallet",
|
perm_identifier: "wallet transaction",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
225
components/wallet-transactions-table.tsx
Normal file
225
components/wallet-transactions-table.tsx
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
import { Calendar } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCaption,
|
||||||
|
TableCell,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { WalletTransaction } from "@/lib/backend-types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getWaleltTransactions } from "@/queries/wallet";
|
||||||
|
import { tryCatch } from "@/utils/tryCatch";
|
||||||
|
import Pagination from "./pagination";
|
||||||
|
import { Badge } from "./ui/badge";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
|
||||||
|
export async function WalletTransactionsTable({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (error.message.includes("Unauthorized")) {
|
||||||
|
redirect("/auth/signin");
|
||||||
|
} else {
|
||||||
|
return <pre>{JSON.stringify(error, null, 2)}</pre>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { data, meta } = transactions;
|
||||||
|
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 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 === "CREDIT"
|
||||||
|
? "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 === "CREDIT" ? (
|
||||||
|
<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 === "CREDIT"
|
||||||
|
? `/top-up/${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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileTransactionDetails({ trx }: { trx: WalletTransaction }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-start border rounded p-2 my-2",
|
||||||
|
trx?.transaction_type === "CREDIT" ? "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 === "CREDIT" ? (
|
||||||
|
<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 === "CREDIT"
|
||||||
|
? `/top-up/${trx.reference_id}`
|
||||||
|
: `/payments/${trx.reference_id}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button size={"sm"} className="w-full">
|
||||||
|
View Details
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -97,3 +97,16 @@ export interface NewPayment {
|
|||||||
number_of_months: number;
|
number_of_months: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface WalletTransaction {
|
||||||
|
id: string;
|
||||||
|
user: Pick<User, "id" | "id_card" | "mobile"> & {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
amount: number;
|
||||||
|
transaction_type: "DEBIT" | "CREDIT";
|
||||||
|
description: string;
|
||||||
|
reference_id: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
44
queries/wallet.ts
Normal file
44
queries/wallet.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/app/auth";
|
||||||
|
import type { ApiError, ApiResponse, WalletTransaction } from "@/lib/backend-types";
|
||||||
|
|
||||||
|
type GenericGetResponseProps = {
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
page?: number;
|
||||||
|
[key: string]: string | number | undefined;
|
||||||
|
};
|
||||||
|
export async function getWaleltTransactions(
|
||||||
|
params: GenericGetResponseProps,
|
||||||
|
allTransactions = false,
|
||||||
|
) {
|
||||||
|
// Build query string from all defined params
|
||||||
|
const query = Object.entries(params)
|
||||||
|
.filter(([_, value]) => value !== undefined && value !== "")
|
||||||
|
.map(
|
||||||
|
([key, value]) =>
|
||||||
|
`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`,
|
||||||
|
)
|
||||||
|
.join("&");
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.SARLINK_API_BASE_URL}/api/billing/wallet-transactions/?${query}&all_transactions=${allTransactions}`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Token ${session?.apiToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = (await response.json()) as ApiError;
|
||||||
|
const errorMessage =
|
||||||
|
errorData.message || errorData.detail || "An error occurred.";
|
||||||
|
const error = new Error(errorMessage);
|
||||||
|
(error as ApiError & { details?: ApiError }).details = errorData; // Attach the errorData to the error object
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as ApiResponse<WalletTransaction>;
|
||||||
|
return data;
|
||||||
|
}
|
Reference in New Issue
Block a user