refactor: add tryCatch utility for error handling, update device-related components and types, and clean up unused code in payment actions
Some checks failed
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 13m55s

This commit is contained in:
i701 2025-04-05 16:07:11 +05:00
parent dbdc1df7d5
commit aa18484475
Signed by: i701
GPG Key ID: 54A0DA1E26D8E587
16 changed files with 641 additions and 599 deletions

View File

@ -1,31 +1,29 @@
"use server"; "use server";
import prisma from "@/lib/db";
import type { PaymentType } from "@/lib/types"; import type { PaymentType } from "@/lib/types";
import { formatMacAddress } from "@/lib/utils"; import { formatMacAddress } from "@/lib/utils";
import type { Prisma } from "@prisma/client";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { addDevicesToGroup } from "./omada-actions"; import { addDevicesToGroup } from "./omada-actions";
export async function createPayment(data: PaymentType) { export async function createPayment(data: PaymentType) {
console.log("data", data); console.log("data", data);
const payment = await prisma.payment.create({ // const payment = await prisma.payment.create({
data: { // data: {
amount: data.amount, // amount: data.amount,
numberOfMonths: data.numberOfMonths, // numberOfMonths: data.numberOfMonths,
paid: data.paid, // paid: data.paid,
userId: data.userId, // userId: data.userId,
devices: { // devices: {
connect: data.deviceIds.map((id) => { // connect: data.deviceIds.map((id) => {
return { // return {
id, // id,
}; // };
}), // }),
}, // },
}, // },
}); // });
redirect(`/payments/${payment.id}`); // redirect(`/payments/${payment.id}`);
} }
type VerifyPaymentType = { type VerifyPaymentType = {
@ -38,12 +36,6 @@ type VerifyPaymentType = {
type?: "TRANSFER" | "WALLET"; type?: "TRANSFER" | "WALLET";
}; };
type PaymentWithDevices = Prisma.PaymentGetPayload<{
include: {
devices: true;
};
}>;
class InsufficientFundsError extends Error { class InsufficientFundsError extends Error {
constructor() { constructor() {
super("Insufficient funds in wallet"); super("Insufficient funds in wallet");

View File

@ -1,12 +1,10 @@
import DevicesForPayment from '@/components/devices-for-payment' import DevicesForPayment from "@/components/devices-for-payment";
import prisma from '@/lib/db'; import React from "react";
import React from 'react'
export default async function DevicesToPay() { export default async function DevicesToPay() {
const billFormula = await prisma.billFormula.findFirst(); return (
return ( <div>
<div> <DevicesForPayment />
<DevicesForPayment billFormula={billFormula ?? undefined} /> </div>
</div> );
)
} }

View File

@ -1,39 +1,34 @@
import prisma from '@/lib/db' import React from "react";
import React from 'react'
export default async function DeviceDetails({ params }: { export default async function DeviceDetails({
params: Promise<{ deviceId: string }> params,
}: {
params: Promise<{ deviceId: string }>;
}) { }) {
const deviceId = (await params)?.deviceId const deviceId = (await params)?.deviceId;
const device = await prisma.device.findUnique({
where: {
id: deviceId,
},
}) return null;
return ( return (
<div> <div>
<div className="flex flex-col justify-between items-start text-gray-500 title-bg py-4 px-2 mb-4"> <div className="flex flex-col justify-between items-start text-gray-500 title-bg py-4 px-2 mb-4">
<h3 className='text-2xl font-bold'> <h3 className="text-2xl font-bold">{device?.name}</h3>
{device?.name} <span>{device?.mac}</span>
</h3> </div>
<span>{device?.mac}</span>
</div>
<div <div
id="user-filters" id="user-filters"
className=" pb-4 gap-4 flex sm:flex-row flex-col items-start justify-start" className=" pb-4 gap-4 flex sm:flex-row flex-col items-start justify-start"
> >
{/* <Search /> */} {/* <Search /> */}
{/* <Filter {/* <Filter
options={sortfilterOptions} options={sortfilterOptions}
defaultOption="asc" defaultOption="asc"
queryParamKey="sortBy" queryParamKey="sortBy"
/> */} /> */}
</div> </div>
{/* <Suspense key={query} fallback={"loading...."}> {/* <Suspense key={query} fallback={"loading...."}>
<DevicesTable searchParams={searchParams} /> <DevicesTable searchParams={searchParams} />
</Suspense> */} </Suspense> */}
</div> </div>
) );
} }

View File

@ -23,8 +23,6 @@ export default async function Devices({
<h3 className="text-sarLinkOrange text-2xl">My Devices</h3> <h3 className="text-sarLinkOrange text-2xl">My Devices</h3>
<AddDeviceDialogForm user_id={session?.user?.id} /> <AddDeviceDialogForm user_id={session?.user?.id} />
</div> </div>
<pre>{JSON.stringify(session, null, 2)}</pre>
<div <div
id="user-filters" id="user-filters"
className=" pb-4 gap-4 flex sm:flex-row flex-col items-start justify-start" className=" pb-4 gap-4 flex sm:flex-row flex-col items-start justify-start"

View File

@ -1,6 +1,5 @@
import NextAuth, { DefaultSession } from "next-auth"; import NextAuth, { DefaultSession, type User } from "next-auth";
import { Session } from "next-auth"; import { Session } from "next-auth";
import type { User } from "./userTypes";
declare module "next-auth" { declare module "next-auth" {
/** /**
* Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context * Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context
@ -13,6 +12,15 @@ declare module "next-auth" {
image?: string | null; image?: string | null;
user?: User & { user?: User & {
expiry?: string; expiry?: string;
id?: number;
username?: string;
user_permissions?: { id: number; name: string }[];
id_card?: string;
first_name?: string;
last_name?: string;
last_login?: string;
date_joined?: string;
is_superuser?: boolean;
}; };
expires: ISODateString; expires: ISODateString;
} }

View File

@ -34,7 +34,7 @@ export function AccountPopover() {
{session.data?.user?.name} {session.data?.user?.name}
</h4> </h4>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{session.data?.user?.phoneNumber} {session.data?.user?.id_card}
</p> </p>
</div> </div>
<Button <Button

View File

@ -12,7 +12,7 @@ import {
SidebarTrigger, SidebarTrigger,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { headers } from "next/headers"; import { redirect } from "next/navigation";
import { AccountPopover } from "./account-popver"; import { AccountPopover } from "./account-popver";
export async function ApplicationLayout({ export async function ApplicationLayout({
@ -20,20 +20,22 @@ export async function ApplicationLayout({
}: { children: React.ReactNode }) { }: { children: React.ReactNode }) {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session) return redirect("/auth/signin");
return ( return (
<SidebarProvider> <SidebarProvider>
<AppSidebar role={"admin"} /> <AppSidebar />
{/* <DeviceCartDrawer billFormula={billFormula || null} /> */} <DeviceCartDrawer />
<SidebarInset> <SidebarInset>
<header className="flex justify-between sticky top-0 bg-background h-16 shrink-0 items-center gap-2 border-b px-4 z-10"> <header className="flex justify-between sticky top-0 bg-background h-16 shrink-0 items-center gap-2 border-b px-4 z-10">
<div className="flex items-center gap-2 "> <div className="flex items-center gap-2 ">
<SidebarTrigger className="-ml-1" /> <SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" /> <Separator orientation="vertical" className="mr-2 h-4" />
{session?.user.role === "ADMIN" && ( <div className="text-sm font-mono px-2 p-1 rounded-md bg-green-500/10 text-green-900 dark:text-green-400">
<span className="text-sm font-mono px-2 p-1 rounded-md bg-green-500/10 text-green-900 dark:text-green-400"> Welcome back,{" "}
Welcome back {session?.user.name} <span className="font-semibold">
{session?.user?.first_name} {session?.user?.last_name}
</span> </span>
)} </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@ -1,124 +1,120 @@
"use client"; "use client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { deviceCartAtom } from "@/lib/atoms";
deviceCartAtom
} from "@/lib/atoms";
import { authClient } from "@/lib/auth-client";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { import { MonitorSmartphone } from "lucide-react";
MonitorSmartphone
} from "lucide-react";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
export function DeviceCartDrawer() { export function DeviceCartDrawer() {
const pathname = usePathname(); const pathname = usePathname();
const devices = useAtomValue(deviceCartAtom); const devices = useAtomValue(deviceCartAtom);
const router = useRouter(); const router = useRouter();
if (pathname === "/payment" || pathname === "/devices-to-pay") {
return null;
}
if (pathname === "/payment" || pathname === "/devices-to-pay") { if (devices.length === 0) return null;
return null; return (
} <Button
size={"lg"}
className="bg-sarLinkOrange absolute bottom-10 w-fit z-20 left-1/2 transform -translate-x-1/2"
onClick={() => router.push("/devices-to-pay")}
variant="outline"
>
<MonitorSmartphone />
Pay {devices.length > 0 && `(${devices.length})`} Device
</Button>
);
// <>
// <Drawer open={isOpen} onOpenChange={setIsOpen}>
// <DrawerTrigger asChild>
// <Button size={"lg"} className="bg-sarLinkOrange absolute bottom-10 w-fit z-20 left-1/2 transform -translate-x-1/2" onClick={() => setIsOpen(!isOpen)} variant="outline">
// <MonitorSmartphone />
// Pay {devices.length > 0 && `(${devices.length})`} Device
// </Button>
// </DrawerTrigger>
// <DrawerContent>
// <div className="mx-auto w-full max-w-sm">
// <DrawerHeader>
// <DrawerTitle>Selected Devices</DrawerTitle>
// <DrawerDescription>Selected devices pay.</DrawerDescription>
// </DrawerHeader>
// <div className="flex max-h-[calc(100svh-400px)] flex-col overflow-auto px-4 pb-4 gap-4">
// <pre>{JSON.stringify(isOpen, null, 2)}</pre>
// {devices.map((device) => (
// <DeviceCard key={device.id} device={device} />
// ))}
// </div>
// <div className="px-4 flex flex-col gap-4">
// <NumberInput
// label="Set No of Months"
// value={months}
// onChange={(value) => setMonths(value)}
// maxAllowed={12}
// isDisabled={devices.length === 0}
// />
// {message && (
// <span className="title-bg text-lime-800 bg-lime-100/50 dark:text-lime-100 rounded text-center p-2 w-full">
// {message}
// </span>
// )}
// </div>
// <DrawerFooter>
// <Button
// onClick={async () => {
// setDisabled(true);
// toast.promise(
// createPayment(data).then((result) => {
// if (result.success) {
// setDeviceCart([]);
// setMonths(1);
// setDisabled(false);
// if (isOpen) router.push(`/payments/${result.paymentId}`);
// setIsOpen(!isOpen);
// return "Payment created!";
// }
// }),
// {
// loading: "Processing payment...",
// success: "Payment created!",
// error: (err) => err.message || "Something went wrong.",
// }
// );
// }}
// className="w-full"
// disabled={devices.length === 0 || disabled}
// >
// {disabled ? (
// <>
// <Loader2 className="ml-2 animate-spin" />
// </>
// ) : (
// <>
// Go to payment
// <CircleDollarSign />
// </>
// )}
// </Button>
// <DrawerClose asChild>
// <Button variant="outline">Cancel</Button>
// </DrawerClose>
// <Button
// onClick={() => {
// setDeviceCart([]);
// setIsOpen(!isOpen);
// }}
// variant="outline"
// >
// Clear Selection
// </Button>
// </DrawerFooter>
// </div>
// </DrawerContent>
// </Drawer>
// </>
// );
if (devices.length === 0) return null
return <Button size={"lg"} className="bg-sarLinkOrange absolute bottom-10 w-fit z-20 left-1/2 transform -translate-x-1/2" onClick={() => router.push("/devices-to-pay")} variant="outline">
<MonitorSmartphone />
Pay {devices.length > 0 && `(${devices.length})`} Device
</Button>
// <>
// <Drawer open={isOpen} onOpenChange={setIsOpen}>
// <DrawerTrigger asChild>
// <Button size={"lg"} className="bg-sarLinkOrange absolute bottom-10 w-fit z-20 left-1/2 transform -translate-x-1/2" onClick={() => setIsOpen(!isOpen)} variant="outline">
// <MonitorSmartphone />
// Pay {devices.length > 0 && `(${devices.length})`} Device
// </Button>
// </DrawerTrigger>
// <DrawerContent>
// <div className="mx-auto w-full max-w-sm">
// <DrawerHeader>
// <DrawerTitle>Selected Devices</DrawerTitle>
// <DrawerDescription>Selected devices pay.</DrawerDescription>
// </DrawerHeader>
// <div className="flex max-h-[calc(100svh-400px)] flex-col overflow-auto px-4 pb-4 gap-4">
// <pre>{JSON.stringify(isOpen, null, 2)}</pre>
// {devices.map((device) => (
// <DeviceCard key={device.id} device={device} />
// ))}
// </div>
// <div className="px-4 flex flex-col gap-4">
// <NumberInput
// label="Set No of Months"
// value={months}
// onChange={(value) => setMonths(value)}
// maxAllowed={12}
// isDisabled={devices.length === 0}
// />
// {message && (
// <span className="title-bg text-lime-800 bg-lime-100/50 dark:text-lime-100 rounded text-center p-2 w-full">
// {message}
// </span>
// )}
// </div>
// <DrawerFooter>
// <Button
// onClick={async () => {
// setDisabled(true);
// toast.promise(
// createPayment(data).then((result) => {
// if (result.success) {
// setDeviceCart([]);
// setMonths(1);
// setDisabled(false);
// if (isOpen) router.push(`/payments/${result.paymentId}`);
// setIsOpen(!isOpen);
// return "Payment created!";
// }
// }),
// {
// loading: "Processing payment...",
// success: "Payment created!",
// error: (err) => err.message || "Something went wrong.",
// }
// );
// }}
// className="w-full"
// disabled={devices.length === 0 || disabled}
// >
// {disabled ? (
// <>
// <Loader2 className="ml-2 animate-spin" />
// </>
// ) : (
// <>
// Go to payment
// <CircleDollarSign />
// </>
// )}
// </Button>
// <DrawerClose asChild>
// <Button variant="outline">Cancel</Button>
// </DrawerClose>
// <Button
// onClick={() => {
// setDeviceCart([]);
// setIsOpen(!isOpen);
// }}
// variant="outline"
// >
// Clear Selection
// </Button>
// </DrawerFooter>
// </div>
// </DrawerContent>
// </Drawer>
// </>
// );
} }

View File

@ -1,107 +1,93 @@
"use client"; "use client";
import { createPayment } from "@/actions/payment"; import { createPayment } from "@/actions/payment";
import DeviceCard from "@/components/device-card"; import DeviceCard from "@/components/device-card";
import NumberInput from "@/components/number-input"; import NumberInput from "@/components/number-input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { deviceCartAtom, numberOfMonths } from "@/lib/atoms";
deviceCartAtom,
numberOfMonths
} from "@/lib/atoms";
import { authClient } from "@/lib/auth-client";
import type { PaymentType } from "@/lib/types"; import type { PaymentType } from "@/lib/types";
import type { BillFormula } from "@prisma/client";
import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { import { CircleDollarSign, Loader2 } from "lucide-react";
CircleDollarSign, import { useSession } from "next-auth/react";
Loader2
} from "lucide-react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export default function DevicesForPayment({ export default function DevicesForPayment() {
billFormula, const baseAmount = 100;
}: { const discountPercentage = 75;
billFormula?: BillFormula; const session = useSession();
}) { const pathname = usePathname();
const baseAmount = billFormula?.baseAmount || 100; const devices = useAtomValue(deviceCartAtom);
const discountPercentage = billFormula?.discountPercentage || 75; const setDeviceCart = useSetAtom(deviceCartAtom);
const session = authClient.useSession(); const [months, setMonths] = useAtom(numberOfMonths);
const pathname = usePathname(); const [message, setMessage] = useState("");
const devices = useAtomValue(deviceCartAtom); const [disabled, setDisabled] = useState(false);
const setDeviceCart = useSetAtom(deviceCartAtom); const [total, setTotal] = useState(0);
const [months, setMonths] = useAtom(numberOfMonths); useEffect(() => {
const [message, setMessage] = useState(""); if (months === 7) {
const [disabled, setDisabled] = useState(false); setMessage("You will get 1 month free.");
const [total, setTotal] = useState(0); } else if (months === 12) {
useEffect(() => { setMessage("You will get 2 months free.");
if (months === 7) { } else {
setMessage("You will get 1 month free."); setMessage("");
} else if (months === 12) { }
setMessage("You will get 2 months free."); setTotal(baseAmount + (devices.length + 1 - 1) * discountPercentage);
} else { }, [months, devices.length]);
setMessage("");
}
setTotal(baseAmount + ((devices.length + 1) - 1) * discountPercentage);
}, [months, devices.length, baseAmount, discountPercentage]);
if (pathname === "/payment") { if (pathname === "/payment") {
return null; return null;
} }
const data: PaymentType = { const data: PaymentType = {
numberOfMonths: months, numberOfMonths: months,
userId: session?.data?.user.id ?? "", userId: session?.data?.user?.id ?? "",
deviceIds: devices.map((device) => device.id), deviceIds: devices.map((device) => device.id),
amount: Number.parseFloat(total.toFixed(2)), amount: Number.parseFloat(total.toFixed(2)),
paid: false, paid: false,
}; };
return ( return (
<div className="max-w-lg mx-auto space-y-4 px-4"> <div className="max-w-lg mx-auto space-y-4 px-4">
<div className="flex max-h-[calc(100svh-400px)] flex-col overflow-auto pb-4 gap-4"> <div className="flex max-h-[calc(100svh-400px)] flex-col overflow-auto pb-4 gap-4">
{devices.map((device) => ( {devices.map((device) => (
<DeviceCard key={device.id} device={device} /> <DeviceCard key={device.id} device={device} />
))} ))}
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<NumberInput <NumberInput
label="Set No of Months" label="Set No of Months"
value={months} value={months}
onChange={(value: number) => setMonths(value)} onChange={(value: number) => setMonths(value)}
maxAllowed={12} maxAllowed={12}
isDisabled={devices.length === 0} isDisabled={devices.length === 0}
/> />
{message && ( {message && (
<span className="title-bg text-lime-800 bg-lime-100/50 dark:text-lime-100 rounded text-center p-2 w-full"> <span className="title-bg text-lime-800 bg-lime-100/50 dark:text-lime-100 rounded text-center p-2 w-full">
{message} {message}
</span> </span>
)} )}
</div> </div>
<Button <Button
onClick={async () => { onClick={async () => {
setDisabled(true); setDisabled(true);
await createPayment(data); await createPayment(data);
setDeviceCart([]); setDeviceCart([]);
setMonths(1); setMonths(1);
setDisabled(false); setDisabled(false);
}}
}} className="w-full"
className="w-full" disabled={devices.length === 0 || disabled}
disabled={devices.length === 0 || disabled} >
> {disabled ? (
{disabled ? ( <>
<> <Loader2 className="ml-2 animate-spin" />
<Loader2 className="ml-2 animate-spin" /> </>
</> ) : (
) : ( <>
<> Go to payment
Go to payment <CircleDollarSign />
<CircleDollarSign /> </>
</> )}
)} </Button>
</Button> </div>
);
</div>
)
} }

View File

@ -9,6 +9,8 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { getDevices } from "@/queries/devices";
import { tryCatch } from "@/utils/tryCatch";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import ClickableRow from "./clickable-row"; import ClickableRow from "./clickable-row";
import DeviceCard from "./device-card"; import DeviceCard from "./device-card";
@ -26,86 +28,17 @@ export async function DevicesTable({
parentalControl?: boolean; parentalControl?: boolean;
}) { }) {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
const isAdmin = session?.user; const isAdmin = session?.user?.is_superuser;
const query = (await searchParams)?.query || ""; const query = (await searchParams)?.query || "";
const page = (await searchParams)?.page;
const sortBy = (await searchParams)?.sortBy || "asc";
// const totalDevices = await prisma.device.count({
// where: {
// userId: isAdmin ? undefined : session?.session.userId,
// OR: [
// {
// name: {
// contains: query || "",
// mode: "insensitive",
// },
// },
// {
// mac: {
// contains: query || "",
// mode: "insensitive",
// },
// },
// ],
// NOT: {
// payments: {
// some: {
// paid: false,
// },
// },
// },
// isActive: isAdmin ? undefined : parentalControl,
// blocked: isAdmin
// ? undefined
// : parentalControl !== undefined
// ? undefined
// : false,
// },
// });
// const totalPages = Math.ceil(totalDevices / 10); const [error, devices] = await tryCatch(getDevices({ query: query }));
const limit = 10; if (error) {
const offset = (Number(page) - 1) * limit || 0; return <pre>{JSON.stringify(error, null, 2)}</pre>;
}
// const devices = await prisma.device.findMany({ const { meta, links, data } = devices;
// where: {
// userId: session?.session.userId,
// OR: [
// {
// name: {
// contains: query || "",
// mode: "insensitive",
// },
// },
// {
// mac: {
// contains: query || "",
// mode: "insensitive",
// },
// },
// ],
// NOT: {
// payments: {
// some: {
// paid: false,
// },
// },
// },
// isActive: parentalControl,
// blocked: parentalControl !== undefined ? undefined : false,
// },
// skip: offset,
// take: limit,
// orderBy: {
// name: `${sortBy}` as "asc" | "desc",
// },
// });
return null;
return ( return (
<div> <div>
{devices.length === 0 ? ( {data.length === 0 ? (
<div className="h-[calc(100svh-400px)] flex flex-col items-center justify-center my-4"> <div className="h-[calc(100svh-400px)] flex flex-col items-center justify-center my-4">
<h3>No devices yet.</h3> <h3>No devices yet.</h3>
</div> </div>
@ -122,7 +55,7 @@ export async function DevicesTable({
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody className="overflow-scroll"> <TableBody className="overflow-scroll">
{devices.map((device) => ( {data.map((device) => (
// <TableRow key={device.id}> // <TableRow key={device.id}>
// <TableCell> // <TableCell>
// <div className="flex flex-col items-start"> // <div className="flex flex-col items-start">
@ -173,21 +106,25 @@ export async function DevicesTable({
<TableCell colSpan={2}> <TableCell colSpan={2}>
{query.length > 0 && ( {query.length > 0 && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Showing {devices.length} locations for &quot;{query} Showing {meta.total} locations for &quot;{query}
&quot; &quot;
</p> </p>
)} )}
</TableCell> </TableCell>
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">
{totalDevices} devices {meta.total} devices
</TableCell> </TableCell>
</TableRow> </TableRow>
</TableFooter> </TableFooter>
</Table> </Table>
<Pagination totalPages={totalPages} currentPage={page} /> <Pagination
totalPages={meta.total / meta.per_page}
currentPage={meta.current_page}
/>
<pre>{JSON.stringify(meta, null, 2)}</pre>
</div> </div>
<div className="sm:hidden my-4"> <div className="sm:hidden my-4">
{devices.map((device) => ( {data.map((device) => (
<DeviceCard <DeviceCard
parentalControl={parentalControl} parentalControl={parentalControl}
key={device.id} key={device.id}

View File

@ -8,25 +8,15 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import prisma from "@/lib/db";
import Link from "next/link"; import Link from "next/link";
import { auth } from "@/app/auth";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Prisma } from "@prisma/client";
import { Calendar } from "lucide-react"; import { Calendar } from "lucide-react";
import { headers } from "next/headers";
import Pagination from "./pagination"; import Pagination from "./pagination";
import { Badge } from "./ui/badge"; import { Badge } from "./ui/badge";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Separator } from "./ui/separator"; import { Separator } from "./ui/separator";
type PaymentWithDevices = Prisma.PaymentGetPayload<{
include: {
devices: true;
};
}>;
export async function PaymentsTable({ export async function PaymentsTable({
searchParams, searchParams,
}: { }: {
@ -36,60 +26,61 @@ export async function PaymentsTable({
sortBy: string; sortBy: string;
}>; }>;
}) { }) {
const session = await auth.api.getSession({ // const session = await auth.api.getSession({
headers: await headers(), // headers: await headers(),
}); // });
const query = (await searchParams)?.query || ""; // const query = (await searchParams)?.query || "";
const page = (await searchParams)?.page; // const page = (await searchParams)?.page;
const totalPayments = await prisma.payment.count({ // const totalPayments = await prisma.payment.count({
where: { // where: {
userId: session?.session.userId, // userId: session?.session.userId,
OR: [ // OR: [
{ // {
devices: { // devices: {
every: { // every: {
name: { // name: {
contains: query || "", // contains: query || "",
mode: "insensitive", // mode: "insensitive",
}, // },
}, // },
}, // },
}, // },
], // ],
}, // },
}); // });
const totalPages = Math.ceil(totalPayments / 10); // const totalPages = Math.ceil(totalPayments / 10);
const limit = 10; // const limit = 10;
const offset = (Number(page) - 1) * limit || 0; // const offset = (Number(page) - 1) * limit || 0;
const payments = await prisma.payment.findMany({ // const payments = await prisma.payment.findMany({
where: { // where: {
userId: session?.session.userId, // userId: session?.session.userId,
OR: [ // OR: [
{ // {
devices: { // devices: {
every: { // every: {
name: { // name: {
contains: query || "", // contains: query || "",
mode: "insensitive", // mode: "insensitive",
}, // },
}, // },
}, // },
}, // },
], // ],
}, // },
include: { // include: {
devices: true, // devices: true,
}, // },
skip: offset, // skip: offset,
take: limit, // take: limit,
orderBy: { // orderBy: {
createdAt: "desc", // createdAt: "desc",
}, // },
}); // });
return null;
return ( return (
<div> <div>
{payments.length === 0 ? ( {payments.length === 0 ? (

View File

@ -10,6 +10,7 @@ import {
Wallet2Icon, Wallet2Icon,
} from "lucide-react"; } from "lucide-react";
import { authOptions } from "@/app/auth";
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
@ -27,76 +28,130 @@ import {
SidebarMenuItem, SidebarMenuItem,
SidebarRail, SidebarRail,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { getServerSession } from "next-auth";
import Link from "next/link"; import Link from "next/link";
const data = { type Permission = {
navMain: [ id: number;
name: string;
};
type Categories = {
id: string;
children: (
| {
title: string;
link: string;
perm_identifier: string;
icon: React.JSX.Element;
}
| {
title: string;
link: string;
icon: React.JSX.Element;
perm_identifier?: undefined;
}
)[];
}[];
export async function AppSidebar({
role,
...props
}: React.ComponentProps<typeof Sidebar>) {
const categories = [
{ {
title: "MENU", id: "MENU",
url: "#", url: "#",
requiredRoles: ["ADMIN", "USER"], children: [
items: [
{ {
title: "Devices", title: "Devices",
url: "/devices", link: "/devices",
perm_identifier: "device",
icon: <Smartphone size={16} />, icon: <Smartphone size={16} />,
}, },
{ {
title: "Payments", title: "Payments",
url: "/payments", link: "/payments",
icon: <CreditCard size={16} />, icon: <CreditCard size={16} />,
perm_identifier: "payment",
}, },
{ {
title: "Parental Control", title: "Parental Control",
url: "/parental-control", link: "/parental-control",
icon: <CreditCard size={16} />, icon: <CreditCard size={16} />,
perm_identifier: "device",
}, },
{ {
title: "Agreements", title: "Agreements",
url: "/agreements", link: "/agreements",
icon: <Handshake size={16} />, icon: <Handshake size={16} />,
perm_identifier: "device",
}, },
{ {
title: "Wallet", title: "Wallet",
url: "/wallet", link: "/wallet",
icon: <Wallet2Icon size={16} />, icon: <Wallet2Icon size={16} />,
perm_identifier: "wallet",
}, },
], ],
}, },
{ {
title: "ADMIN CONTROL", id: "ADMIN CONTROL",
url: "#", url: "#",
requiredRoles: ["ADMIN"], children: [
items: [
{ {
title: "Users", title: "Users",
url: "/users", link: "/users",
icon: <UsersRound size={16} />, icon: <UsersRound size={16} />,
perm_identifier: "device",
}, },
{ {
title: "User Devices", title: "User Devices",
url: "/user-devices", link: "/user-devices",
icon: <MonitorSpeaker size={16} />, icon: <MonitorSpeaker size={16} />,
perm_identifier: "device",
}, },
{ {
title: "User Payments", title: "User Payments",
url: "/user-payments", link: "/user-payments",
icon: <Coins size={16} />, icon: <Coins size={16} />,
perm_identifier: "payment",
}, },
{ {
title: "Price Calculator", title: "Price Calculator",
url: "/price-calculator", link: "/price-calculator",
icon: <Calculator size={16} />, icon: <Calculator size={16} />,
perm_identifier: "device",
}, },
], ],
}, },
], ];
};
const session = await getServerSession(authOptions);
const filteredCategories = categories.map((category) => {
const filteredChildren = category.children.filter((child) => {
const permIdentifier = child.perm_identifier;
return session?.user?.user_permissions?.some((permission: Permission) => {
const permissionParts = permission.name.split(" ");
const modelNameFromPermission = permissionParts.slice(2).join(" ");
return modelNameFromPermission === permIdentifier;
});
});
return { ...category, children: filteredChildren };
});
const filteredCategoriesWithChildren = filteredCategories.filter(
(category) => category.children.length > 0,
);
let CATEGORIES: Categories;
if (session?.user?.is_superuser) {
CATEGORIES = categories;
} else {
CATEGORIES = filteredCategoriesWithChildren;
}
export function AppSidebar({
role,
...props
}: React.ComponentProps<typeof Sidebar> & { role: string }) {
return ( return (
<Sidebar {...props} className="z-50"> <Sidebar {...props} className="z-50">
<SidebarHeader> <SidebarHeader>
@ -105,53 +160,46 @@ export function AppSidebar({
</h4> </h4>
</SidebarHeader> </SidebarHeader>
<SidebarContent className="gap-0"> <SidebarContent className="gap-0">
{data.navMain {CATEGORIES.map((item) => {
.filter( return (
(item) => <Collapsible
!item.requiredRoles || item.requiredRoles.includes(role || ""), key={item.id}
) title={item.id}
.map((item) => { defaultOpen
if (item.requiredRoles?.includes(role)) { className="group/collapsible"
return ( >
<Collapsible <SidebarGroup>
key={item.title} <SidebarGroupLabel
title={item.title} asChild
defaultOpen className="group/label text-sm text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
className="group/collapsible"
> >
<SidebarGroup> <CollapsibleTrigger>
<SidebarGroupLabel {item.id}{" "}
asChild <ChevronRight className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-90" />
className="group/label text-sm text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground" </CollapsibleTrigger>
> </SidebarGroupLabel>
<CollapsibleTrigger> <CollapsibleContent>
{item.title}{" "} <SidebarGroupContent>
<ChevronRight className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-90" /> <SidebarMenu>
</CollapsibleTrigger> {item.children.map((item) => (
</SidebarGroupLabel> <SidebarMenuItem key={item.title}>
<CollapsibleContent> <SidebarMenuButton className="py-6" asChild>
<SidebarGroupContent> <Link className="text-md" href={item.link}>
<SidebarMenu> {item.icon}
{item.items.map((item) => ( <span className="opacity-70 ml-2">
<SidebarMenuItem key={item.title}> {item.title}
<SidebarMenuButton className="py-6" asChild> </span>
<Link className="text-md" href={item.url}> </Link>
{item.icon} </SidebarMenuButton>
<span className="opacity-70 ml-2"> </SidebarMenuItem>
{item.title} ))}
</span> </SidebarMenu>
</Link> </SidebarGroupContent>
</SidebarMenuButton> </CollapsibleContent>
</SidebarMenuItem> </SidebarGroup>
))} </Collapsible>
</SidebarMenu> );
</SidebarGroupContent> })}
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
);
}
})}
</SidebarContent> </SidebarContent>
<SidebarRail /> <SidebarRail />
</Sidebar> </Sidebar>

View File

@ -1,19 +1,20 @@
"use client"; "use client";
import { AddDevice } from "@/actions/user-actions";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { addDevice } from "@/queries/devices";
import { tryCatch } from "@/utils/tryCatch";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2, Plus } from "lucide-react"; import { Loader2, Plus } from "lucide-react";
@ -23,112 +24,112 @@ import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
export default function AddDeviceDialogForm({ user_id }: { user_id?: string }) { export default function AddDeviceDialogForm({ user_id }: { user_id?: string }) {
const formSchema = z.object({
name: z.string().min(2, { message: "Name is required." }),
mac_address: z
.string()
.min(2, { message: "MAC Address is required." })
.regex(
/^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/,
"Please enter a valid MAC address",
),
});
const formSchema = z.object({ const [disabled, setDisabled] = useState(false);
name: z.string().min(2, { message: "Name is required." }), const [open, setOpen] = useState(false);
mac_address: z const {
.string() register,
.min(2, { message: "MAC Address is required." }) handleSubmit,
.regex( formState: { errors },
/^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/, } = useForm<z.infer<typeof formSchema>>({
"Please enter a valid MAC address", resolver: zodResolver(formSchema),
), });
});
const [disabled, setDisabled] = useState(false); if (!user_id) {
const [open, setOpen] = useState(false); return null;
const { }
register,
handleSubmit,
formState: { errors },
} = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
});
if (!user_id) { const onSubmit: SubmitHandler<z.infer<typeof formSchema>> = async (data) => {
return null console.log(data);
} setDisabled(true);
const [error, response] = await tryCatch(
addDevice({
mac: data.mac_address,
name: data.name,
}),
);
if (error) {
toast.error(error.message || "Something went wrong.");
setDisabled(false);
} else {
setOpen(false);
setDisabled(false);
toast.success("Device successfully added!");
}
};
const onSubmit: SubmitHandler<z.infer<typeof formSchema>> = (data) => { return (
console.log(data); <Dialog open={open} onOpenChange={setOpen}>
setDisabled(true) <DialogTrigger asChild>
toast.promise(AddDevice({ mac_address: data.mac_address, name: data.name, user_id: user_id }), { <Button
loading: 'Adding new device...', className="gap-2 items-center"
success: () => { disabled={disabled}
setDisabled(false) variant="default"
setOpen((prev) => !prev) >
return 'Device successfully added!' Add Device
}, <Plus size={16} />
error: (error) => { </Button>
setDisabled(false) </DialogTrigger>
return error || 'Something went wrong.' <DialogContent className="sm:max-w-[425px]">
}, <DialogHeader>
}) <DialogTitle>New Device</DialogTitle>
}; <DialogDescription>
To add a new device, enter the device name and mac address below.
Click save when you are done.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="grid gap-4 py-4">
<div className="flex flex-col gap-2">
<div>
<Label htmlFor="device_name" className="text-right">
Device Name
</Label>
<Input
placeholder="eg: Iphone X"
type="text"
{...register("name")}
id="device_name"
className="col-span-3"
/>
<span className="text-red-500 text-sm">
{errors.name?.message}
</span>
</div>
<div>
<Label htmlFor="address" className="text-right">
return ( Mac Address
<Dialog open={open} onOpenChange={setOpen}> </Label>
<DialogTrigger asChild> <Input
<Button placeholder="Mac address of your device"
className="gap-2 items-center" {...register("mac_address")}
disabled={disabled} id="mac_address"
variant="default" className="col-span-3"
> />
Add Device <span className="text-red-500 text-sm">
<Plus size={16} /> {errors.mac_address?.message}
</Button> </span>
</DialogTrigger> </div>
<DialogContent className="sm:max-w-[425px]"> </div>
<DialogHeader> </div>
<DialogTitle>New Device</DialogTitle> <DialogFooter>
<DialogDescription> <Button disabled={disabled} type="submit">
To add a new device, enter the device name and mac address below. Click save when you are done. {disabled ? <Loader2 className="animate-spin" /> : "Save"}
</DialogDescription> </Button>
</DialogHeader> </DialogFooter>
<form onSubmit={handleSubmit(onSubmit)}> </form>
<div className="grid gap-4 py-4"> </DialogContent>
<div className="flex flex-col gap-2"> </Dialog>
<div> );
<Label htmlFor="device_name" className="text-right">
Device Name
</Label>
<Input
placeholder="eg: Iphone X"
type="text"
{...register("name")}
id="device_name"
className="col-span-3"
/>
<span className="text-red-500 text-sm">
{errors.name?.message}
</span>
</div>
<div>
<Label htmlFor="address" className="text-right">
Mac Address
</Label>
<Input
placeholder="Mac address of your device"
{...register("mac_address")}
id="mac_address"
className="col-span-3"
/>
<span className="text-red-500 text-sm">
{errors.mac_address?.message}
</span>
</div>
</div>
</div>
<DialogFooter>
<Button disabled={disabled} type="submit">
{disabled ? <Loader2 className="animate-spin" /> : "Save"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
} }

View File

@ -29,3 +29,24 @@ export interface Island {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
export interface Device {
id: number;
name: string;
mac: string;
reason_for_blocking: string | null;
is_active: boolean;
registered: boolean;
blocked: boolean;
blocked_by: string;
expiry_date: string | null;
created_at: string;
updated_at: string;
user: number;
}
export interface Api400Error {
data: {
message: string;
};
}

61
queries/devices.ts Normal file
View File

@ -0,0 +1,61 @@
"use server";
import { authOptions } from "@/app/auth";
import type { Api400Error, ApiResponse, Device } from "@/lib/backend-types";
import { getServerSession } from "next-auth";
import { revalidatePath } from "next/cache";
type GetDevicesProps = {
query?: string;
page?: number;
sortBy?: string;
status?: string;
};
export async function getDevices({ query }: GetDevicesProps) {
const session = await getServerSession(authOptions);
const respose = await fetch(
`${process.env.SARLINK_API_BASE_URL}/api/devices/?name=${query}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Token ${session?.apiToken}`,
},
},
);
const data = (await respose.json()) as ApiResponse<Device>;
return data;
}
export async function addDevice({
name,
mac,
}: {
name: string;
mac: string;
}) {
type SingleDevice = Pick<Device, "name" | "mac">;
const session = await getServerSession(authOptions);
const response = await fetch(
`${process.env.SARLINK_API_BASE_URL}/api/devices/`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Token ${session?.apiToken}`,
},
body: JSON.stringify({
name: name,
mac: mac,
}),
},
);
if (!response.ok) {
const errorData = await response.json();
// Throw an error with the message from the API
throw new Error(errorData.message || "Something went wrong.");
}
const data = (await response.json()) as SingleDevice;
revalidatePath("/devices");
return data;
}

8
utils/tryCatch.ts Normal file
View File

@ -0,0 +1,8 @@
export async function tryCatch<T, E = Error>(promise: T | Promise<T>) {
try {
const data = await promise;
return [null, data] as const;
} catch (error) {
return [error as E, null] as const;
}
}