feat(user-topups): add user topups page with dynamic filtering and admin table integration
Some checks failed
Build and Push Docker Images / Build and Push Docker Images (push) Has been cancelled

feat(admin-devices-table): update admin check to use is_admin and clean up device display logic 
feat(admin-topup-table): create admin topups table with pagination and detail view links 
fix(user-payments-table): correct user data access and display payment amount with currency 
feat(app-sidebar): add link for user topups in the admin sidebar 
fix(backend-types): enhance Payment interface to include user details for better data handling 
This commit is contained in:
2025-07-24 23:01:41 +05:00
parent d7b8e4ec64
commit 1f6fe7db38
11 changed files with 322 additions and 26 deletions

View File

@ -18,7 +18,7 @@ This is a web portal for SAR Link customers.
### Parental Control ### Parental Control
- [x] Fix block device feature - [x] Fix block device feature
- [ ] Add all the filters for parental control table (mobile responsive) - [x] Add all the filters for parental control table (mobile responsive)
- [ ] Disable blocking if payment is pending or omit from the table if device payment is pending - [ ] Disable blocking if payment is pending or omit from the table if device payment is pending
### Agreements ### Agreements
@ -30,7 +30,7 @@ This is a web portal for SAR Link customers.
### Users ### Users
- [x] Show users table - [x] Show users table
- [ ] handle verify api no response case - [ ] handle verify api no response case
- [ ] Add all relavant filters for users table - [x] Add all relavant filters for users table
- [x] Verify or reject users with a custom message - [x] Verify or reject users with a custom message
- [ ] Add functionality to send custom sms to users in user:id page - [ ] Add functionality to send custom sms to users in user:id page

View File

@ -60,7 +60,7 @@ export default async function UserDevices({
/> />
</div> </div>
<Suspense key={query} fallback={"loading...."}> <Suspense key={query} fallback={"loading...."}>
<AdminDevicesTable parentalControl={true} searchParams={searchParams} /> <AdminDevicesTable searchParams={searchParams} />
</Suspense> </Suspense>
</div> </div>
); );

View File

@ -0,0 +1,86 @@
import { Suspense } from "react";
import { AdminTopupsTable } from "@/components/admin/admin-topup-table";
import DynamicFilter from "@/components/generic-filter";
export default async function UserTopups({
searchParams,
}: {
searchParams: Promise<{
[key: string]: string;
}>;
}) {
const query = (await searchParams)?.query || "";
// const session = await getServerSession(authOptions);
return (
<div>
<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">User Topups</h3>
</div>
<DynamicFilter
title="User Topups Filter"
description="Filter user topups by status, topup expiry, or amount."
inputs={[
{
name: "user",
label: "User",
type: "string",
placeholder: "Enter user name",
},
{
label: "Status",
name: "status",
type: "radio-group",
options: [
{
label: "All",
value: "",
},
{
label: "Pending",
value: "PENDING",
},
{
label: "Cancelled",
value: "CANCELLED",
},
{
label: "Paid",
value: "PAID",
},
],
},
{
label: "Topup Expiry",
name: "is_expired",
type: "radio-group",
options: [
{
label: "All",
value: "",
},
{
label: "Expired",
value: "true",
},
{
label: "Not Expired",
value: "false",
},
],
},
{
label: "Topup Amount",
name: "amount",
type: "dual-range-slider",
min: 0,
max: 1000,
step: 10,
},
]}
/>
<Suspense key={query} fallback={"loading...."}>
<AdminTopupsTable searchParams={searchParams} />
</Suspense>
</div>
);
}

View File

@ -18,21 +18,18 @@ import { getDevices } from "@/queries/devices";
import { tryCatch } from "@/utils/tryCatch"; import { tryCatch } from "@/utils/tryCatch";
import BlockDeviceDialog from "../block-device-dialog"; import BlockDeviceDialog from "../block-device-dialog";
import ClientErrorMessage from "../client-error-message"; import ClientErrorMessage from "../client-error-message";
import DeviceCard from "../device-card";
import Pagination from "../pagination"; import Pagination from "../pagination";
export async function AdminDevicesTable({ export async function AdminDevicesTable({
searchParams, searchParams,
parentalControl,
}: { }: {
searchParams: Promise<{ searchParams: Promise<{
[key: string]: unknown; [key: string]: unknown;
}>; }>;
parentalControl?: boolean;
}) { }) {
const resolvedParams = await searchParams; const resolvedParams = await searchParams;
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
const isAdmin = session?.user?.is_superuser; const isAdmin = session?.user?.is_admin;
const page = Number.parseInt(resolvedParams.page as string) || 1; const page = Number.parseInt(resolvedParams.page as string) || 1;
const limit = 10; const limit = 10;
@ -67,7 +64,7 @@ export async function AdminDevicesTable({
</div> </div>
) : ( ) : (
<> <>
<div className="hidden sm:block"> <div>
<Table className="overflow-scroll"> <Table className="overflow-scroll">
<TableCaption>Table of all devices.</TableCaption> <TableCaption>Table of all devices.</TableCaption>
<TableHeader> <TableHeader>
@ -167,15 +164,7 @@ export async function AdminDevicesTable({
</TableFooter> </TableFooter>
</Table> </Table>
</div> </div>
<div className="sm:hidden my-4">
{data?.map((device) => (
<DeviceCard
parentalControl={parentalControl}
key={device.id}
device={device}
/>
))}
</div>
<Pagination <Pagination
totalPages={meta?.last_page} totalPages={meta?.last_page}
currentPage={meta?.current_page} currentPage={meta?.current_page}

View File

@ -0,0 +1,205 @@
import { Calendar } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTopups } from "@/actions/payment";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { Topup } from "@/lib/backend-types";
import { cn } from "@/lib/utils";
import { tryCatch } from "@/utils/tryCatch";
import Pagination from "../pagination";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
export async function AdminTopupsTable({
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, topups] = await tryCatch(getTopups(apiParams,));
if (error) {
if (error.message.includes("Unauthorized")) {
redirect("/auth/signin");
} else {
return <pre>{JSON.stringify(error, null, 2)}</pre>;
}
}
const { data, meta } = topups;
return (
<div>
{data?.length === 0 ? (
<div className="h-[calc(100svh-400px)] flex flex-col items-center justify-center my-4">
<h3>No topups yet.</h3>
</div>
) : (
<>
<div className="hidden sm:block">
<Table className="overflow-scroll">
<TableCaption>Table of all topups.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Status</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Action</TableHead>
</TableRow>
</TableHeader>
<TableBody className="overflow-scroll">
{topups?.data?.map((topup) => (
<TableRow key={topup.id}>
<TableCell>
<div className="flex flex-col items-start">
{topup?.user?.name}
<span className="text-muted-foreground">{topup?.user?.id_card}</span>
</div>
</TableCell>
<TableCell>
<span className="font-semibold pr-2">
{topup.paid ? (
<Badge
className="bg-green-100 dark:bg-green-700"
variant="outline"
>
{topup.status}
</Badge>
) : topup.is_expired ? (
<Badge>Expired</Badge>
) : (
<Badge variant="outline">{topup.status}</Badge>
)}
</span>
</TableCell>
<TableCell>
<span className="font-semibold pr-2">
{topup.amount.toFixed(2)}
</span>
MVR
</TableCell>
<TableCell>
<div
>
<div className="flex items-center gap-2 mt-2">
<Link
className="font-medium hover:underline"
href={`/top-ups/${topup.id}`}
>
<Button size={"sm"} variant="outline">
View Details
</Button>
</Link>
</div>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={4} className="text-muted-foreground">
{meta?.total === 1 ? (
<p className="text-center">Total {meta?.total} topup.</p>
) : (
<p className="text-center">Total {meta?.total} topups.</p>
)}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
<div className="sm:hidden block">
{data.map((topup) => (
<MobileTopupDetails key={topup.id} topup={topup} />
))}
</div>
<Pagination
totalPages={meta?.last_page}
currentPage={meta?.current_page}
/>
</>
)}
</div>
);
}
function MobileTopupDetails({ topup }: { topup: Topup }) {
return (
<div
className={cn(
"flex flex-col items-start border rounded p-2 my-2",
topup?.paid
? "bg-green-500/10 border-dashed border-green=500"
: "bg-yellow-500/10 border-dashed border-yellow-500 dark:border-yellow-500/50",
)}
>
<div className="flex items-center gap-2">
<Calendar size={16} opacity={0.5} />
<span className="text-muted-foreground text-sm">
{new Date(topup.created_at).toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
})}
</span>
</div>
<div className="flex flex-col items-start text-sm border rounded p-2 mt-2 w-full bg-white dark:bg-black">
{topup?.user?.name}
<span className="text-muted-foreground">{topup?.user?.id_card}</span>
</div>
<div className="flex items-center gap-2 mt-2">
<Link
className="font-medium hover:underline"
href={`/top-ups/${topup.id}`}
>
<Button size={"sm"} variant="outline">
View Details
</Button>
</Link>
</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">
{topup.amount.toFixed(2)} MVR
</span>
</div>
<span className="font-semibold pr-2">
{topup.paid ? (
<Badge className="bg-green-100 dark:bg-green-700" variant="outline">
{topup.status}
</Badge>
) : topup.is_expired ? (
<Badge>Expired</Badge>
) : (
<Badge variant="secondary">{topup.status}</Badge>
)}
</span>
</div>
</div>
);
}

View File

@ -95,13 +95,13 @@ export async function UsersPaymentsTable({
<TableCell className="font-medium"> <TableCell className="font-medium">
{/* {payment.user.id_card} */} {/* {payment.user.id_card} */}
<div className="flex flex-col items-start"> <div className="flex flex-col items-start">
{payment.devices[0]?.user?.name} {payment?.user?.name}
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{payment.devices[0]?.user?.id_card} {payment?.user?.id_card}
</span> </span>
</div>{" "} </div>{" "}
</TableCell> </TableCell>
<TableCell>{payment.amount}</TableCell> <TableCell>{payment.amount} MVR</TableCell>
<TableCell>{payment.number_of_months} Months</TableCell> <TableCell>{payment.number_of_months} Months</TableCell>
<TableCell> <TableCell>
@ -144,7 +144,7 @@ export async function UsersPaymentsTable({
</TableCell> </TableCell>
<TableCell> <TableCell>
<Link href={`/payments/${payment.id}/verify`}> <Link href={`/payments/${payment.id}`}>
<Button>Details</Button> <Button>Details</Button>
</Link> </Link>
</TableCell> </TableCell>

View File

@ -139,7 +139,7 @@ export default function BlockDeviceDialog({
id="reason_for_blocking" id="reason_for_blocking"
defaultValue={(state?.payload?.get("reason_for_blocking") || "") as string} defaultValue={(state?.payload?.get("reason_for_blocking") || "") as string}
className={cn( className={cn(
"col-span-5", "col-span-5 mt-2",
(state.fieldErrors?.reason_for_blocking) && "ring-2 ring-red-500", (state.fieldErrors?.reason_for_blocking) && "ring-2 ring-red-500",
)} )}
/> />

View File

@ -12,12 +12,13 @@ import { Badge } from "./ui/badge";
export default function DeviceCard({ export default function DeviceCard({
device, device,
parentalControl, parentalControl,
}: { device: Device; parentalControl?: boolean }) { }: { device: Device; parentalControl?: boolean, isAdmin?: boolean }) {
const [devices, setDeviceCart] = useAtom(deviceCartAtom); const [devices, setDeviceCart] = useAtom(deviceCartAtom);
const isChecked = devices.some((d) => d.id === device.id); const isChecked = devices.some((d) => d.id === device.id);
return ( return (
// biome-ignore lint/a11y/noStaticElementInteractions: <dw about it>
<div <div
onKeyUp={() => { }} onKeyUp={() => { }}
onClick={() => { onClick={() => {
@ -58,6 +59,7 @@ export default function DeviceCard({
</Badge> </Badge>
</div> </div>
{device.is_active ? ( {device.is_active ? (
<div className="text-muted-foreground ml-0.5"> <div className="text-muted-foreground ml-0.5">
Active until{" "} Active until{" "}
@ -77,7 +79,7 @@ export default function DeviceCard({
)} )}
{device.has_a_pending_payment && ( {device.has_a_pending_payment && (
<Link href={`/payments/${device.pending_payment_id}`}> <Link href={`/payments/${device.pending_payment_id}`}>
<span className="bg-muted rounded px-2 p-1 mt-2 flex hover:underline items-center justify-center gap-2 text-muted-foreground text-yellow-600"> <span className="bg-muted rounded px-2 p-1 mt-2 flex hover:underline items-center justify-center gap-2 text-yellow-600">
Payment Pending{" "} Payment Pending{" "}
<HandCoins className="animate-pulse" size={14} /> <HandCoins className="animate-pulse" size={14} />
</span> </span>

View File

@ -189,7 +189,7 @@ export async function PaymentsTable({
); );
} }
function MobilePaymentDetails({ payment }: { payment: Payment }) { export function MobilePaymentDetails({ payment, isAdmin = false }: { payment: Payment, isAdmin?: boolean }) {
return ( return (
<div <div
className={cn( className={cn(
@ -264,6 +264,12 @@ function MobilePaymentDetails({ payment }: { payment: Payment }) {
<Badge variant="secondary">{payment.status}</Badge> <Badge variant="secondary">{payment.status}</Badge>
)} )}
</span> </span>
{isAdmin && (
<div className="my-2 text-primary flex flex-col items-start text-sm border rounded p-2 mt-2 w-full bg-white dark:bg-black">
{payment?.user?.name}
<span className="text-muted-foreground">{payment?.user?.id_card}</span>
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -122,6 +122,12 @@ export async function AppSidebar({
icon: <Coins size={16} />, icon: <Coins size={16} />,
perm_identifier: "payment", perm_identifier: "payment",
}, },
{
title: "User Topups",
link: "/user-topups",
icon: <Coins size={16} />,
perm_identifier: "topup",
},
{ {
title: "Price Calculator", title: "Price Calculator",
link: "/price-calculator", link: "/price-calculator",

View File

@ -87,7 +87,9 @@ export interface Payment {
updated_at: string; updated_at: string;
status: "CANCELLED" | "PENDING" | "PAID"; status: "CANCELLED" | "PENDING" | "PAID";
mib_reference: string | null; mib_reference: string | null;
user: number; user: Pick<User, "id" | "id_card" | "mobile"> & {
name: string;
};
} }
export interface NewPayment { export interface NewPayment {