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 { authClient } from "@/lib/auth-client";
import React from "react";
import { PaymentsTable } from "@/components/payments-table";
import Search from "@/components/search";
import { Suspense } from "react";
export default function MyPayments() {
const session = authClient.useSession();
export default async function Devices({
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>
<h3>Client session</h3>
<pre>{JSON.stringify(session.data, null, 2)}</pre>
</div>
);
<div
id="user-filters"
className=" border-b-2 pb-4 gap-4 flex sm:flex-row flex-col items-start justify-start"
>
<Search />
</div>
<Suspense key={query} fallback={"loading...."}>
<PaymentsTable searchParams={searchParams} />
</Suspense>
</div>
);
}

View File

@ -1,125 +1,8 @@
"use client";
import {
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,
]);
import PriceCalculator from '@/components/price-calculator'
import React from 'react'
export default function Pricing() {
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>
);
}
// 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>
);
<PriceCalculator />
)
}

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) ? (
<>
Added
Selected
<CheckCheck />
</>
) : (
<>
Add to cart
Select device
<BadgePlus />
</>

View File

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

View File

@ -1,8 +1,7 @@
"use client"
"use client";
import * as React from "react"
import { Button } from "@/components/ui/button"
import { createPayment } from "@/actions/payment";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerClose,
@ -12,98 +11,178 @@ import {
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import { cartDrawerOpenAtom, deviceCartAtom } from "@/lib/atoms"
import type { Device } from "@prisma/client"
import { useAtom, useAtomValue, useSetAtom } from "jotai"
import { CircleDollarSign, ShoppingCart, Trash2 } from "lucide-react"
import Link from "next/link"
import { usePathname } from "next/navigation"
} from "@/components/ui/drawer";
import {
cartDrawerOpenAtom,
deviceCartAtom,
numberOfMonths,
} from "@/lib/atoms";
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()
const devices = useAtomValue(deviceCartAtom)
const setDeviceCart = useSetAtom(deviceCartAtom)
const [isOpen, setIsOpen] = useAtom(cartDrawerOpenAtom)
export function DeviceCartDrawer({
billFormula,
}: {
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") {
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 (
<Drawer open={isOpen} onOpenChange={setIsOpen}>
<DrawerTrigger asChild>
<Button
onClick={() => setIsOpen(!isOpen)}
variant="outline">
<ShoppingCart />
Cart {devices.length > 0 && `(${devices.length})`}
<Button onClick={() => setIsOpen(!isOpen)} variant="outline">
<MonitorSmartphone />
Selected Devices {devices.length > 0 && `(${devices.length})`}
</Button>
</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-sm">
<DrawerHeader>
<DrawerTitle>Cart Devices</DrawerTitle>
<DrawerDescription>Devices in your cart to pay.</DrawerDescription>
<DrawerTitle>Selected Devices</DrawerTitle>
<DrawerDescription>Selected devices pay.</DrawerDescription>
</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) => (
<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>
<Link aria-disabled={devices.length === 0} href={devices.length === 0 ? "#" : "/payment"}>
<Button
onClick={() => {
setIsOpen(!isOpen)
}}
className="w-full" disabled={devices.length === 0}>
Go to payment
<CircleDollarSign />
</Button>
</Link>
<Button
onClick={async () => {
setDisabled(true)
const payment = await createPayment(data)
setDisabled(false)
setDeviceCart([])
setMonths(1)
if (payment) {
router.push(`/payments/${payment.id}`);
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>
<Button variant="outline">Cancel</Button>
</DrawerClose>
<Button
onClick={() => {
setDeviceCart([])
setDeviceCart([]);
}}
variant="outline">Reset Cart</Button>
variant="outline"
>
Reset
</Button>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
)
);
}
function DeviceCard({ device }: { device: Device }) {
const setDeviceCart = useSetAtom(deviceCartAtom)
const setDeviceCart = useSetAtom(deviceCartAtom);
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>
<label htmlFor="input-33" className="block px-3 pt-2 text-xs font-medium text-foreground">
<label
htmlFor="input-33"
className="block px-3 pt-2 text-xs font-medium text-foreground"
>
{device.name}
</label>
<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}
readOnly
disabled
placeholder={"MAC Address"}
/>
</div>
<Button
onClick={() => {
setDeviceCart((prev) => prev.filter((d) => d.id !== device.id))
setDeviceCart((prev) => prev.filter((d) => d.id !== device.id));
}}
variant={"destructive"}>
variant={"destructive"}
>
Remove
<Trash2 />
</Button>
</div>
)
}
);
}

View File

@ -9,6 +9,7 @@ import {
TableRow,
} from "@/components/ui/table";
import prisma from "@/lib/db";
import Link from "next/link";
import AddDevicesToCartButton from "./add-devices-to-cart-button";
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,
@ -92,7 +103,24 @@ export async function DevicesTable({
<TableBody className="overflow-scroll">
{devices.map((device) => (
<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>
<AddDevicesToCartButton device={device} />

View File

@ -1,4 +1,3 @@
'use client'
import {
Table,
TableBody,
@ -7,26 +6,33 @@ import {
TableFooter,
TableRow,
} from "@/components/ui/table"
import { deviceCartAtom } from '@/lib/atoms'
import type { BillFormula } from "@prisma/client"
import { useAtomValue } from 'jotai'
import type { BillFormula, Prisma } from "@prisma/client"
import React from 'react'
export default function DevicesToPay({ billFormula }: { billFormula?: BillFormula }) {
const devices = useAtomValue(deviceCartAtom)
if (devices.length === 0) {
type PaymentWithDevices = Prisma.PaymentGetPayload<{
include: {
devices: true
}
}>
export default function DevicesToPay({ billFormula, payment }: { billFormula?: BillFormula, payment?: PaymentWithDevices }) {
const devices = payment?.devices
if (devices?.length === 0) {
return null
}
const baseAmount = billFormula?.baseAmount ?? 100
const discountPercentage = billFormula?.discountPercentage ?? 75
// 100+(n1)×75
const total = baseAmount + (devices.length - 1) * discountPercentage
const total = baseAmount + (devices?.length ?? 1 - 1) * discountPercentage
return (
<div className='w-full'>
<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">
{devices.map((device) => (
{devices?.map((device) => (
<div key={device.id} className="bg-muted border rounded p-2 flex gap-2 items-center">
<div className="flex flex-col">
<div className="text-sm font-medium">{device.name}</div>
@ -44,7 +50,7 @@ export default function DevicesToPay({ billFormula }: { billFormula?: BillFormul
<TableBody>
<TableRow>
<TableCell>Total Devices</TableCell>
<TableCell className="text-right">{devices.length}</TableCell>
<TableCell className="text-right">{devices?.length}</TableCell>
</TableRow>
</TableBody>
<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 numberOfDevicesAtom = atom(1);
export const numberOfDaysAtom = atom(30);
export const numberOfMonths = atom(1);
export const formulaResultAtom = atom("");
export const deviceCartAtom = atom<Device[]>([]);
export const cartDrawerOpenAtom = atom(false);
@ -18,6 +19,7 @@ export const atoms = {
discountPercentageAtom,
numberOfDevicesAtom,
numberOfDaysAtom,
numberOfMonths,
formulaResultAtom,
deviceCartAtom,
cartDrawerOpenAtom,

View File

@ -12,3 +12,13 @@ export async function AdminAuthGuard() {
}
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();
export const auth = betterAuth({
trustedOrigins: ["http://localhost:3000", "http://192.168.18.194:3000"],
user: {
additionalFields: {
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 {
id String @id @default(cuid())
name String
mac String
isActive Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
User User? @relation(fields: [userId], references: [id])
userId String?
Bill Payment? @relation(fields: [billId], references: [id])
billId String?
id String @id @default(cuid())
name String
mac String
isActive Boolean @default(false)
expiryDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
User User? @relation(fields: [userId], references: [id])
userId String?
payment Payment? @relation(fields: [paymentId], references: [id])
paymentId String?
}
model Payment {
id String @id @default(cuid())
name String
amount Int
paid Boolean @default(false)
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
devices Device[]
userId String
id String @id @default(cuid())
numberOfMonths Int
amount Float
paid Boolean @default(false)
user User @relation(fields: [userId], references: [id])
paidAt DateTime?
expiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
devices Device[]
userId String
}
model BillFormula {