refactor: add animations
All checks were successful
Build and Push Docker Images / Build and Push Docker Images (push) Successful in 10m27s

This commit is contained in:
2025-09-24 19:33:48 +05:00
parent f8774f51e6
commit 9ad1887f88
10 changed files with 994 additions and 993 deletions

View File

@@ -27,11 +27,16 @@ export default async function DeviceDetails({
<div> <div>
<div className="flex items-center justify-between title-bg title-bg ring-2 ring-sarLinkOrange/50 rounded-lg p-4"> <div className="flex items-center justify-between title-bg title-bg ring-2 ring-sarLinkOrange/50 rounded-lg p-4">
<div className="flex flex-col justify-between items-start"> <div className="flex flex-col justify-between items-start">
<h3 className="text-2xl text-sarLinkOrange font-bold"> <h3 className="text-2xl text-sarLinkOrange motion-preset-slide-down-md font-bold">
{device?.name} {device?.name}
</h3> </h3>
<Badge variant={"secondary"}>{device?.mac}</Badge> <Badge
<p className="text-muted-foreground text-sm mt-2"> className="motion-preset-slide-down-md motion-delay-75"
variant={"secondary"}
>
{device?.mac}
</Badge>
<p className="text-muted-foreground text-sm mt-2 motion-preset-slide-down-md motion-delay-100">
Device active until{" "} Device active until{" "}
{new Date(device?.expiry_date || "").toLocaleDateString("en-US", { {new Date(device?.expiry_date || "").toLocaleDateString("en-US", {
month: "short", month: "short",
@@ -40,7 +45,7 @@ export default async function DeviceDetails({
})} })}
</p> </p>
</div> </div>
<div className="flex items-center gap-2 flex-col"> <div className="flex items-center gap-2 flex-col motion-preset-fade">
{device?.expiry_date && new Date() < new Date(device.expiry_date) && ( {device?.expiry_date && new Date() < new Date(device.expiry_date) && (
<p className="text-base font-semibold font-mono w-full text-center px-2 p-1 rounded-md bg-green-500/10 text-green-900 dark:text-green-400"> <p className="text-base font-semibold font-mono w-full text-center px-2 p-1 rounded-md bg-green-500/10 text-green-900 dark:text-green-400">
ACTIVE ACTIVE

View File

@@ -9,96 +9,99 @@ import { cn } from "@/lib/utils";
import AddDevicesToCartButton from "./add-devices-to-cart-button"; import AddDevicesToCartButton from "./add-devices-to-cart-button";
import BlockDeviceDialog from "./block-device-dialog"; import BlockDeviceDialog from "./block-device-dialog";
export default function ClickableRow({ export default function ClickableRow({
device, device,
parentalControl, parentalControl,
admin = false, admin = false,
idx,
}: { }: {
device: Device; device: Device;
parentalControl?: boolean; parentalControl?: boolean;
admin?: boolean; admin?: boolean;
idx?: number;
}) { }) {
const [devices, setDeviceCart] = useAtom(deviceCartAtom); const [devices, setDeviceCart] = useAtom(deviceCartAtom);
return ( return (
<TableRow <TableRow
key={device.id} key={device.id}
className={cn( className={cn(
(parentalControl === false && device.blocked) || device.is_active (parentalControl === false && device.blocked) || device.is_active
? "cursor-not-allowed hover:bg-accent-foreground/10" ? "cursor-not-allowed hover:bg-accent-foreground/10"
: "cursor-pointer hover:bg-muted-foreground/10", : "cursor-pointer hover:bg-muted-foreground/10",
)} `motion-preset-fade-md motion-delay-${(idx || 1) * 75}ms`,
onClick={() => { )}
if (device.blocked) return; onClick={() => {
if (device.is_active === true) return; if (device.blocked) return;
if (device.has_a_pending_payment === true) return; if (device.is_active === true) return;
if (parentalControl === true) return; if (device.has_a_pending_payment === true) return;
setDeviceCart((prev) => if (parentalControl === true) return;
devices.some((d) => d.id === device.id) setDeviceCart((prev) =>
? prev.filter((d) => d.id !== device.id) devices.some((d) => d.id === device.id)
: [...prev, device], ? prev.filter((d) => d.id !== device.id)
); : [...prev, device],
}} );
> }}
<TableCell> >
<div className="flex flex-col items-start"> <TableCell>
<Link <div className="flex flex-col items-start">
className={cn( <Link
"hover:underline font-semibold", className={cn(
device.is_active ? "text-green-600" : "", "hover:underline font-semibold",
)} device.is_active ? "text-green-600" : "",
href={`/devices/${device.id}`} )}
onClick={(e) => e.stopPropagation()} href={`/devices/${device.id}`}
> onClick={(e) => e.stopPropagation()}
{device.name} >
</Link> {device.name}
{device.is_active ? ( </Link>
<div className="text-muted-foreground"> {device.is_active ? (
Active until{" "} <div className="text-muted-foreground">
<span className="font-semibold"> Active until{" "}
{new Date(device.expiry_date || "").toLocaleDateString( <span className="font-semibold">
"en-US", {new Date(device.expiry_date || "").toLocaleDateString(
{ "en-US",
month: "short", {
day: "2-digit", month: "short",
year: "numeric", day: "2-digit",
}, year: "numeric",
)} },
</span> )}
</div> </span>
) : ( </div>
<p className="text-muted-foreground">Device Inactive</p> ) : (
)} <p className="text-muted-foreground">Device Inactive</p>
{device.has_a_pending_payment && ( )}
<Link href={`/payments/${device.pending_payment_id}`}> {device.has_a_pending_payment && (
<span className="bg-muted rounded px-2 p-1 mt-2 flex hover:underline items-center justify-center gap-2 text-muted-foreground"> <Link href={`/payments/${device.pending_payment_id}`}>
Payment Pending{" "} <span className="bg-muted rounded px-2 p-1 mt-2 flex hover:underline items-center justify-center gap-2 text-muted-foreground">
<HandCoins className="animate-pulse" size={14} /> Payment Pending{" "}
</span> <HandCoins className="animate-pulse" size={14} />
</Link> </span>
)} </Link>
)}
{device.blocked_by === "ADMIN" && device.blocked && ( {device.blocked_by === "ADMIN" && device.blocked && (
<div className="p-2 rounded border my-2 bg-white dark:bg-neutral-800 shadow"> <div className="p-2 rounded border my-2 bg-white dark:bg-neutral-800 shadow">
<span className="font-semibold">Comment</span> <span className="font-semibold">Comment</span>
<p className="text-neutral-400">{device?.reason_for_blocking}</p> <p className="text-neutral-400">{device?.reason_for_blocking}</p>
</div> </div>
)} )}
</div> </div>
</TableCell> </TableCell>
<TableCell className="font-medium">{device.mac}</TableCell> <TableCell className="font-medium">{device.mac}</TableCell>
<TableCell className="font-medium">{device?.vendor}</TableCell> <TableCell className="font-medium">{device?.vendor}</TableCell>
<TableCell> <TableCell>
{!parentalControl ? ( {!parentalControl ? (
<AddDevicesToCartButton device={device} /> <AddDevicesToCartButton device={device} />
) : ( ) : (
<BlockDeviceDialog <BlockDeviceDialog
admin={admin} admin={admin}
type={device.blocked ? "unblock" : "block"} type={device.blocked ? "unblock" : "block"}
device={device} device={device}
parentalControl={parentalControl} parentalControl={parentalControl}
/> />
)} )}
</TableCell> </TableCell>
</TableRow> </TableRow>
); );
} }

View File

@@ -10,108 +10,108 @@ import BlockDeviceDialog from "./block-device-dialog";
import { Badge } from "./ui/badge"; import { Badge } from "./ui/badge";
export default function DeviceCard({ export default function DeviceCard({
device, device,
parentalControl, parentalControl,
isAdmin, isAdmin,
}: { }: {
device: Device; device: Device;
parentalControl?: boolean; parentalControl?: boolean;
isAdmin?: 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> // biome-ignore lint/a11y/noStaticElementInteractions: <dw about it>
<div <div
onKeyUp={() => {}} onKeyUp={() => {}}
onClick={() => { onClick={() => {
if (device.blocked) return; if (device.blocked) return;
if (device.is_active === true) return; if (device.is_active === true) return;
if (device.has_a_pending_payment === true) return; if (device.has_a_pending_payment === true) return;
if (parentalControl === true) return; if (parentalControl === true) return;
setDeviceCart((prev) => setDeviceCart((prev) =>
devices.some((d) => d.id === device.id) devices.some((d) => d.id === device.id)
? prev.filter((d) => d.id !== device.id) ? prev.filter((d) => d.id !== device.id)
: [...prev, device], : [...prev, device],
); );
}} }}
className="w-full" className="w-full"
> >
<div <div
className={cn( className={cn(
"flex text-sm justify-between items-center my-2 p-4 border rounded-md", "flex text-sm justify-between items-center my-2 p-4 border rounded-md motion-preset-fade-md",
isChecked ? "bg-accent" : "", isChecked ? "bg-accent" : "",
device.is_active device.is_active
? "cursor-not-allowed text-green-600 hover:bg-accent-foreground/10" ? "cursor-not-allowed text-green-600 hover:bg-accent-foreground/10"
: "cursor-pointer hover:bg-muted-foreground/10", : "cursor-pointer hover:bg-muted-foreground/10",
)} )}
> >
<div className=""> <div className="">
<div className="font-semibold flex flex-col items-start gap-2 mb-2"> <div className="font-semibold flex flex-col items-start gap-2 mb-2">
<Link <Link
className={cn( className={cn(
"font-medium hover:underline ml-0.5", "font-medium hover:underline ml-0.5",
device.is_active ? "text-green-600" : "", device.is_active ? "text-green-600" : "",
)} )}
href={`/devices/${device.id}`} href={`/devices/${device.id}`}
> >
{device.name} {device.name}
</Link> </Link>
<Badge variant={"outline"}> <Badge variant={"outline"}>
<span className="font-medium">{device.mac}</span> <span className="font-medium">{device.mac}</span>
</Badge> </Badge>
<Badge variant={"outline"}> <Badge variant={"outline"}>
<span className="font-medium">{device.vendor}</span> <span className="font-medium">{device.vendor}</span>
</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{" "}
<span className="font-semibold"> <span className="font-semibold">
{new Date(device.expiry_date || "").toLocaleDateString( {new Date(device.expiry_date || "").toLocaleDateString(
"en-US", "en-US",
{ {
month: "short", month: "short",
day: "2-digit", day: "2-digit",
year: "numeric", year: "numeric",
}, },
)} )}
</span> </span>
</div> </div>
) : ( ) : (
<p className="text-muted-foreground ml-0.5">Device Inactive</p> <p className="text-muted-foreground ml-0.5">Device Inactive</p>
)} )}
{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-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>
</Link> </Link>
)} )}
{device.blocked && device.blocked_by === "ADMIN" && ( {device.blocked && device.blocked_by === "ADMIN" && (
<div className="p-2 rounded border my-2 w-full"> <div className="p-2 rounded border my-2 w-full">
<span className="uppercase text-red-500">Blocked by admin </span> <span className="uppercase text-red-500">Blocked by admin </span>
<p className="text-neutral-500">{device?.reason_for_blocking}</p> <p className="text-neutral-500">{device?.reason_for_blocking}</p>
</div> </div>
)} )}
</div> </div>
<div> <div>
{!parentalControl ? ( {!parentalControl ? (
<AddDevicesToCartButton device={device} /> <AddDevicesToCartButton device={device} />
) : ( ) : (
<BlockDeviceDialog <BlockDeviceDialog
admin={isAdmin} admin={isAdmin}
type={device.blocked ? "unblock" : "block"} type={device.blocked ? "unblock" : "block"}
device={device} device={device}
/> />
)} )}
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@@ -2,13 +2,13 @@ import { redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/app/auth"; import { authOptions } from "@/app/auth";
import { import {
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableFooter, TableFooter,
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { getDevices } from "@/queries/devices"; import { getDevices } from "@/queries/devices";
import { tryCatch } from "@/utils/tryCatch"; import { tryCatch } from "@/utils/tryCatch";
@@ -18,107 +18,108 @@ import DeviceCard from "./device-card";
import Pagination from "./pagination"; import Pagination from "./pagination";
export async function DevicesTable({ export async function DevicesTable({
searchParams, searchParams,
parentalControl, parentalControl,
additionalFilters = {}, additionalFilters = {},
}: { }: {
searchParams: Promise<{ searchParams: Promise<{
[key: string]: unknown; [key: string]: unknown;
}>; }>;
parentalControl?: boolean; parentalControl?: boolean;
additionalFilters?: Record<string, string | number | boolean>; additionalFilters?: Record<string, string | number | boolean>;
}) { }) {
const resolvedParams = await searchParams; const resolvedParams = await searchParams;
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
const isAdmin = session?.user?.is_admin; 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;
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
// Build params object for getDevices // Build params object for getDevices
const apiParams: Record<string, string | number | undefined> = {}; const apiParams: Record<string, string | number | undefined> = {};
for (const [key, value] of Object.entries(resolvedParams)) { for (const [key, value] of Object.entries(resolvedParams)) {
if (value !== undefined && value !== "") { if (value !== undefined && value !== "") {
apiParams[key] = typeof value === "number" ? value : String(value); apiParams[key] = typeof value === "number" ? value : String(value);
} }
} }
for (const [key, value] of Object.entries(additionalFilters)) { for (const [key, value] of Object.entries(additionalFilters)) {
if (value !== undefined && value !== "") { if (value !== undefined && value !== "") {
apiParams[key] = typeof value === "number" ? value : String(value); apiParams[key] = typeof value === "number" ? value : String(value);
} }
} }
apiParams.limit = limit; apiParams.limit = limit;
apiParams.offset = offset; apiParams.offset = offset;
const [error, devices] = await tryCatch(getDevices(apiParams)); const [error, devices] = await tryCatch(getDevices(apiParams));
if (error) { if (error) {
if (error.message === "UNAUTHORIZED") { if (error.message === "UNAUTHORIZED") {
redirect("/auth/signin"); redirect("/auth/signin");
} else { } else {
return <ClientErrorMessage message={error.message} />; return <ClientErrorMessage message={error.message} />;
} }
} }
const { meta, data } = devices; const { meta, data } = devices;
return ( return (
<div> <div>
{data?.length === 0 ? ( {data?.length === 0 ? (
<div className="h-[calc(100svh-400px)] text-muted-foreground flex flex-col items-center justify-center my-4"> <div className="h-[calc(100svh-400px)] text-muted-foreground flex flex-col items-center justify-center my-4">
<h3>{parentalControl ? "No active devices" : "No devices."}</h3> <h3>{parentalControl ? "No active devices" : "No devices."}</h3>
</div> </div>
) : ( ) : (
<> <>
<div className="hidden sm:block"> <div className="hidden sm:block">
<Table className="overflow-scroll"> <Table className="overflow-scroll">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Device Name</TableHead> <TableHead>Device Name</TableHead>
<TableHead>MAC Address</TableHead> <TableHead>MAC Address</TableHead>
<TableHead>Vendor</TableHead> <TableHead>Vendor</TableHead>
<TableHead>#</TableHead> <TableHead>#</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody className="overflow-scroll"> <TableBody className="overflow-scroll">
{data?.map((device) => ( {data?.map((device, idx) => (
<ClickableRow <ClickableRow
admin={isAdmin} admin={isAdmin}
key={device.id} key={device.id}
device={device} device={device}
parentalControl={parentalControl} parentalControl={parentalControl}
/> idx={idx + 1}
))} />
</TableBody> ))}
<TableFooter> </TableBody>
<TableRow> <TableFooter>
<TableCell colSpan={4} className="text-muted-foreground"> <TableRow>
{meta?.total === 1 ? ( <TableCell colSpan={4} className="text-muted-foreground">
<p className="text-center">Total {meta?.total} device.</p> {meta?.total === 1 ? (
) : ( <p className="text-center">Total {meta?.total} device.</p>
<p className="text-center"> ) : (
Total {meta?.total} devices. <p className="text-center">
</p> Total {meta?.total} devices.
)} </p>
</TableCell> )}
</TableRow> </TableCell>
</TableFooter> </TableRow>
</Table> </TableFooter>
</div> </Table>
<div className="sm:hidden my-4"> </div>
{data?.map((device) => ( <div className="sm:hidden my-4">
<DeviceCard {data?.map((device) => (
parentalControl={parentalControl} <DeviceCard
key={device.id} parentalControl={parentalControl}
device={device} key={device.id}
isAdmin={isAdmin} device={device}
/> isAdmin={isAdmin}
))} />
</div> ))}
<Pagination </div>
totalPages={meta?.last_page} <Pagination
currentPage={meta?.current_page} totalPages={meta?.last_page}
/> currentPage={meta?.current_page}
</> />
)} </>
</div> )}
); </div>
);
} }

View File

@@ -126,6 +126,7 @@ export default function DevicesToPay({
type="submit" type="submit"
variant={"secondary"} variant={"secondary"}
size={"lg"} size={"lg"}
className="w-full"
> >
{isPending {isPending
? "Processing payment..." ? "Processing payment..."
@@ -145,7 +146,7 @@ export default function DevicesToPay({
disabled={isPending || disabled} disabled={isPending || disabled}
type="submit" type="submit"
size={"lg"} size={"lg"}
className="mb-4" className="mb-4 w-full"
> >
{isPending ? "Processing payment..." : "I have paid"} {isPending ? "Processing payment..." : "I have paid"}
{isPending ? ( {isPending ? (
@@ -162,10 +163,8 @@ export default function DevicesToPay({
</TableCaption> </TableCaption>
<TableBody className=""> <TableBody className="">
<TableRow> <TableRow>
<TableCell className="motion-preset-slide-left-sm"> <TableCell>Payment created</TableCell>
Payment created <TableCell className="text-right motion-preset-slide-up-sm">
</TableCell>
<TableCell className="text-right motion-preset-slide-right-sm">
{new Date(payment?.created_at ?? "").toLocaleDateString( {new Date(payment?.created_at ?? "").toLocaleDateString(
"en-US", "en-US",
{ {
@@ -180,31 +179,22 @@ export default function DevicesToPay({
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell className="motion-preset-slide-left-sm motion-delay-75"> <TableCell>Total Devices</TableCell>
Total Devices <TableCell className="text-right text-xl motion-preset-slide-up-sm motion-delay-75">
</TableCell>
<TableCell className="text-right text-xl motion-preset-slide-right-sm motion-delay-75">
{devices?.length} {devices?.length}
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell className="motion-preset-slide-left-sm motion-delay-100"> <TableCell>Duration</TableCell>
Duration <TableCell className="text-right text-xl motion-preset-slide-up-sm motion-delay-100">
</TableCell>
<TableCell className="text-right text-xl motion-preset-slide-right-sm motion-delay-100">
{payment?.number_of_months} Months {payment?.number_of_months} Months
</TableCell> </TableCell>
</TableRow> </TableRow>
</TableBody> </TableBody>
<TableFooter> <TableFooter>
<TableRow className=""> <TableRow className="">
<TableCell <TableCell colSpan={1}>Total Due</TableCell>
className="motion-preset-slide-left-sm motion-delay-150" <TableCell className="text-right text-3xl font-bold motion-preset-slide-up-sm motion-delay-150">
colSpan={1}
>
Total Due
</TableCell>
<TableCell className="text-right text-3xl font-bold motion-preset-slide-right-sm motion-delay-150">
{payment?.amount?.toFixed(2)} {payment?.amount?.toFixed(2)}
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@@ -3,13 +3,13 @@ import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getPayments } from "@/actions/payment"; import { getPayments } from "@/actions/payment";
import { import {
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableFooter, TableFooter,
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import type { Payment } from "@/lib/backend-types"; import type { Payment } from "@/lib/backend-types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -20,265 +20,265 @@ import { Button } from "./ui/button";
import { Separator } from "./ui/separator"; import { Separator } from "./ui/separator";
export async function PaymentsTable({ export async function PaymentsTable({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ searchParams: Promise<{
[key: string]: unknown; [key: string]: unknown;
}>; }>;
}) { }) {
const resolvedParams = await searchParams; const resolvedParams = await searchParams;
const page = Number.parseInt(resolvedParams.page as string) || 1; const page = Number.parseInt(resolvedParams.page as string) || 1;
const limit = 10; const limit = 10;
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const apiParams: Record<string, string | number | undefined> = {}; const apiParams: Record<string, string | number | undefined> = {};
for (const [key, value] of Object.entries(resolvedParams)) { for (const [key, value] of Object.entries(resolvedParams)) {
if (value !== undefined && value !== "") { if (value !== undefined && value !== "") {
apiParams[key] = typeof value === "number" ? value : String(value); apiParams[key] = typeof value === "number" ? value : String(value);
} }
} }
apiParams.limit = limit; apiParams.limit = limit;
apiParams.offset = offset; apiParams.offset = offset;
const [error, payments] = await tryCatch(getPayments(apiParams)); const [error, payments] = await tryCatch(getPayments(apiParams));
if (error) { if (error) {
if (error.message.includes("Unauthorized")) { if (error.message.includes("Unauthorized")) {
redirect("/auth/signin"); redirect("/auth/signin");
} else { } else {
return <pre>{JSON.stringify(error, null, 2)}</pre>; return <pre>{JSON.stringify(error, null, 2)}</pre>;
} }
} }
const { data, meta } = payments; const { data, meta } = payments;
return ( return (
<div> <div>
{data?.length === 0 ? ( {data?.length === 0 ? (
<div className="h-[calc(100svh-400px)] text-muted-foreground flex flex-col items-center justify-center my-4"> <div className="h-[calc(100svh-400px)] text-muted-foreground flex flex-col items-center justify-center my-4">
<h3>No Payments.</h3> <h3>No Payments.</h3>
</div> </div>
) : ( ) : (
<> <>
<div className="hidden sm:block"> <div className="hidden sm:block">
<Table className="overflow-scroll"> <Table className="overflow-scroll">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Details</TableHead> <TableHead>Details</TableHead>
<TableHead>Duration</TableHead> <TableHead>Duration</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Amount</TableHead> <TableHead>Amount</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody className="overflow-scroll"> <TableBody className="overflow-scroll">
{payments?.data?.map((payment) => ( {payments?.data?.map((payment) => (
<TableRow key={payment.id}> <TableRow className="motion-preset-fade-md" key={payment.id}>
<TableCell> <TableCell>
<div <div
className={cn( className={cn(
"flex flex-col items-start border rounded p-2", "flex flex-col items-start border rounded p-2",
payment?.paid payment?.paid
? "bg-green-500/10 border-dashed border-green-500" ? "bg-green-500/10 border-dashed border-green-500"
: payment?.is_expired : payment?.is_expired
? "bg-gray-500/10 border-dashed border-gray-500 dark:border-gray-500/50" ? "bg-gray-500/10 border-dashed border-gray-500 dark:border-gray-500/50"
: "bg-yellow-500/10 border-dashed border-yellow-500 dark:border-yellow-500/50", : "bg-yellow-500/10 border-dashed border-yellow-500 dark:border-yellow-500/50",
)} )}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Calendar size={16} opacity={0.5} /> <Calendar size={16} opacity={0.5} />
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{new Date(payment.created_at).toLocaleDateString( {new Date(payment.created_at).toLocaleDateString(
"en-US", "en-US",
{ {
month: "short", month: "short",
day: "2-digit", day: "2-digit",
year: "numeric", year: "numeric",
minute: "2-digit", minute: "2-digit",
hour: "2-digit", hour: "2-digit",
timeZone: "Indian/Maldives", // Force consistent timezone timeZone: "Indian/Maldives", // Force consistent timezone
}, },
)} )}
</span> </span>
</div> </div>
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<Link <Link
className="font-medium hover:underline" className="font-medium hover:underline"
href={`/payments/${payment.id}`} href={`/payments/${payment.id}`}
> >
<Button size={"sm"} variant="outline"> <Button size={"sm"} variant="outline">
View Details View Details
</Button> </Button>
</Link> </Link>
</div> </div>
<div className="bg-white dark:bg-black p-2 rounded mt-2 w-full border"> <div className="bg-white dark:bg-black p-2 rounded mt-2 w-full border">
<h3 className="text-sm font-medium">Devices</h3> <h3 className="text-sm font-medium">Devices</h3>
<ol className="list-disc list-inside text-sm"> <ol className="list-disc list-inside text-sm">
{payment.devices.map((device) => ( {payment.devices.map((device) => (
<li <li
key={device.id} key={device.id}
className="text-sm text-muted-foreground" className="text-sm text-muted-foreground"
> >
{device.name} {device.name}
</li> </li>
))} ))}
</ol> </ol>
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell className="font-medium"> <TableCell className="font-medium">
{payment.number_of_months} Months {payment.number_of_months} Months
</TableCell> </TableCell>
<TableCell> <TableCell>
<span className="font-semibold pr-2"> <span className="font-semibold pr-2">
{payment.paid ? ( {payment.paid ? (
<Badge <Badge
className={cn( className={cn(
payment.status === "PENDING" payment.status === "PENDING"
? "bg-yellow-100 text-yellow-700 dark:bg-yellow-700 dark:text-yellow-100" ? "bg-yellow-100 text-yellow-700 dark:bg-yellow-700 dark:text-yellow-100"
: "bg-green-100 dark:bg-green-700", : "bg-green-100 dark:bg-green-700",
)} )}
variant="outline" variant="outline"
> >
{payment.status} {payment.status}
</Badge> </Badge>
) : payment.is_expired ? ( ) : payment.is_expired ? (
<Badge>Expired</Badge> <Badge>Expired</Badge>
) : ( ) : (
<Badge variant="outline">{payment.status}</Badge> <Badge variant="outline">{payment.status}</Badge>
)} )}
</span> </span>
</TableCell> </TableCell>
<TableCell> <TableCell>
<span className="font-semibold pr-2"> <span className="font-semibold pr-2">
{payment.amount.toFixed(2)} {payment.amount.toFixed(2)}
</span> </span>
MVR MVR
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
<TableFooter> <TableFooter>
<TableRow> <TableRow>
<TableCell colSpan={4} className="text-muted-foreground"> <TableCell colSpan={4} className="text-muted-foreground">
{meta?.total === 1 ? ( {meta?.total === 1 ? (
<p className="text-center"> <p className="text-center">
Total {meta?.total} payment. Total {meta?.total} payment.
</p> </p>
) : ( ) : (
<p className="text-center"> <p className="text-center">
Total {meta?.total} payments. Total {meta?.total} payments.
</p> </p>
)}{" "} )}{" "}
</TableCell> </TableCell>
</TableRow> </TableRow>
</TableFooter> </TableFooter>
</Table> </Table>
<Pagination <Pagination
totalPages={meta.last_page} totalPages={meta.last_page}
currentPage={meta.current_page} currentPage={meta.current_page}
/> />
</div> </div>
<div className="sm:hidden block"> <div className="sm:hidden block">
{data.map((payment) => ( {data.map((payment) => (
<MobilePaymentDetails key={payment.id} payment={payment} /> <MobilePaymentDetails key={payment.id} payment={payment} />
))} ))}
</div> </div>
</> </>
)} )}
</div> </div>
); );
} }
export function MobilePaymentDetails({ export function MobilePaymentDetails({
payment, payment,
isAdmin = false, isAdmin = false,
}: { }: {
payment: Payment; payment: Payment;
isAdmin?: boolean; isAdmin?: boolean;
}) { }) {
return ( return (
<div <div
className={cn( className={cn(
"flex flex-col items-start border rounded p-2 my-2", "flex flex-col items-start border rounded p-2 my-2 motion-preset-fade-md",
payment?.paid payment?.paid
? "bg-green-500/10 border-dashed border-green-500" ? "bg-green-500/10 border-dashed border-green-500"
: payment?.is_expired : payment?.is_expired
? "bg-gray-500/10 border-dashed border-gray-500 dark:border-gray-500/50" ? "bg-gray-500/10 border-dashed border-gray-500 dark:border-gray-500/50"
: "bg-yellow-500/10 border-dashed border-yellow-500 dark:border-yellow-500/50", : "bg-yellow-500/10 border-dashed border-yellow-500 dark:border-yellow-500/50",
)} )}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Calendar size={16} opacity={0.5} /> <Calendar size={16} opacity={0.5} />
<span className="text-muted-foreground text-sm"> <span className="text-muted-foreground text-sm">
{new Date(payment.created_at).toLocaleDateString("en-US", { {new Date(payment.created_at).toLocaleDateString("en-US", {
month: "short", month: "short",
day: "2-digit", day: "2-digit",
year: "numeric", year: "numeric",
minute: "2-digit", minute: "2-digit",
hour: "2-digit", hour: "2-digit",
timeZone: "Indian/Maldives", // Force consistent timezone timeZone: "Indian/Maldives", // Force consistent timezone
})} })}
</span> </span>
</div> </div>
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<Link <Link
className="font-medium hover:underline" className="font-medium hover:underline"
href={`/payments/${payment.id}`} href={`/payments/${payment.id}`}
> >
<Button size={"sm"} variant="outline"> <Button size={"sm"} variant="outline">
View Details View Details
</Button> </Button>
</Link> </Link>
</div> </div>
<div className="bg-white dark:bg-black p-2 rounded mt-2 w-full border"> <div className="bg-white dark:bg-black p-2 rounded mt-2 w-full border">
<h3 className="text-sm font-medium">Devices</h3> <h3 className="text-sm font-medium">Devices</h3>
<ol className="list-disc list-inside text-sm"> <ol className="list-disc list-inside text-sm">
{payment.devices.map((device) => ( {payment.devices.map((device) => (
<li key={device.id} className="text-sm text-muted-foreground"> <li key={device.id} className="text-sm text-muted-foreground">
{device.name} {device.name}
</li> </li>
))} ))}
</ol> </ol>
<div className="block sm:hidden"> <div className="block sm:hidden">
<Separator className="my-2" /> <Separator className="my-2" />
<h3 className="text-sm font-medium">Duration</h3> <h3 className="text-sm font-medium">Duration</h3>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{payment.number_of_months} Months {payment.number_of_months} Months
</span> </span>
<Separator className="my-2" /> <Separator className="my-2" />
<h3 className="text-sm font-medium">Amount</h3> <h3 className="text-sm font-medium">Amount</h3>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{payment.amount.toFixed(2)} MVR {payment.amount.toFixed(2)} MVR
</span> </span>
<span className="font-semibold pr-2"> <span className="font-semibold pr-2">
{payment.paid ? ( {payment.paid ? (
<Badge <Badge
className={cn( className={cn(
payment.status === "PENDING" payment.status === "PENDING"
? "bg-yellow-100 text-yellow-700 dark:bg-yellow-700 dark:text-yellow-100" ? "bg-yellow-100 text-yellow-700 dark:bg-yellow-700 dark:text-yellow-100"
: "bg-green-100 dark:bg-green-700", : "bg-green-100 dark:bg-green-700",
)} )}
variant="outline" variant="outline"
> >
{payment.status} {payment.status}
</Badge> </Badge>
) : payment.is_expired ? ( ) : payment.is_expired ? (
<Badge>Expired</Badge> <Badge>Expired</Badge>
) : ( ) : (
<Badge variant="secondary">{payment.status}</Badge> <Badge variant="secondary">{payment.status}</Badge>
)} )}
</span> </span>
{isAdmin && ( {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"> <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} {payment?.user?.name}
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{payment?.user?.id_card} {payment?.user?.id_card}
</span> </span>
</div> </div>
)} )}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@@ -3,13 +3,13 @@ import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTopups } from "@/actions/payment"; import { getTopups } from "@/actions/payment";
import { import {
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableFooter, TableFooter,
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import type { Topup } from "@/lib/backend-types"; import type { Topup } from "@/lib/backend-types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -19,199 +19,201 @@ import { Badge } from "./ui/badge";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
export async function TopupsTable({ export async function TopupsTable({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ searchParams: Promise<{
[key: string]: unknown; [key: string]: unknown;
}>; }>;
}) { }) {
const resolvedParams = await searchParams; const resolvedParams = await searchParams;
const page = Number.parseInt(resolvedParams.page as string) || 1; const page = Number.parseInt(resolvedParams.page as string) || 1;
const limit = 10; const limit = 10;
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
// Build params object // Build params object
const apiParams: Record<string, string | number | undefined> = {}; const apiParams: Record<string, string | number | undefined> = {};
for (const [key, value] of Object.entries(resolvedParams)) { for (const [key, value] of Object.entries(resolvedParams)) {
if (value !== undefined && value !== "") { if (value !== undefined && value !== "") {
apiParams[key] = typeof value === "number" ? value : String(value); apiParams[key] = typeof value === "number" ? value : String(value);
} }
} }
apiParams.limit = limit; apiParams.limit = limit;
apiParams.offset = offset; apiParams.offset = offset;
const [error, topups] = await tryCatch(getTopups(apiParams)); const [error, topups] = await tryCatch(getTopups(apiParams));
if (error) { if (error) {
if (error.message.includes("Unauthorized")) { if (error.message.includes("Unauthorized")) {
redirect("/auth/signin"); redirect("/auth/signin");
} else { } else {
return <pre>{JSON.stringify(error, null, 2)}</pre>; return <pre>{JSON.stringify(error, null, 2)}</pre>;
} }
} }
const { data, meta } = topups; const { data, meta } = topups;
return ( return (
<div> <div>
{data?.length === 0 ? ( {data?.length === 0 ? (
<div className="h-[calc(100svh-400px)] flex text-muted-foreground flex-col items-center justify-center my-4"> <div className="h-[calc(100svh-400px)] flex text-muted-foreground flex-col items-center justify-center my-4">
<h3>No topups.</h3> <h3>No topups.</h3>
</div> </div>
) : ( ) : (
<> <>
<div className="hidden sm:block"> <div className="hidden sm:block">
<Table className="overflow-scroll"> <Table className="overflow-scroll">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Details</TableHead> <TableHead>Details</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Amount</TableHead> <TableHead>Amount</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody className="overflow-scroll"> <TableBody className="overflow-scroll">
{topups?.data?.map((topup) => ( {topups?.data?.map((topup) => (
<TableRow key={topup.id}> <TableRow key={topup.id}>
<TableCell> <TableCell>
<div <div
className={cn( className={cn(
"flex flex-col items-start border rounded p-2", "flex flex-col items-start border rounded p-2 motion-preset-fade-md",
topup?.paid topup?.paid
? "bg-green-500/10 border-dashed border-green-500" ? "bg-green-500/10 border-dashed border-green-500"
: topup?.is_expired : topup?.is_expired
? "bg-gray-500/10 border-dashed border-gray-500 dark:border-gray-500/50" ? "bg-gray-500/10 border-dashed border-gray-500 dark:border-gray-500/50"
: "bg-yellow-500/10 border-dashed border-yellow-500 dark:border-yellow-500/50", : "bg-yellow-500/10 border-dashed border-yellow-500 dark:border-yellow-500/50",
)} )}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Calendar size={16} opacity={0.5} /> <Calendar size={16} opacity={0.5} />
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{new Date(topup.created_at).toLocaleDateString( {new Date(topup.created_at).toLocaleDateString(
"en-US", "en-US",
{ {
month: "short", month: "short",
day: "2-digit", day: "2-digit",
year: "numeric", year: "numeric",
minute: "2-digit", minute: "2-digit",
hour: "2-digit", hour: "2-digit",
}, },
)} )}
</span> </span>
</div> </div>
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<Link <Link
className="font-medium hover:underline" className="font-medium hover:underline"
href={`/top-ups/${topup.id}`} href={`/top-ups/${topup.id}`}
> >
<Button size={"sm"} variant="outline"> <Button size={"sm"} variant="outline">
View Details View Details
</Button> </Button>
</Link> </Link>
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<span className="font-semibold pr-2"> <span className="font-semibold pr-2">
{topup.paid ? ( {topup.paid ? (
<Badge <Badge
className="bg-green-100 dark:bg-green-700" className="bg-green-100 dark:bg-green-700"
variant="outline" variant="outline"
> >
{topup.status} {topup.status}
</Badge> </Badge>
) : topup.is_expired ? ( ) : topup.is_expired ? (
<Badge>Expired</Badge> <Badge>Expired</Badge>
) : ( ) : (
<Badge variant="outline">{topup.status}</Badge> <Badge variant="outline">{topup.status}</Badge>
)} )}
</span> </span>
</TableCell> </TableCell>
<TableCell> <TableCell>
<span className="font-semibold pr-2"> <span className="font-semibold pr-2">
{topup.amount.toFixed(2)} {topup.amount.toFixed(2)}
</span> </span>
MVR MVR
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
<TableFooter> <TableFooter>
<TableRow> <TableRow>
<TableCell colSpan={4} className="text-muted-foreground"> <TableCell colSpan={4} className="text-muted-foreground">
{meta?.total === 1 ? ( {meta?.total === 1 ? (
<p className="text-center">Total {meta?.total} topup.</p> <p className="text-center">Total {meta?.total} topup.</p>
) : ( ) : (
<p className="text-center">Total {meta?.total} topups.</p> <p className="text-center">Total {meta?.total} topups.</p>
)} )}
</TableCell> </TableCell>
</TableRow> </TableRow>
</TableFooter> </TableFooter>
</Table> </Table>
</div> </div>
<div className="sm:hidden block"> <div className="sm:hidden block">
{data.map((topup) => ( {data.map((topup) => (
<MobileTopupDetails key={topup.id} topup={topup} /> <MobileTopupDetails key={topup.id} topup={topup} />
))} ))}
</div> </div>
<Pagination <Pagination
totalPages={meta?.last_page} totalPages={meta?.last_page}
currentPage={meta?.current_page} currentPage={meta?.current_page}
/> />
</> </>
)} )}
</div> </div>
); );
} }
function MobileTopupDetails({ topup }: { topup: Topup }) { function MobileTopupDetails({ topup }: { topup: Topup }) {
return ( return (
<div <div
className={cn( className={cn(
"flex flex-col items-start border rounded p-2 my-2", "flex flex-col items-start border rounded p-2 my-2 motion-preset-fade-md",
topup?.paid topup?.paid
? "bg-green-500/10 border-dashed border-green=500" ? "bg-green-500/10 border-dashed border-green-500"
: "bg-yellow-500/10 border-dashed border-yellow-500 dark:border-yellow-500/50", : topup?.is_expired
)} ? "bg-gray-500/10 border-dashed border-gray-500 dark:border-gray-500/50"
> : "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"> <div className="flex items-center gap-2">
{new Date(topup.created_at).toLocaleDateString("en-US", { <Calendar size={16} opacity={0.5} />
month: "short", <span className="text-muted-foreground text-sm">
day: "2-digit", {new Date(topup.created_at).toLocaleDateString("en-US", {
year: "numeric", month: "short",
})} day: "2-digit",
</span> year: "numeric",
</div> })}
</span>
</div>
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<Link <Link
className="font-medium hover:underline" className="font-medium hover:underline"
href={`/top-ups/${topup.id}`} href={`/top-ups/${topup.id}`}
> >
<Button size={"sm"} variant="outline"> <Button size={"sm"} variant="outline">
View Details View Details
</Button> </Button>
</Link> </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="bg-white dark:bg-black p-2 rounded mt-2 w-full border flex justify-between items-center">
<div className="block sm:hidden"> <div className="block sm:hidden">
<h3 className="text-sm font-medium">Amount</h3> <h3 className="text-sm font-medium">Amount</h3>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{topup.amount.toFixed(2)} MVR {topup.amount.toFixed(2)} MVR
</span> </span>
</div> </div>
<span className="font-semibold pr-2"> <span className="font-semibold pr-2">
{topup.paid ? ( {topup.paid ? (
<Badge className="bg-green-100 dark:bg-green-700" variant="outline"> <Badge className="bg-green-100 dark:bg-green-700" variant="outline">
{topup.status} {topup.status}
</Badge> </Badge>
) : topup.is_expired ? ( ) : topup.is_expired ? (
<Badge>Expired</Badge> <Badge>Expired</Badge>
) : ( ) : (
<Badge variant="secondary">{topup.status}</Badge> <Badge variant="secondary">{topup.status}</Badge>
)} )}
</span> </span>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,226 +1,226 @@
import { import {
BadgePlus, BadgePlus,
Calculator, Calculator,
ChevronRight, ChevronRight,
Coins, Coins,
CreditCard, CreditCard,
Handshake, Handshake,
MonitorSpeaker, MonitorSpeaker,
Smartphone, Smartphone,
UsersRound, UsersRound,
Wallet2Icon, Wallet2Icon,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/app/auth"; import { authOptions } from "@/app/auth";
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
SidebarGroup, SidebarGroup,
SidebarGroupContent, SidebarGroupContent,
SidebarGroupLabel, SidebarGroupLabel,
SidebarHeader, SidebarHeader,
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
SidebarRail, SidebarRail,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
type Permission = { type Permission = {
id: number; id: number;
name: string; name: string;
}; };
type Categories = { type Categories = {
id: string; id: string;
children: ( children: (
| { | {
title: string; title: string;
link: string; link: string;
perm_identifier: string; perm_identifier: string;
icon: React.JSX.Element; icon: React.JSX.Element;
} }
| { | {
title: string; title: string;
link: string; link: string;
icon: React.JSX.Element; icon: React.JSX.Element;
perm_identifier?: undefined; perm_identifier?: undefined;
} }
)[]; )[];
}[]; }[];
export async function AppSidebar({ export async function AppSidebar({
...props ...props
}: React.ComponentProps<typeof Sidebar>) { }: React.ComponentProps<typeof Sidebar>) {
const categories = [ const categories = [
{ {
id: "MENU", id: "MENU",
url: "#", url: "#",
children: [ children: [
{ {
title: "Devices", title: "Devices",
link: "/devices?page=1", link: "/devices?page=1",
perm_identifier: "device", perm_identifier: "device",
icon: <Smartphone size={16} />, icon: <Smartphone size={16} />,
}, },
{ {
title: "Parental Control", title: "Parental Control",
link: "/parental-control?page=1", link: "/parental-control?page=1",
icon: <CreditCard size={16} />, icon: <CreditCard size={16} />,
perm_identifier: "device", perm_identifier: "device",
}, },
{ {
title: "Subscriptions", title: "Subscriptions",
link: "/payments?page=1", link: "/payments?page=1",
icon: <CreditCard size={16} />, icon: <CreditCard size={16} />,
perm_identifier: "payment", perm_identifier: "payment",
}, },
{ {
title: "Top Ups", title: "Top Ups",
link: "/top-ups?page=1", link: "/top-ups?page=1",
icon: <BadgePlus size={16} />, icon: <BadgePlus size={16} />,
perm_identifier: "topup", perm_identifier: "topup",
}, },
{ {
title: "Transaction History", title: "Transaction History",
link: "/wallet", link: "/wallet",
icon: <Wallet2Icon size={16} />, icon: <Wallet2Icon size={16} />,
perm_identifier: "wallet transaction", perm_identifier: "wallet transaction",
}, },
{ {
title: "Agreements", title: "Agreements",
link: "/agreements", link: "/agreements",
icon: <Handshake size={16} />, icon: <Handshake size={16} />,
perm_identifier: "device", perm_identifier: "device",
}, },
], ],
}, },
{ {
id: "ADMIN CONTROL", id: "ADMIN CONTROL",
url: "#", url: "#",
children: [ children: [
{ {
title: "Users", title: "Users",
link: "/users", link: "/users",
icon: <UsersRound size={16} />, icon: <UsersRound size={16} />,
perm_identifier: "device", perm_identifier: "device",
}, },
{ {
title: "User Devices", title: "User Devices",
link: "/user-devices", link: "/user-devices",
icon: <MonitorSpeaker size={16} />, icon: <MonitorSpeaker size={16} />,
perm_identifier: "device", perm_identifier: "device",
}, },
{ {
title: "User Payments", title: "User Payments",
link: "/user-payments", link: "/user-payments",
icon: <Coins size={16} />, icon: <Coins size={16} />,
perm_identifier: "payment", perm_identifier: "payment",
}, },
{ {
title: "User Topups", title: "User Topups",
link: "/user-topups", link: "/user-topups",
icon: <Coins size={16} />, icon: <Coins size={16} />,
perm_identifier: "topup", perm_identifier: "topup",
}, },
{ {
title: "Price Calculator", title: "Price Calculator",
link: "/price-calculator", link: "/price-calculator",
icon: <Calculator size={16} />, icon: <Calculator size={16} />,
perm_identifier: "device", perm_identifier: "device",
}, },
], ],
}, },
]; ];
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
let CATEGORIES: Categories; let CATEGORIES: Categories;
if (session?.user?.is_admin) { if (session?.user?.is_admin) {
CATEGORIES = categories; CATEGORIES = categories;
} else { } else {
// Filter out ADMIN CONTROL category for non-admin users // Filter out ADMIN CONTROL category for non-admin users
const nonAdminCategories = categories.filter( const nonAdminCategories = categories.filter(
(category) => category.id !== "ADMIN CONTROL", (category) => category.id !== "ADMIN CONTROL",
); );
const filteredCategories = nonAdminCategories.map((category) => { const filteredCategories = nonAdminCategories.map((category) => {
const filteredChildren = category.children.filter((child) => { const filteredChildren = category.children.filter((child) => {
const permIdentifier = child.perm_identifier; const permIdentifier = child.perm_identifier;
return session?.user?.user_permissions?.some( return session?.user?.user_permissions?.some(
(permission: Permission) => { (permission: Permission) => {
const permissionParts = permission.name.split(" "); const permissionParts = permission.name.split(" ");
const modelNameFromPermission = permissionParts.slice(2).join(" "); const modelNameFromPermission = permissionParts.slice(2).join(" ");
return modelNameFromPermission === permIdentifier; return modelNameFromPermission === permIdentifier;
}, },
); );
}); });
return { ...category, children: filteredChildren }; return { ...category, children: filteredChildren };
}); });
CATEGORIES = filteredCategories.filter( CATEGORIES = filteredCategories.filter(
(category) => category.children.length > 0, (category) => category.children.length > 0,
); );
} }
return ( return (
<Sidebar {...props} className="z-50"> <Sidebar {...props} className="z-50">
<SidebarHeader> <SidebarHeader>
<h4 className="p-2 rounded title-bg border text-center uppercase "> <h4 className="p-2 rounded title-bg border text-center uppercase ">
Sar Link Portal Sar Link Portal
</h4> </h4>
</SidebarHeader> </SidebarHeader>
<SidebarContent className="gap-0"> <SidebarContent className="gap-0">
{CATEGORIES.map((item) => { {CATEGORIES.map((item) => {
return ( return (
<Collapsible <Collapsible
key={item.id} key={item.id}
title={item.id} title={item.id}
defaultOpen defaultOpen
className="group/collapsible" className="group/collapsible"
> >
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel <SidebarGroupLabel
asChild asChild
className="group/label text-sm text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground" className="group/label text-sm text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
> >
<CollapsibleTrigger> <CollapsibleTrigger>
{item.id}{" "} {item.id}{" "}
<ChevronRight className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-90" /> <ChevronRight className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-90" />
</CollapsibleTrigger> </CollapsibleTrigger>
</SidebarGroupLabel> </SidebarGroupLabel>
<CollapsibleContent> <CollapsibleContent>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{item.children.map((item) => ( {item.children.map((item) => (
<SidebarMenuItem key={item.title}> <SidebarMenuItem key={item.title}>
<SidebarMenuButton className="py-6" asChild> <SidebarMenuButton className="py-6" asChild>
<Link className="text-md" href={item.link}> <Link className="text-md" href={item.link}>
{item.icon} {item.icon}
<span <span
className={`opacity-70 motion-preset-slide-left-md ml-2`} className={`opacity-70 motion-preset-fade motion-duration-300 ml-2`}
> >
{item.title} {item.title}
</span> </span>
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
))} ))}
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</CollapsibleContent> </CollapsibleContent>
</SidebarGroup> </SidebarGroup>
</Collapsible> </Collapsible>
); );
})} })}
</SidebarContent> </SidebarContent>
<SidebarRail /> <SidebarRail />
</Sidebar> </Sidebar>
); );
} }

View File

@@ -66,7 +66,7 @@ export async function WalletTransactionsTable({
</div> </div>
) : ( ) : (
<div> <div>
<div className="flex gap-4 mb-4 w-full"> <div className="flex gap-4 mb-4 w-full motion-preset-fade-md">
<div className="bg-red-300 ring-4 ring-red-500/20 w-full sm:w-fit dark:bg-red-950 dark:text-red-400 text-red-900 p-2 px-4 rounded-md mb-2"> <div className="bg-red-300 ring-4 ring-red-500/20 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 uppercase font-barlow"> <h5 className="text-lg font-semibold uppercase font-barlow">
Total Debit Total Debit
@@ -95,7 +95,7 @@ export async function WalletTransactionsTable({
{transactions?.data?.map((trx) => ( {transactions?.data?.map((trx) => (
<TableRow <TableRow
className={cn( className={cn(
"items-start border rounded p-2", "items-start border rounded p-2 motion-preset-slide-down-sm",
trx?.transaction_type === "TOPUP" trx?.transaction_type === "TOPUP"
? "credit-bg" ? "credit-bg"
: "debit-bg", : "debit-bg",

View File

@@ -4,36 +4,36 @@ import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
interface WelcomeBannerProps { interface WelcomeBannerProps {
firstName?: string | null; firstName?: string | null;
lastName?: string | null; lastName?: string | null;
} }
export function WelcomeBanner({ firstName, lastName }: WelcomeBannerProps) { export function WelcomeBanner({ firstName, lastName }: WelcomeBannerProps) {
const [isVisible, setIsVisible] = useState(true); const [isVisible, setIsVisible] = useState(true);
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setIsVisible(false); setIsVisible(false);
}, 4000); }, 4000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, []); }, []);
return ( return (
<AnimatePresence> <AnimatePresence>
{isVisible && ( {isVisible && (
<motion.div <motion.div
className="text-sm font-mono px-2 p-1 bg-green-500/10 text-green-900 dark:text-green-400" className="text-sm font-mono px-2 p-1 bg-green-500/10 text-green-900 dark:text-green-400"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
> >
Welcome,{" "} Welcome,{" "}
<p className="font-semibold motion-preset-slide-down inline-block motion-delay-200"> <p className="font-semibold motion-preset-fade inline-block motion-delay-200">
{firstName} {lastName} {firstName} {lastName}
</p> </p>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
); );
} }