🔧 Build Nextjs 15 & React 19 Dashboard App Step By Step
Nachrichtenbereich: 🔧 Programmierung
🔗 Quelle: dev.to
Hello and welcome to my coding course to build a full-fledged admin dashboard by the best tech stack in the world: Nextjs 15, React 19, Drizzle Orm, and Shadcn UI.
👉 Code : https://github.com/basir/next-15-admin-dashboard
👉 Demo : https://next-15-admin-dashboard.vercel.app
👉 Q/A : https://github.com/basir/next-15-admin-dashboard/issues
Watch Nextjs 15 & React 19 Dashboard App Step By Step Tutorial
This admin dashboard is the updated version of acme project on https://nextjs.org/learn
Here I walk you though all steps to build a real-world admin dashboard from scratch.
- we will develop a responsive homepage that follows the best design practices we have. A header with hero section and call to action button to login.
- A dashboard screen with sidebar navigation on desktop and header menu on mobile device.
- We'll create stat boxes, bar charts, data tables on dashboard page.
- invoice management from where you can filter, create, update and delete invoices.
- also we'll create customers page where you can filter users based on their name and email.
My name is Basir and I’ll be your instructor in this course. I am a senior web developer in international companies like ROI Vision in Montreal, and a coding instructor with 50 thousands students around the world.
You need to open the code editor along with me and start coding throughout this course.
I teach you:
- creating admin dashboard web app by next.js 15 and react 19
- designing header, footer, sidebar, menu and search box by shadcn and tailwind
- enable partial pre-rendering to improve website performance
- create database models by drizzle orm and postgres database to handle invoices, customers and users.
- handling form inputs by useActionState and Zod data validator
- updating data by server actions without using any api
- rendering beautiful charts by recharts
- handling authentication and authorization by next-auth
- and toggling dark and light theme by next-theme
- at the end you'll learn how to deploy admin dashboard on vercel.
I designed this course for beginner web developers who want to learn all new features of next 15 and react 19 features in a real-world project. If you are or want to a web developer, take this course to become a professional web developer, have a great project in your portfolio and get a job in 22 million job opportunities around the world.
The only requirement for this course is having basic knowledge on react and next.js.
01. create next app
- npm install -g pnpm
- pnpm create next-app@rc
- pnpm dev
- lib/constants.ts
export const SERVER_URL =
process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3000'
export const APP_NAME = process.env.NEXT_PUBLIC_APP_NAME || 'NextAdmin'
export const APP_DESCRIPTION =
process.env.NEXT_PUBLIC_APP_DESCRIPTION ||
'An modern dashboard built with Next.js 15, Postgres, Shadcn'
export const ITEMS_PER_PAGE = Number(process.env.ITEMS_PER_PAGE) || 5
- components/shared/fonts.ts
import { Inter, Lusitana } from 'next/font/google'
export const inter = Inter({ subsets: ['latin'] })
export const lusitana = Lusitana({
weight: ['400', '700'],
subsets: ['latin'],
})
- app/layout.tsx
export const metadata: Metadata = {
title: {
template: `%s | ${APP_NAME}`,
default: APP_NAME,
},
description: APP_DESCRIPTION,
metadataBase: new URL(SERVER_URL),
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" suppressHydrationWarning>
<body className={`${inter.className} antialiased`}>{children}</body>
</html>
)
}
- components/shared/app-logo.tsx
export default function AppLogo() {
return (
<Link href="/" className="flex-start">
<div
className={`${lusitana.className} flex flex-row items-end space-x-2`}
>
<Image
src="/logo.png"
width={32}
height={32}
alt={`${APP_NAME} logo`}
priority
/>
<span className="text-xl">{APP_NAME}</span>
</div>
</Link>
)
}
- app/page.tsx
export default function Page() {
return (
<main className="flex min-h-screen flex-col ">
<div className="flex h-20 shrink-0 items-center rounded-lg p-4 md:h-40 bg-secondary">
<AppLogo />
</div>
<div className="mt-4 flex grow flex-col gap-4 md:flex-row">
<div className="flex flex-col justify-center gap-6 rounded-lg px-6 py-10 md:w-2/5 md:px-20">
<p
className={`${lusitana.className} text-xl md:text-3xl md:leading-normal`}
>
<strong>Welcome to Next 15 Admin Dashboard.</strong>
</p>
<Link href="/login">
<span>Log in</span> <ArrowRightIcon className="w-6" />
</Link>
</div>
<div className="flex items-center justify-center p-6 md:w-3/5 md:px-28 md:py-12">
<Image
src="/hero-desktop.png"
width={1000}
height={760}
alt="Screenshots of the dashboard project showing desktop version"
className="hidden md:block"
/>
<Image
src="/hero-mobile.png"
width={560}
height={620}
alt="Screenshot of the dashboard project showing mobile version"
className="block md:hidden"
/>
</div>
</div>
</main>
)
}
02. create login page
- pnpm add next-auth@beta bcryptjs
- pnpm add -D @types/bcryptjs
-
lib/placeholder-data.ts
const users = [ { id: '410544b2-4001-4271-9855-fec4b6a6442a', name: 'User', email: '[email protected]', password: hashSync('123456', 10), }, ] export { users }
-
auth.config.ts
import type { NextAuthConfig } from 'next-auth' export const authConfig = { pages: { signIn: '/login', }, providers: [ // added later in auth.ts since it requires bcrypt which is only compatible with Node.js // while this file is also used in non-Node.js environments ], callbacks: { authorized({ auth, request: { nextUrl } }) { const isLoggedIn = !!auth?.user const isOnDashboard = nextUrl.pathname.startsWith('/dashboard') if (isOnDashboard) { if (isLoggedIn) return true return false // Redirect unauthenticated users to login page } else if (isLoggedIn) { return Response.redirect(new URL('/dashboard', nextUrl)) } return true }, }, } satisfies NextAuthConfig
-
auth.ts
export const { auth, signIn, signOut } = NextAuth({ ...authConfig, providers: [ credentials({ async authorize(credentials) { const user = users.find((x) => x.email === credentials.email) if (!user) return null const passwordsMatch = await compare( credentials.password as string, user.password ) if (passwordsMatch) return user console.log('Invalid credentials') return null }, }), ], })
-
middleware.ts
import NextAuth from 'next-auth' import { authConfig } from './auth.config' export default NextAuth(authConfig).auth export const config = { // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher matcher: [ '/((?!api|_next/static|_next/image|.*\\.svg$|.*\\.png$|.*\\.jpeg$).*)', ], }
-
lib/actions/user.actions.ts
'use server' export async function authenticate( prevState: string | undefined, formData: FormData ) { try { await signIn('credentials', formData) } catch (error) { if (error instanceof AuthError) { switch (error.type) { case 'CredentialsSignin': return 'Invalid credentials.' default: return 'Something went wrong.' } } throw error } }
install shadcn-ui from https://ui.shadcn.com/docs/installation/next
pnpm dlx shadcn-ui@latest add button card
-
components/shared/login-form.tsx
export default function LoginForm() { const [errorMessage, formAction, isPending] = useActionState( authenticate, undefined ) return ( <form action={formAction}> <div className="flex-1 rounded-lg px-6 pb-4 pt-8"> <h1 className={`${lusitana.className} mb-3 text-2xl`}> Please log in to continue. </h1> <div className="w-full"> <div> <label className="mb-3 mt-5 block text-xs font-medium " htmlFor="email" > Email </label> <div className="relative"> <input className="peer block w-full rounded-md border py-[9px] pl-10 text-sm outline-2 " id="email" type="email" name="email" placeholder="Enter your email address" required /> <AtSign className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 " /> </div> </div> <div className="mt-4"> <label className="mb-3 mt-5 block text-xs font-medium " htmlFor="password" > Password </label> <div className="relative"> <input className="peer block w-full rounded-md border py-[9px] pl-10 text-sm outline-2 " id="password" type="password" name="password" placeholder="Enter password" required minLength={6} /> <LockKeyhole className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 " /> </div> </div> </div> <div className="mt-4"> <Button aria-disabled={isPending}> Log in <ArrowRightIcon className="ml-auto h-5 w-5 " /> </Button> </div> <div className="flex h-8 items-end space-x-1" aria-live="polite" aria-atomic="true" > {errorMessage && ( <> <CircleAlert className="h-5 w-5 text-red-500" /> <p className="text-sm text-red-500">{errorMessage}</p> </> )} </div> </div> </form> ) }
app/login/page.tsx
export default function LoginPage() {
return (
<div className="flex justify-center items-center min-h-screen w-full ">
<main className="w-full max-w-md mx-auto">
<Card>
<CardHeader className="space-y-4 flex justify-center items-center">
<AppLogo />
</CardHeader>
<CardContent className="space-y-4">
<LoginForm />
</CardContent>
</Card>
</main>
</div>
)
}
03. create dashboard page
- pnpm dlx shadcn-ui@latest add dropdown-menu
- pnpm add next-themes
- app/layout.tsx
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
- components/shared/dashboard/mode-toggle.tsx
export default function ModeToggle() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = React.useState(false)
React.useEffect(() => {
setMounted(true)
}, [])
if (!mounted) {
return null
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="w-full text-muted-foreground justify-start focus-visible:ring-0 focus-visible:ring-offset-0"
>
<SunMoon className="w-6 mr-2" />
<span className="hidden md:block">
{capitalizeFirstLetter(theme!)} Theme
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>Appearance</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={theme === 'system'}
onClick={() => setTheme('system')}
>
System
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={theme === 'light'}
onClick={() => setTheme('light')}
>
Light
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={theme === 'dark'}
onClick={() => setTheme('dark')}
>
Dark
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
- components/shared/dashboard/sidenav.tsx
export default function SideNav() {
return (
<div className="flex h-full flex-col px-3 py-4 md:px-2">
<div>
<AppLogo />
</div>
<div className="flex grow flex-row space-x-2 md:flex-col md:space-x-0 md:space-y-2 md:mt-2">
<NavLinks />
<div className="h-auto w-full grow rounded-md md:block"></div>
<div className="flex md:flex-col ">
<ModeToggle />
<form
action={async () => {
'use server'
await signOut()
}}
>
<Button
variant="ghost"
className="w-full justify-start text-muted-foreground"
>
<PowerIcon className="w-6 mr-2" />
<div className="hidden md:block">Sign Out</div>
</Button>
</form>
</div>
</div>
</div>
)
}
- app/dashboard/layout.tsx
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen flex-col md:flex-row md:overflow-hidden">
<div className="w-full flex-none md:w-52 bg-secondary">
<SideNav />
</div>
<div className="grow p-6 md:overflow-y-auto ">{children}</div>
</div>
)
}
- pnpm dlx shadcn-ui@latest add skeleton
- components/shared/skeletons.tsx
export function CardSkeleton() {
return (
<Card>
<CardHeader className="flex flex-row space-y-0 space-x-3 ">
<Skeleton className="w-6 h-6 rounded-full" />
<Skeleton className="w-20 h-6" />
</CardHeader>
<CardContent>
<Skeleton className="h-10 w-full" />
</CardContent>
</Card>
)
}
export function CardsSkeleton() {
return (
<>
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</>
)
}
export function RevenueChartSkeleton() {
return (
<Card className="w-full md:col-span-4">
<CardHeader>
<Skeleton className="w-36 h-6 mb-4" />
</CardHeader>
<CardContent>
<Skeleton className="sm:grid-cols-13 mt-0 grid h-[450px] grid-cols-12 items-end gap-2 rounded-md p-4 md:gap-4" />
</CardContent>
</Card>
)
}
export function InvoiceSkeleton() {
return (
<div className="flex flex-row items-center justify-between border-b py-4">
<div className="flex items-center space-x-4">
<Skeleton className="w-6 h-6 rounded-full" />
<div className="min-w-0 space-y-2">
<Skeleton className="w-20 h-6" />
<Skeleton className="w-20 h-6" />
</div>
</div>
<Skeleton className="w-20 h-6" />
</div>
)
}
export function LatestInvoicesSkeleton() {
return (
<Card className="flex w-full flex-col md:col-span-4">
<CardHeader>
<Skeleton className="w-36 h-6 mb-4" />
</CardHeader>
<CardContent>
<div>
<InvoiceSkeleton />
<InvoiceSkeleton />
<InvoiceSkeleton />
<InvoiceSkeleton />
<InvoiceSkeleton />
</div>
</CardContent>
</Card>
)
}
export default function DashboardSkeleton() {
return (
<>
<Skeleton className="w-36 h-6 mb-4" />
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</div>
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
<RevenueChartSkeleton />
<LatestInvoicesSkeleton />
</div>
</>
)
}
- app/dashboard/(overview)/page.tsx
export default async function Page() {
return (
<main>
<h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
Dashboard
</h1>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<CardsSkeleton />
</div>
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
<RevenueChartSkeleton />
<LatestInvoicesSkeleton />
</div>
</main>
)
}
- dd
import DashboardSkeleton from '@/components/shared/skeletons'
export default function Loading() {
return <DashboardSkeleton />
}
04. connect to database
- create postgres database on https://vercel.com/storage/postgres
- pnpm add drizzle-orm @vercel/postgres
- pnpm add -D drizzle-kit
- db/env-config.ts
import { loadEnvConfig } from '@next/env'
const projectDir = process.cwd()
loadEnvConfig(projectDir)
- db/schema.ts
import {
pgTable,
uuid,
varchar,
unique,
integer,
text,
date,
} from 'drizzle-orm/pg-core'
import { sql } from 'drizzle-orm'
export const customers = pgTable('customers', {
id: uuid('id')
.default(sql`uuid_generate_v4()`)
.primaryKey()
.notNull(),
name: varchar('name', { length: 255 }).notNull(),
email: varchar('email', { length: 255 }).notNull(),
image_url: varchar('image_url', { length: 255 }).notNull(),
})
export const revenue = pgTable(
'revenue',
{
month: varchar('month', { length: 4 }).notNull(),
revenue: integer('revenue').notNull(),
},
(table) => {
return {
revenue_month_key: unique('revenue_month_key').on(table.month),
}
}
)
export const users = pgTable(
'users',
{
id: uuid('id')
.default(sql`uuid_generate_v4()`)
.primaryKey()
.notNull(),
name: varchar('name', { length: 255 }).notNull(),
email: text('email').notNull(),
password: text('password').notNull(),
},
(table) => {
return {
users_email_key: unique('users_email_key').on(table.email),
}
}
)
export const invoices = pgTable('invoices', {
id: uuid('id')
.default(sql`uuid_generate_v4()`)
.primaryKey()
.notNull(),
customer_id: uuid('customer_id').notNull(),
amount: integer('amount').notNull(),
status: varchar('status', { length: 255 }).notNull(),
date: date('date').notNull(),
})
- db/drizzle.ts
import * as schema from './schema'
import { drizzle } from 'drizzle-orm/vercel-postgres'
import { sql } from '@vercel/postgres'
const db = drizzle(sql, {
schema,
})
export default db
- drizzle.config.ts
import '@/db/env-config'
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
schema: './db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.POSTGRES_URL!,
},
})
- lib/placeholder-data.ts
const customers = [
{
id: 'd6e15727-9fe1-4961-8c5b-ea44a9bd81aa',
name: 'Amari Hart',
email: '[email protected]',
image_url: '/customers/a1.jpeg',
},
{
id: '3958dc9e-712f-4377-85e9-fec4b6a6442a',
name: 'Alexandria Brown',
email: '[email protected]',
image_url: '/customers/a2.jpeg',
},
{
id: '3958dc9e-742f-4377-85e9-fec4b6a6442a',
name: 'Emery Cabrera',
email: '[email protected]',
image_url: '/customers/a3.jpeg',
},
{
id: '76d65c26-f784-44a2-ac19-586678f7c2f2',
name: 'Michael Novotny',
email: '[email protected]',
image_url: '/customers/a4.jpeg',
},
{
id: 'CC27C14A-0ACF-4F4A-A6C9-D45682C144B9',
name: 'Lily Conrad',
email: '[email protected]',
image_url: '/customers/a5.jpeg',
},
{
id: '13D07535-C59E-4157-A011-F8D2EF4E0CBB',
name: 'Ricky Mata',
email: '[email protected]',
image_url: '/customers/a6.jpeg',
},
]
const invoices = [
{
customer_id: customers[0].id,
amount: 15795,
status: 'pending',
date: '2022-12-06',
},
{
customer_id: customers[1].id,
amount: 20348,
status: 'pending',
date: '2022-11-14',
},
{
customer_id: customers[4].id,
amount: 3040,
status: 'paid',
date: '2022-10-29',
},
{
customer_id: customers[3].id,
amount: 44800,
status: 'paid',
date: '2023-09-10',
},
{
customer_id: customers[5].id,
amount: 34577,
status: 'pending',
date: '2023-08-05',
},
{
customer_id: customers[2].id,
amount: 54246,
status: 'pending',
date: '2023-07-16',
},
{
customer_id: customers[0].id,
amount: 666,
status: 'pending',
date: '2023-06-27',
},
{
customer_id: customers[3].id,
amount: 32545,
status: 'paid',
date: '2023-06-09',
},
{
customer_id: customers[4].id,
amount: 1250,
status: 'paid',
date: '2023-06-17',
},
{
customer_id: customers[5].id,
amount: 8546,
status: 'paid',
date: '2023-06-07',
},
{
customer_id: customers[1].id,
amount: 500,
status: 'paid',
date: '2023-08-19',
},
{
customer_id: customers[5].id,
amount: 8945,
status: 'paid',
date: '2023-06-03',
},
{
customer_id: customers[2].id,
amount: 1000,
status: 'paid',
date: '2022-06-05',
},
]
const revenue = [
{ month: 'Jan', revenue: 2000 },
{ month: 'Feb', revenue: 1800 },
{ month: 'Mar', revenue: 2200 },
{ month: 'Apr', revenue: 2500 },
{ month: 'May', revenue: 2300 },
{ month: 'Jun', revenue: 3200 },
{ month: 'Jul', revenue: 3500 },
{ month: 'Aug', revenue: 3700 },
{ month: 'Sep', revenue: 2500 },
{ month: 'Oct', revenue: 2800 },
{ month: 'Nov', revenue: 3000 },
{ month: 'Dec', revenue: 4800 },
]
export { users, customers, invoices, revenue }
- db/seed.ts
import '@/db/env-config'
import { customers, invoices, revenue, users } from '@/lib/placeholder-data'
import db from './drizzle'
import * as schema from './schema'
import { exit } from 'process'
const main = async () => {
try {
await db.transaction(async (tx) => {
await tx.delete(schema.revenue)
await tx.delete(schema.invoices)
await tx.delete(schema.customers)
await tx.delete(schema.users)
await tx.insert(schema.users).values(users)
await tx.insert(schema.customers).values(customers)
await tx.insert(schema.invoices).values(invoices)
await tx.insert(schema.revenue).values(revenue)
})
console.log('Database seeded successfully')
exit(0)
} catch (error) {
console.error(error)
throw new Error('Failed to seed database')
}
}
main()
05. load data from database
- lib/actions/invoice.actions.ts
export async function fetchCardData() {
try {
const invoiceCountPromise = db.select({ count: count() }).from(invoices)
const customerCountPromise = db
.select({ count: count() })
.from(customers)
const invoiceStatusPromise = db
.select({
paid: sql<number>`SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END)`,
pending: sql<number>`SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END)`,
})
.from(invoices)
const data = await Promise.all([
invoiceCountPromise,
customerCountPromise,
invoiceStatusPromise,
])
const numberOfInvoices = Number(data[0][0].count ?? '0')
const numberOfCustomers = Number(data[1][0].count ?? '0')
const totalPaidInvoices = formatCurrency(data[2][0].paid ?? '0')
const totalPendingInvoices = formatCurrency(data[2][0].pending ?? '0')
return {
numberOfCustomers,
numberOfInvoices,
totalPaidInvoices,
totalPendingInvoices,
}
} catch (error) {
console.error('Database Error:', error)
throw new Error('Failed to fetch card data.')
}
}
- components/shared/dashboard/stat-cards-wrapper.tsx
const iconMap = {
collected: BanknoteIcon,
customers: UsersIcon,
pending: ClockIcon,
invoices: InboxIcon,
}
export default async function StatCardsWrapper() {
const {
numberOfInvoices,
numberOfCustomers,
totalPaidInvoices,
totalPendingInvoices,
} = await fetchCardData()
return (
<>
<StatCard
title="Collected"
value={totalPaidInvoices}
type="collected"
/>
<StatCard
title="Pending"
value={totalPendingInvoices}
type="pending"
/>
<StatCard
title="Total Invoices"
value={numberOfInvoices}
type="invoices"
/>
<StatCard
title="Total Customers"
value={numberOfCustomers}
type="customers"
/>
</>
)
}
export function StatCard({
title,
value,
type,
}: {
title: string
value: number | string
type: 'invoices' | 'customers' | 'pending' | 'collected'
}) {
const Icon = iconMap[type]
return (
<Card>
<CardHeader className="flex flex-row space-y-0 space-x-3 ">
{Icon ? <Icon className="h-5 w-5 " /> : null}
<h3 className="ml-2 text-sm font-medium">{title}</h3>
</CardHeader>
<CardContent>
<p
className={`${lusitana.className}
truncate rounded-xl p-4 text-2xl`}
>
{value}
</p>
</CardContent>
</Card>
)
}
- app/dashboard/(overview)/page.tsx
<h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
Dashboard
</h1>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<Suspense fallback={<CardsSkeleton />}>
<StatCardsWrapper />
</Suspense>
</div>
06. display revenue chart
- pnpm add recharts react-is@rc
- components/shared/dashboard/revenue-chart.tsx
'use client'
export default function RevenueChart({
revenue,
}: {
revenue: { month: string; revenue: number }[]
}) {
if (!revenue || revenue.length === 0) {
return <p className="mt-4 text-gray-400">No data available.</p>
}
return (
<ResponsiveContainer width="100%" height={450}>
<BarChart data={revenue}>
<XAxis
dataKey="month"
fontSize={12}
tickLine={false}
axisLine={true}
/>
<YAxis
fontSize={12}
tickLine={false}
axisLine={true}
tickFormatter={(value: number) => `$${value}`}
/>
<Bar
dataKey="revenue"
fill="currentColor"
radius={[4, 4, 0, 0]}
className="fill-primary"
/>
</BarChart>
</ResponsiveContainer>
)
}
- components/shared/dashboard/revenue-chart-wrapper.tsx
export default async function RevenueChartWrapper() {
const revenue = await fetchRevenue()
return (
<Card className="w-full md:col-span-4">
<CardHeader>
<h2 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
Recent Revenue
</h2>
</CardHeader>
<CardContent className="p-0">
<RevenueChart revenue={revenue} />
</CardContent>
</Card>
)
}
- app/dashboard/(overview)/page.tsx
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
<Suspense fallback={<RevenueChartSkeleton />}>
<RevenueChartWrapper />
</Suspense>
</div>
07. create latest invoices table
- lib/actions/invoice.actions.ts
export async function fetchLatestInvoices() {
try {
const data = await db
.select({
amount: invoices.amount,
name: customers.name,
image_url: customers.image_url,
email: customers.email,
id: invoices.id,
})
.from(invoices)
.innerJoin(customers, eq(invoices.customer_id, customers.id))
.orderBy(desc(invoices.date))
.limit(5)
const latestInvoices = data.map((invoice) => ({
...invoice,
amount: formatCurrency(invoice.amount),
}))
return latestInvoices
} catch (error) {
console.error('Database Error:', error)
throw new Error('Failed to fetch the latest invoices.')
}
}
- components/shared/dashboard/latest-invoices.tsx
export default async function LatestInvoices() {
const latestInvoices = await fetchLatestInvoices()
return (
<Card className="flex w-full flex-col md:col-span-4">
<CardHeader>
<h2 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
Latest Invoices
</h2>
</CardHeader>
<CardContent>
<div>
<div>
{latestInvoices.map((invoice, i) => {
return (
<div
key={invoice.id}
className={cn(
'flex flex-row items-center justify-between py-4',
{
'border-t': i !== 0,
}
)}
>
<div className="flex items-center">
<Image
src={invoice.image_url}
alt={`${invoice.name}'s profile picture`}
className="mr-4 rounded-full"
width={32}
height={32}
/>
<div className="min-w-0">
<p className="truncate text-sm font-semibold md:text-base">
{invoice.name}
</p>
<p className="hidden text-sm text-gray-500 sm:block">
{invoice.email}
</p>
</div>
</div>
<p
className={`${lusitana.className} truncate text-sm font-medium md:text-base`}
>
{invoice.amount}
</p>
</div>
)
})}
</div>
<div className="flex items-center pb-2 pt-6">
<RefreshCcw className="h-5 w-5 text-gray-500" />
<h3 className="ml-2 text-sm text-gray-500 ">Updated just now</h3>
</div>
</div>
</CardContent>
</Card>
)
}
- app/dashboard/(overview)/page.tsx
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
<Suspense fallback={<LatestInvoicesSkeleton />}>
<LatestInvoices />
</Suspense>
</div>
08. authenticate user from database
- lib/actions/user.actions.ts
export async function getUser(email: string) {
const user = await db.query.users.findFirst({
where: eq(users.email, email as string),
})
if (!user) throw new Error('User not found')
return user
}
- auth.ts
export const { auth, signIn, signOut } = NextAuth({
...authConfig,
providers: [
Credentials({
async authorize(credentials) {
const parsedCredentials = z
.object({ email: z.string().email(), password: z.string().min(6) })
.safeParse(credentials)
if (parsedCredentials.success) {
const { email, password } = parsedCredentials.data
const user = await getUser(email)
if (!user) return null
const passwordsMatch = await bcryptjs.compare(
password,
user.password
)
if (passwordsMatch) return user
}
console.log('Invalid credentials')
return null
},
}),
],
})
09. list or delete invoices
- pnpm add use-debounce
- lib/actions/invoice.actions.ts
export async function deleteInvoice(id: string) {
try {
await db.delete(invoices).where(eq(invoices.id, id))
revalidatePath('/dashboard/invoices')
return { message: 'Deleted Invoice' }
} catch (error) {
return { message: 'Database Error: Failed to Delete Invoice.' }
}
}
export async function fetchFilteredInvoices(
query: string,
currentPage: number
) {
const offset = (currentPage - 1) * ITEMS_PER_PAGE
try {
const data = await db
.select({
id: invoices.id,
amount: invoices.amount,
name: customers.name,
email: customers.email,
image_url: customers.image_url,
status: invoices.status,
date: invoices.date,
})
.from(invoices)
.innerJoin(customers, eq(invoices.customer_id, customers.id))
.where(
or(
ilike(customers.name, sql`${`%${query}%`}`),
ilike(customers.email, sql`${`%${query}%`}`),
ilike(invoices.status, sql`${`%${query}%`}`)
)
)
.orderBy(desc(invoices.date))
.limit(ITEMS_PER_PAGE)
.offset(offset)
return data
} catch (error) {
console.error('Database Error:', error)
throw new Error('Failed to fetch invoices.')
}
}
export async function fetchInvoicesPages(query: string) {
try {
const data = await db
.select({
count: count(),
})
.from(invoices)
.innerJoin(customers, eq(invoices.customer_id, customers.id))
.where(
or(
ilike(customers.name, sql`${`%${query}%`}`),
ilike(customers.email, sql`${`%${query}%`}`),
ilike(invoices.status, sql`${`%${query}%`}`)
)
)
const totalPages = Math.ceil(Number(data[0].count) / ITEMS_PER_PAGE)
return totalPages
} catch (error) {
console.error('Database Error:', error)
throw new Error('Failed to fetch total number of invoices.')
}
}
- components/shared/search.tsx
export default function Search({ placeholder }: { placeholder: string }) {
const searchParams = useSearchParams()
const { replace } = useRouter()
const pathname = usePathname()
const handleSearch = useDebouncedCallback((term: string) => {
console.log(`Searching... ${term}`)
const params = new URLSearchParams(searchParams)
params.set('page', '1')
if (term) {
params.set('query', term)
} else {
params.delete('query')
}
replace(`${pathname}?${params.toString()}`)
}, 300)
return (
<div className="relative flex flex-1 flex-shrink-0">
<label htmlFor="search" className="sr-only">
Search
</label>
<input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
placeholder={placeholder}
onChange={(e) => {
handleSearch(e.target.value)
}}
defaultValue={searchParams.get('query')?.toString()}
/>
<SearchIcon className="absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
</div>
)
}
- components/shared/invoices/buttons.tsx
export function UpdateInvoice({ id }: { id: string }) {
return (
<Button variant="outline" asChild>
<Link href={`/dashboard/invoices/${id}/edit`}>
<PencilIcon className="w-5" />
</Link>
</Button>
)
}
export function DeleteInvoice({ id }: { id: string }) {
const deleteInvoiceWithId = deleteInvoice.bind(null, id)
return (
<form action={deleteInvoiceWithId}>
<Button variant="outline" type="submit">
<span className="sr-only">Delete</span>
<TrashIcon className="w-5" />
</Button>
</form>
)
}
- components/shared/invoices/status.tsx
import { Badge } from '@/components/ui/badge'
import { CheckIcon, ClockIcon } from 'lucide-react'
export default function InvoiceStatus({ status }: { status: string }) {
return (
<Badge variant={status === 'paid' ? 'secondary' : 'default'}>
{status === 'pending' ? (
<>
Pending
<ClockIcon className="ml-1 w-4" />
</>
) : null}
{status === 'paid' ? (
<>
Paid
<CheckIcon className="ml-1 w-4" />
</>
) : null}
</Badge>
)
}
- lib/utils.ts
export const formatCurrency = (amount: number) => {
return (amount / 100).toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
})
}
export const formatDateToLocal = (
dateStr: string,
locale: string = 'en-US'
) => {
const date = new Date(dateStr)
const options: Intl.DateTimeFormatOptions = {
day: 'numeric',
month: 'short',
year: 'numeric',
}
const formatter = new Intl.DateTimeFormat(locale, options)
return formatter.format(date)
}
- components/shared/invoices/table.tsx
export default async function InvoicesTable({
query,
currentPage,
}: {
query: string
currentPage: number
}) {
const invoices = await fetchFilteredInvoices(query, currentPage)
return (
<div className="mt-6 flow-root">
<div className="inline-block min-w-full align-middle">
<div className="rounded-lg p-2 md:pt-0">
<div className="md:hidden">
{invoices?.map((invoice) => (
<div key={invoice.id} className="mb-2 w-full rounded-md p-4">
<div className="flex items-center justify-between border-b pb-4">
<div>
<div className="mb-2 flex items-center">
<Image
src={invoice.image_url}
className="mr-2 rounded-full"
width={28}
height={28}
alt={`${invoice.name}'s profile picture`}
/>
<p>{invoice.name}</p>
</div>
<p className="text-sm text-muted">{invoice.email}</p>
</div>
<InvoiceStatus status={invoice.status} />
</div>
<div className="flex w-full items-center justify-between pt-4">
<div>
<p className="text-xl font-medium">
{formatCurrency(invoice.amount)}
</p>
<p>{formatDateToLocal(invoice.date)}</p>
</div>
<div className="flex justify-end gap-2">
<UpdateInvoice id={invoice.id} />
<DeleteInvoice id={invoice.id} />
</div>
</div>
</div>
))}
</div>
<table className="hidden min-w-full md:table">
<thead className="rounded-lg text-left text-sm font-normal">
<tr>
<th scope="col" className="px-4 py-5 font-medium sm:pl-6">
Customer
</th>
<th scope="col" className="px-3 py-5 font-medium">
Email
</th>
<th scope="col" className="px-3 py-5 font-medium">
Amount
</th>
<th scope="col" className="px-3 py-5 font-medium">
Date
</th>
<th scope="col" className="px-3 py-5 font-medium">
Status
</th>
<th scope="col" className="relative py-3 pl-6 pr-3">
<span className="sr-only">Edit</span>
</th>
</tr>
</thead>
<tbody>
{invoices?.map((invoice) => (
<tr
key={invoice.id}
className="w-full border-b py-3 text-sm last-of-type:border-none [&:first-child>td:first-child]:rounded-tl-lg [&:first-child>td:last-child]:rounded-tr-lg [&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg"
>
<td className="whitespace-nowrap py-3 pl-6 pr-3">
<div className="flex items-center gap-3">
<Image
src={invoice.image_url}
className="rounded-full"
width={28}
height={28}
alt={`${invoice.name}'s profile picture`}
/>
<p>{invoice.name}</p>
</div>
</td>
<td className="whitespace-nowrap px-3 py-3">
{invoice.email}
</td>
<td className="whitespace-nowrap px-3 py-3">
{formatCurrency(invoice.amount)}
</td>
<td className="whitespace-nowrap px-3 py-3">
{formatDateToLocal(invoice.date)}
</td>
<td className="whitespace-nowrap px-3 py-3">
<InvoiceStatus status={invoice.status} />
</td>
<td className="whitespace-nowrap py-3 pl-6 pr-3">
<div className="flex justify-end gap-3">
<UpdateInvoice id={invoice.id} />
<DeleteInvoice id={invoice.id} />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}
- lib/utils.ts
export const generatePagination = (
currentPage: number,
totalPages: number
) => {
// If the total number of pages is 7 or less,
// display all pages without any ellipsis.
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1)
}
// If the current page is among the first 3 pages,
// show the first 3, an ellipsis, and the last 2 pages.
if (currentPage <= 3) {
return [1, 2, 3, '...', totalPages - 1, totalPages]
}
// If the current page is among the last 3 pages,
// show the first 2, an ellipsis, and the last 3 pages.
if (currentPage >= totalPages - 2) {
return [1, 2, '...', totalPages - 2, totalPages - 1, totalPages]
}
// If the current page is somewhere in the middle,
// show the first page, an ellipsis, the current page and its neighbors,
// another ellipsis, and the last page.
return [
1,
'...',
currentPage - 1,
currentPage,
currentPage + 1,
'...',
totalPages,
]
}
- components/shared/invoices/pagination.tsx
export default function Pagination({ totalPages }: { totalPages: number }) {
const pathname = usePathname()
const searchParams = useSearchParams()
const currentPage = Number(searchParams.get('page')) || 1
const createPageURL = (pageNumber: number | string) => {
const params = new URLSearchParams(searchParams)
params.set('page', pageNumber.toString())
return `${pathname}?${params.toString()}`
}
const allPages = generatePagination(currentPage, totalPages)
return (
<div className="inline-flex">
<PaginationArrow
direction="left"
href={createPageURL(currentPage - 1)}
isDisabled={currentPage <= 1}
/>
<div className="flex -space-x-px">
{allPages.map((page, index) => {
let position: 'first' | 'last' | 'single' | 'middle' | undefined
if (index === 0) position = 'first'
if (index === allPages.length - 1) position = 'last'
if (allPages.length === 1) position = 'single'
if (page === '...') position = 'middle'
return (
<PaginationNumber
key={`${page}-${index}`}
href={createPageURL(page)}
page={page}
position={position}
isActive={currentPage === page}
/>
)
})}
</div>
<PaginationArrow
direction="right"
href={createPageURL(currentPage + 1)}
isDisabled={currentPage >= totalPages}
/>
</div>
)
}
function PaginationNumber({
page,
href,
isActive,
position,
}: {
page: number | string
href: string
position?: 'first' | 'last' | 'middle' | 'single'
isActive: boolean
}) {
const className = cn(
'flex h-10 w-10 items-center justify-center text-sm border',
{
'rounded-l-md': position === 'first' || position === 'single',
'rounded-r-md': position === 'last' || position === 'single',
'z-10 bg-primary text-secondary': isActive,
'hover:bg-secondary': !isActive && position !== 'middle',
'text-gray-300': position === 'middle',
}
)
return isActive || position === 'middle' ? (
<div className={className}>{page}</div>
) : (
<Link href={href} className={className}>
{page}
</Link>
)
}
function PaginationArrow({
href,
direction,
isDisabled,
}: {
href: string
direction: 'left' | 'right'
isDisabled?: boolean
}) {
const className = cn(
'flex h-10 w-10 items-center justify-center rounded-md border',
{
'pointer-events-none text-gray-300': isDisabled,
'hover:bg-secondary': !isDisabled,
'mr-2 md:mr-4': direction === 'left',
'ml-2 md:ml-4': direction === 'right',
}
)
const icon =
direction === 'left' ? (
<ArrowLeftIcon className="w-4" />
) : (
<ArrowRightIcon className="w-4" />
)
return isDisabled ? (
<div className={className}>{icon}</div>
) : (
<Link className={className} href={href}>
{icon}
</Link>
)
}
-
app/dashboard/invoices/page.tsx
export const metadata: Metadata = { title: 'Invoices', } export default async function Page({ searchParams, }: { searchParams?: { query?: string page?: string } }) { const query = searchParams?.query || '' const currentPage = Number(searchParams?.page) || 1 const totalPages = await fetchInvoicesPages(query) return ( <div className="w-full"> <div className="flex w-full items-center justify-between"> <h1 className={`${lusitana.className} text-2xl`}>Invoices</h1> </div> <div className="mt-4 flex items-center justify-between gap-2 md:mt-8"> <Search placeholder="Search invoices..." /> <CreateInvoice /> </div> <Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />} > <Table query={query} currentPage={currentPage} /> </Suspense> <div className="mt-5 flex w-full justify-center"> <Pagination totalPages={totalPages} /> </div> </div> ) }
-
app/dashboard/invoices/error.tsx
'use client' import { useEffect } from 'react' export default function Error({ error, reset, }: { error: Error & { digest?: string } reset: () => void }) { useEffect(() => { // Optionally log the error to an error reporting service console.error(error) }, [error]) return ( <main className="flex h-full flex-col items-center justify-center"> <h2 className="text-center">Something went wrong!</h2> <button className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-400" onClick={ // Attempt to recover by trying to re-render the invoices route () => reset() } > Try again </button> </main> ) }
10. create or update invoices
- types/index.ts
// This file contains type definitions for your data.
export type FormattedCustomersTable = {
id: string
name: string
email: string
image_url: string
total_invoices: number
total_pending: string
total_paid: string
}
export type CustomerField = {
id: string
name: string
}
export type InvoiceForm = {
id: string
customer_id: string
amount: number
status: 'pending' | 'paid'
}
- lib/actions/invoice.actions.ts
const FormSchema = z.object({
id: z.string(),
customerId: z.string({
invalid_type_error: 'Please select a customer.',
}),
amount: z.coerce
.number()
.gt(0, { message: 'Please enter an amount greater than $0.' }),
status: z.enum(['pending', 'paid'], {
invalid_type_error: 'Please select an invoice status.',
}),
date: z.string(),
})
const CreateInvoice = FormSchema.omit({ id: true, date: true })
const UpdateInvoice = FormSchema.omit({ date: true, id: true })
export type State = {
errors?: {
customerId?: string[]
amount?: string[]
status?: string[]
}
message?: string | null
}
export async function createInvoice(prevState: State, formData: FormData) {
// Validate form fields using Zod
const validatedFields = CreateInvoice.safeParse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
})
// If form validation fails, return errors early. Otherwise, continue.
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Missing Fields. Failed to Create Invoice.',
}
}
// Prepare data for insertion into the database
const { customerId, amount, status } = validatedFields.data
const amountInCents = amount * 100
const date = new Date().toISOString().split('T')[0]
// Insert data into the database
try {
await db.insert(invoices).values({
customer_id: customerId,
amount: amountInCents,
status,
date,
})
} catch (error) {
// If a database error occurs, return a more specific error.
return {
message: 'Database Error: Failed to Create Invoice.',
}
}
// Revalidate the cache for the invoices page and redirect the user.
revalidatePath('/dashboard/invoices')
redirect('/dashboard/invoices')
}
export async function updateInvoice(
id: string,
prevState: State,
formData: FormData
) {
const validatedFields = UpdateInvoice.safeParse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
})
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Missing Fields. Failed to Update Invoice.',
}
}
const { customerId, amount, status } = validatedFields.data
const amountInCents = amount * 100
try {
await db
.update(invoices)
.set({
customer_id: customerId,
amount: amountInCents,
status,
})
.where(eq(invoices.id, id))
} catch (error) {
return { message: 'Database Error: Failed to Update Invoice.' }
}
revalidatePath('/dashboard/invoices')
redirect('/dashboard/invoices')
}
- components/shared/invoices/create-form.tsx
'use client'
export default function Form({ customers }: { customers: CustomerField[] }) {
const initialState: State = { message: null, errors: {} }
const [state, formAction] = useActionState(createInvoice, initialState)
return (
<form action={formAction}>
<div className="rounded-md p-4 md:p-6">
{/* Customer Name */}
<div className="mb-4">
<label
htmlFor="customer"
className="mb-2 block text-sm font-medium"
>
Choose customer
</label>
<div className="relative">
<select
id="customer"
name="customerId"
className="peer block w-full cursor-pointer rounded-md border py-2 pl-10 text-sm outline-2 "
defaultValue=""
aria-describedby="customer-error"
>
<option value="" disabled>
Select a customer
</option>
{customers.map((customer) => (
<option key={customer.id} value={customer.id}>
{customer.name}
</option>
))}
</select>
<UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 " />
</div>
<div id="customer-error" aria-live="polite" aria-atomic="true">
{state.errors?.customerId &&
state.errors.customerId.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
{/* Invoice Amount */}
<div className="mb-4">
<label htmlFor="amount" className="mb-2 block text-sm font-medium">
Choose an amount
</label>
<div className="relative mt-2 rounded-md">
<div className="relative">
<input
id="amount"
name="amount"
type="number"
step="0.01"
placeholder="Enter USD amount"
className="peer block w-full rounded-md border py-2 pl-10 text-sm outline-2 "
aria-describedby="amount-error"
/>
<DollarSign className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 " />
</div>
</div>
<div id="amount-error" aria-live="polite" aria-atomic="true">
{state.errors?.amount &&
state.errors.amount.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
{/* Invoice Status */}
<fieldset>
<legend className="mb-2 block text-sm font-medium">
Set the invoice status
</legend>
<div className="rounded-md border px-[14px] py-3">
<div className="flex gap-4">
<div className="flex items-center">
<input
id="pending"
name="status"
type="radio"
value="pending"
className="text-white-600 h-4 w-4 cursor-pointer focus:ring-2"
/>
<label
htmlFor="pending"
className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium "
>
Pending <ClockIcon className="h-4 w-4" />
</label>
</div>
<div className="flex items-center">
<input
id="paid"
name="status"
type="radio"
value="paid"
className="h-4 w-4 cursor-pointer focus:ring-2"
/>
<label
htmlFor="paid"
className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium "
>
Paid <CheckIcon className="h-4 w-4" />
</label>
</div>
</div>
</div>
<div id="status-error" aria-live="polite" aria-atomic="true">
{state.errors?.status &&
state.errors.status.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</fieldset>
<div aria-live="polite" aria-atomic="true">
{state.message ? (
<p className="mt-2 text-sm text-red-500">{state.message}</p>
) : null}
</div>
</div>
<div className="mt-6 flex justify-end gap-4">
<Button variant="outline" asChild>
<Link href="/dashboard/invoices">Cancel</Link>
</Button>
<Button type="submit">Create Invoice</Button>
</div>
</form>
)
}
- components/shared/invoices/breadcrumbs.tsx
import Link from 'next/link'
import { lusitana } from '@/components/shared/fonts'
import { cn } from '@/lib/utils'
interface Breadcrumb {
label: string
href: string
active?: boolean
}
export default function Breadcrumbs({
breadcrumbs,
}: {
breadcrumbs: Breadcrumb[]
}) {
return (
<nav aria-label="Breadcrumb" className="mb-6 block">
<ol className={cn(lusitana.className, 'flex text-xl md:text-2xl')}>
{breadcrumbs.map((breadcrumb, index) => (
<li key={breadcrumb.href} aria-current={breadcrumb.active}>
<Link href={breadcrumb.href}>{breadcrumb.label}</Link>
{index < breadcrumbs.length - 1 ? (
<span className="mx-3 inline-block">/</span>
) : null}
</li>
))}
</ol>
</nav>
)
}
- app/dashboard/invoices/create/page.tsx
export const metadata: Metadata = {
title: 'Create Invoice',
}
export default async function Page() {
const customers = await fetchCustomers()
return (
<main>
<Breadcrumbs
breadcrumbs={[
{ label: 'Invoices', href: '/dashboard/invoices' },
{
label: 'Create Invoice',
href: '/dashboard/invoices/create',
active: true,
},
]}
/>
<Form customers={customers} />
</main>
)
}
- app/dashboard/invoices/[id]/edit/not-found.tsx
import { Frown } from 'lucide-react'
import Link from 'next/link'
export default function NotFound() {
return (
<main className="flex h-full flex-col items-center justify-center gap-2">
<Frown className="w-10 text-gray-400" />
<h2 className="text-xl font-semibold">404 Not Found</h2>
<p>Could not find the requested invoice.</p>
<Link
href="/dashboard/invoices"
className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-400"
>
Go Back
</Link>
</main>
)
}
- lib/actions/invoice.actions.ts
export async function fetchInvoiceById(id: string) {
try {
const data = await db
.select({
id: invoices.id,
customer_id: invoices.customer_id,
amount: invoices.amount,
status: invoices.status,
date: invoices.date,
})
.from(invoices)
.where(eq(invoices.id, id))
const invoice = data.map((invoice) => ({
...invoice,
// Convert amount from cents to dollars
status: invoice.status === 'paid' ? 'paid' : 'pending',
amount: invoice.amount / 100,
}))
return invoice[0] as InvoiceForm
} catch (error) {
console.error('Database Error:', error)
throw new Error('Failed to fetch invoice.')
}
}
- components/shared/invoices/edit-form.tsx
export default function EditInvoiceForm({
invoice,
customers,
}: {
invoice: InvoiceForm
customers: CustomerField[]
}) {
const initialState: State = { message: null, errors: {} }
const updateInvoiceWithId = updateInvoice.bind(null, invoice.id)
const [state, formAction] = useActionState(
updateInvoiceWithId,
initialState
)
return (
<form action={formAction}>
<div className="rounded-md p-4 md:p-6">
{/* Customer Name */}
<div className="mb-4">
<label
htmlFor="customer"
className="mb-2 block text-sm font-medium"
>
Choose customer
</label>
<div className="relative">
<select
id="customer"
name="customerId"
className="peer block w-full cursor-pointer rounded-md border py-2 pl-10 text-sm outline-2 "
defaultValue={invoice.customer_id}
aria-describedby="customer-error"
>
<option value="" disabled>
Select a customer
</option>
{customers.map((customer) => (
<option key={customer.id} value={customer.id}>
{customer.name}
</option>
))}
</select>
<UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 " />
</div>
<div id="customer-error" aria-live="polite" aria-atomic="true">
{state.errors?.customerId &&
state.errors.customerId.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
{/* Invoice Amount */}
<div className="mb-4">
<label htmlFor="amount" className="mb-2 block text-sm font-medium">
Choose an amount
</label>
<div className="relative mt-2 rounded-md">
<div className="relative">
<input
id="amount"
name="amount"
type="number"
defaultValue={invoice.amount}
step="0.01"
placeholder="Enter USD amount"
className="peer block w-full rounded-md border py-2 pl-10 text-sm outline-2 "
aria-describedby="amount-error"
/>
<DollarSignIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 " />
</div>
</div>
<div id="amount-error" aria-live="polite" aria-atomic="true">
{state.errors?.amount &&
state.errors.amount.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
{/* Invoice Status */}
<fieldset>
<legend className="mb-2 block text-sm font-medium">
Set the invoice status
</legend>
<div className="rounded-md border px-[14px] py-3">
<div className="flex gap-4">
<div className="flex items-center">
<input
id="pending"
name="status"
type="radio"
value="pending"
defaultChecked={invoice.status === 'pending'}
className="h-4 w-4 focus:ring-2"
/>
<label
htmlFor="pending"
className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium "
>
Pending <ClockIcon className="h-4 w-4" />
</label>
</div>
<div className="flex items-center">
<input
id="paid"
name="status"
type="radio"
value="paid"
defaultChecked={invoice.status === 'paid'}
className="h-4 w-4 focus:ring-2"
/>
<label
htmlFor="paid"
className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium "
>
Paid <CheckIcon className="h-4 w-4" />
</label>
</div>
</div>
</div>
<div id="status-error" aria-live="polite" aria-atomic="true">
{state.errors?.status &&
state.errors.status.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</fieldset>
<div aria-live="polite" aria-atomic="true">
{state.message ? (
<p className="my-2 text-sm text-red-500">{state.message}</p>
) : null}
</div>
</div>
<div className="mt-6 flex justify-end gap-4">
<Button variant="ghost">
<Link href="/dashboard/invoices">Cancel</Link>
</Button>
<Button type="submit">Edit Invoice</Button>
</div>
</form>
)
}
- app/dashboard/invoices/[id]/edit/page.tsx
export const metadata: Metadata = {
title: 'Edit Invoice',
}
export default async function Page({ params }: { params: { id: string } }) {
const id = params.id
const [invoice, customers] = await Promise.all([
fetchInvoiceById(id),
fetchCustomers(),
])
if (!invoice) {
notFound()
}
return (
<main>
<Breadcrumbs
breadcrumbs={[
{ label: 'Invoices', href: '/dashboard/invoices' },
{
label: 'Edit Invoice',
href: `/dashboard/invoices/${id}/edit`,
active: true,
},
]}
/>
<Form invoice={invoice} customers={customers} />
</main>
)
}
11. list customers
- lib/actions/customers.actions.ts
export async function fetchFilteredCustomers(query: string) {
const data = await db
.select({
id: customers.id,
name: customers.name,
email: customers.email,
image_url: customers.image_url,
total_invoices: sql<number>`count(${invoices.id})`,
total_pending: sql<number>`SUM(CASE WHEN ${invoices.status} = 'pending' THEN ${invoices.amount} ELSE 0 END)`,
total_paid: sql<number>`SUM(CASE WHEN ${invoices.status} = 'paid' THEN ${invoices.amount} ELSE 0 END)`,
})
.from(customers)
.leftJoin(invoices, eq(customers.id, invoices.customer_id))
.where(
or(
ilike(customers.name, sql`${`%${query}%`}`),
ilike(customers.email, sql`${`%${query}%`}`)
)
)
.groupBy(
customers.id,
customers.name,
customers.email,
customers.image_url
)
.orderBy(asc(customers.id))
return data.map((row) => ({
...row,
total_invoices: row.total_invoices ?? 0,
total_pending: formatCurrency(row.total_pending ?? 0),
total_paid: formatCurrency(row.total_paid ?? 0),
}))
}
- components/shared/customers/table.tsx
export default async function CustomersTable({
customers,
}: {
customers: FormattedCustomersTable[]
}) {
return (
<div className="w-full">
<h1 className={`${lusitana.className} mb-8 text-xl md:text-2xl`}>
Customers
</h1>
<Search placeholder="Search customers..." />
<div className="mt-6 flow-root">
<div className="overflow-x-auto">
<div className="inline-block min-w-full align-middle">
<div className="overflow-hidden rounded-md p-2 md:pt-0">
<div className="md:hidden">
{customers?.map((customer) => (
<div
key={customer.id}
className="mb-2 w-full rounded-md p-4"
>
<div className="flex items-center justify-between border-b pb-4">
<div>
<div className="mb-2 flex items-center">
<div className="flex items-center gap-3">
<Image
src={customer.image_url}
className="rounded-full"
alt={`${customer.name}'s profile picture`}
width={28}
height={28}
/>
<p>{customer.name}</p>
</div>
</div>
<p className="text-sm text-muted">
{customer.email}
</p>
</div>
</div>
<div className="flex w-full items-center justify-between border-b py-5">
<div className="flex w-1/2 flex-col">
<p className="text-xs">Pending</p>
<p className="font-medium">
{customer.total_pending}
</p>
</div>
<div className="flex w-1/2 flex-col">
<p className="text-xs">Paid</p>
<p className="font-medium">{customer.total_paid}</p>
</div>
</div>
<div className="pt-4 text-sm">
<p>{customer.total_invoices} invoices</p>
</div>
</div>
))}
</div>
<table className="hidden min-w-full rounded-md md:table">
<thead className="rounded-md text-left text-sm font-normal">
<tr>
<th
scope="col"
className="px-4 py-5 font-medium sm:pl-6"
>
Name
</th>
<th scope="col" className="px-3 py-5 font-medium">
Email
</th>
<th scope="col" className="px-3 py-5 font-medium">
Total Invoices
</th>
<th scope="col" className="px-3 py-5 font-medium">
Total Pending
</th>
<th scope="col" className="px-4 py-5 font-medium">
Total Paid
</th>
</tr>
</thead>
<tbody className="divide-y ">
{customers.map((customer) => (
<tr key={customer.id} className="group">
<td className="whitespace-nowrap py-5 pl-4 pr-3 text-sm group-first-of-type:rounded-md group-last-of-type:rounded-md sm:pl-6">
<div className="flex items-center gap-3">
<Image
src={customer.image_url}
className="rounded-full"
alt={`${customer.name}'s profile picture`}
width={28}
height={28}
/>
<p>{customer.name}</p>
</div>
</td>
<td className="whitespace-nowrap px-4 py-5 text-sm">
{customer.email}
</td>
<td className="whitespace-nowrap px-4 py-5 text-sm">
{customer.total_invoices}
</td>
<td className="whitespace-nowrap px-4 py-5 text-sm">
{customer.total_pending}
</td>
<td className="whitespace-nowrap px-4 py-5 text-sm group-first-of-type:rounded-md group-last-of-type:rounded-md">
{customer.total_paid}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
)
}
- app/dashboard/customers/page.tsx
export const metadata: Metadata = {
title: 'Customers',
}
export default async function Page({
searchParams,
}: {
searchParams?: {
query?: string
page?: string
}
}) {
const query = searchParams?.query || ''
const customers = await fetchFilteredCustomers(query)
return (
<main>
<CustomersTable customers={customers} />
</main>
)
}
12. enable partial pre-rendering
- next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
ppr: 'incremental',
},
}
export default nextConfig
- app/layout.tsx
export const experimental_ppr = true
13. deploy-on-vercel
- create vercel account
- connect github to vercel
- create new app
- select github repo
- add env variables
- deploy app