├── .nvmrc ├── bun.lockb ├── pb-seed ├── seed-data.zip └── pb_schema.json ├── public ├── hero-desktop.png ├── hero-mobile.png └── customers │ ├── amy-burns.png │ ├── balazs-orban.png │ ├── evil-rabbit.png │ ├── jared-palmer.png │ ├── lee-robinson.png │ ├── steph-dietz.png │ ├── steven-tey.png │ ├── emil-kowalski.png │ ├── hector-simpson.png │ ├── delba-de-oliveira.png │ ├── guillermo-rauch.png │ └── michael-novotny.png ├── next.config.js ├── postcss.config.js ├── app ├── dashboard │ ├── (overview) │ │ ├── loading.tsx │ │ └── page.tsx │ ├── layout.tsx │ ├── invoices │ │ ├── [id] │ │ │ └── edit │ │ │ │ ├── not-found.tsx │ │ │ │ └── page.tsx │ │ ├── create │ │ │ └── page.tsx │ │ ├── error.tsx │ │ └── page.tsx │ └── customers │ │ └── page.tsx ├── ui │ ├── fonts.ts │ ├── global.css │ ├── acme-logo.tsx │ ├── button.tsx │ ├── invoices │ │ ├── status.tsx │ │ ├── breadcrumbs.tsx │ │ ├── buttons.tsx │ │ ├── pagination.tsx │ │ ├── table.tsx │ │ ├── create-form.tsx │ │ └── edit-form.tsx │ ├── dashboard │ │ ├── sidenav.tsx │ │ ├── nav-links.tsx │ │ ├── cards.tsx │ │ ├── revenue-chart.tsx │ │ └── latest-invoices.tsx │ ├── search.tsx │ ├── login-form.tsx │ ├── customers │ │ └── table.tsx │ └── skeletons.tsx ├── login │ └── page.tsx ├── layout.tsx ├── lib │ ├── pb.ts │ ├── utils.ts │ ├── definitions.ts │ ├── actions.ts │ ├── placeholder-data.js │ └── data-pb.ts └── page.tsx ├── .gitignore ├── middleware.ts ├── tailwind.config.ts ├── tsconfig.json ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 2 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mez/nextjs-pocketbase-dashboard/HEAD/bun.lockb -------------------------------------------------------------------------------- /pb-seed/seed-data.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mez/nextjs-pocketbase-dashboard/HEAD/pb-seed/seed-data.zip -------------------------------------------------------------------------------- /public/hero-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mez/nextjs-pocketbase-dashboard/HEAD/public/hero-desktop.png -------------------------------------------------------------------------------- /public/hero-mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mez/nextjs-pocketbase-dashboard/HEAD/public/hero-mobile.png -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | module.exports = nextConfig; 5 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/customers/amy-burns.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mez/nextjs-pocketbase-dashboard/HEAD/public/customers/amy-burns.png -------------------------------------------------------------------------------- /public/customers/balazs-orban.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mez/nextjs-pocketbase-dashboard/HEAD/public/customers/balazs-orban.png -------------------------------------------------------------------------------- /public/customers/evil-rabbit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mez/nextjs-pocketbase-dashboard/HEAD/public/customers/evil-rabbit.png -------------------------------------------------------------------------------- /public/customers/jared-palmer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mez/nextjs-pocketbase-dashboard/HEAD/public/customers/jared-palmer.png -------------------------------------------------------------------------------- /public/customers/lee-robinson.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mez/nextjs-pocketbase-dashboard/HEAD/public/customers/lee-robinson.png -------------------------------------------------------------------------------- /public/customers/steph-dietz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mez/nextjs-pocketbase-dashboard/HEAD/public/customers/steph-dietz.png -------------------------------------------------------------------------------- /public/customers/steven-tey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mez/nextjs-pocketbase-dashboard/HEAD/public/customers/steven-tey.png -------------------------------------------------------------------------------- /public/customers/emil-kowalski.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mez/nextjs-pocketbase-dashboard/HEAD/public/customers/emil-kowalski.png -------------------------------------------------------------------------------- /public/customers/hector-simpson.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mez/nextjs-pocketbase-dashboard/HEAD/public/customers/hector-simpson.png -------------------------------------------------------------------------------- /public/customers/delba-de-oliveira.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mez/nextjs-pocketbase-dashboard/HEAD/public/customers/delba-de-oliveira.png -------------------------------------------------------------------------------- /public/customers/guillermo-rauch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mez/nextjs-pocketbase-dashboard/HEAD/public/customers/guillermo-rauch.png -------------------------------------------------------------------------------- /public/customers/michael-novotny.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mez/nextjs-pocketbase-dashboard/HEAD/public/customers/michael-novotny.png -------------------------------------------------------------------------------- /app/dashboard/(overview)/loading.tsx: -------------------------------------------------------------------------------- 1 | import DashboardSkeleton from "@/app/ui/skeletons" 2 | 3 | export default function Loading() { 4 | return ( 5 | 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /app/ui/fonts.ts: -------------------------------------------------------------------------------- 1 | import {Inter, Lusitana} from 'next/font/google'; 2 | 3 | export const inter = Inter({ subsets: ['latin']}) 4 | export const lusitana = Lusitana({weight: ["400" , "700"], subsets: ['latin']}) -------------------------------------------------------------------------------- /app/ui/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | input[type='number'] { 6 | -moz-appearance: textfield; 7 | appearance: textfield; 8 | } 9 | 10 | input[type='number']::-webkit-inner-spin-button { 11 | -webkit-appearance: none; 12 | margin: 0; 13 | } 14 | 15 | input[type='number']::-webkit-outer-spin-button { 16 | -webkit-appearance: none; 17 | margin: 0; 18 | } 19 | -------------------------------------------------------------------------------- /app/ui/acme-logo.tsx: -------------------------------------------------------------------------------- 1 | import { GlobeAltIcon } from '@heroicons/react/24/outline'; 2 | import { lusitana } from '@/app/ui/fonts'; 3 | 4 | export default function AcmeLogo() { 5 | return ( 6 |
9 | 10 |

Acme

11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /app/dashboard/layout.tsx: -------------------------------------------------------------------------------- 1 | import SideNav from "@/app/ui/dashboard/sidenav"; 2 | 3 | import React from 'react' 4 | 5 | export default function Layout({ children }: { children: React.ReactNode }) { 6 | return ( 7 |
8 |
9 | 10 |
11 |
{children}
12 |
13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | .env 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /app/login/page.tsx: -------------------------------------------------------------------------------- 1 | import AcmeLogo from '@/app/ui/acme-logo'; 2 | import LoginForm from '@/app/ui/login-form'; 3 | 4 | export default function LoginPage() { 5 | return ( 6 |
7 |
8 |
9 |
10 | 11 |
12 |
13 | 14 |
15 |
16 | ); 17 | } -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | import { NextRequest } from 'next/server' 3 | import { initPocketBaseFromRequest } from './app/lib/pb'; 4 | 5 | // This function can be marked `async` if using `await` inside 6 | export async function middleware(request: NextRequest) { 7 | const pb = await initPocketBaseFromRequest(request); 8 | 9 | if (!pb.authStore.isValid) { 10 | return NextResponse.redirect(new URL('/login', request.url)); 11 | } 12 | 13 | return NextResponse.next(); 14 | } 15 | 16 | // See "Matching Paths" below to learn more 17 | export const config = { 18 | matcher: ['/((?!api|_next/static|_next/image|.png|login).*)'], 19 | } 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import '@/app/ui/global.css' 2 | import { inter } from './ui/fonts'; 3 | import { Metadata } from 'next'; 4 | 5 | 6 | export const metadata: Metadata = { 7 | title: { 8 | template: '%s | Acme Dashboard', 9 | default: 'Acme Dashboard' 10 | }, 11 | description: 'The official Next.js Course Dashboard, built with App Router.', 12 | metadataBase: new URL('https://next-learn-dashboard.vercel.sh'), 13 | } 14 | 15 | 16 | export default function RootLayout({ 17 | children, 18 | }: { 19 | children: React.ReactNode; 20 | }) { 21 | return ( 22 | 23 | {children} 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /app/dashboard/invoices/[id]/edit/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { FaceFrownIcon } from '@heroicons/react/24/outline'; 3 | 4 | export default function NotFound() { 5 | return ( 6 |
7 | 8 |

404 Not Found

9 |

Could not find the requested invoice.

10 | 14 | Go Back 15 | 16 |
17 | ); 18 | } -------------------------------------------------------------------------------- /app/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | 3 | interface ButtonProps extends React.ButtonHTMLAttributes { 4 | children: React.ReactNode; 5 | } 6 | 7 | export function Button({ children, className, ...rest }: ButtonProps) { 8 | return ( 9 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /app/dashboard/invoices/create/page.tsx: -------------------------------------------------------------------------------- 1 | import Form from '@/app/ui/invoices/create-form'; 2 | import Breadcrumbs from '@/app/ui/invoices/breadcrumbs'; 3 | import { fetchCustomers } from '@/app/lib/data-pb'; 4 | import { CustomerField } from '@/app/lib/definitions'; 5 | 6 | export default async function Page() { 7 | const customers = await fetchCustomers(); 8 | 9 | return ( 10 |
11 | 21 |
22 |
23 | ); 24 | } -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss'; 2 | 3 | const config: Config = { 4 | content: [ 5 | './pages/**/*.{js,ts,jsx,tsx,mdx}', 6 | './components/**/*.{js,ts,jsx,tsx,mdx}', 7 | './app/**/*.{js,ts,jsx,tsx,mdx}', 8 | ], 9 | theme: { 10 | extend: { 11 | gridTemplateColumns: { 12 | '13': 'repeat(13, minmax(0, 1fr))', 13 | }, 14 | colors: { 15 | blue: { 16 | 400: '#2589FE', 17 | 500: '#0070F3', 18 | 600: '#2F6FEB', 19 | }, 20 | }, 21 | }, 22 | keyframes: { 23 | shimmer: { 24 | '100%': { 25 | transform: 'translateX(100%)', 26 | }, 27 | }, 28 | }, 29 | }, 30 | 31 | plugins: [require('@tailwindcss/forms')], 32 | }; 33 | export default config; 34 | -------------------------------------------------------------------------------- /app/dashboard/customers/page.tsx: -------------------------------------------------------------------------------- 1 | import Table from '@/app/ui/customers/table'; 2 | import { InvoicesTableSkeleton } from '@/app/ui/skeletons'; 3 | import { Suspense } from 'react'; 4 | import { fetchFilteredCustomers } from '@/app/lib/data-pb'; 5 | 6 | export default async function Page({ 7 | searchParams, 8 | }: { 9 | searchParams?: { 10 | query?: string; 11 | page?: string; 12 | }; 13 | }) { 14 | const query = searchParams?.query || ''; 15 | const currentPage = Number(searchParams?.page) || 1; 16 | 17 | const customers = await fetchFilteredCustomers(query); 18 | 19 | return ( 20 |
21 | }> 22 | 23 | 24 | 25 | ); 26 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": [ 26 | "next-env.d.ts", 27 | "**/*.ts", 28 | "**/*.tsx", 29 | ".next/types/**/*.ts", 30 | "app/lib/placeholder-data.js", 31 | "scripts/seed.js" 32 | ], 33 | "exclude": ["node_modules"] 34 | } 35 | -------------------------------------------------------------------------------- /app/ui/invoices/status.tsx: -------------------------------------------------------------------------------- 1 | import { CheckIcon, ClockIcon } from '@heroicons/react/24/outline'; 2 | import clsx from 'clsx'; 3 | 4 | export default function InvoiceStatus({ status }: { status: string }) { 5 | return ( 6 | 15 | {status === 'pending' ? ( 16 | <> 17 | Pending 18 | 19 | 20 | ) : null} 21 | {status === 'paid' ? ( 22 | <> 23 | Paid 24 | 25 | 26 | ) : null} 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /app/dashboard/invoices/error.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect } from 'react'; 4 | 5 | export default function Error({ 6 | error, 7 | reset, 8 | }: { 9 | error: Error & { digest?: string }; 10 | reset: () => void; 11 | }) { 12 | useEffect(() => { 13 | // Optionally log the error to an error reporting service 14 | console.error(error); 15 | }, [error]); 16 | 17 | return ( 18 |
19 |

Something went wrong!

20 | 29 |
30 | ); 31 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "build": "next build", 5 | "dev": "next dev", 6 | "start": "next start", 7 | "lint": "next lint" 8 | }, 9 | "dependencies": { 10 | "@heroicons/react": "^2.0.18", 11 | "@tailwindcss/forms": "^0.5.6", 12 | "@types/node": "20.5.7", 13 | "autoprefixer": "10.4.15", 14 | "bcrypt": "^5.1.1", 15 | "clsx": "^2.0.0", 16 | "next": "^14.0.0", 17 | "pocketbase": "^0.19.0", 18 | "postcss": "8.4.31", 19 | "react": "18.2.0", 20 | "react-dom": "18.2.0", 21 | "tailwindcss": "3.3.3", 22 | "typescript": "5.2.2", 23 | "zod": "^3.22.2" 24 | }, 25 | "devDependencies": { 26 | "@types/bcrypt": "^5.0.1", 27 | "@types/react": "18.2.21", 28 | "@types/react-dom": "18.2.14", 29 | "dotenv": "^16.3.1", 30 | "eslint": "8.52.0", 31 | "eslint-config-next": "14.0.1", 32 | "prettier": "^3.0.3" 33 | }, 34 | "engines": { 35 | "node": ">=18" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/dashboard/invoices/[id]/edit/page.tsx: -------------------------------------------------------------------------------- 1 | import Form from '@/app/ui/invoices/edit-form'; 2 | import Breadcrumbs from '@/app/ui/invoices/breadcrumbs'; 3 | import { fetchCustomers, fetchInvoiceById } from '@/app/lib/data-pb'; 4 | import { notFound } from 'next/navigation'; 5 | 6 | 7 | interface PageProps { 8 | params: { 9 | id: string 10 | } 11 | } 12 | 13 | export default async function Page({ params }: PageProps) { 14 | const id = params.id; 15 | 16 | const [invoice, customers] = await Promise.all( 17 | [ 18 | fetchInvoiceById(id), 19 | fetchCustomers() 20 | ] 21 | ) 22 | 23 | if (!invoice) { 24 | notFound(); 25 | } 26 | 27 | return ( 28 |
29 | 39 | 40 |
41 | ); 42 | } -------------------------------------------------------------------------------- /app/ui/invoices/breadcrumbs.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx'; 2 | import Link from 'next/link'; 3 | import { lusitana } from '@/app/ui/fonts'; 4 | 5 | interface Breadcrumb { 6 | label: string; 7 | href: string; 8 | active?: boolean; 9 | } 10 | 11 | export default function Breadcrumbs({ 12 | breadcrumbs, 13 | }: { 14 | breadcrumbs: Breadcrumb[]; 15 | }) { 16 | return ( 17 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /app/dashboard/(overview)/page.tsx: -------------------------------------------------------------------------------- 1 | import Cards, { Card } from '@/app/ui/dashboard/cards'; 2 | import RevenueChart from '@/app/ui/dashboard/revenue-chart'; 3 | import LatestInvoices from '@/app/ui/dashboard/latest-invoices'; 4 | import { lusitana } from '@/app/ui/fonts'; 5 | 6 | import { Suspense } from 'react'; 7 | import { CardsSkeleton, LatestInvoicesSkeleton, RevenueChartSkeleton } from '@/app/ui/skeletons'; 8 | 9 | export default async function Page() { 10 | 11 | 12 | return ( 13 |
14 |

15 | Dashboard 16 |

17 |
18 | }> 19 | 20 | 21 |
22 |
23 | }> 24 | 25 | 26 | }> 27 | 28 | 29 |
30 |
31 | ); 32 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Next.js App Router Course With Pocketbase! - Final 2 | 3 | This is the final template for the Next.js App Router Course. It contains the final code for the dashboard application. Pocketbase was used for all the database + auth related tasks. 4 | 5 | For more information, see the [course curriculum](https://nextjs.org/learn) on the Next.js Website. 6 | 7 | I was inspired to do this with pocketbase to give any new user of NextJS that happens to also be using [Pocketbase](https://pocketbase.io/) a sorts of reference. 8 | 9 | 10 | ### If you want to get this working.... 11 | Just follow along with the course and then reference this repo for the database interaction to see how I did it using pocketbase. Some working knowledge of how pocketbase stores records and collections is required. Pocketbase is so awesome and hope you find this repo useful! 12 | 13 | ### Pocketbase Schema and Seed Data 14 | 15 | Check out the `/pb-seed` and you'll find the collections schema I exported. Including a backup of the database. You can just use these 16 | to seed your database! 17 | 18 | ### If you find an issue or have a suggestion for better coding practice!?!? 19 | 20 | Please create an issue here so that we can all learn! -------------------------------------------------------------------------------- /app/lib/pb.ts: -------------------------------------------------------------------------------- 1 | 2 | import Pocketbase from 'pocketbase'; 3 | import { NextRequest } from 'next/server' 4 | import { cookies } from 'next/headers'; 5 | 6 | 7 | const PB_URL = 'http://127.0.0.1:8090'; 8 | 9 | export async function initPocketbaseFromCookie() { 10 | const pb = new Pocketbase(PB_URL); 11 | 12 | // load state from cookie, won't refresh auth, middleware handles that. 13 | pb.authStore.loadFromCookie(cookies().get('pb_auth')?.value || ''); 14 | 15 | return pb; 16 | } 17 | 18 | export async function initPocketBaseFromRequest(request: NextRequest) { 19 | const pb = new Pocketbase(PB_URL); 20 | 21 | // load the store data from the request cookie string 22 | pb.authStore.loadFromCookie(request?.cookies.get('pb_auth')?.value || ''); 23 | 24 | // send back the default 'pb_auth' cookie to the client with the latest store state 25 | pb.authStore.onChange(() => { 26 | request.cookies.set('pb_auth', pb.authStore.exportToCookie()); 27 | }); 28 | 29 | try { 30 | // get an up-to-date auth store state by verifying and refreshing the loaded auth model (if any) 31 | pb.authStore.isValid && await pb.collection('users').authRefresh(); 32 | } catch (_) { 33 | // clear the auth store on failed refresh 34 | pb.authStore.clear(); 35 | } 36 | 37 | return pb 38 | } 39 | -------------------------------------------------------------------------------- /app/ui/dashboard/sidenav.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import NavLinks from '@/app/ui/dashboard/nav-links'; 3 | import AcmeLogo from '@/app/ui/acme-logo'; 4 | import { PowerIcon } from '@heroicons/react/24/outline'; 5 | import { signout } from '@/app/lib/actions'; 6 | 7 | export default function SideNav() { 8 | return ( 9 |
10 | 14 |
15 | 16 |
17 | 18 |
19 | 20 |
21 | 22 | 26 | 27 |
28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /app/ui/invoices/buttons.tsx: -------------------------------------------------------------------------------- 1 | import { deleteInvoice } from '@/app/lib/actions'; 2 | import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline'; 3 | import Link from 'next/link'; 4 | 5 | export function CreateInvoice() { 6 | return ( 7 | 11 | Create Invoice{' '} 12 | 13 | 14 | ); 15 | } 16 | 17 | export function UpdateInvoice({ id }: { id: string }) { 18 | return ( 19 | 23 | 24 | 25 | ); 26 | } 27 | 28 | export function DeleteInvoice({ id }: { id: string }) { 29 | const deleteInvoiceWithId = deleteInvoice.bind(null, id); 30 | return ( 31 |
32 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /app/ui/search.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; 4 | import { usePathname, useRouter, useSearchParams } from 'next/navigation'; 5 | import { useDebouncedCallback } from 'use-debounce'; 6 | 7 | export default function Search({ placeholder }: { placeholder: string }) { 8 | const searchParams = useSearchParams(); 9 | const pathname = usePathname(); 10 | const { replace } = useRouter(); 11 | 12 | const handleSearch = useDebouncedCallback((term: string) => { 13 | console.log(term); 14 | const params = new URLSearchParams(searchParams); 15 | params.set('page', '1'); 16 | if (term) { 17 | params.set('query', term); 18 | } else { 19 | params.delete('query'); 20 | } 21 | 22 | replace(`${pathname}?${params.toString()}`); 23 | }, 300); 24 | 25 | return ( 26 |
27 | 30 | handleSearch(e.target.value)} 34 | defaultValue={searchParams.get('query')?.toString()} 35 | /> 36 | 37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /app/ui/dashboard/nav-links.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | UserGroupIcon, 5 | HomeIcon, 6 | DocumentDuplicateIcon, 7 | } from '@heroicons/react/24/outline'; 8 | import Link from 'next/link'; 9 | import { usePathname } from 'next/navigation'; 10 | import clsx from 'clsx'; 11 | 12 | // Map of links to display in the side navigation. 13 | // Depending on the size of the application, this would be stored in a database. 14 | const links = [ 15 | { name: 'Home', href: '/dashboard', icon: HomeIcon }, 16 | { 17 | name: 'Invoices', 18 | href: '/dashboard/invoices', 19 | icon: DocumentDuplicateIcon, 20 | }, 21 | { name: 'Customers', href: '/dashboard/customers', icon: UserGroupIcon }, 22 | ]; 23 | 24 | export default function NavLinks() { 25 | const pathname = usePathname(); 26 | 27 | return ( 28 | <> 29 | {links.map((link) => { 30 | const LinkIcon = link.icon; 31 | return ( 32 | 40 | 41 |

{link.name}

42 | 43 | ); 44 | })} 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /app/dashboard/invoices/page.tsx: -------------------------------------------------------------------------------- 1 | import Pagination from '@/app/ui/invoices/pagination'; 2 | import Search from '@/app/ui/search'; 3 | import Table from '@/app/ui/invoices/table'; 4 | import { CreateInvoice } from '@/app/ui/invoices/buttons'; 5 | import { lusitana } from '@/app/ui/fonts'; 6 | import { InvoicesTableSkeleton } from '@/app/ui/skeletons'; 7 | import { Suspense } from 'react'; 8 | import { fetchInvoicesPages } from '@/app/lib/data-pb'; 9 | import { Metadata } from 'next'; 10 | 11 | 12 | export const metadata: Metadata = { 13 | title: 'Invoices', 14 | } 15 | 16 | export default async function Page({ 17 | searchParams, 18 | }: { 19 | searchParams?: { 20 | query?: string; 21 | page?: string; 22 | }; 23 | }) { 24 | const query = searchParams?.query || ''; 25 | const currentPage = Number(searchParams?.page) || 1; 26 | 27 | const totalPages = await fetchInvoicesPages(query); 28 | 29 | return ( 30 |
31 |
32 |

Invoices

33 |
34 |
35 | 36 | 37 |
38 | }> 39 |
40 | 41 |
42 | 43 |
44 | 45 | ); 46 | } -------------------------------------------------------------------------------- /app/ui/dashboard/cards.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | BanknotesIcon, 3 | ClockIcon, 4 | UserGroupIcon, 5 | InboxIcon, 6 | } from '@heroicons/react/24/outline'; 7 | import { lusitana } from '@/app/ui/fonts'; 8 | import { fetchCardData } from '@/app/lib/data-pb'; 9 | 10 | const iconMap = { 11 | collected: BanknotesIcon, 12 | customers: UserGroupIcon, 13 | pending: ClockIcon, 14 | invoices: InboxIcon, 15 | }; 16 | 17 | export default async function Cards() { 18 | 19 | const { 20 | numberOfInvoices, 21 | numberOfCustomers, 22 | totalPaidInvoices, 23 | totalPendingInvoices 24 | } = await fetchCardData(); 25 | 26 | 27 | return ( 28 | <> 29 | {/* NOTE: comment in this code when you get to this point in the course */} 30 | 31 | 32 | 33 | 34 | 39 | 40 | ); 41 | } 42 | 43 | export function Card({ 44 | title, 45 | value, 46 | type, 47 | }: { 48 | title: string; 49 | value: number | string; 50 | type: 'invoices' | 'customers' | 'pending' | 'collected'; 51 | }) { 52 | const Icon = iconMap[type]; 53 | 54 | return ( 55 |
56 |
57 | {Icon ? : null} 58 |

{title}

59 |
60 |

64 | {value} 65 |

66 |
67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import AcmeLogo from '@/app/ui/acme-logo'; 2 | import { lusitana } from './ui/fonts'; 3 | import Image from 'next/image'; 4 | import Link from 'next/link'; 5 | 6 | export default function Page() { 7 | return ( 8 |
9 |
10 | 11 |
12 |
13 |
14 |
17 |

18 | Welcome to Acme. This is the example for the{' '} 19 | 20 | Next.js Learn Course 21 | 22 | , brought to you by Vercel. 23 |

24 | 28 | Log in 29 | 30 |
31 |
32 | 39 | 46 |
47 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /app/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { Revenue } from './definitions'; 2 | 3 | export const formatCurrency = (amount: number) => { 4 | return (amount / 100).toLocaleString('en-US', { 5 | style: 'currency', 6 | currency: 'USD', 7 | }); 8 | }; 9 | 10 | export const formatDateToLocal = ( 11 | dateStr: string, 12 | locale: string = 'en-US', 13 | ) => { 14 | const date = new Date(dateStr); 15 | const options: Intl.DateTimeFormatOptions = { 16 | day: 'numeric', 17 | month: 'short', 18 | year: 'numeric', 19 | }; 20 | const formatter = new Intl.DateTimeFormat(locale, options); 21 | return formatter.format(date); 22 | }; 23 | 24 | export const generateYAxis = (revenue: Revenue[]) => { 25 | // Calculate what labels we need to display on the y-axis 26 | // based on highest record and in 1000s 27 | const yAxisLabels = []; 28 | const highestRecord = Math.max(...revenue.map((month) => month.revenue)); 29 | const topLabel = Math.ceil(highestRecord / 1000) * 1000; 30 | 31 | for (let i = topLabel; i >= 0; i -= 1000) { 32 | yAxisLabels.push(`$${i / 1000}K`); 33 | } 34 | 35 | return { yAxisLabels, topLabel }; 36 | }; 37 | 38 | export const generatePagination = (currentPage: number, totalPages: number) => { 39 | // If the total number of pages is 7 or less, 40 | // display all pages without any ellipsis. 41 | if (totalPages <= 7) { 42 | return Array.from({ length: totalPages }, (_, i) => i + 1); 43 | } 44 | 45 | // If the current page is among the first 3 pages, 46 | // show the first 3, an ellipsis, and the last 2 pages. 47 | if (currentPage <= 3) { 48 | return [1, 2, 3, '...', totalPages - 1, totalPages]; 49 | } 50 | 51 | // If the current page is among the last 3 pages, 52 | // show the first 2, an ellipsis, and the last 3 pages. 53 | if (currentPage >= totalPages - 2) { 54 | return [1, 2, '...', totalPages - 2, totalPages - 1, totalPages]; 55 | } 56 | 57 | // If the current page is somewhere in the middle, 58 | // show the first page, an ellipsis, the current page and its neighbors, 59 | // another ellipsis, and the last page. 60 | return [ 61 | 1, 62 | '...', 63 | currentPage - 1, 64 | currentPage, 65 | currentPage + 1, 66 | '...', 67 | totalPages, 68 | ]; 69 | }; 70 | -------------------------------------------------------------------------------- /app/lib/definitions.ts: -------------------------------------------------------------------------------- 1 | // This file contains type definitions for your data. 2 | // It describes the shape of the data, and what data type each property should accept. 3 | // For simplicity of teaching, we're manually defining these types. 4 | // However, these types are generated automatically if you're using an ORM such as Prisma. 5 | export type User = { 6 | id: string; 7 | name: string; 8 | email: string; 9 | password: string; 10 | }; 11 | 12 | export type Customer = { 13 | id: string; 14 | name: string; 15 | email: string; 16 | image_url: string; 17 | }; 18 | 19 | export type Invoice = { 20 | id: string; 21 | customer_id: string; 22 | amount: number; 23 | date: string; 24 | // In TypeScript, this is called a string union type. 25 | // It means that the "status" property can only be one of the two strings: 'pending' or 'paid'. 26 | status: 'pending' | 'paid'; 27 | }; 28 | 29 | export type Revenue = { 30 | month: string; 31 | revenue: number; 32 | }; 33 | 34 | export type LatestInvoice = { 35 | id: string; 36 | name: string; 37 | image_url: string; 38 | email: string; 39 | amount: string; 40 | }; 41 | 42 | // The database returns a number for amount, but we later format it to a string with the formatCurrency function 43 | export type LatestInvoiceRaw = Omit & { 44 | amount: number; 45 | }; 46 | 47 | export type InvoicesTable = { 48 | id: string; 49 | customer_id: string; 50 | name: string; 51 | email: string; 52 | image_url: string; 53 | date: string; 54 | amount: number; 55 | status: 'pending' | 'paid'; 56 | }; 57 | 58 | export type CustomersTable = { 59 | id: string; 60 | name: string; 61 | email: string; 62 | image_url: string; 63 | total_invoices: number; 64 | total_pending: number; 65 | total_paid: number; 66 | }; 67 | 68 | export type FormattedCustomersTable = { 69 | id: string; 70 | name: string; 71 | email: string; 72 | image_url: string; 73 | total_invoices: number; 74 | total_pending: string; 75 | total_paid: string; 76 | }; 77 | 78 | export type CustomerField = { 79 | id: string; 80 | name: string; 81 | }; 82 | 83 | export type InvoiceForm = { 84 | id: string; 85 | customer_id: string; 86 | amount: number; 87 | status: 'pending' | 'paid'; 88 | }; 89 | -------------------------------------------------------------------------------- /app/ui/dashboard/revenue-chart.tsx: -------------------------------------------------------------------------------- 1 | import { generateYAxis } from '@/app/lib/utils'; 2 | import { CalendarIcon } from '@heroicons/react/24/outline'; 3 | import { lusitana } from '@/app/ui/fonts'; 4 | import { Revenue } from '@/app/lib/definitions'; 5 | import { fetchRevenue } from '@/app/lib/data-pb'; 6 | 7 | // This component is representational only. 8 | // For data visualization UI, check out: 9 | // https://www.tremor.so/ 10 | // https://www.chartjs.org/ 11 | // https://airbnb.io/visx/ 12 | 13 | export default async function RevenueChart() { 14 | 15 | const revenue: Revenue[] = await fetchRevenue(); 16 | 17 | const chartHeight = 350; 18 | // NOTE: comment in this code when you get to this point in the course 19 | 20 | const { yAxisLabels, topLabel } = generateYAxis(revenue); 21 | 22 | if (!revenue || revenue.length === 0) { 23 | return

No data available.

; 24 | } 25 | 26 | return ( 27 |
28 |

29 | Recent Revenue 30 |

31 | {/* NOTE: comment in this code when you get to this point in the course */} 32 | 33 |
34 |
35 |
39 | {yAxisLabels.map((label) => ( 40 |

{label}

41 | ))} 42 |
43 | 44 | {revenue.map((month) => ( 45 |
46 |
52 |

53 | {month.month} 54 |

55 |
56 | ))} 57 |
58 |
59 | 60 |

Last 12 months

61 |
62 |
63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /app/ui/dashboard/latest-invoices.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowPathIcon } from '@heroicons/react/24/outline'; 2 | import clsx from 'clsx'; 3 | import Image from 'next/image'; 4 | import { lusitana } from '@/app/ui/fonts'; 5 | import { LatestInvoice } from '@/app/lib/definitions'; 6 | import { fetchLatestInvoices } from '@/app/lib/data-pb'; 7 | 8 | export default async function LatestInvoices() { 9 | 10 | const latestInvoices: LatestInvoice[] = await fetchLatestInvoices(); 11 | 12 | return ( 13 |
14 |

15 | Latest Invoices 16 |

17 |
18 | {/* NOTE: comment in this code when you get to this point in the course */} 19 | 20 |
21 | {latestInvoices.map((invoice, i) => { 22 | return ( 23 |
32 |
33 | 40 |
41 |

42 | {invoice.name} 43 |

44 |

45 | {invoice.email} 46 |

47 |
48 |
49 |

52 | {invoice.amount} 53 |

54 |
55 | ); 56 | })} 57 |
58 |
59 | 60 |

Updated just now

61 |
62 |
63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /app/ui/login-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { lusitana } from '@/app/ui/fonts'; 4 | import { 5 | AtSymbolIcon, 6 | KeyIcon, 7 | ExclamationCircleIcon, 8 | } from '@heroicons/react/24/outline'; 9 | import { ArrowRightIcon } from '@heroicons/react/20/solid'; 10 | import { Button } from './button'; 11 | import { useFormState, useFormStatus } from 'react-dom'; 12 | import { authenticate } from '@/app/lib/actions'; 13 | import { useEffect } from 'react'; 14 | import { redirect } from 'next/navigation'; 15 | 16 | export default function LoginForm() { 17 | 18 | const [code, action] = useFormState(authenticate, undefined); 19 | 20 | useEffect(() => { 21 | if (code === 'ok') { 22 | redirect('/dashboard'); 23 | } 24 | 25 | }, [code]) 26 | 27 | 28 | return ( 29 |
30 |
31 |

32 | Please log in to continue. 33 |

34 |
35 |
36 | 42 |
43 | 51 | 52 |
53 |
54 |
55 | 61 |
62 | 71 | 72 |
73 |
74 |
75 | 76 |
77 | {code === 'AuthError' && ( 78 | <> 79 | 80 |

81 | Invalid credentials 82 |

83 | 84 | )} 85 |
86 |
87 | 88 | ); 89 | } 90 | 91 | function LoginButton() { 92 | const { pending } = useFormStatus(); 93 | 94 | return ( 95 | 98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /app/ui/invoices/pagination.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline'; 4 | import clsx from 'clsx'; 5 | import Link from 'next/link'; 6 | import { generatePagination } from '@/app/lib/utils'; 7 | import { usePathname, useSearchParams } from 'next/navigation'; 8 | 9 | 10 | export default function Pagination({ totalPages }: { totalPages: number }) { 11 | // NOTE: comment in this code when you get to this point in the course 12 | 13 | const pathname = usePathname(); 14 | const searchParams = useSearchParams(); 15 | const currentPage = Number(searchParams.get('page')) || 1; 16 | 17 | const createPageURL = (pageNumber: number | string) => { 18 | const params = new URLSearchParams(searchParams); 19 | params.set('page', pageNumber.toString()); 20 | return `${pathname}?${params.toString()}`; 21 | }; 22 | 23 | const allPages = generatePagination(currentPage, totalPages); 24 | 25 | return ( 26 | <> 27 | {/* NOTE: comment in this code when you get to this point in the course */} 28 | 29 |
30 | 35 | 36 |
37 | {allPages.map((page, index) => { 38 | let position: 'first' | 'last' | 'single' | 'middle' | undefined; 39 | 40 | if (index === 0) position = 'first'; 41 | if (index === allPages.length - 1) position = 'last'; 42 | if (allPages.length === 1) position = 'single'; 43 | if (page === '...') position = 'middle'; 44 | 45 | return ( 46 | 53 | ); 54 | })} 55 |
56 | 57 | = totalPages} 61 | /> 62 |
63 | 64 | ); 65 | } 66 | 67 | function PaginationNumber({ 68 | page, 69 | href, 70 | isActive, 71 | position, 72 | }: { 73 | page: number | string; 74 | href: string; 75 | position?: 'first' | 'last' | 'middle' | 'single'; 76 | isActive: boolean; 77 | }) { 78 | const className = clsx( 79 | 'flex h-10 w-10 items-center justify-center text-sm border', 80 | { 81 | 'rounded-l-md': position === 'first' || position === 'single', 82 | 'rounded-r-md': position === 'last' || position === 'single', 83 | 'z-10 bg-blue-600 border-blue-600 text-white': isActive, 84 | 'hover:bg-gray-100': !isActive && position !== 'middle', 85 | 'text-gray-300': position === 'middle', 86 | }, 87 | ); 88 | 89 | return isActive || position === 'middle' ? ( 90 |
{page}
91 | ) : ( 92 | 93 | {page} 94 | 95 | ); 96 | } 97 | 98 | function PaginationArrow({ 99 | href, 100 | direction, 101 | isDisabled, 102 | }: { 103 | href: string; 104 | direction: 'left' | 'right'; 105 | isDisabled?: boolean; 106 | }) { 107 | const className = clsx( 108 | 'flex h-10 w-10 items-center justify-center rounded-md border', 109 | { 110 | 'pointer-events-none text-gray-300': isDisabled, 111 | 'hover:bg-gray-100': !isDisabled, 112 | 'mr-2 md:mr-4': direction === 'left', 113 | 'ml-2 md:ml-4': direction === 'right', 114 | }, 115 | ); 116 | 117 | const icon = 118 | direction === 'left' ? ( 119 | 120 | ) : ( 121 | 122 | ); 123 | 124 | return isDisabled ? ( 125 |
{icon}
126 | ) : ( 127 | 128 | {icon} 129 | 130 | ); 131 | } 132 | -------------------------------------------------------------------------------- /app/lib/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | import { z } from 'zod'; 3 | import { revalidatePath } from 'next/cache'; 4 | import { redirect } from 'next/navigation'; 5 | import { cookies } from 'next/headers'; 6 | import { initPocketbaseFromCookie } from './pb'; 7 | 8 | 9 | const InvoiceSchema = z.object({ 10 | id: z.string(), 11 | customerId: z.string({ 12 | invalid_type_error: 'Please select a customer.', 13 | }), 14 | amount: z.coerce.number().gt(0, { 15 | message: 'Please enter an amount greater than $0.' 16 | }), 17 | status: z.enum(['pending', 'paid'], { 18 | invalid_type_error: 'Please select an invoice status.' 19 | }), 20 | date: z.string() 21 | }); 22 | 23 | export type State = { 24 | errors?: { 25 | customerId?: string[]; 26 | amount?: string[]; 27 | status?: string[]; 28 | }; 29 | message?: string | null; 30 | }; 31 | 32 | const CreateInvoice = InvoiceSchema.omit({ id: true, date: true }); 33 | const UpdateInvoice = InvoiceSchema.omit({ id: true, date: true }); 34 | 35 | export async function createInvoice(prevState: State, formData: FormData) { 36 | const pb = await initPocketbaseFromCookie(); 37 | 38 | const validatedFields = CreateInvoice.safeParse({ 39 | customerId: formData.get('customerId'), 40 | amount: formData.get('amount'), 41 | status: formData.get('status') 42 | }); 43 | 44 | if (!validatedFields.success) { 45 | return { 46 | errors: validatedFields.error.flatten().fieldErrors, 47 | message: 'Missing Fields. Failed to Create Invoice.', 48 | }; 49 | } 50 | 51 | const { customerId, amount, status } = validatedFields.data; 52 | 53 | const amountInCents = amount * 100; 54 | const date = new Date().toISOString().split('T')[0]; 55 | 56 | try { 57 | await pb.collection('invoices').create({ 58 | customer: customerId, 59 | status, 60 | amount: amountInCents, 61 | date 62 | }) 63 | 64 | } catch (error) { 65 | return { 66 | message: 'Database error: Failed to create invoice.' 67 | } 68 | } 69 | revalidatePath('/dashboard/invoices'); 70 | redirect('/dashboard/invoices'); 71 | } 72 | 73 | 74 | export async function updateInvoice(id: string, prevState: State, formData: FormData) { 75 | const pb = await initPocketbaseFromCookie(); 76 | 77 | const validatedFields = UpdateInvoice.safeParse( 78 | { 79 | customerId: formData.get('customerId'), 80 | amount: formData.get('amount'), 81 | status: formData.get('status') 82 | } 83 | ) 84 | 85 | if (!validatedFields.success) { 86 | return { 87 | errors: validatedFields.error.flatten().fieldErrors, 88 | message: 'Missing Fields. Failed to Create Invoice.', 89 | }; 90 | } 91 | 92 | const { customerId, amount, status } = validatedFields.data; 93 | 94 | const amountInCents = amount * 100; 95 | 96 | try { 97 | 98 | await pb.collection('invoices').update(id, { 99 | customer: customerId, 100 | status, 101 | amount: amountInCents 102 | }) 103 | } catch (error) { 104 | return { 105 | message: 'Database error: Failed to update invoice.' 106 | } 107 | } 108 | 109 | revalidatePath('/dashboard/invoices'); 110 | redirect('/dashboard/invoices'); 111 | } 112 | 113 | export async function deleteInvoice(id: string) { 114 | try { 115 | const pb = await initPocketbaseFromCookie(); 116 | 117 | await pb.collection('invoices').delete(id); 118 | revalidatePath('/dashboard/invoices'); 119 | return { 120 | message: 'Deleted invoice.' 121 | } 122 | } catch (error) { 123 | return { 124 | message: 'Database error: Failed to delete invoice.' 125 | } 126 | } 127 | } 128 | 129 | export async function authenticate(prevState: string | undefined, formData: FormData) { 130 | try { 131 | const pb = await initPocketbaseFromCookie(); 132 | 133 | // I would create a zod schema here to validate the email and password. 134 | // Too lazy for now.... look above for example of data validation. 135 | const auth = await pb.collection('users').authWithPassword(formData.get('email') as string, formData.get('password') as string) 136 | 137 | if (pb.authStore.isValid) { 138 | cookies().set('pb_auth', pb.authStore.exportToCookie()); 139 | } 140 | 141 | return 'ok'; 142 | } catch (error) { 143 | console.log(error); 144 | 145 | return 'AuthError'; 146 | } 147 | } 148 | 149 | export async function signout() { 150 | const pb = await initPocketbaseFromCookie(); 151 | 152 | pb.authStore.clear(); 153 | cookies().delete('pb_auth'); 154 | 155 | redirect('/login'); 156 | } -------------------------------------------------------------------------------- /app/lib/placeholder-data.js: -------------------------------------------------------------------------------- 1 | // This file contains placeholder data that you'll be replacing with real data in the Data Fetching chapter: 2 | // https://nextjs.org/learn/dashboard-app/fetching-data 3 | const users = [ 4 | { 5 | id: '410544b2-4001-4271-9855-fec4b6a6442a', 6 | name: 'User', 7 | email: 'user@nextmail.com', 8 | password: '123456', 9 | }, 10 | ]; 11 | 12 | const customers = [ 13 | { 14 | id: '3958dc9e-712f-4377-85e9-fec4b6a6442a', 15 | name: 'Delba de Oliveira', 16 | email: 'delba@oliveira.com', 17 | image_url: '/customers/delba-de-oliveira.png', 18 | }, 19 | { 20 | id: '3958dc9e-742f-4377-85e9-fec4b6a6442a', 21 | name: 'Lee Robinson', 22 | email: 'lee@robinson.com', 23 | image_url: '/customers/lee-robinson.png', 24 | }, 25 | { 26 | id: '3958dc9e-737f-4377-85e9-fec4b6a6442a', 27 | name: 'Hector Simpson', 28 | email: 'hector@simpson.com', 29 | image_url: '/customers/hector-simpson.png', 30 | }, 31 | { 32 | id: '50ca3e18-62cd-11ee-8c99-0242ac120002', 33 | name: 'Steven Tey', 34 | email: 'steven@tey.com', 35 | image_url: '/customers/steven-tey.png', 36 | }, 37 | { 38 | id: '3958dc9e-787f-4377-85e9-fec4b6a6442a', 39 | name: 'Steph Dietz', 40 | email: 'steph@dietz.com', 41 | image_url: '/customers/steph-dietz.png', 42 | }, 43 | { 44 | id: '76d65c26-f784-44a2-ac19-586678f7c2f2', 45 | name: 'Michael Novotny', 46 | email: 'michael@novotny.com', 47 | image_url: '/customers/michael-novotny.png', 48 | }, 49 | { 50 | id: 'd6e15727-9fe1-4961-8c5b-ea44a9bd81aa', 51 | name: 'Evil Rabbit', 52 | email: 'evil@rabbit.com', 53 | image_url: '/customers/evil-rabbit.png', 54 | }, 55 | { 56 | id: '126eed9c-c90c-4ef6-a4a8-fcf7408d3c66', 57 | name: 'Emil Kowalski', 58 | email: 'emil@kowalski.com', 59 | image_url: '/customers/emil-kowalski.png', 60 | }, 61 | { 62 | id: 'CC27C14A-0ACF-4F4A-A6C9-D45682C144B9', 63 | name: 'Amy Burns', 64 | email: 'amy@burns.com', 65 | image_url: '/customers/amy-burns.png', 66 | }, 67 | { 68 | id: '13D07535-C59E-4157-A011-F8D2EF4E0CBB', 69 | name: 'Balazs Orban', 70 | email: 'balazs@orban.com', 71 | image_url: '/customers/balazs-orban.png', 72 | }, 73 | ]; 74 | 75 | const invoices = [ 76 | { 77 | customer_id: customers[0].id, 78 | amount: 15795, 79 | status: 'pending', 80 | date: '2022-12-06', 81 | }, 82 | { 83 | customer_id: customers[1].id, 84 | amount: 20348, 85 | status: 'pending', 86 | date: '2022-11-14', 87 | }, 88 | { 89 | customer_id: customers[4].id, 90 | amount: 3040, 91 | status: 'paid', 92 | date: '2022-10-29', 93 | }, 94 | { 95 | customer_id: customers[3].id, 96 | amount: 44800, 97 | status: 'paid', 98 | date: '2023-09-10', 99 | }, 100 | { 101 | customer_id: customers[5].id, 102 | amount: 34577, 103 | status: 'pending', 104 | date: '2023-08-05', 105 | }, 106 | { 107 | customer_id: customers[7].id, 108 | amount: 54246, 109 | status: 'pending', 110 | date: '2023-07-16', 111 | }, 112 | { 113 | customer_id: customers[6].id, 114 | amount: 666, 115 | status: 'pending', 116 | date: '2023-06-27', 117 | }, 118 | { 119 | customer_id: customers[3].id, 120 | amount: 32545, 121 | status: 'paid', 122 | date: '2023-06-09', 123 | }, 124 | { 125 | customer_id: customers[4].id, 126 | amount: 1250, 127 | status: 'paid', 128 | date: '2023-06-17', 129 | }, 130 | { 131 | customer_id: customers[5].id, 132 | amount: 8546, 133 | status: 'paid', 134 | date: '2023-06-07', 135 | }, 136 | { 137 | customer_id: customers[1].id, 138 | amount: 500, 139 | status: 'paid', 140 | date: '2023-08-19', 141 | }, 142 | { 143 | customer_id: customers[5].id, 144 | amount: 8945, 145 | status: 'paid', 146 | date: '2023-06-03', 147 | }, 148 | { 149 | customer_id: customers[2].id, 150 | amount: 8945, 151 | status: 'paid', 152 | date: '2023-06-18', 153 | }, 154 | { 155 | customer_id: customers[0].id, 156 | amount: 8945, 157 | status: 'paid', 158 | date: '2023-10-04', 159 | }, 160 | { 161 | customer_id: customers[2].id, 162 | amount: 1000, 163 | status: 'paid', 164 | date: '2022-06-05', 165 | }, 166 | ]; 167 | 168 | const revenue = [ 169 | { month: 'Jan', revenue: 2000 }, 170 | { month: 'Feb', revenue: 1800 }, 171 | { month: 'Mar', revenue: 2200 }, 172 | { month: 'Apr', revenue: 2500 }, 173 | { month: 'May', revenue: 2300 }, 174 | { month: 'Jun', revenue: 3200 }, 175 | { month: 'Jul', revenue: 3500 }, 176 | { month: 'Aug', revenue: 3700 }, 177 | { month: 'Sep', revenue: 2500 }, 178 | { month: 'Oct', revenue: 2800 }, 179 | { month: 'Nov', revenue: 3000 }, 180 | { month: 'Dec', revenue: 4800 }, 181 | ]; 182 | 183 | module.exports = { 184 | users, 185 | customers, 186 | invoices, 187 | revenue, 188 | }; 189 | -------------------------------------------------------------------------------- /app/ui/invoices/table.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import { UpdateInvoice, DeleteInvoice } from '@/app/ui/invoices/buttons'; 3 | import InvoiceStatus from '@/app/ui/invoices/status'; 4 | import { formatDateToLocal, formatCurrency } from '@/app/lib/utils'; 5 | import { fetchFilteredInvoices } from '@/app/lib/data-pb'; 6 | 7 | export default async function InvoicesTable({ 8 | query, 9 | currentPage, 10 | }: { 11 | query: string; 12 | currentPage: number; 13 | }) { 14 | const invoices = await fetchFilteredInvoices(query, currentPage); 15 | 16 | 17 | return ( 18 |
19 |
20 |
21 |
22 | {invoices?.map((invoice) => ( 23 |
27 |
28 |
29 |
30 | 37 |

{invoice.name}

38 |
39 |

{invoice.email}

40 |
41 | 42 |
43 |
44 |
45 |

46 | {formatCurrency(invoice.amount)} 47 |

48 |

{formatDateToLocal(invoice.date)}

49 |
50 |
51 | 52 | 53 |
54 |
55 |
56 | ))} 57 |
58 |
59 | 60 | 61 | 64 | 67 | 70 | 73 | 76 | 79 | 80 | 81 | 82 | {invoices?.map((invoice) => ( 83 | 87 | 99 | 102 | 105 | 108 | 111 | 117 | 118 | ))} 119 | 120 |
62 | Customer 63 | 65 | Email 66 | 68 | Amount 69 | 71 | Date 72 | 74 | Status 75 | 77 | Edit 78 |
88 |
89 | {`${invoice.name}'s 96 |

{invoice.name}

97 |
98 |
100 | {invoice.email} 101 | 103 | {formatCurrency(invoice.amount)} 104 | 106 | {formatDateToLocal(invoice.date)} 107 | 109 | 110 | 112 |
113 | 114 | 115 |
116 |
121 |
122 | 123 | 124 | 125 | ); 126 | } 127 | -------------------------------------------------------------------------------- /app/ui/customers/table.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import { lusitana } from '@/app/ui/fonts'; 3 | import Search from '../search'; 4 | import { CustomersTable, FormattedCustomersTable } from '@/app/lib/definitions'; 5 | 6 | export default async function CustomersTable({ 7 | customers, 8 | }: { 9 | customers: FormattedCustomersTable[]; 10 | }) { 11 | return ( 12 |
13 |

14 | Customers 15 |

16 | 17 |
18 |
19 |
20 |
21 |
22 | {customers?.map((customer) => ( 23 |
27 |
28 |
29 |
30 |
31 | {`${customer.name}'s 38 |

{customer.name}

39 |
40 |
41 |

42 | {customer.email} 43 |

44 |
45 |
46 |
47 |
48 |

Pending

49 |

{customer.total_pending}

50 |
51 |
52 |

Paid

53 |

{customer.total_paid}

54 |
55 |
56 |
57 |

{customer.total_invoices} invoices

58 |
59 |
60 | ))} 61 |
62 | 63 | 64 | 65 | 68 | 71 | 74 | 77 | 80 | 81 | 82 | 83 | 84 | {customers.map((customer) => ( 85 | 86 | 98 | 101 | 104 | 107 | 110 | 111 | ))} 112 | 113 |
66 | Name 67 | 69 | Email 70 | 72 | Total Invoices 73 | 75 | Total Pending 76 | 78 | Total Paid 79 |
87 |
88 | {`${customer.name}'s 95 |

{customer.name}

96 |
97 |
99 | {customer.email} 100 | 102 | {customer.total_invoices} 103 | 105 | {customer.total_pending} 106 | 108 | {customer.total_paid} 109 |
114 |
115 |
116 |
117 |
118 |
119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /app/ui/invoices/create-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { CustomerField } from '@/app/lib/definitions'; 4 | import Link from 'next/link'; 5 | import { 6 | CheckIcon, 7 | ClockIcon, 8 | CurrencyDollarIcon, 9 | UserCircleIcon, 10 | } from '@heroicons/react/24/outline'; 11 | import { Button } from '../button'; 12 | import { createInvoice } from '@/app/lib/actions'; 13 | import { useFormState } from 'react-dom'; 14 | 15 | 16 | export default function Form({ customers }: { customers: CustomerField[] }) { 17 | 18 | const initialState = { message: null, errors: {} }; 19 | const [state, dispatch] = useFormState(createInvoice, initialState); 20 | return ( 21 |
22 |
23 | {/* Customer Name */} 24 |
25 | 28 |
29 | 45 | 46 |
47 | {state.errors?.customerId ? ( 48 |
53 | {state.errors.customerId.map((error: string) => ( 54 |

{error}

55 | ))} 56 |
57 | ) : null} 58 |
59 | 60 | {/* Invoice Amount */} 61 |
62 | 65 |
66 |
67 | 76 | 77 |
78 |
79 | {state.errors?.amount ? ( 80 |
85 | {state.errors.amount.map((error: string) => ( 86 |

{error}

87 | ))} 88 |
89 | ) : null} 90 |
91 | 92 | {/* Invoice Status */} 93 |
94 | 97 |
98 |
99 |
100 | 108 | 114 |
115 |
116 | 124 | 130 |
131 |
132 |
133 | {state.errors?.status ? ( 134 |
139 | {state.errors.status.map((error: string) => ( 140 |

{error}

141 | ))} 142 |
143 | ) : null} 144 |
145 |
146 |
147 | 151 | Cancel 152 | 153 | 154 |
155 |
156 | ); 157 | } 158 | -------------------------------------------------------------------------------- /app/ui/invoices/edit-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { CustomerField, InvoiceForm } from '@/app/lib/definitions'; 4 | import { 5 | CheckIcon, 6 | ClockIcon, 7 | CurrencyDollarIcon, 8 | UserCircleIcon, 9 | } from '@heroicons/react/24/outline'; 10 | import Link from 'next/link'; 11 | import { Button } from '@/app/ui/button'; 12 | import { updateInvoice } from '@/app/lib/actions'; 13 | import { useFormState } from 'react-dom'; 14 | 15 | export default function EditInvoiceForm({ 16 | invoice, 17 | customers, 18 | }: { 19 | invoice: InvoiceForm; 20 | customers: CustomerField[]; 21 | }) { 22 | 23 | const initialState = { message: null, error: {} }; 24 | const updateInvoiceWithId = updateInvoice.bind(null, invoice.id); 25 | const [state, dispatch] = useFormState(updateInvoiceWithId, initialState); 26 | 27 | return ( 28 |
29 |
30 | {/* Invoice ID */} 31 | 32 | {/* Customer Name */} 33 |
34 | 37 |
38 | 54 | 55 |
56 | {state.errors?.customerId ? ( 57 |
62 | {state.errors.customerId.map((error: string) => ( 63 |

{error}

64 | ))} 65 |
66 | ) : null} 67 |
68 | 69 | {/* Invoice Amount */} 70 |
71 | 74 |
75 |
76 | 85 | 86 |
87 |
88 | {state.errors?.amount ? ( 89 |
94 | {state.errors.amount.map((error: string) => ( 95 |

{error}

96 | ))} 97 |
98 | ) : null} 99 |
100 | 101 | {/* Invoice Status */} 102 |
103 | 106 |
107 |
108 |
109 | 118 | 124 |
125 |
126 | 135 | 141 |
142 |
143 |
144 | {state.errors?.status ? ( 145 |
150 | {state.errors.status.map((error: string) => ( 151 |

{error}

152 | ))} 153 |
154 | ) : null} 155 |
156 |
157 |
158 | 162 | Cancel 163 | 164 | 165 |
166 |
167 | ); 168 | } 169 | -------------------------------------------------------------------------------- /app/lib/data-pb.ts: -------------------------------------------------------------------------------- 1 | import { RecordListOptions } from 'pocketbase'; 2 | 3 | import { 4 | CustomerField, 5 | CustomersTable, 6 | FormattedCustomersTable, 7 | InvoiceForm, 8 | Revenue, 9 | } from './definitions'; 10 | import { formatCurrency } from './utils'; 11 | 12 | import { unstable_noStore as noStore } from 'next/cache'; 13 | import { initPocketbaseFromCookie } from './pb'; 14 | 15 | const ITEMS_PER_PAGE = 6; 16 | 17 | export async function fetchRevenue() { 18 | noStore(); 19 | // Add noStore() here prevent the response from being cached. 20 | // This is equivalent to in fetch(..., {cache: 'no-store'}). 21 | 22 | try { 23 | // Artificially delay a reponse for demo purposes. 24 | // Don't do this in real life :) 25 | 26 | // console.log('Fetching revenue data...'); 27 | // await new Promise((resolve) => setTimeout(resolve, 3000)); 28 | const pb = await initPocketbaseFromCookie(); 29 | 30 | const data = await pb.collection('revenue').getFullList(); 31 | 32 | // console.log('Data fetch complete after 3 seconds.'); 33 | 34 | return data; 35 | } catch (error) { 36 | console.error('Database Error:', error); 37 | throw new Error('Failed to fetch revenue data.'); 38 | } 39 | } 40 | 41 | export async function fetchLatestInvoices() { 42 | noStore(); 43 | 44 | try { 45 | const pb = await initPocketbaseFromCookie(); 46 | 47 | const data = await pb.collection('latestInvoices').getFullList() 48 | 49 | const latestInvoices = data.map((invoice) => ({ 50 | ...invoice, 51 | amount: formatCurrency(invoice.amount), 52 | })); 53 | return latestInvoices; 54 | } catch (error) { 55 | console.error('Database Error:', error); 56 | throw new Error('Failed to fetch the latest invoices.'); 57 | } 58 | } 59 | 60 | export async function fetchCardData() { 61 | noStore(); 62 | 63 | try { 64 | const pb = await initPocketbaseFromCookie(); 65 | 66 | // you could write custom view collection for the count, but I just use the totalItems property. 67 | const invoiceCountPromise = pb.collection('invoices').getList(1, 1) // sql`SELECT COUNT(*) FROM invoices`; 68 | const customerCountPromise = pb.collection('customers').getList(1, 1) // sql`SELECT COUNT(*) FROM customers`; 69 | 70 | // here I create a custom view collection. makes my life easier. Just raw dog with sql! 71 | // https://pocketbase.io/docs/collections/#view-collection 72 | const invoiceStatusPromise = pb.collection('invoiceStatus').getList(1, 1); 73 | 74 | const data = await Promise.all([ 75 | invoiceCountPromise, 76 | customerCountPromise, 77 | invoiceStatusPromise, 78 | ]); 79 | 80 | const numberOfInvoices = Number(data[0].totalItems ?? '0'); 81 | const numberOfCustomers = Number(data[1].totalItems ?? '0'); 82 | const totalPaidInvoices = formatCurrency(data[2].items[0].paid ?? '0'); 83 | const totalPendingInvoices = formatCurrency(data[2].items[0].pending ?? '0'); 84 | 85 | 86 | return { 87 | numberOfCustomers, 88 | numberOfInvoices, 89 | totalPaidInvoices, 90 | totalPendingInvoices, 91 | }; 92 | } catch (error) { 93 | console.error('Database Error:', error); 94 | throw new Error('Failed to card data.'); 95 | } 96 | } 97 | 98 | 99 | export async function fetchFilteredInvoices( 100 | query: string, 101 | currentPage: number, 102 | ) { 103 | noStore(); 104 | 105 | try { 106 | const pb = await initPocketbaseFromCookie(); 107 | 108 | const queryOptions: RecordListOptions = { 109 | fields: 'id, amount, date, status, expand.customer.email, expand.customer.name, expand.customer.image_url', 110 | expand: 'customer', 111 | sort: '-date,-amount', 112 | filter: pb.filter("status ~ {:query} || customer.name ~ {:query} || customer.email ~ {:query}", { query }) 113 | }; 114 | 115 | const invoices = await pb.collection('invoices').getList(currentPage, ITEMS_PER_PAGE, queryOptions); 116 | 117 | const data = invoices.items.map((invoice) => ({ 118 | ...invoice, 119 | ...invoice.expand?.customer, 120 | })); 121 | 122 | return data; 123 | 124 | 125 | } catch (error) { 126 | console.error('Database Error:', error); 127 | throw new Error('Failed to fetch invoices.'); 128 | } 129 | } 130 | 131 | export async function fetchInvoicesPages(query: string) { 132 | noStore(); 133 | 134 | try { 135 | const pb = await initPocketbaseFromCookie(); 136 | 137 | const queryOptions: RecordListOptions = { 138 | fields: 'id', 139 | expand: 'customer', 140 | filter: pb.filter("status ~ {:query} || customer.name ~ {:query} || customer.email ~ {:query}", { query }) 141 | }; 142 | 143 | const invoices = await pb.collection('invoices').getList(1, ITEMS_PER_PAGE, queryOptions); 144 | 145 | 146 | return invoices.totalPages; 147 | 148 | } catch (error) { 149 | console.error('Database Error:', error); 150 | throw new Error('Failed to fetch total number of invoices.'); 151 | } 152 | } 153 | 154 | export async function fetchInvoiceById(id: string) { 155 | noStore(); 156 | 157 | try { 158 | const pb = await initPocketbaseFromCookie(); 159 | 160 | const data = await pb.collection('invoices').getOne(id, { 161 | fields: 'id, customer, amount, status' 162 | }) 163 | 164 | const invoice: InvoiceForm = { 165 | id: data.id, 166 | status: data.status, 167 | // Convert amount from cents to dollars 168 | customer_id: data.customer, 169 | amount: data.amount / 100, 170 | }; 171 | 172 | return invoice; 173 | } catch (error) { 174 | console.error('Database Error:', error); 175 | } 176 | } 177 | 178 | export async function fetchCustomers() { 179 | noStore(); 180 | 181 | try { 182 | const pb = await initPocketbaseFromCookie(); 183 | 184 | const customers = await pb.collection('customers').getFullList({ 185 | fields: 'id, name', 186 | sort: 'name' 187 | }) 188 | 189 | 190 | 191 | return customers; 192 | } catch (err) { 193 | console.error('Database Error:', err); 194 | throw new Error('Failed to fetch all customers.'); 195 | } 196 | } 197 | 198 | export async function fetchFilteredCustomers(query: string) { 199 | noStore(); 200 | 201 | try { 202 | const pb = await initPocketbaseFromCookie(); 203 | 204 | // most of the query is done in the view collection! 205 | const queryOptions: RecordListOptions = { 206 | filter: pb.filter("name ~ {:query} || email ~ {:query}", { query }) 207 | }; 208 | 209 | const data = await pb.collection('customersWithInvoicesInfo').getFullList(queryOptions) 210 | 211 | const customers = data.map((customer) => ({ 212 | ...customer, 213 | total_pending: formatCurrency(Number(customer.total_pending)), 214 | total_paid: formatCurrency(Number(customer.total_paid)), 215 | })); 216 | 217 | return customers; 218 | } catch (err) { 219 | console.error('Database Error:', err); 220 | } 221 | } 222 | 223 | -------------------------------------------------------------------------------- /app/ui/skeletons.tsx: -------------------------------------------------------------------------------- 1 | // Loading animation 2 | const shimmer = 3 | 'before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_2s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/60 before:to-transparent'; 4 | 5 | export function CardSkeleton() { 6 | return ( 7 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | ); 19 | } 20 | 21 | export function CardsSkeleton() { 22 | return ( 23 | <> 24 | 25 | 26 | 27 | 28 | 29 | ); 30 | } 31 | 32 | export function RevenueChartSkeleton() { 33 | return ( 34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | ); 45 | } 46 | 47 | export function InvoiceSkeleton() { 48 | return ( 49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | ); 60 | } 61 | 62 | export function LatestInvoicesSkeleton() { 63 | return ( 64 |
67 |
68 |
69 |
70 | 71 | 72 | 73 | 74 | 75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | ); 83 | } 84 | 85 | export default function DashboardSkeleton() { 86 | return ( 87 | <> 88 |
91 |
92 | 93 | 94 | 95 | 96 |
97 |
98 | 99 | 100 |
101 | 102 | ); 103 | } 104 | 105 | export function TableRowSkeleton() { 106 | return ( 107 | 108 | {/* Customer Name and Image */} 109 | 110 |
111 |
112 |
113 |
114 | 115 | {/* Email */} 116 | 117 |
118 | 119 | {/* Amount */} 120 | 121 |
122 | 123 | {/* Date */} 124 | 125 |
126 | 127 | {/* Status */} 128 | 129 |
130 | 131 | {/* Actions */} 132 | 133 |
134 |
135 |
136 |
137 | 138 | 139 | ); 140 | } 141 | 142 | export function InvoicesMobileSkeleton() { 143 | return ( 144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 | ); 164 | } 165 | 166 | export function InvoicesTableSkeleton() { 167 | return ( 168 |
169 |
170 |
171 |
172 | 173 | 174 | 175 | 176 | 177 | 178 |
179 | 180 | 181 | 182 | 185 | 188 | 191 | 194 | 197 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 |
183 | Customer 184 | 186 | Email 187 | 189 | Amount 190 | 192 | Date 193 | 195 | Status 196 | 201 | Edit 202 |
214 |
215 |
216 |
217 | ); 218 | } 219 | -------------------------------------------------------------------------------- /pb-seed/pb_schema.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "_pb_users_auth_", 4 | "name": "users", 5 | "type": "auth", 6 | "system": false, 7 | "schema": [ 8 | { 9 | "system": false, 10 | "id": "users_name", 11 | "name": "name", 12 | "type": "text", 13 | "required": false, 14 | "presentable": false, 15 | "unique": false, 16 | "options": { 17 | "min": null, 18 | "max": null, 19 | "pattern": "" 20 | } 21 | }, 22 | { 23 | "system": false, 24 | "id": "users_avatar", 25 | "name": "avatar", 26 | "type": "file", 27 | "required": false, 28 | "presentable": false, 29 | "unique": false, 30 | "options": { 31 | "maxSelect": 1, 32 | "maxSize": 5242880, 33 | "mimeTypes": [ 34 | "image/jpeg", 35 | "image/png", 36 | "image/svg+xml", 37 | "image/gif", 38 | "image/webp" 39 | ], 40 | "thumbs": null, 41 | "protected": false 42 | } 43 | } 44 | ], 45 | "indexes": [], 46 | "listRule": "id = @request.auth.id", 47 | "viewRule": "id = @request.auth.id", 48 | "createRule": "", 49 | "updateRule": "id = @request.auth.id", 50 | "deleteRule": "id = @request.auth.id", 51 | "options": { 52 | "allowEmailAuth": true, 53 | "allowOAuth2Auth": true, 54 | "allowUsernameAuth": true, 55 | "exceptEmailDomains": null, 56 | "manageRule": null, 57 | "minPasswordLength": 8, 58 | "onlyEmailDomains": null, 59 | "requireEmail": false 60 | } 61 | }, 62 | { 63 | "id": "jawhyawpsq137sn", 64 | "name": "revenue", 65 | "type": "base", 66 | "system": false, 67 | "schema": [ 68 | { 69 | "system": false, 70 | "id": "nsvtt4vb", 71 | "name": "month", 72 | "type": "text", 73 | "required": false, 74 | "presentable": false, 75 | "unique": false, 76 | "options": { 77 | "min": null, 78 | "max": null, 79 | "pattern": "" 80 | } 81 | }, 82 | { 83 | "system": false, 84 | "id": "6vle8thd", 85 | "name": "revenue", 86 | "type": "number", 87 | "required": false, 88 | "presentable": false, 89 | "unique": false, 90 | "options": { 91 | "min": null, 92 | "max": null, 93 | "noDecimal": false 94 | } 95 | } 96 | ], 97 | "indexes": [], 98 | "listRule": "", 99 | "viewRule": "", 100 | "createRule": "", 101 | "updateRule": "", 102 | "deleteRule": "", 103 | "options": {} 104 | }, 105 | { 106 | "id": "eduh5f7h1d48749", 107 | "name": "invoices", 108 | "type": "base", 109 | "system": false, 110 | "schema": [ 111 | { 112 | "system": false, 113 | "id": "qcuidulp", 114 | "name": "amount", 115 | "type": "number", 116 | "required": false, 117 | "presentable": false, 118 | "unique": false, 119 | "options": { 120 | "min": null, 121 | "max": null, 122 | "noDecimal": false 123 | } 124 | }, 125 | { 126 | "system": false, 127 | "id": "qwibewxn", 128 | "name": "status", 129 | "type": "text", 130 | "required": false, 131 | "presentable": false, 132 | "unique": false, 133 | "options": { 134 | "min": null, 135 | "max": null, 136 | "pattern": "" 137 | } 138 | }, 139 | { 140 | "system": false, 141 | "id": "zhielknw", 142 | "name": "date", 143 | "type": "date", 144 | "required": false, 145 | "presentable": false, 146 | "unique": false, 147 | "options": { 148 | "min": "", 149 | "max": "" 150 | } 151 | }, 152 | { 153 | "system": false, 154 | "id": "vpsfyvw8", 155 | "name": "customer", 156 | "type": "relation", 157 | "required": false, 158 | "presentable": false, 159 | "unique": false, 160 | "options": { 161 | "collectionId": "cjsd5suq5gqzr1h", 162 | "cascadeDelete": false, 163 | "minSelect": null, 164 | "maxSelect": 1, 165 | "displayFields": null 166 | } 167 | } 168 | ], 169 | "indexes": [], 170 | "listRule": "", 171 | "viewRule": "", 172 | "createRule": "", 173 | "updateRule": "", 174 | "deleteRule": "", 175 | "options": {} 176 | }, 177 | { 178 | "id": "ogsfw05zk52zf0e", 179 | "name": "latestInvoices", 180 | "type": "view", 181 | "system": false, 182 | "schema": [ 183 | { 184 | "system": false, 185 | "id": "wltbgs2c", 186 | "name": "amount", 187 | "type": "number", 188 | "required": false, 189 | "presentable": false, 190 | "unique": false, 191 | "options": { 192 | "min": null, 193 | "max": null, 194 | "noDecimal": false 195 | } 196 | }, 197 | { 198 | "system": false, 199 | "id": "symxqw7i", 200 | "name": "name", 201 | "type": "text", 202 | "required": false, 203 | "presentable": false, 204 | "unique": false, 205 | "options": { 206 | "min": null, 207 | "max": null, 208 | "pattern": "" 209 | } 210 | }, 211 | { 212 | "system": false, 213 | "id": "d1xg4jnk", 214 | "name": "image_url", 215 | "type": "text", 216 | "required": false, 217 | "presentable": false, 218 | "unique": false, 219 | "options": { 220 | "min": null, 221 | "max": null, 222 | "pattern": "" 223 | } 224 | }, 225 | { 226 | "system": false, 227 | "id": "xyt0cir1", 228 | "name": "email", 229 | "type": "email", 230 | "required": false, 231 | "presentable": false, 232 | "unique": false, 233 | "options": { 234 | "exceptDomains": null, 235 | "onlyDomains": null 236 | } 237 | } 238 | ], 239 | "indexes": [], 240 | "listRule": "", 241 | "viewRule": "", 242 | "createRule": null, 243 | "updateRule": null, 244 | "deleteRule": null, 245 | "options": { 246 | "query": "SELECT \n invoices.amount, \n customers.name, \n customers.image_url, \n customers.email, \n invoices.id\nFROM invoices\nJOIN customers ON invoices.customer = customers.id\nORDER BY invoices.date DESC\nLIMIT 5" 247 | } 248 | }, 249 | { 250 | "id": "jdf0qoaoo897y8u", 251 | "name": "invoiceStatus", 252 | "type": "view", 253 | "system": false, 254 | "schema": [ 255 | { 256 | "system": false, 257 | "id": "4jkfmbfo", 258 | "name": "paid", 259 | "type": "json", 260 | "required": false, 261 | "presentable": false, 262 | "unique": false, 263 | "options": {} 264 | }, 265 | { 266 | "system": false, 267 | "id": "uu2y6enp", 268 | "name": "pending", 269 | "type": "json", 270 | "required": false, 271 | "presentable": false, 272 | "unique": false, 273 | "options": {} 274 | } 275 | ], 276 | "indexes": [], 277 | "listRule": "", 278 | "viewRule": "", 279 | "createRule": null, 280 | "updateRule": null, 281 | "deleteRule": null, 282 | "options": { 283 | "query": "SELECT\n (ROW_NUMBER() OVER()) as id,\n SUM(CASE WHEN invoices.status = 'paid' THEN invoices.amount ELSE 0 END) AS \"paid\",\n SUM(CASE WHEN invoices.status = 'pending' THEN invoices.amount ELSE 0 END) AS \"pending\"\nFROM invoices" 284 | } 285 | }, 286 | { 287 | "id": "cjsd5suq5gqzr1h", 288 | "name": "customers", 289 | "type": "base", 290 | "system": false, 291 | "schema": [ 292 | { 293 | "system": false, 294 | "id": "vocrnbla", 295 | "name": "name", 296 | "type": "text", 297 | "required": false, 298 | "presentable": false, 299 | "unique": false, 300 | "options": { 301 | "min": null, 302 | "max": null, 303 | "pattern": "" 304 | } 305 | }, 306 | { 307 | "system": false, 308 | "id": "yyeb4omw", 309 | "name": "email", 310 | "type": "email", 311 | "required": false, 312 | "presentable": false, 313 | "unique": false, 314 | "options": { 315 | "exceptDomains": null, 316 | "onlyDomains": null 317 | } 318 | }, 319 | { 320 | "system": false, 321 | "id": "wyoasbx5", 322 | "name": "image_url", 323 | "type": "text", 324 | "required": false, 325 | "presentable": false, 326 | "unique": false, 327 | "options": { 328 | "min": null, 329 | "max": null, 330 | "pattern": "" 331 | } 332 | } 333 | ], 334 | "indexes": [], 335 | "listRule": "", 336 | "viewRule": "", 337 | "createRule": "", 338 | "updateRule": "", 339 | "deleteRule": "", 340 | "options": {} 341 | }, 342 | { 343 | "id": "fz3hvtognanjfbd", 344 | "name": "customersWithInvoicesInfo", 345 | "type": "view", 346 | "system": false, 347 | "schema": [ 348 | { 349 | "system": false, 350 | "id": "hioxlmhd", 351 | "name": "name", 352 | "type": "text", 353 | "required": false, 354 | "presentable": false, 355 | "unique": false, 356 | "options": { 357 | "min": null, 358 | "max": null, 359 | "pattern": "" 360 | } 361 | }, 362 | { 363 | "system": false, 364 | "id": "4f0kghtg", 365 | "name": "email", 366 | "type": "email", 367 | "required": false, 368 | "presentable": false, 369 | "unique": false, 370 | "options": { 371 | "exceptDomains": null, 372 | "onlyDomains": null 373 | } 374 | }, 375 | { 376 | "system": false, 377 | "id": "akyizlzp", 378 | "name": "image_url", 379 | "type": "text", 380 | "required": false, 381 | "presentable": false, 382 | "unique": false, 383 | "options": { 384 | "min": null, 385 | "max": null, 386 | "pattern": "" 387 | } 388 | }, 389 | { 390 | "system": false, 391 | "id": "lbsyt075", 392 | "name": "total_invoices", 393 | "type": "number", 394 | "required": false, 395 | "presentable": false, 396 | "unique": false, 397 | "options": { 398 | "min": null, 399 | "max": null, 400 | "noDecimal": false 401 | } 402 | }, 403 | { 404 | "system": false, 405 | "id": "mlxzju1a", 406 | "name": "total_pending", 407 | "type": "json", 408 | "required": false, 409 | "presentable": false, 410 | "unique": false, 411 | "options": {} 412 | }, 413 | { 414 | "system": false, 415 | "id": "rqhhs83a", 416 | "name": "total_paid", 417 | "type": "json", 418 | "required": false, 419 | "presentable": false, 420 | "unique": false, 421 | "options": {} 422 | } 423 | ], 424 | "indexes": [], 425 | "listRule": "", 426 | "viewRule": "", 427 | "createRule": null, 428 | "updateRule": null, 429 | "deleteRule": null, 430 | "options": { 431 | "query": "SELECT\n customers.id,\n customers.name,\n\tcustomers.email,\n\tcustomers.image_url,\n\tCOUNT(invoices.id) AS total_invoices,\n\tSUM(CASE WHEN invoices.status = 'pending' THEN invoices.amount ELSE 0 END) AS total_pending,\n\tSUM(CASE WHEN invoices.status = 'paid' THEN invoices.amount ELSE 0 END) AS total_paid\nFROM customers\nLEFT JOIN invoices ON customers.id = invoices.customer\nGROUP BY customers.id, customers.name, customers.email, customers.image_url\nORDER BY customers.name ASC" 432 | } 433 | } 434 | ] --------------------------------------------------------------------------------