From 644e4f730f0ffe9f109e33e7fa8e834b04bc8104 Mon Sep 17 00:00:00 2001 From: i701 Date: Sun, 27 Jul 2025 12:34:59 +0500 Subject: [PATCH] =?UTF-8?q?feat(user):=20add=20admin=20topup=20functionali?= =?UTF-8?q?ty=20in=20user=20details=20page=20=E2=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- actions/payment.ts | 3 +- actions/user-actions.ts | 53 +++ .../[userId]/{verify => details}/page.tsx | 2 + components/admin/admin-topup-form.tsx | 112 +++++ components/ui/app-sidebar.tsx | 20 +- components/user-table.tsx | 2 +- components/wallet-transactions-table.tsx | 446 +++++++++--------- lib/backend-types.ts | 3 +- queries/devices.ts | 11 +- queries/users.ts | 4 +- queries/wallet.ts | 78 +-- 11 files changed, 447 insertions(+), 287 deletions(-) rename app/(dashboard)/users/[userId]/{verify => details}/page.tsx (98%) create mode 100644 components/admin/admin-topup-form.tsx diff --git a/actions/payment.ts b/actions/payment.ts index 1f07ba0..33143e5 100644 --- a/actions/payment.ts +++ b/actions/payment.ts @@ -24,7 +24,8 @@ export async function createPayment(data: NewPayment) { const session = await getServerSession(authOptions); console.log("data", data); const response = await fetch( - `${process.env.SARLINK_API_BASE_URL // }); + `${ + process.env.SARLINK_API_BASE_URL // }); }/api/billing/payment/`, { method: "POST", diff --git a/actions/user-actions.ts b/actions/user-actions.ts index 497b4c6..dafc5df 100644 --- a/actions/user-actions.ts +++ b/actions/user-actions.ts @@ -224,3 +224,56 @@ export async function updateUserAgreement( message: "User agreement updated successfully", }; } + +export type AddTopupFormState = { + status: boolean; + message: string; + fieldErrors?: { + amount?: string[]; + }; + payload?: FormData; +}; + +export async function adminUserTopup( + _prevState: AddTopupFormState, + formData: FormData, +): Promise { + const user_id = formData.get("user_id") as string; + const amount = formData.get("amount") as string; + const session = await getServerSession(authOptions); + + const response = await fetch( + `${process.env.SARLINK_API_BASE_URL}/api/billing/admin-topup/`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Token ${session?.apiToken}`, + }, + body: JSON.stringify({ + amount: Number.parseInt(amount), + user_id: Number.parseInt(user_id), + }), + }, + ); + if (!response.ok) { + const errorData = await response.json(); + return { + status: false, + message: + errorData.message || + errorData.detail || + "An error occurred while topping up the user.", + fieldErrors: {}, + payload: formData, + }; + } + + revalidatePath("/users/[userId]/topup", "page"); + return { + status: true, + message: "User topped up successfully", + fieldErrors: {}, + payload: formData, + }; +} diff --git a/app/(dashboard)/users/[userId]/verify/page.tsx b/app/(dashboard)/users/[userId]/details/page.tsx similarity index 98% rename from app/(dashboard)/users/[userId]/verify/page.tsx rename to app/(dashboard)/users/[userId]/details/page.tsx index 0287829..05fa87a 100644 --- a/app/(dashboard)/users/[userId]/verify/page.tsx +++ b/app/(dashboard)/users/[userId]/details/page.tsx @@ -2,6 +2,7 @@ import { EyeIcon, FileTextIcon, PencilIcon } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { redirect } from "next/navigation"; +import AddTopupDialogForm from "@/components/admin/admin-topup-form"; import ClientErrorMessage from "@/components/client-error-message"; import InputReadOnly from "@/components/input-read-only"; import { Badge } from "@/components/ui/badge"; @@ -49,6 +50,7 @@ export default async function VerifyUserPage({
{dbUser && !dbUser?.verified && } {dbUser && !dbUser?.verified && } + + + + + New Manual Topup + + To add a new manual topup, enter the amount below. Click save when + you are done. + + +
+
+
+
+ + + + {state.fieldErrors?.amount && ( + + {state.fieldErrors.amount[0]} + + )} +
+
+
+ + + +
+
+ + ); +} diff --git a/components/ui/app-sidebar.tsx b/components/ui/app-sidebar.tsx index 1a62557..7eba979 100644 --- a/components/ui/app-sidebar.tsx +++ b/components/ui/app-sidebar.tsx @@ -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; + } )[]; }[]; diff --git a/components/user-table.tsx b/components/user-table.tsx index a47bed7..a70e872 100644 --- a/components/user-table.tsx +++ b/components/user-table.tsx @@ -127,7 +127,7 @@ export async function UsersTable({ {user.mobile} - + diff --git a/components/wallet-transactions-table.tsx b/components/wallet-transactions-table.tsx index 1d34738..5568fb3 100644 --- a/components/wallet-transactions-table.tsx +++ b/components/wallet-transactions-table.tsx @@ -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 = {}; - 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 = {}; + 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
{JSON.stringify(error, null, 2)}
; - } - } - 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 ( -
- {data?.length === 0 ? ( -
-

No transactions yet.

-
- ) : ( -
-
-
-
- Total Debit -
-

{totalDebit.toFixed(2)} MVR

-
-
-
- Total Credit -
-

{totalCredit.toFixed(2)} MVR

-
-
-
+ if (error) { + if (error.message.includes("Unauthorized")) { + redirect("/auth/signin"); + } else { + return
{JSON.stringify(error, null, 2)}
; + } + } + 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 ( +
+ {data?.length === 0 ? ( +
+

No transactions yet.

+
+ ) : ( +
+
+
+
Total Debit
+

{totalDebit.toFixed(2)} MVR

+
+
+
Total Credit
+

{totalCredit.toFixed(2)} MVR

+
+
+
+ + Table of all transactions. + + + Description + Amount + Transaction Type + View Details + Created at + + + + {transactions?.data?.map((trx) => ( + + + + {trx.description} + + + {trx.amount.toFixed(2)} MVR -
- Table of all transactions. - - - Description - Amount - Transaction Type - View Details - Created at - - - - {transactions?.data?.map((trx) => ( - - - - {trx.description} - - - {trx.amount.toFixed(2)} MVR + + + {trx.transaction_type === "TOPUP" ? ( + + {trx.transaction_type} + + ) : ( + + {trx.transaction_type} + + )} + + - - - {trx.transaction_type === "TOPUP" ? ( - - {trx.transaction_type} - - ) : ( - - {trx.transaction_type} - - )} - - - - - - {new Date(trx.created_at).toLocaleDateString("en-US", { - month: "short", - day: "2-digit", - year: "numeric", - minute: "2-digit", - hour: "2-digit", - })} - - - - - - - ))} - - - - - {meta?.total === 1 ? ( -

- Total {meta?.total} transaction. -

- ) : ( -

- Total {meta?.total} transactions. -

- )} -
-
-
-
-
-
- {data.map((trx) => ( - - ))} -
- -
- ) - } -
- ); + + + {new Date(trx.created_at).toLocaleDateString("en-US", { + month: "short", + day: "2-digit", + year: "numeric", + minute: "2-digit", + hour: "2-digit", + })} + + + + + + + ))} + + + + + {meta?.total === 1 ? ( +

+ Total {meta?.total} transaction. +

+ ) : ( +

+ Total {meta?.total} transactions. +

+ )} +
+
+
+ +
+
+ {data.map((trx) => ( + + ))} +
+ +
+ )} +
+ ); } function MobileTransactionDetails({ trx }: { trx: WalletTransaction }) { - return ( -
-
-
- - - {new Date(trx.created_at).toLocaleDateString("en-US", { - month: "short", - day: "2-digit", - year: "numeric", - minute: "2-digit", - hour: "2-digit", - })} - -
-

{trx.description}

-
+ return ( +
+
+
+ + + {new Date(trx.created_at).toLocaleDateString("en-US", { + month: "short", + day: "2-digit", + year: "numeric", + minute: "2-digit", + hour: "2-digit", + })} + +
+

{trx.description}

+
-
-
-

Amount

- - {trx.amount.toFixed(2)} MVR - -
- - {trx.transaction_type === "TOPUP" ? ( - - {trx.transaction_type} - - ) : ( - - {trx.transaction_type} - - )} - -
-
- - - -
-
- ); +
+
+

Amount

+ + {trx.amount.toFixed(2)} MVR + +
+ + {trx.transaction_type === "TOPUP" ? ( + + {trx.transaction_type} + + ) : ( + + {trx.transaction_type} + + )} + +
+
+ + + +
+
+ ); } diff --git a/lib/backend-types.ts b/lib/backend-types.ts index 943ddf3..a5b13ed 100644 --- a/lib/backend-types.ts +++ b/lib/backend-types.ts @@ -98,7 +98,6 @@ export interface NewPayment { amount: number; } - export interface WalletTransaction { id: string; user: Pick & { @@ -109,4 +108,4 @@ export interface WalletTransaction { description: string; reference_id: string; created_at: string; -} \ No newline at end of file +} diff --git a/queries/devices.ts b/queries/devices.ts index c42ac30..27fcf01 100644 --- a/queries/devices.ts +++ b/queries/devices.ts @@ -3,7 +3,7 @@ import { revalidatePath } from "next/cache"; import { getServerSession } from "next-auth"; import { authOptions } from "@/app/auth"; -import { BlockDeviceFormState } from "@/components/block-device-dialog"; +import type { BlockDeviceFormState } from "@/components/block-device-dialog"; import type { AddDeviceFormState, initialState, @@ -63,7 +63,7 @@ export async function getDevice({ deviceId }: { deviceId: string }) { } export async function addDeviceAction( - prevState: AddDeviceFormState, + _prevState: AddDeviceFormState, formData: FormData, ): Promise { const name = formData.get("name") as string; @@ -135,7 +135,7 @@ export async function addDeviceAction( } export async function blockDeviceAction( - prevState: BlockDeviceFormState, + _prevState: BlockDeviceFormState, formData: FormData, ): Promise { const deviceId = formData.get("deviceId") as string; @@ -196,10 +196,7 @@ export async function blockDeviceAction( }, ); - const result = await handleApiResponse( - response, - "blockDeviceAction", - ); + await handleApiResponse(response, "blockDeviceAction"); revalidatePath("/devices"); revalidatePath("/parental-control"); diff --git a/queries/users.ts b/queries/users.ts index 24811c9..c9b635e 100644 --- a/queries/users.ts +++ b/queries/users.ts @@ -8,10 +8,10 @@ import { handleApiResponse } from "@/utils/tryCatch"; type ParamProps = { [key: string]: string | number | undefined; }; -export async function getUsers(params: ParamProps) { +export async function getUsers(params?: ParamProps) { const session = await getServerSession(authOptions); - const query = Object.entries(params) + const query = Object.entries(params ?? {}) .filter(([_, value]) => value !== undefined && value !== "") .map( ([key, value]) => diff --git a/queries/wallet.ts b/queries/wallet.ts index b224dd7..80f23dd 100644 --- a/queries/wallet.ts +++ b/queries/wallet.ts @@ -1,44 +1,48 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@/app/auth"; -import type { ApiError, ApiResponse, WalletTransaction } from "@/lib/backend-types"; +import type { + ApiError, + ApiResponse, + WalletTransaction, +} from "@/lib/backend-types"; type GenericGetResponseProps = { - offset?: number; - limit?: number; - page?: number; - [key: string]: string | number | undefined; + offset?: number; + limit?: number; + page?: number; + [key: string]: string | number | undefined; }; export async function getWaleltTransactions( - params: GenericGetResponseProps, - allTransactions = false, + 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; - return data; -} \ No newline at end of file + // 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; + return data; +}