Add payment processing and device management features

- Introduced createPayment action for handling payment creation.
- Added PaymentsTable component for displaying payment records with pagination.
- Implemented new PaymentPage for viewing individual payment details and associated devices.
- Refactored DeviceCartDrawer to integrate payment creation and device selection.
- Enhanced DevicesToPay component to display devices based on payment status.
- Updated PriceCalculator component for better user input handling.
- Introduced NumberInput component for consistent number input across forms.
- Modified Prisma schema to include new fields for payments and devices.
- Improved overall user experience with responsive design adjustments and new UI elements.
This commit is contained in:
i701 2024-12-07 14:09:53 +05:00
parent c6f45710ca
commit e815da495a
22 changed files with 651 additions and 242 deletions

26
actions/payment.ts Normal file
View File

@ -0,0 +1,26 @@
"use server";
import prisma from "@/lib/db";
import type { PaymentType } from "@/lib/types";
import { revalidatePath } from "next/cache";
export async function createPayment(data: PaymentType) {
console.log("hi", data);
const payment = await prisma.payment.create({
data: {
amount: data.amount,
numberOfMonths: data.numberOfMonths,
paid: data.paid,
userId: data.userId,
devices: {
connect: data.deviceIds.map((id) => {
return {
id,
};
}),
},
},
});
revalidatePath("/devices");
return payment;
}

View File

@ -1,24 +0,0 @@
import DevicesToPay from '@/components/devices-to-pay';
import prisma from '@/lib/db';
import React from 'react'
export default async function PaymentPage() {
const formula = await prisma.billFormula.findFirst();
return (
<div>
<div className="flex justify-between items-center border-b-2 text-gray-500 text-2xl font-bold title-bg py-4 px-2 mb-4">
<h3>
Payment
</h3>
</div>
<div
id="user-filters"
className="pb-4 gap-4 flex sm:flex-row flex-col items-start justify-start"
>
<DevicesToPay billFormula={formula ?? undefined} />
</div>
</div>
)
}

View File

@ -0,0 +1,37 @@
import DevicesToPay from "@/components/devices-to-pay";
import { hasSession } from "@/lib/auth-guard";
import prisma from "@/lib/db";
import React from "react";
export default async function PaymentPage({
params,
}: { params: Promise<{ paymentId: string }> }) {
const paymentId = (await params).paymentId;
const payment = await prisma.payment.findUnique({
where: {
id: paymentId,
},
include: {
devices: true,
},
});
await hasSession();
const formula = await prisma.billFormula.findFirst();
return (
<div>
<div className="flex justify-between items-center border-b-2 text-gray-500 text-2xl font-bold title-bg py-4 px-2 mb-4">
<h3>Payment</h3>
</div>
<div
id="user-filters"
className="pb-4 gap-4 flex sm:flex-row flex-col items-start justify-start"
>
<DevicesToPay
billFormula={formula ?? undefined}
payment={payment || undefined}
/>
</div>
</div>
);
}

View File

@ -1,14 +1,33 @@
"use client"; import { PaymentsTable } from "@/components/payments-table";
import { authClient } from "@/lib/auth-client"; import Search from "@/components/search";
import React from "react"; import { Suspense } from "react";
export default function MyPayments() { export default async function Devices({
const session = authClient.useSession(); searchParams,
}: {
searchParams: Promise<{
query: string;
page: number;
sortBy: string;
status: string;
}>;
}) {
const query = (await searchParams)?.query || "";
return (
<div>
<div className="flex justify-between items-center border-b-2 text-gray-500 text-2xl font-bold title-bg py-4 px-2 mb-4">
<h3>My Payments</h3>
</div>
return ( <div
<div> id="user-filters"
<h3>Client session</h3> className=" border-b-2 pb-4 gap-4 flex sm:flex-row flex-col items-start justify-start"
<pre>{JSON.stringify(session.data, null, 2)}</pre> >
</div> <Search />
); </div>
<Suspense key={query} fallback={"loading...."}>
<PaymentsTable searchParams={searchParams} />
</Suspense>
</div>
);
} }

View File

@ -1,125 +1,8 @@
"use client"; import PriceCalculator from '@/components/price-calculator'
import { import React from 'react'
discountPercentageAtom,
formulaResultAtom,
initialPriceAtom,
numberOfDaysAtom,
numberOfDevicesAtom,
} from "@/lib/atoms";
import { useAtom } from "jotai";
import { Minus, Plus } from "lucide-react";
import { useEffect } from "react";
import {
Button,
Group,
Input,
Label,
NumberField,
} from "react-aria-components";
export default function PriceCalculator() {
const [initialPrice, setInitialPrice] = useAtom(initialPriceAtom);
const [discountPercentage, setDiscountPercentage] = useAtom(
discountPercentageAtom,
);
const [numberOfDevices, setNumberOfDevices] = useAtom(numberOfDevicesAtom);
const [numberOfDays, setNumberOfDays] = useAtom(numberOfDaysAtom);
const [formulaResult, setFormulaResult] = useAtom(formulaResultAtom);
useEffect(() => {
const basePrice = initialPrice + (numberOfDevices - 1) * discountPercentage;
setFormulaResult(
`Price for ${numberOfDevices} device(s) over ${numberOfDays} day(s): MVR ${basePrice.toFixed(2)}`,
);
}, [
initialPrice,
discountPercentage,
numberOfDevices,
numberOfDays,
setFormulaResult,
]);
export default function Pricing() {
return ( return (
<div className="border p-2 rounded-xl"> <PriceCalculator />
<div className="flex flex-col justify-between items-start text-gray-500 title-bg p-2 mb-4"> )
<h3 className="text-2xl font-semibold">Price Calculator</h3>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{/* Initial Price Input */}
<NumberInput
label="Initial Price"
value={initialPrice}
onChange={(value) => setInitialPrice(value)}
/>
{/* Number of Devices Input */}
<NumberInput
label="Number of Devices"
value={numberOfDevices}
onChange={(value) => setNumberOfDevices(value)}
/>
{/* Number of Days Input */}
<NumberInput
label="Number of Days"
value={numberOfDays}
onChange={(value) => setNumberOfDays(value)}
/>
{/* Discount Percentage Input */}
<NumberInput
label="Discount Percentage"
value={discountPercentage}
onChange={(value) => setDiscountPercentage(value)}
/>
</div>
<div className="mt-4">
<div className="title-bg relative rounded-lg border border-input shadow-sm shadow-black/5 transition-shadow focus-within:border-ring focus-within:outline-none focus-within:ring-[3px] focus-within:ring-ring/20 has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 [&:has(input:is(:disabled))_*]:pointer-events-none">
<label
htmlFor=""
className="block px-3 pt-2 text-md font-medium text-foreground"
>
Total
</label>
<input
className="flex font-mono font-semibold h-10 w-full bg-transparent px-3 pb-2 text-sm text-foreground placeholder:text-muted-foreground/70 focus-visible:outline-none"
value={formulaResult}
readOnly
placeholder={"Result"}
/>
</div>
</div>
</div>
);
}
// Dependencies: pnpm install lucide-react react-aria-components
function NumberInput({
label,
value,
onChange,
}: { label: string; value: number; onChange: (value: number) => void }) {
return (
<NumberField value={value} minValue={0} onChange={onChange}>
<div className="space-y-2">
<Label className="text-sm font-medium text-foreground">{label}</Label>
<Group className="relative inline-flex h-9 w-full items-center overflow-hidden whitespace-nowrap rounded-lg border border-input text-sm shadow-sm shadow-black/5 transition-shadow data-[focus-within]:border-ring data-[disabled]:opacity-50 data-[focus-within]:outline-none data-[focus-within]:ring-[3px] data-[focus-within]:ring-ring/20">
<Button
slot="decrement"
className="-ms-px flex aspect-square h-[inherit] items-center justify-center rounded-s-lg border border-input bg-background text-sm text-muted-foreground/80 transition-shadow hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
>
<Minus size={16} strokeWidth={2} aria-hidden="true" />
</Button>
<Input className="w-full grow bg-background px-3 py-2 text-center tabular-nums text-foreground focus:outline-none" />
<Button
slot="increment"
className="-me-px flex aspect-square h-[inherit] items-center justify-center rounded-e-lg border border-input bg-background text-sm text-muted-foreground/80 transition-shadow hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
>
<Plus size={16} strokeWidth={2} aria-hidden="true" />
</Button>
</Group>
</div>
</NumberField>
);
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -16,12 +16,12 @@ export default function AddDevicesToCartButton({ device }: { device: Device }) {
> >
{devices.some((d) => d.id === device.id) ? ( {devices.some((d) => d.id === device.id) ? (
<> <>
Added Selected
<CheckCheck /> <CheckCheck />
</> </>
) : ( ) : (
<> <>
Add to cart Select device
<BadgePlus /> <BadgePlus />
</> </>

View File

@ -9,6 +9,7 @@ import {
SidebarTrigger, SidebarTrigger,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import prisma from "@/lib/db";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { AccountPopover } from "./account-popver"; import { AccountPopover } from "./account-popver";
@ -18,6 +19,7 @@ export async function ApplicationLayout({
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: await headers() headers: await headers()
}); });
const billFormula = await prisma.billFormula.findFirst();
return ( return (
<SidebarProvider> <SidebarProvider>
@ -30,7 +32,7 @@ export async function ApplicationLayout({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<DeviceCartDrawer /> <DeviceCartDrawer billFormula={billFormula || null} />
<ModeToggle /> <ModeToggle />
<AccountPopover /> <AccountPopover />
</div> </div>

View File

@ -1,8 +1,7 @@
"use client" "use client";
import * as React from "react" import { createPayment } from "@/actions/payment";
import { Button } from "@/components/ui/button";
import { Button } from "@/components/ui/button"
import { import {
Drawer, Drawer,
DrawerClose, DrawerClose,
@ -12,98 +11,178 @@ import {
DrawerHeader, DrawerHeader,
DrawerTitle, DrawerTitle,
DrawerTrigger, DrawerTrigger,
} from "@/components/ui/drawer" } from "@/components/ui/drawer";
import { cartDrawerOpenAtom, deviceCartAtom } from "@/lib/atoms" import {
import type { Device } from "@prisma/client" cartDrawerOpenAtom,
import { useAtom, useAtomValue, useSetAtom } from "jotai" deviceCartAtom,
import { CircleDollarSign, ShoppingCart, Trash2 } from "lucide-react" numberOfMonths,
import Link from "next/link" } from "@/lib/atoms";
import { usePathname } from "next/navigation" import { authClient } from "@/lib/auth-client";
import type { PaymentType } from "@/lib/types";
import type { BillFormula, Device } from "@prisma/client";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import {
CircleDollarSign,
Loader2,
MonitorSmartphone,
Trash2,
} from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import NumberInput from "./number-input";
export function DeviceCartDrawer() {
const pathname = usePathname() export function DeviceCartDrawer({
const devices = useAtomValue(deviceCartAtom) billFormula,
const setDeviceCart = useSetAtom(deviceCartAtom) }: {
const [isOpen, setIsOpen] = useAtom(cartDrawerOpenAtom) billFormula: BillFormula | null;
}) {
const baseAmount = billFormula?.baseAmount || 100;
const discountPercentage = billFormula?.discountPercentage || 75;
const session = authClient.useSession();
const pathname = usePathname();
const devices = useAtomValue(deviceCartAtom);
const setDeviceCart = useSetAtom(deviceCartAtom);
const [months, setMonths] = useAtom(numberOfMonths);
const [isOpen, setIsOpen] = useAtom(cartDrawerOpenAtom);
const [message, setMessage] = useState("");
const [disabled, setDisabled] = useState(false);
const [total, setTotal] = useState(0);
const router = useRouter();
useEffect(() => {
if (months === 7) {
setMessage("You will get 1 month free.");
} else if (months === 12) {
setMessage("You will get 2 months free.");
} else {
setMessage("");
}
setTotal(baseAmount + (devices.length - 1) * discountPercentage);
}, [months, devices.length, baseAmount, discountPercentage]);
if (pathname === "/payment") { if (pathname === "/payment") {
return null return null;
} }
const data: PaymentType = {
numberOfMonths: months,
userId: session?.data?.user.id ?? "",
deviceIds: devices.map((device) => device.id),
amount: Number.parseFloat(total.toFixed(2)),
paid: false,
};
return ( return (
<Drawer open={isOpen} onOpenChange={setIsOpen}> <Drawer open={isOpen} onOpenChange={setIsOpen}>
<DrawerTrigger asChild> <DrawerTrigger asChild>
<Button <Button onClick={() => setIsOpen(!isOpen)} variant="outline">
onClick={() => setIsOpen(!isOpen)} <MonitorSmartphone />
variant="outline"> Selected Devices {devices.length > 0 && `(${devices.length})`}
<ShoppingCart />
Cart {devices.length > 0 && `(${devices.length})`}
</Button> </Button>
</DrawerTrigger> </DrawerTrigger>
<DrawerContent> <DrawerContent>
<div className="mx-auto w-full max-w-sm"> <div className="mx-auto w-full max-w-sm">
<DrawerHeader> <DrawerHeader>
<DrawerTitle>Cart Devices</DrawerTitle> <DrawerTitle>Selected Devices</DrawerTitle>
<DrawerDescription>Devices in your cart to pay.</DrawerDescription> <DrawerDescription>Selected devices pay.</DrawerDescription>
</DrawerHeader> </DrawerHeader>
<div className="flex max-h-[calc(100svh-200px)] flex-col overflow-auto px-4 pb-4 gap-4"> <div className="flex max-h-[calc(100svh-400px)] flex-col overflow-auto px-4 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="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> <DrawerFooter>
<Link aria-disabled={devices.length === 0} href={devices.length === 0 ? "#" : "/payment"}> <Button
<Button onClick={async () => {
onClick={() => { setDisabled(true)
setIsOpen(!isOpen) const payment = await createPayment(data)
}} setDisabled(false)
className="w-full" disabled={devices.length === 0}> setDeviceCart([])
Go to payment setMonths(1)
<CircleDollarSign /> if (payment) {
</Button> router.push(`/payments/${payment.id}`);
</Link> setIsOpen(!isOpen);
} else {
toast.error("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> <DrawerClose asChild>
<Button variant="outline">Cancel</Button> <Button variant="outline">Cancel</Button>
</DrawerClose> </DrawerClose>
<Button <Button
onClick={() => { onClick={() => {
setDeviceCart([]) setDeviceCart([]);
}} }}
variant="outline">Reset Cart</Button> variant="outline"
>
Reset
</Button>
</DrawerFooter> </DrawerFooter>
</div> </div>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
) );
} }
function DeviceCard({ device }: { device: Device }) { function DeviceCard({ device }: { device: Device }) {
const setDeviceCart = useSetAtom(deviceCartAtom) const setDeviceCart = useSetAtom(deviceCartAtom);
return ( return (
<div className="relative flex h-full w-full items-center pr-4 justify-between rounded-lg border border-input bg-background shadow-sm shadow-black/5 transition-shadow focus-within:border-ring focus-within:outline-none focus-within:ring-[3px] focus-within:ring-ring/20 has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 [&:has(input:is(:disabled))_*]:pointer-events-none"> <div className="relative flex h-full w-full items-center pr-4 justify-between rounded-lg border border-input bg-background shadow-sm shadow-black/5 transition-shadow focus-within:border-ring focus-within:outline-none focus-within:ring-[3px] focus-within:ring-ring/20">
<div> <div>
<label
<label htmlFor="input-33" className="block px-3 pt-2 text-xs font-medium text-foreground"> htmlFor="input-33"
className="block px-3 pt-2 text-xs font-medium text-foreground"
>
{device.name} {device.name}
</label> </label>
<input <input
className="flex h-10 w-full bg-transparent px-3 pb-2 text-sm text-foreground placeholder:text-muted-foreground/70 focus-visible:outline-none" className="flex h-10 opacity-50 w-full bg-transparent px-3 pb-2 text-sm text-foreground placeholder:text-muted-foreground/70 focus-visible:outline-none"
value={device.mac} value={device.mac}
readOnly readOnly
disabled
placeholder={"MAC Address"} placeholder={"MAC Address"}
/> />
</div> </div>
<Button <Button
onClick={() => { onClick={() => {
setDeviceCart((prev) => prev.filter((d) => d.id !== device.id)) setDeviceCart((prev) => prev.filter((d) => d.id !== device.id));
}} }}
variant={"destructive"}> variant={"destructive"}
>
Remove Remove
<Trash2 /> <Trash2 />
</Button> </Button>
</div> </div>
) );
} }

View File

@ -9,6 +9,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import prisma from "@/lib/db"; import prisma from "@/lib/db";
import Link from "next/link";
import AddDevicesToCartButton from "./add-devices-to-cart-button"; import AddDevicesToCartButton from "./add-devices-to-cart-button";
import Pagination from "./pagination"; import Pagination from "./pagination";
@ -40,6 +41,11 @@ export async function DevicesTable({
}, },
}, },
], ],
NOT: {
payment: {
paid: false
}
},
}, },
}); });
@ -63,6 +69,11 @@ export async function DevicesTable({
}, },
}, },
], ],
NOT: {
payment: {
paid: false
}
},
}, },
skip: offset, skip: offset,
@ -92,7 +103,24 @@ export async function DevicesTable({
<TableBody className="overflow-scroll"> <TableBody className="overflow-scroll">
{devices.map((device) => ( {devices.map((device) => (
<TableRow key={device.id}> <TableRow key={device.id}>
<TableCell className="font-medium">{device.name}</TableCell> <TableCell>
<div className="flex flex-col items-start">
<Link
className="font-medium hover:underline"
href={`/devices/${device.id}`}
>
{device.name}
</Link>
<span className="text-muted-foreground">
Active until{" "}
{new Date().toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
})}
</span>
</div>
</TableCell>
<TableCell className="font-medium">{device.mac}</TableCell> <TableCell className="font-medium">{device.mac}</TableCell>
<TableCell> <TableCell>
<AddDevicesToCartButton device={device} /> <AddDevicesToCartButton device={device} />

View File

@ -1,4 +1,3 @@
'use client'
import { import {
Table, Table,
TableBody, TableBody,
@ -7,26 +6,33 @@ import {
TableFooter, TableFooter,
TableRow, TableRow,
} from "@/components/ui/table" } from "@/components/ui/table"
import { deviceCartAtom } from '@/lib/atoms' import type { BillFormula, Prisma } from "@prisma/client"
import type { BillFormula } from "@prisma/client"
import { useAtomValue } from 'jotai'
import React from 'react' import React from 'react'
export default function DevicesToPay({ billFormula }: { billFormula?: BillFormula }) {
const devices = useAtomValue(deviceCartAtom) type PaymentWithDevices = Prisma.PaymentGetPayload<{
if (devices.length === 0) { include: {
devices: true
}
}>
export default function DevicesToPay({ billFormula, payment }: { billFormula?: BillFormula, payment?: PaymentWithDevices }) {
const devices = payment?.devices
if (devices?.length === 0) {
return null return null
} }
const baseAmount = billFormula?.baseAmount ?? 100 const baseAmount = billFormula?.baseAmount ?? 100
const discountPercentage = billFormula?.discountPercentage ?? 75 const discountPercentage = billFormula?.discountPercentage ?? 75
// 100+(n1)×75 // 100+(n1)×75
const total = baseAmount + (devices.length - 1) * discountPercentage const total = baseAmount + (devices?.length ?? 1 - 1) * discountPercentage
return ( return (
<div className='w-full'> <div className='w-full'>
<div className='p-2 flex flex-col gap-2'> <div className='p-2 flex flex-col gap-2'>
<h3 className='title-bg my-1 font-semibold text-lg'>Devices to pay</h3> <h3 className='title-bg my-1 font-semibold text-lg'>
{!payment?.paid ? 'Devices to pay' : 'Devices Paid'}
</h3>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{devices.map((device) => ( {devices?.map((device) => (
<div key={device.id} className="bg-muted border rounded p-2 flex gap-2 items-center"> <div key={device.id} className="bg-muted border rounded p-2 flex gap-2 items-center">
<div className="flex flex-col"> <div className="flex flex-col">
<div className="text-sm font-medium">{device.name}</div> <div className="text-sm font-medium">{device.name}</div>
@ -44,7 +50,7 @@ export default function DevicesToPay({ billFormula }: { billFormula?: BillFormul
<TableBody> <TableBody>
<TableRow> <TableRow>
<TableCell>Total Devices</TableCell> <TableCell>Total Devices</TableCell>
<TableCell className="text-right">{devices.length}</TableCell> <TableCell className="text-right">{devices?.length}</TableCell>
</TableRow> </TableRow>
</TableBody> </TableBody>
<TableFooter> <TableFooter>

View File

@ -0,0 +1,50 @@
import { cn } from "@/lib/utils";
import { Minus, Plus } from "lucide-react";
import { useEffect } from "react";
import {
Button,
Group,
Input,
Label,
NumberField,
} from "react-aria-components";
export default function NumberInput({
maxAllowed,
label,
value,
onChange,
className,
isDisabled,
}: { maxAllowed?: number, label: string; value: number; onChange: (value: number) => void, className?: string, isDisabled?: boolean }) {
useEffect(() => {
if (maxAllowed) {
if (value > maxAllowed) {
onChange(maxAllowed);
}
}
}, [maxAllowed, value, onChange]);
return (
<NumberField isDisabled={isDisabled} className={cn(className)} value={value} minValue={0} onChange={onChange}>
<div className="space-y-2">
<Label className="text-sm font-medium text-foreground">{label}</Label>
<Group className="relative inline-flex h-9 w-full items-center overflow-hidden whitespace-nowrap rounded-lg border border-input text-sm shadow-sm shadow-black/5 transition-shadow data-[focus-within]:border-ring data-[disabled]:opacity-50 data-[focus-within]:outline-none data-[focus-within]:ring-[3px] data-[focus-within]:ring-ring/20">
<Button
slot="decrement"
className="-ms-px flex aspect-square h-[inherit] items-center justify-center rounded-s-lg border border-input bg-background text-sm text-muted-foreground/80 transition-shadow hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
>
<Minus size={16} strokeWidth={2} aria-hidden="true" />
</Button>
<Input className="w-full grow bg-background px-3 py-2 text-center tabular-nums text-foreground focus:outline-none" />
<Button
slot="increment"
className="-me-px flex aspect-square h-[inherit] items-center justify-center rounded-e-lg border border-input bg-background text-sm text-muted-foreground/80 transition-shadow hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
>
<Plus size={16} strokeWidth={2} aria-hidden="true" />
</Button>
</Group>
</div>
</NumberField>
);
}

View File

@ -0,0 +1,166 @@
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import prisma from "@/lib/db";
import Link from "next/link";
import { Calendar } from "lucide-react";
import Pagination from "./pagination";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
export async function PaymentsTable({
searchParams,
}: {
searchParams: Promise<{
query: string;
page: number;
sortBy: string;
}>;
}) {
const query = (await searchParams)?.query || "";
const page = (await searchParams)?.page;
const totalPayments = await prisma.payment.count({
where: {
OR: [
{
devices: {
every: {
name: {
contains: query || "",
mode: "insensitive",
},
},
},
},
],
},
});
const totalPages = Math.ceil(totalPayments / 10);
const limit = 10;
const offset = (Number(page) - 1) * limit || 0;
const payments = await prisma.payment.findMany({
where: {
OR: [
{
devices: {
every: {
name: {
contains: query || "",
mode: "insensitive",
},
},
},
},
],
},
include: {
devices: true
},
skip: offset,
take: limit,
orderBy: {
createdAt: "desc",
},
});
return (
<div>
{payments.length === 0 ? (
<div className="h-[calc(100svh-400px)] flex flex-col items-center justify-center my-4">
<h3>No Payments yet.</h3>
</div>
) : (
<>
<Table className="overflow-scroll">
<TableCaption>Table of all devices.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Details</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody className="overflow-scroll">
{payments.map((payment) => (
<TableRow key={payment.id}>
<TableCell>
<div className="flex flex-col items-start title-bg border rounded p-2">
<div className="flex items-center gap-2">
<Calendar size={16} opacity={0.5} />
<span className="text-muted-foreground">
{new Date(payment.createdAt).toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
})}
</span>
</div>
<div className="flex items-center gap-2 mt-2">
<Link className="font-medium hover:underline" href={`/payments/${payment.id}`}>
<Button size={"sm"} variant="outline">
View Details
</Button>
</Link>
<Badge className="p-2" variant={payment.paid ? "outline" : "secondary"}>
{payment.paid ? "Paid" : "Unpaid"}
</Badge>
</div>
<div className="bg-white dark:bg-black p-2 rounded mt-2 w-full">
<h3 className="text-sm font-medium">Devices</h3>
<ol className="list-disc list-inside text-sm">
{payment.devices.map((device) => (
<li key={device.id} className="text-sm text-muted-foreground">
{device.name}
</li>
))}
</ol>
</div>
</div>
</TableCell>
<TableCell className="font-medium">
{payment.numberOfMonths} Months
</TableCell>
<TableCell>
<span className="font-semibold pr-2">
{payment.amount.toFixed(2)}
</span>
MVR
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={2}>
{query.length > 0 && (
<p className="text-sm text-muted-foreground">
Showing {payments.length} locations for &quot;{query}
&quot;
</p>
)}
</TableCell>
<TableCell className="text-muted-foreground">
{totalPayments} payments
</TableCell>
</TableRow>
</TableFooter>
</Table>
<Pagination totalPages={totalPages} currentPage={page} />
</>
)}
</div>
);
}

View File

@ -0,0 +1,88 @@
"use client";
import {
discountPercentageAtom,
formulaResultAtom,
initialPriceAtom,
numberOfDaysAtom,
numberOfDevicesAtom,
} from "@/lib/atoms";
import { useAtom } from "jotai";
import { useEffect } from "react";
import NumberInput from "./number-input";
export default function PriceCalculator() {
const [initialPrice, setInitialPrice] = useAtom(initialPriceAtom);
const [discountPercentage, setDiscountPercentage] = useAtom(
discountPercentageAtom,
);
const [numberOfDevices, setNumberOfDevices] = useAtom(numberOfDevicesAtom);
const [numberOfDays, setNumberOfDays] = useAtom(numberOfDaysAtom);
const [formulaResult, setFormulaResult] = useAtom(formulaResultAtom);
useEffect(() => {
const basePrice = initialPrice + (numberOfDevices - 1) * discountPercentage;
setFormulaResult(
`Price for ${numberOfDevices} device(s) over ${numberOfDays} day(s): MVR ${basePrice.toFixed(2)}`,
);
}, [
initialPrice,
discountPercentage,
numberOfDevices,
numberOfDays,
setFormulaResult,
]);
return (
<div className="border p-2 rounded-xl">
<div className="flex flex-col justify-between items-start text-gray-500 title-bg p-2 mb-4">
<h3 className="text-2xl font-semibold">Price Calculator</h3>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{/* Initial Price Input */}
<NumberInput
label="Initial Price"
value={initialPrice}
onChange={(value) => setInitialPrice(value)}
/>
{/* Number of Devices Input */}
<NumberInput
label="Number of Devices"
value={numberOfDevices}
onChange={(value) => setNumberOfDevices(value)}
/>
{/* Number of Days Input */}
<NumberInput
label="Number of Days"
value={numberOfDays}
onChange={(value) => setNumberOfDays(value)}
/>
{/* Discount Percentage Input */}
<NumberInput
label="Discount Percentage"
value={discountPercentage}
onChange={(value) => setDiscountPercentage(value)}
/>
</div>
<div className="mt-4">
<div className="title-bg relative rounded-lg border border-input shadow-sm shadow-black/5 transition-shadow focus-within:border-ring focus-within:outline-none focus-within:ring-[3px] focus-within:ring-ring/20 has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 [&:has(input:is(:disabled))_*]:pointer-events-none">
<label
htmlFor=""
className="block px-3 pt-2 text-md font-medium text-foreground"
>
Total
</label>
<input
className="flex font-mono font-semibold h-10 w-full bg-transparent px-3 pb-2 text-sm text-foreground placeholder:text-muted-foreground/70 focus-visible:outline-none"
value={formulaResult}
readOnly
placeholder={"Result"}
/>
</div>
</div>
</div>
);
}

View File

@ -9,6 +9,7 @@ export const initialPriceAtom = atom(100);
export const discountPercentageAtom = atom(75); export const discountPercentageAtom = atom(75);
export const numberOfDevicesAtom = atom(1); export const numberOfDevicesAtom = atom(1);
export const numberOfDaysAtom = atom(30); export const numberOfDaysAtom = atom(30);
export const numberOfMonths = atom(1);
export const formulaResultAtom = atom(""); export const formulaResultAtom = atom("");
export const deviceCartAtom = atom<Device[]>([]); export const deviceCartAtom = atom<Device[]>([]);
export const cartDrawerOpenAtom = atom(false); export const cartDrawerOpenAtom = atom(false);
@ -18,6 +19,7 @@ export const atoms = {
discountPercentageAtom, discountPercentageAtom,
numberOfDevicesAtom, numberOfDevicesAtom,
numberOfDaysAtom, numberOfDaysAtom,
numberOfMonths,
formulaResultAtom, formulaResultAtom,
deviceCartAtom, deviceCartAtom,
cartDrawerOpenAtom, cartDrawerOpenAtom,

View File

@ -12,3 +12,13 @@ export async function AdminAuthGuard() {
} }
return true; return true;
} }
export async function hasSession() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return redirect("/login");
}
return true;
}

View File

@ -6,6 +6,7 @@ import { phoneNumber } from "better-auth/plugins";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
export const auth = betterAuth({ export const auth = betterAuth({
trustedOrigins: ["http://localhost:3000", "http://192.168.18.194:3000"],
user: { user: {
additionalFields: { additionalFields: {
role: { role: {

7
lib/types.ts Normal file
View File

@ -0,0 +1,7 @@
export type PaymentType = {
numberOfMonths: number;
userId: string;
deviceIds: string[];
amount: number;
paid: boolean;
};

View File

@ -0,0 +1,23 @@
/*
Warnings:
- You are about to drop the column `billId` on the `Device` table. All the data in the column will be lost.
- You are about to drop the column `name` on the `Payment` table. All the data in the column will be lost.
- Added the required column `numberOfMonths` to the `Payment` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "Device" DROP CONSTRAINT "Device_billId_fkey";
-- AlterTable
ALTER TABLE "Device" DROP COLUMN "billId",
ADD COLUMN "expiryDate" TIMESTAMP(3),
ADD COLUMN "paymentId" TEXT;
-- AlterTable
ALTER TABLE "Payment" DROP COLUMN "name",
ADD COLUMN "numberOfMonths" INTEGER NOT NULL,
ALTER COLUMN "amount" SET DATA TYPE DOUBLE PRECISION;
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_paymentId_fkey" FOREIGN KEY ("paymentId") REFERENCES "Payment"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Payment" ADD COLUMN "paidAt" TIMESTAMP(3);

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Payment" ADD COLUMN "expiresAt" TIMESTAMP(3);

View File

@ -109,29 +109,31 @@ model Island {
} }
model Device { model Device {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
mac String mac String
isActive Boolean @default(false) isActive Boolean @default(false)
createdAt DateTime @default(now()) expiryDate DateTime?
updatedAt DateTime @updatedAt createdAt DateTime @default(now())
User User? @relation(fields: [userId], references: [id]) updatedAt DateTime @updatedAt
userId String? User User? @relation(fields: [userId], references: [id])
Bill Payment? @relation(fields: [billId], references: [id]) userId String?
billId String? payment Payment? @relation(fields: [paymentId], references: [id])
paymentId String?
} }
model Payment { model Payment {
id String @id @default(cuid()) id String @id @default(cuid())
name String numberOfMonths Int
amount Int amount Float
paid Boolean @default(false) paid Boolean @default(false)
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
paidAt DateTime?
createdAt DateTime @default(now()) expiresAt DateTime?
updatedAt DateTime @updatedAt createdAt DateTime @default(now())
devices Device[] updatedAt DateTime @updatedAt
userId String devices Device[]
userId String
} }
model BillFormula { model BillFormula {