diff --git a/actions/payment.ts b/actions/payment.ts new file mode 100644 index 0000000..e1f75f9 --- /dev/null +++ b/actions/payment.ts @@ -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; +} diff --git a/app/(dashboard)/payment/page.tsx b/app/(dashboard)/payment/page.tsx deleted file mode 100644 index 9792ebe..0000000 --- a/app/(dashboard)/payment/page.tsx +++ /dev/null @@ -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 ( -
-
-

- Payment -

-
- -
- - -
-
- ) -} diff --git a/app/(dashboard)/payments/[paymentId]/page.tsx b/app/(dashboard)/payments/[paymentId]/page.tsx new file mode 100644 index 0000000..40ce820 --- /dev/null +++ b/app/(dashboard)/payments/[paymentId]/page.tsx @@ -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 ( +
+
+

Payment

+
+ +
+ +
+
+ ); +} diff --git a/app/(dashboard)/payments/page.tsx b/app/(dashboard)/payments/page.tsx index 0d3e249..ec73203 100644 --- a/app/(dashboard)/payments/page.tsx +++ b/app/(dashboard)/payments/page.tsx @@ -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 ( +
+
+

My Payments

+
- return ( -
-

Client session

-
{JSON.stringify(session.data, null, 2)}
-
- ); +
+ +
+ + + +
+ ); } diff --git a/app/(dashboard)/price-calculator/page.tsx b/app/(dashboard)/price-calculator/page.tsx index 3218101..8a4d414 100644 --- a/app/(dashboard)/price-calculator/page.tsx +++ b/app/(dashboard)/price-calculator/page.tsx @@ -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 ( -
-
-

Price Calculator

-
-
- {/* Initial Price Input */} - setInitialPrice(value)} - /> - {/* Number of Devices Input */} - setNumberOfDevices(value)} - /> - {/* Number of Days Input */} - setNumberOfDays(value)} - /> - - {/* Discount Percentage Input */} - setDiscountPercentage(value)} - /> -
- -
-
- - -
-
-
- ); -} - -// Dependencies: pnpm install lucide-react react-aria-components - -function NumberInput({ - label, - value, - onChange, -}: { label: string; value: number; onChange: (value: number) => void }) { - return ( - -
- - - - - - -
-
- ); + + ) } diff --git a/app/favicon.ico b/app/favicon.ico index 45767d4..86f7217 100644 Binary files a/app/favicon.ico and b/app/favicon.ico differ diff --git a/components/add-devices-to-cart-button.tsx b/components/add-devices-to-cart-button.tsx index d269372..eccd295 100644 --- a/components/add-devices-to-cart-button.tsx +++ b/components/add-devices-to-cart-button.tsx @@ -16,12 +16,12 @@ export default function AddDevicesToCartButton({ device }: { device: Device }) { > {devices.some((d) => d.id === device.id) ? ( <> - Added + Selected ) : ( <> - Add to cart + Select device diff --git a/components/auth/application-layout.tsx b/components/auth/application-layout.tsx index ff648f2..147a255 100644 --- a/components/auth/application-layout.tsx +++ b/components/auth/application-layout.tsx @@ -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 ( @@ -30,7 +32,7 @@ export async function ApplicationLayout({
- +
diff --git a/components/device-cart.tsx b/components/device-cart.tsx index 4d05475..5249bea 100644 --- a/components/device-cart.tsx +++ b/components/device-cart.tsx @@ -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 ( -
- Cart Devices - Devices in your cart to pay. + Selected Devices + Selected devices pay. -
+
{devices.map((device) => ( ))}
+
+ setMonths(value)} + maxAllowed={12} + isDisabled={devices.length === 0} + /> + {message && ( + + {message} + + )} +
- - - + - - + variant="outline" + > + Reset +
- ) + ); } - function DeviceCard({ device }: { device: Device }) { - const setDeviceCart = useSetAtom(deviceCartAtom) + const setDeviceCart = useSetAtom(deviceCartAtom); return ( -
+
- -
- ) -} \ No newline at end of file + ); +} diff --git a/components/devices-table.tsx b/components/devices-table.tsx index 9b8ffb6..6cbdc79 100644 --- a/components/devices-table.tsx +++ b/components/devices-table.tsx @@ -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({ {devices.map((device) => ( - {device.name} + +
+ + {device.name} + + + Active until{" "} + {new Date().toLocaleDateString("en-US", { + month: "short", + day: "2-digit", + year: "numeric", + })} + +
+
{device.mac} diff --git a/components/devices-to-pay.tsx b/components/devices-to-pay.tsx index 2f206db..6d1227b 100644 --- a/components/devices-to-pay.tsx +++ b/components/devices-to-pay.tsx @@ -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+(n−1)×75 - const total = baseAmount + (devices.length - 1) * discountPercentage + const total = baseAmount + (devices?.length ?? 1 - 1) * discountPercentage return (
-

Devices to pay

+

+ {!payment?.paid ? 'Devices to pay' : 'Devices Paid'} +

- {devices.map((device) => ( + {devices?.map((device) => (
{device.name}
@@ -44,7 +50,7 @@ export default function DevicesToPay({ billFormula }: { billFormula?: BillFormul Total Devices - {devices.length} + {devices?.length} diff --git a/components/number-input.tsx b/components/number-input.tsx new file mode 100644 index 0000000..e75099e --- /dev/null +++ b/components/number-input.tsx @@ -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 ( + +
+ + + + + + +
+
+ ); +} diff --git a/components/payments-table.tsx b/components/payments-table.tsx new file mode 100644 index 0000000..0c133d8 --- /dev/null +++ b/components/payments-table.tsx @@ -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 ( +
+ {payments.length === 0 ? ( +
+

No Payments yet.

+
+ ) : ( + <> + + Table of all devices. + + + Details + Duration + + Amount + + + + {payments.map((payment) => ( + + +
+
+ + + {new Date(payment.createdAt).toLocaleDateString("en-US", { + month: "short", + day: "2-digit", + year: "numeric", + })} + +
+ +
+ + + + + {payment.paid ? "Paid" : "Unpaid"} + +
+
+

Devices

+
    + {payment.devices.map((device) => ( +
  1. + {device.name} +
  2. + ))} +
+
+
+
+ + {payment.numberOfMonths} Months + + + + {payment.amount.toFixed(2)} + + MVR + +
+ ))} +
+ + + + {query.length > 0 && ( +

+ Showing {payments.length} locations for "{query} + " +

+ )} +
+ + {totalPayments} payments + +
+
+
+ + + )} +
+ ); +} diff --git a/components/price-calculator.tsx b/components/price-calculator.tsx new file mode 100644 index 0000000..668028f --- /dev/null +++ b/components/price-calculator.tsx @@ -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 ( +
+
+

Price Calculator

+
+
+ {/* Initial Price Input */} + setInitialPrice(value)} + /> + {/* Number of Devices Input */} + setNumberOfDevices(value)} + /> + {/* Number of Days Input */} + setNumberOfDays(value)} + /> + + {/* Discount Percentage Input */} + setDiscountPercentage(value)} + /> +
+ +
+
+ + +
+
+
+ ); +} + diff --git a/lib/atoms.ts b/lib/atoms.ts index 19a4b66..620d532 100644 --- a/lib/atoms.ts +++ b/lib/atoms.ts @@ -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([]); export const cartDrawerOpenAtom = atom(false); @@ -18,6 +19,7 @@ export const atoms = { discountPercentageAtom, numberOfDevicesAtom, numberOfDaysAtom, + numberOfMonths, formulaResultAtom, deviceCartAtom, cartDrawerOpenAtom, diff --git a/lib/auth-guard.ts b/lib/auth-guard.ts index 720e6aa..3dcfbdc 100644 --- a/lib/auth-guard.ts +++ b/lib/auth-guard.ts @@ -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; +} diff --git a/lib/auth.ts b/lib/auth.ts index c0a31e6..07c2772 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -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: { diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 0000000..053fdea --- /dev/null +++ b/lib/types.ts @@ -0,0 +1,7 @@ +export type PaymentType = { + numberOfMonths: number; + userId: string; + deviceIds: string[]; + amount: number; + paid: boolean; +}; diff --git a/prisma/migrations/20241207032927_add/migration.sql b/prisma/migrations/20241207032927_add/migration.sql new file mode 100644 index 0000000..4f5ab2a --- /dev/null +++ b/prisma/migrations/20241207032927_add/migration.sql @@ -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; diff --git a/prisma/migrations/20241207051101_add/migration.sql b/prisma/migrations/20241207051101_add/migration.sql new file mode 100644 index 0000000..ea7fcd2 --- /dev/null +++ b/prisma/migrations/20241207051101_add/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Payment" ADD COLUMN "paidAt" TIMESTAMP(3); diff --git a/prisma/migrations/20241207051242_add/migration.sql b/prisma/migrations/20241207051242_add/migration.sql new file mode 100644 index 0000000..496f8b4 --- /dev/null +++ b/prisma/migrations/20241207051242_add/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Payment" ADD COLUMN "expiresAt" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e598a7e..6b731de 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 {