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>
This commit is contained in:
Delba de Oliveira
2023-09-19 18:55:02 +01:00
committed by GitHub
parent 2ccc7c6461
commit 24bcb816e5
14 changed files with 338 additions and 152 deletions

View File

@@ -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;

View File

@@ -0,0 +1,5 @@
import DashboardSkeleton from '@/app/ui/skeletons';
export default function Loading() {
return <DashboardSkeleton />;
}

View File

@@ -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 (
<main>
<DashboardOverview />
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<Card title="Collected" value={totalPaidInvoices} type="collected" />
<Card title="Pending" value={totalPendingInvoices} type="pending" />
<Card title="Total Invoices" value={numberOfInvoices} type="invoices" />
<Card
title="Total Customers"
value={numberOfCustomers}
type="customers"
/>
</div>
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
<Suspense fallback={<RevenueChartSkeleton />}>
<RevenueChart />
</Suspense>
<LatestInvoices latestInvoices={latestInvoices} />
</div>
</main>
);
}

View File

@@ -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;
}

View File

@@ -0,0 +1,132 @@
import { sql } from '@vercel/postgres';
import { formatCurrency } from './utils';
import { Revenue, LatestInvoice } from './definitions';
export async function fetchRevenue(): Promise<Revenue[]> {
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<Revenue[]> {
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}`;
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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() {

View File

@@ -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 (
<div className="w-full rounded-xl border p-6 shadow-sm md:col-span-4 lg:col-span-3">
<div className="w-full rounded-xl border bg-white p-6 shadow-sm md:col-span-4 lg:col-span-3">
<h2 className="font-semibold">Latest Invoices</h2>
{lastFiveInvoices.map((invoice) => {
const customer = customers.find(
(customer) => customer.id === invoice.customer_id,
);
{latestInvoices.map((invoice) => {
return (
<div
key={invoice.id}
@@ -27,26 +18,23 @@ export default async function LatestInvoices({
>
<div className="flex items-center">
<Image
src={customer?.image_url || ''}
alt={customer?.name || ''}
src={invoice.image_url}
alt={invoice.name}
className="mr-4 rounded-full"
width={32}
height={32}
/>
<div className="min-w-0">
<p className="truncate text-sm font-semibold md:text-base">
{customer?.name}
{invoice.name}
</p>
<p className="hidden text-sm text-gray-500 sm:block">
{customer?.email}
{invoice.email}
</p>
</div>
</div>
<p className="truncate text-sm font-medium md:text-base">
{(invoice.amount / 100).toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
})}
{invoice.amount}
</p>
</div>
);

View File

@@ -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 (
<>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<Card title="Collected" value={totalPaidInvoices} type="collected" />
<Card title="Pending" value={totalPendingInvoices} type="pending" />
<Card title="Total Invoices" value={numberOfInvoices} type="invoices" />
<Card
title="Total Customers"
value={numberOfCustomers}
type="customers"
/>
</div>
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
<RevenueChart revenue={revenue} />
<LatestInvoices invoices={invoices} customers={customers} />
</div>
</>
);
}

View File

@@ -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 (
<div className="rounded-xl border p-6 shadow-sm md:col-span-5">
<div className="rounded-xl border bg-white p-6 shadow-sm md:col-span-5">
<h2 className="font-semibold">Revenue</h2>
<div className="sm:grid-cols-13 mt-4 grid grid-cols-12 items-end gap-2 md:gap-4">
{/* y-axis */}

View File

@@ -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;

View File

@@ -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 (
<div
className={`${shimmer} relative h-[114px] overflow-hidden rounded-xl border bg-white p-6 shadow-sm`}
>
<div className="flex justify-between">
<div className="h-6 w-20 rounded-lg bg-gray-100"></div>
<div className="h-6 w-6 rounded-lg bg-gray-100"></div>
</div>
<div className="mt-4 h-8 w-1/2 rounded-lg bg-gray-100"></div>
</div>
);
}
export function RevenueChartSkeleton() {
return (
<div
className={`${shimmer} relative h-[464px] overflow-hidden rounded-xl border bg-white p-6 shadow-sm md:col-span-5`}
>
<div className="h-8 w-32 rounded-lg bg-gray-100"></div>
<div className="my-4 h-[370px] w-full rounded-lg bg-gray-100"></div>
</div>
);
}
export function LatestInvoicesSkeleton() {
return (
<div
className={`${shimmer} relative w-full overflow-hidden rounded-xl border bg-white p-6 shadow-sm md:col-span-4 lg:col-span-3`}
>
<div className="h-8 w-40 rounded-lg bg-gray-100"></div>
<div className="mt-8 flex flex-row items-center justify-between">
<div className="flex items-center">
<div className="h-10 w-10 rounded-lg bg-gray-100"></div>
<div className="ml-4 h-10 w-44 rounded-lg bg-gray-100"></div>
</div>
<div className="h-10 w-16 rounded-lg bg-gray-100"></div>
</div>
<div className="mt-8 flex flex-row items-center justify-between">
<div className="flex items-center">
<div className="h-10 w-10 rounded-lg bg-gray-100"></div>
<div className="ml-4 h-10 w-44 rounded-lg bg-gray-100"></div>
</div>
<div className="h-10 w-16 rounded-lg bg-gray-100"></div>
</div>
<div className="mt-8 flex flex-row items-center justify-between">
<div className="flex items-center">
<div className="h-10 w-10 rounded-lg bg-gray-100"></div>
<div className="ml-4 h-10 w-44 rounded-lg bg-gray-100"></div>
</div>
<div className="h-10 w-16 rounded-lg bg-gray-100"></div>
</div>
<div className="mt-8 flex flex-row items-center justify-between">
<div className="flex items-center">
<div className="h-10 w-10 rounded-lg bg-gray-100"></div>
<div className="ml-4 h-10 w-44 rounded-lg bg-gray-100"></div>
</div>
<div className="h-10 w-16 rounded-lg bg-gray-100"></div>
</div>
<div className="mt-8 flex flex-row items-center justify-between">
<div className="flex items-center">
<div className="h-10 w-10 rounded-lg bg-gray-100"></div>
<div className="ml-4 h-10 w-44 rounded-lg bg-gray-100"></div>
</div>
<div className="h-10 w-16 rounded-lg bg-gray-100"></div>
</div>
</div>
);
}
export default function DashboardSkeleton() {
return (
<>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</div>
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
<RevenueChartSkeleton />
<LatestInvoicesSkeleton />
</div>
</>
);
}

View File

@@ -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;