From 24bcb816e510529ceb1015bfb53e19287cd1286d Mon Sep 17 00:00:00 2001 From: Delba de Oliveira <32464864+delbaoliveira@users.noreply.github.com> Date: Tue, 19 Sep 2023 18:55:02 +0100 Subject: [PATCH] Add code for chapters 7-8 (#164) * Rename file and add data fetches for overview page * Rename calculations.ts to utils.ts * Update code to match course * Add temporary calculation * Fix error * Move types to data fetching file * Add error handling * Parallelize data fetches * Add skeletons * Add delayed data request * Fix ts errors * Code for chapter 8 * Fix error * Clean up --------- Co-authored-by: Stephanie Dietz <49788645+StephDietz@users.noreply.github.com> --- .../app/dashboard/invoices/[id]/edit/page.tsx | 2 +- dashboard/15-final/app/dashboard/loading.tsx | 5 + dashboard/15-final/app/dashboard/page.tsx | 37 ++++- dashboard/15-final/app/lib/data-fetches.ts | 65 --------- dashboard/15-final/app/lib/data.ts | 132 ++++++++++++++++++ dashboard/15-final/app/lib/definitions.ts | 8 ++ .../app/lib/{calculations.ts => utils.ts} | 52 +++++-- dashboard/15-final/app/ui/customers/table.tsx | 4 +- .../app/ui/dashboard/latest-invoices.tsx | 32 ++--- .../15-final/app/ui/dashboard/overview.tsx | 40 ------ .../app/ui/dashboard/revenue-chart.tsx | 13 +- dashboard/15-final/app/ui/invoices/table.tsx | 2 +- dashboard/15-final/app/ui/skeletons.tsx | 90 ++++++++++++ dashboard/15-final/tailwind.config.ts | 8 ++ 14 files changed, 338 insertions(+), 152 deletions(-) create mode 100644 dashboard/15-final/app/dashboard/loading.tsx delete mode 100644 dashboard/15-final/app/lib/data-fetches.ts create mode 100644 dashboard/15-final/app/lib/data.ts rename dashboard/15-final/app/lib/{calculations.ts => utils.ts} (53%) delete mode 100644 dashboard/15-final/app/ui/dashboard/overview.tsx create mode 100644 dashboard/15-final/app/ui/skeletons.tsx diff --git a/dashboard/15-final/app/dashboard/invoices/[id]/edit/page.tsx b/dashboard/15-final/app/dashboard/invoices/[id]/edit/page.tsx index 6aca81f..6f6931b 100644 --- a/dashboard/15-final/app/dashboard/invoices/[id]/edit/page.tsx +++ b/dashboard/15-final/app/dashboard/invoices/[id]/edit/page.tsx @@ -1,7 +1,7 @@ import InvoiceForm from '@/app/ui/invoices/form'; import { notFound } from 'next/navigation'; import { Invoice } from '@/app/lib/definitions'; -import { fetchInvoiceById } from '@/app/lib/data-fetches'; +import { fetchInvoiceById } from '@/app/lib/data'; export default async function Page({ params }: { params: { id: string } }) { const id = params.id ? parseInt(params.id) : null; diff --git a/dashboard/15-final/app/dashboard/loading.tsx b/dashboard/15-final/app/dashboard/loading.tsx new file mode 100644 index 0000000..3eeaa28 --- /dev/null +++ b/dashboard/15-final/app/dashboard/loading.tsx @@ -0,0 +1,5 @@ +import DashboardSkeleton from '@/app/ui/skeletons'; + +export default function Loading() { + return ; +} diff --git a/dashboard/15-final/app/dashboard/page.tsx b/dashboard/15-final/app/dashboard/page.tsx index 0a65819..80456ae 100644 --- a/dashboard/15-final/app/dashboard/page.tsx +++ b/dashboard/15-final/app/dashboard/page.tsx @@ -1,9 +1,40 @@ -import DashboardOverview from '@/app/ui/dashboard/overview'; +import Card from '@/app/ui/dashboard/card'; +import RevenueChart from '@/app/ui/dashboard/revenue-chart'; +import LatestInvoices from '@/app/ui/dashboard/latest-invoices'; +import { + fetchLatestInvoices, + fetchCounts, + fetchTotalAmountByStatus, +} from '@/app/lib/data'; +import { Suspense } from 'react'; +import { RevenueChartSkeleton } from '@/app/ui/skeletons'; + +export const dynamic = 'force-dynamic'; + +export default async function Page() { + const latestInvoices = await fetchLatestInvoices(); + const { numberOfInvoices, numberOfCustomers } = await fetchCounts(); + const { totalPaidInvoices, totalPendingInvoices } = + await fetchTotalAmountByStatus(); -export default function Page() { return ( - + + + + + + + + }> + + + + ); } diff --git a/dashboard/15-final/app/lib/data-fetches.ts b/dashboard/15-final/app/lib/data-fetches.ts deleted file mode 100644 index 94f5f84..0000000 --- a/dashboard/15-final/app/lib/data-fetches.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { sql } from '@vercel/postgres'; - -export async function fetchAllInvoices() { - const invoicesData = await sql`SELECT * FROM invoices`; - return invoicesData.rows; -} - -export async function fetchAllCustomers() { - const customersData = await sql`SELECT * FROM customers`; - return customersData.rows; -} - -export async function fetchAllRevenue() { - const revenueData = await sql`SELECT * FROM revenue`; - return revenueData.rows; -} - -export async function fetchFilteredInvoices( - searchTerm: string, - currentPage: number, - ITEMS_PER_PAGE: number, -) { - const invoicesData = await sql` - SELECT - invoices.*, - customers.name AS customer_name, - customers.email AS customer_email, - customers.image_url AS customer_image - FROM - invoices - JOIN - customers ON invoices.customer_id = customers.id - WHERE - invoices.id::text ILIKE ${`%${searchTerm}%`} OR - customers.name ILIKE ${`%${searchTerm}%`} OR - customers.email ILIKE ${`%${searchTerm}%`} OR - invoices.amount::text ILIKE ${`%${searchTerm}%`} OR - invoices.date::text ILIKE ${`%${searchTerm}%`} OR - invoices.status ILIKE ${`%${searchTerm}%`} - LIMIT ${ITEMS_PER_PAGE} - OFFSET ${(currentPage - 1) * ITEMS_PER_PAGE} - `; - return invoicesData.rows; -} - -export async function fetchInvoiceCountBySearchTerm(searchTerm: string) { - const { rows: countRows } = await sql` - SELECT COUNT(*) - FROM invoices - LEFT JOIN customers ON invoices.customer_id = customers.id - WHERE (invoices.id::text ILIKE ${`%${searchTerm}%`} OR customers.name ILIKE ${`%${searchTerm}%`} OR customers.email ILIKE ${`%${searchTerm}%`}) -`; - return countRows[0].count; -} - -export async function fetchInvoiceById(id: number | null) { - return await sql`SELECT * from INVOICES where id=${id}`; -} - -export async function fetchLatestInvoices() { - const latestInvoices = await sql`SELECT * FROM invoices - ORDER BY date DESC - LIMIT 5;`; - return latestInvoices.rows; -} diff --git a/dashboard/15-final/app/lib/data.ts b/dashboard/15-final/app/lib/data.ts new file mode 100644 index 0000000..d757cd9 --- /dev/null +++ b/dashboard/15-final/app/lib/data.ts @@ -0,0 +1,132 @@ +import { sql } from '@vercel/postgres'; +import { formatCurrency } from './utils'; +import { Revenue, LatestInvoice } from './definitions'; + +export async function fetchRevenue(): Promise { + try { + const revenueData = await sql`SELECT * FROM revenue`; + return revenueData.rows as Revenue[]; + } catch (error) { + console.error('Failed to fetch revenue data:', error); + throw new Error('Failed to fetch revenue data.'); + } +} + +export async function fetchRevenueDelayed(): Promise { + try { + // We artificially delay a reponse for demo purposes. + // Don't do this in real life :) + console.log('Fetching revenue data...'); + await new Promise((resolve) => setTimeout(resolve, 3000)); + + const revenueData = await sql`SELECT * FROM revenue`; + console.log('Data fetch complete after 3 seconds.'); + + return revenueData.rows as Revenue[]; + } catch (error) { + console.error('Failed to fetch revenue data:', error); + throw new Error('Failed to fetch revenue data.'); + } +} + +export async function fetchCounts() { + try { + const invoiceCount = await sql`SELECT COUNT(*) FROM invoices`; + const numberOfInvoices = parseInt(invoiceCount.rows[0].count, 10); + + const customerCount = await sql`SELECT COUNT(*) FROM customers`; + const numberOfCustomers = parseInt(customerCount.rows[0].count, 10); + + return { numberOfCustomers, numberOfInvoices }; + } catch (error) { + console.error('Failed to fetch counts:', error); + throw new Error('Failed to fetch counts.'); + } +} + +export async function fetchTotalAmountByStatus() { + try { + const totalAmount = await sql`SELECT + SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS "paid", + SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) AS "pending" + FROM invoices`; + const totalPaidInvoices = formatCurrency(totalAmount.rows[0].paid); + const totalPendingInvoices = formatCurrency(totalAmount.rows[0].pending); + + return { totalPaidInvoices, totalPendingInvoices }; + } catch (error) { + console.error('Failed to fetch total amounts by status:', error); + throw new Error('Failed to fetch total amounts by status.'); + } +} + +export async function fetchLatestInvoices() { + try { + const data = await sql` + SELECT invoices.amount, customers.name, customers.image_url, customers.email + FROM invoices + JOIN customers ON invoices.customer_id = customers.id + ORDER BY invoices.date DESC + LIMIT 5`; + const latestInvoices = data.rows.map((invoice) => ({ + ...invoice, + amount: formatCurrency(invoice.amount), + })); + return latestInvoices as LatestInvoice[]; + } catch (error) { + console.error('Failed to fetch the latest invoices:', error); + throw new Error('Failed to fetch the latest invoices.'); + } +} + +export async function fetchAllInvoices() { + const invoicesData = await sql`SELECT * FROM invoices`; + return invoicesData.rows; +} + +export async function fetchAllCustomers() { + const customersData = await sql`SELECT * FROM customers`; + return customersData.rows; +} + +export async function fetchFilteredInvoices( + searchTerm: string, + currentPage: number, + ITEMS_PER_PAGE: number, +) { + const invoicesData = await sql` + SELECT + invoices.*, + customers.name AS customer_name, + customers.email AS customer_email, + customers.image_url AS customer_image + FROM + invoices + JOIN + customers ON invoices.customer_id = customers.id + WHERE + invoices.id::text ILIKE ${`%${searchTerm}%`} OR + customers.name ILIKE ${`%${searchTerm}%`} OR + customers.email ILIKE ${`%${searchTerm}%`} OR + invoices.amount::text ILIKE ${`%${searchTerm}%`} OR + invoices.date::text ILIKE ${`%${searchTerm}%`} OR + invoices.status ILIKE ${`%${searchTerm}%`} + LIMIT ${ITEMS_PER_PAGE} + OFFSET ${(currentPage - 1) * ITEMS_PER_PAGE} + `; + return invoicesData.rows; +} + +export async function fetchInvoiceCountBySearchTerm(searchTerm: string) { + const { rows: countRows } = await sql` + SELECT COUNT(*) + FROM invoices + LEFT JOIN customers ON invoices.customer_id = customers.id + WHERE (invoices.id::text ILIKE ${`%${searchTerm}%`} OR customers.name ILIKE ${`%${searchTerm}%`} OR customers.email ILIKE ${`%${searchTerm}%`}) +`; + return countRows[0].count; +} + +export async function fetchInvoiceById(id: number | null) { + return await sql`SELECT * from INVOICES where id=${id}`; +} diff --git a/dashboard/15-final/app/lib/definitions.ts b/dashboard/15-final/app/lib/definitions.ts index bbb5675..84eb7cc 100644 --- a/dashboard/15-final/app/lib/definitions.ts +++ b/dashboard/15-final/app/lib/definitions.ts @@ -25,6 +25,14 @@ export type Invoice = { date: string; }; +export type LatestInvoice = { + id: number; + name: string; + image_url: string; + email: string; + amount: string; +}; + export type Revenue = { month: string; revenue: number; diff --git a/dashboard/15-final/app/lib/calculations.ts b/dashboard/15-final/app/lib/utils.ts similarity index 53% rename from dashboard/15-final/app/lib/calculations.ts rename to dashboard/15-final/app/lib/utils.ts index 2dd143a..3b9582d 100644 --- a/dashboard/15-final/app/lib/calculations.ts +++ b/dashboard/15-final/app/lib/utils.ts @@ -1,7 +1,43 @@ -import { Invoice, Revenue } from './definitions'; -import { fetchLatestInvoices } from './data-fetches'; +import { Invoice, Revenue, Customer, LatestInvoice } from './definitions'; -export const calculateAllInvoices = ( +export const formatCurrency = (amount: number) => { + return (amount / 100).toLocaleString('en-US', { + style: 'currency', + currency: 'USD', + }); +}; + +export function findLatestInvoices(invoices: Invoice[], customers: Customer[]) { + // Sort the invoices by date in descending order and take the top 5 + const latestInvoices = [...invoices] + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) + .slice(0, 5); + + // Find corresponding customers for the latest 5 invoices + const latestInvoicesWithCustomerInfo = latestInvoices.map((invoice) => { + const customer = customers.find( + (customer) => customer.id === invoice.customer_id, + ); + + // Format the amount to USD + const formattedAmount = invoice.amount.toLocaleString('en-US', { + style: 'currency', + currency: 'USD', + }); + + return { + id: invoice.id, + name: customer?.name, + image_url: customer?.image_url, + email: customer?.email, + amount: formattedAmount, + }; + }); + + return latestInvoicesWithCustomerInfo; +} + +export const calculateInvoicesByStatus = ( invoices: Invoice[], status: 'pending' | 'paid', ) => { @@ -29,12 +65,6 @@ export const calculateCustomerInvoices = ( }); }; -// Once a database is connected, we can use SQL to query the database directly -// This will be more efficient than querying all invoices and then filtering them -// E.g. "SELECT * FROM invoices -// ORDER BY date DESC -// LIMIT 5;" - export const countCustomerInvoices = ( invoices: Invoice[], customerId: number, @@ -43,10 +73,6 @@ export const countCustomerInvoices = ( .length; }; -export const findLatestInvoices = async () => { - return await fetchLatestInvoices(); -}; - export const generateYAxis = (revenue: Revenue[]) => { // Calculate what labels we need to display on the y-axis // based on highest record and in 1000s diff --git a/dashboard/15-final/app/ui/customers/table.tsx b/dashboard/15-final/app/ui/customers/table.tsx index af08b5b..a8864ff 100644 --- a/dashboard/15-final/app/ui/customers/table.tsx +++ b/dashboard/15-final/app/ui/customers/table.tsx @@ -1,9 +1,9 @@ import { countCustomerInvoices, calculateCustomerInvoices, -} from '@/app/lib/calculations'; +} from '@/app/lib/utils'; import { Customer, Invoice } from '@/app/lib/definitions'; -import { fetchAllCustomers, fetchAllInvoices } from '@/app/lib/data-fetches'; +import { fetchAllCustomers, fetchAllInvoices } from '@/app/lib/data'; import Image from 'next/image'; export default async function CustomersTable() { diff --git a/dashboard/15-final/app/ui/dashboard/latest-invoices.tsx b/dashboard/15-final/app/ui/dashboard/latest-invoices.tsx index e2c3970..c610ae1 100644 --- a/dashboard/15-final/app/ui/dashboard/latest-invoices.tsx +++ b/dashboard/15-final/app/ui/dashboard/latest-invoices.tsx @@ -1,25 +1,16 @@ -// InvoiceList.tsx -import { Customer, Invoice } from '@/app/lib/definitions'; -import { findLatestInvoices } from '@/app/lib/calculations'; +import { LatestInvoice } from '@/app/lib/definitions'; import Image from 'next/image'; export default async function LatestInvoices({ - invoices, - customers, + latestInvoices, }: { - invoices: Invoice[]; - customers: Customer[]; + latestInvoices: LatestInvoice[]; }) { - const lastFiveInvoices = await findLatestInvoices(); - return ( - + Latest Invoices - {lastFiveInvoices.map((invoice) => { - const customer = customers.find( - (customer) => customer.id === invoice.customer_id, - ); + {latestInvoices.map((invoice) => { return ( - {customer?.name} + {invoice.name} - {customer?.email} + {invoice.email} - {(invoice.amount / 100).toLocaleString('en-US', { - style: 'currency', - currency: 'USD', - })} + {invoice.amount} ); diff --git a/dashboard/15-final/app/ui/dashboard/overview.tsx b/dashboard/15-final/app/ui/dashboard/overview.tsx deleted file mode 100644 index 32e39dc..0000000 --- a/dashboard/15-final/app/ui/dashboard/overview.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import Card from '@/app/ui/dashboard/card'; -import { calculateAllInvoices } from '@/app/lib/calculations'; -import RevenueChart from '@/app/ui/dashboard/revenue-chart'; -import LatestInvoices from '@/app/ui/dashboard/latest-invoices'; -import { Customer, Invoice, Revenue } from '@/app/lib/definitions'; -import { - fetchAllCustomers, - fetchAllInvoices, - fetchAllRevenue, -} from '@/app/lib/data-fetches'; - -export default async function DashboardOverview() { - const invoices = (await fetchAllInvoices()) as Invoice[]; - const customers = (await fetchAllCustomers()) as Customer[]; - const revenue = (await fetchAllRevenue()) as Revenue[]; - - const totalPaidInvoices = calculateAllInvoices(invoices, 'paid'); - const totalPendingInvoices = calculateAllInvoices(invoices, 'pending'); - const numberOfInvoices = invoices.length; - const numberOfCustomers = customers.length; - - return ( - <> - - - - - - - - - - - > - ); -} diff --git a/dashboard/15-final/app/ui/dashboard/revenue-chart.tsx b/dashboard/15-final/app/ui/dashboard/revenue-chart.tsx index 5e8798c..9a3100b 100644 --- a/dashboard/15-final/app/ui/dashboard/revenue-chart.tsx +++ b/dashboard/15-final/app/ui/dashboard/revenue-chart.tsx @@ -1,12 +1,15 @@ -import { Revenue } from '@/app/lib/definitions'; -import { generateYAxis } from '@/app/lib/calculations'; +import { generateYAxis } from '@/app/lib/utils'; +import { fetchRevenueDelayed } from '@/app/lib/data'; // This component is representational only. // For data visualization UI, check out: +// https://www.tremor.so/ // https://www.chartjs.org/ // https://airbnb.io/visx/ -// https://www.tremor.so/ -export default function RevenueChart({ revenue }: { revenue: Revenue[] }) { + +export default async function RevenueChart() { + const revenue = await fetchRevenueDelayed(); + const chartHeight = 350; const { yAxisLabels, topLabel } = generateYAxis(revenue); @@ -15,7 +18,7 @@ export default function RevenueChart({ revenue }: { revenue: Revenue[] }) { } return ( - + Revenue {/* y-axis */} diff --git a/dashboard/15-final/app/ui/invoices/table.tsx b/dashboard/15-final/app/ui/invoices/table.tsx index b10c711..63f25da 100644 --- a/dashboard/15-final/app/ui/invoices/table.tsx +++ b/dashboard/15-final/app/ui/invoices/table.tsx @@ -11,7 +11,7 @@ import PaginationButtons from './pagination'; import { fetchFilteredInvoices, fetchInvoiceCountBySearchTerm, -} from '@/app/lib/data-fetches'; +} from '@/app/lib/data'; const ITEMS_PER_PAGE = 10; diff --git a/dashboard/15-final/app/ui/skeletons.tsx b/dashboard/15-final/app/ui/skeletons.tsx new file mode 100644 index 0000000..48a9508 --- /dev/null +++ b/dashboard/15-final/app/ui/skeletons.tsx @@ -0,0 +1,90 @@ +// Loading animation +const shimmer = + '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'; + +export function CardSkeleton() { + return ( + + + + + + + + ); +} + +export function RevenueChartSkeleton() { + return ( + + + + + ); +} + +export function LatestInvoicesSkeleton() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default function DashboardSkeleton() { + return ( + <> + + + + + + + + + + + > + ); +} diff --git a/dashboard/15-final/tailwind.config.ts b/dashboard/15-final/tailwind.config.ts index 1e343b0..aec8276 100644 --- a/dashboard/15-final/tailwind.config.ts +++ b/dashboard/15-final/tailwind.config.ts @@ -12,7 +12,15 @@ const config: Config = { '13': 'repeat(13, minmax(0, 1fr))', }, }, + keyframes: { + shimmer: { + '100%': { + transform: 'translateX(100%)', + }, + }, + }, }, + plugins: [require('@tailwindcss/forms')], }; export default config;
- {customer?.name} + {invoice.name}
- {customer?.email} + {invoice.email}
- {(invoice.amount / 100).toLocaleString('en-US', { - style: 'currency', - currency: 'USD', - })} + {invoice.amount}