This commit is contained in:
bigdeejay
2025-01-22 17:58:47 +09:00
parent ef90ee4042
commit 19a7d45b7b
63 changed files with 7108 additions and 0 deletions

144
app/lib/actions.ts Normal file
View File

@@ -0,0 +1,144 @@
'use server';
import { z } from 'zod';
import {sql} from '@vercel/postgres';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation'
import {signIn} from "@/auth";
import {AuthError} from "next-auth";
const FormSchema = z.object({
id: z.string(),
customerId: z.string({
invalid_type_error: 'Please select a customer.'
}),
amount: z.coerce
.number()
.gt(0, {message: 'Please enter an amount greater than $0.'})
,
status: z.enum(['pending', 'paid'], {
invalid_type_error: 'Please select an invoice status.'
}),
date: z.string(),
});
export type State = {
errors?: {
customerId?: string[];
amount?: string[];
status?: string[];
};
message?: string | null;
};
export async function createInvoice(prevState: State, formData: FormData) {
const validatedFields = CreateInvoice.safeParse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status')
});
// If form validation fails, return errors early. Otherwise, continue.
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Missing Fields. Failed to Create Invoice.',
};
}
const { customerId, amount, status } = validatedFields.data;
const amountInCents = amount * 100;
const date = new Date().toISOString().split('t')[0];
try {
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
} catch {
return {
message: 'Database Error: Failed to create invoice.'
}
}
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');}
const CreateInvoice = FormSchema.omit({ id: true, date: true });
// Use Zod to update the expected types
const UpdateInvoice = FormSchema.omit({ id: true, date: true });
// ...
export async function updateInvoice(
id: string,
prevState: State,
formData: FormData,
) {
const validatedFields = UpdateInvoice.safeParse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Missing Fields. Failed to Update Invoice.',
}
}
const { customerId, amount, status } = validatedFields.data;
const amountInCents = amount * 100;
try {
await sql`
UPDATE invoices
SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
WHERE id = ${id}
`;
} catch {
return {
message: 'Database Error: Failed to update invoice.'
}
}
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
export async function deleteInvoice(
id: string,
) {
try {
await sql`DELETE FROM invoices WHERE id = ${id}`;
revalidatePath('/dashboard/invoices');
return {message: 'Deleted Invoice.'}
} catch {
return {
errors: {},
message: 'Database Error: Failed to Delete invoice.'
}
}
}
export async function authenticate(
prevState: string | undefined,
formData: FormData,
) {
try {
await signIn('credentials', formData);
} catch (error) {
if (error instanceof AuthError) {
switch (error.type) {
case 'CredentialsSignin':
return 'Invalid credentials.';
default:
return 'Something went wrong.';
}
}
throw error;
}
}

217
app/lib/data.ts Normal file
View File

@@ -0,0 +1,217 @@
import { sql } from '@vercel/postgres';
import {
CustomerField,
CustomersTableType,
InvoiceForm,
InvoicesTable,
LatestInvoiceRaw,
Revenue,
} from './definitions';
import { formatCurrency } from './utils';
export async function fetchRevenue() {
try {
// Artificially delay a response for demo purposes.
// Don't do this in production :)
console.log('Fetching revenue data...');
await new Promise((resolve) => setTimeout(resolve, 3000));
const data = await sql<Revenue>`SELECT * FROM revenue`;
console.log('Data fetch completed after 3 seconds.');
return data.rows;
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch revenue data.');
}
}
export async function fetchLatestInvoices() {
try {
const data = await sql<LatestInvoiceRaw>`
SELECT invoices.amount, customers.name, customers.image_url, customers.email, invoices.id
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;
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch the latest invoices.');
}
}
export async function fetchCardData() {
try {
// You can probably combine these into a single SQL query
// However, we are intentionally splitting them to demonstrate
// how to initialize multiple queries in parallel with JS.
const invoiceCountPromise = sql`SELECT COUNT(*) FROM invoices`;
const customerCountPromise = sql`SELECT COUNT(*) FROM customers`;
const invoiceStatusPromise = 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 data = await Promise.all([
invoiceCountPromise,
customerCountPromise,
invoiceStatusPromise,
]);
const numberOfInvoices = Number(data[0].rows[0].count ?? '0');
const numberOfCustomers = Number(data[1].rows[0].count ?? '0');
const totalPaidInvoices = formatCurrency(data[2].rows[0].paid ?? '0');
const totalPendingInvoices = formatCurrency(data[2].rows[0].pending ?? '0');
return {
numberOfCustomers,
numberOfInvoices,
totalPaidInvoices,
totalPendingInvoices,
};
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch card data.');
}
}
const ITEMS_PER_PAGE = 6;
export async function fetchFilteredInvoices(
query: string,
currentPage: number,
) {
const offset = (currentPage - 1) * ITEMS_PER_PAGE;
try {
const invoices = await sql<InvoicesTable>`
SELECT
invoices.id,
invoices.amount,
invoices.date,
invoices.status,
customers.name,
customers.email,
customers.image_url
FROM invoices
JOIN customers ON invoices.customer_id = customers.id
WHERE
customers.name ILIKE ${`%${query}%`} OR
customers.email ILIKE ${`%${query}%`} OR
invoices.amount::text ILIKE ${`%${query}%`} OR
invoices.date::text ILIKE ${`%${query}%`} OR
invoices.status ILIKE ${`%${query}%`}
ORDER BY invoices.date DESC
LIMIT ${ITEMS_PER_PAGE} OFFSET ${offset}
`;
return invoices.rows;
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch invoices.');
}
}
export async function fetchInvoicesPages(query: string) {
try {
const count = await sql`SELECT COUNT(*)
FROM invoices
JOIN customers ON invoices.customer_id = customers.id
WHERE
customers.name ILIKE ${`%${query}%`} OR
customers.email ILIKE ${`%${query}%`} OR
invoices.amount::text ILIKE ${`%${query}%`} OR
invoices.date::text ILIKE ${`%${query}%`} OR
invoices.status ILIKE ${`%${query}%`}
`;
const totalPages = Math.ceil(Number(count.rows[0].count) / ITEMS_PER_PAGE);
return totalPages;
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch total number of invoices.');
}
}
export async function fetchInvoiceById(id: string) {
try {
const data = await sql<InvoiceForm>`
SELECT
invoices.id,
invoices.customer_id,
invoices.amount,
invoices.status
FROM invoices
WHERE invoices.id = ${id};
`;
const invoice = data.rows.map((invoice) => ({
...invoice,
// Convert amount from cents to dollars
amount: invoice.amount / 100,
}));
return invoice[0];
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch invoice.');
}
}
export async function fetchCustomers() {
try {
const data = await sql<CustomerField>`
SELECT
id,
name
FROM customers
ORDER BY name ASC
`;
const customers = data.rows;
return customers;
} catch (err) {
console.error('Database Error:', err);
throw new Error('Failed to fetch all customers.');
}
}
export async function fetchFilteredCustomers(query: string) {
try {
const data = await sql<CustomersTableType>`
SELECT
customers.id,
customers.name,
customers.email,
customers.image_url,
COUNT(invoices.id) AS total_invoices,
SUM(CASE WHEN invoices.status = 'pending' THEN invoices.amount ELSE 0 END) AS total_pending,
SUM(CASE WHEN invoices.status = 'paid' THEN invoices.amount ELSE 0 END) AS total_paid
FROM customers
LEFT JOIN invoices ON customers.id = invoices.customer_id
WHERE
customers.name ILIKE ${`%${query}%`} OR
customers.email ILIKE ${`%${query}%`}
GROUP BY customers.id, customers.name, customers.email, customers.image_url
ORDER BY customers.name ASC
`;
const customers = data.rows.map((customer) => ({
...customer,
total_pending: formatCurrency(customer.total_pending),
total_paid: formatCurrency(customer.total_paid),
}));
return customers;
} catch (err) {
console.error('Database Error:', err);
throw new Error('Failed to fetch customers table.');
}
}

88
app/lib/definitions.ts Normal file
View File

@@ -0,0 +1,88 @@
// This file contains type definitions for your data.
// It describes the shape of the data, and what data type each property should accept.
// For simplicity of teaching, we're manually defining these types.
// However, these types are generated automatically if you're using an ORM such as Prisma.
export type User = {
id: string;
name: string;
email: string;
password: string;
};
export type Customer = {
id: string;
name: string;
email: string;
image_url: string;
};
export type Invoice = {
id: string;
customer_id: string;
amount: number;
date: string;
// In TypeScript, this is called a string union type.
// It means that the "status" property can only be one of the two strings: 'pending' or 'paid'.
status: 'pending' | 'paid';
};
export type Revenue = {
month: string;
revenue: number;
};
export type LatestInvoice = {
id: string;
name: string;
image_url: string;
email: string;
amount: string;
};
// The database returns a number for amount, but we later format it to a string with the formatCurrency function
export type LatestInvoiceRaw = Omit<LatestInvoice, 'amount'> & {
amount: number;
};
export type InvoicesTable = {
id: string;
customer_id: string;
name: string;
email: string;
image_url: string;
date: string;
amount: number;
status: 'pending' | 'paid';
};
export type CustomersTableType = {
id: string;
name: string;
email: string;
image_url: string;
total_invoices: number;
total_pending: number;
total_paid: number;
};
export type FormattedCustomersTable = {
id: string;
name: string;
email: string;
image_url: string;
total_invoices: number;
total_pending: string;
total_paid: string;
};
export type CustomerField = {
id: string;
name: string;
};
export type InvoiceForm = {
id: string;
customer_id: string;
amount: number;
status: 'pending' | 'paid';
};

147
app/lib/placeholder-data.ts Normal file
View File

@@ -0,0 +1,147 @@
// This file contains placeholder data that you'll be replacing with real data in the Data Fetching chapter:
// https://nextjs.org/learn/dashboard-app/fetching-data
const users = [
{
id: '410544b2-4001-4271-9855-fec4b6a6442a',
name: 'User',
email: 'user@nextmail.com',
password: '123456',
},
];
const customers = [
{
id: 'd6e15727-9fe1-4961-8c5b-ea44a9bd81aa',
name: 'Evil Rabbit',
email: 'evil@rabbit.com',
image_url: '/customers/evil-rabbit.png',
},
{
id: '3958dc9e-712f-4377-85e9-fec4b6a6442a',
name: 'Delba de Oliveira',
email: 'delba@oliveira.com',
image_url: '/customers/delba-de-oliveira.png',
},
{
id: '3958dc9e-742f-4377-85e9-fec4b6a6442a',
name: 'Lee Robinson',
email: 'lee@robinson.com',
image_url: '/customers/lee-robinson.png',
},
{
id: '76d65c26-f784-44a2-ac19-586678f7c2f2',
name: 'Michael Novotny',
email: 'michael@novotny.com',
image_url: '/customers/michael-novotny.png',
},
{
id: 'CC27C14A-0ACF-4F4A-A6C9-D45682C144B9',
name: 'Amy Burns',
email: 'amy@burns.com',
image_url: '/customers/amy-burns.png',
},
{
id: '13D07535-C59E-4157-A011-F8D2EF4E0CBB',
name: 'Balazs Orban',
email: 'balazs@orban.com',
image_url: '/customers/balazs-orban.png',
},
];
const invoices = [
{
customer_id: customers[0].id,
amount: 15795,
status: 'pending',
date: '2022-12-06',
},
{
customer_id: customers[1].id,
amount: 20348,
status: 'pending',
date: '2022-11-14',
},
{
customer_id: customers[4].id,
amount: 3040,
status: 'paid',
date: '2022-10-29',
},
{
customer_id: customers[3].id,
amount: 44800,
status: 'paid',
date: '2023-09-10',
},
{
customer_id: customers[5].id,
amount: 34577,
status: 'pending',
date: '2023-08-05',
},
{
customer_id: customers[2].id,
amount: 54246,
status: 'pending',
date: '2023-07-16',
},
{
customer_id: customers[0].id,
amount: 666,
status: 'pending',
date: '2023-06-27',
},
{
customer_id: customers[3].id,
amount: 32545,
status: 'paid',
date: '2023-06-09',
},
{
customer_id: customers[4].id,
amount: 1250,
status: 'paid',
date: '2023-06-17',
},
{
customer_id: customers[5].id,
amount: 8546,
status: 'paid',
date: '2023-06-07',
},
{
customer_id: customers[1].id,
amount: 500,
status: 'paid',
date: '2023-08-19',
},
{
customer_id: customers[5].id,
amount: 8945,
status: 'paid',
date: '2023-06-03',
},
{
customer_id: customers[2].id,
amount: 1000,
status: 'paid',
date: '2022-06-05',
},
];
const revenue = [
{ month: 'Jan', revenue: 2000 },
{ month: 'Feb', revenue: 1800 },
{ month: 'Mar', revenue: 2200 },
{ month: 'Apr', revenue: 2500 },
{ month: 'May', revenue: 2300 },
{ month: 'Jun', revenue: 3200 },
{ month: 'Jul', revenue: 3500 },
{ month: 'Aug', revenue: 3700 },
{ month: 'Sep', revenue: 2500 },
{ month: 'Oct', revenue: 2800 },
{ month: 'Nov', revenue: 3000 },
{ month: 'Dec', revenue: 4800 },
];
export { users, customers, invoices, revenue };

69
app/lib/utils.ts Normal file
View File

@@ -0,0 +1,69 @@
import { Revenue } from './definitions';
export const formatCurrency = (amount: number) => {
return (amount / 100).toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
});
};
export const formatDateToLocal = (
dateStr: string,
locale: string = 'en-US',
) => {
const date = new Date(dateStr);
const options: Intl.DateTimeFormatOptions = {
day: 'numeric',
month: 'short',
year: 'numeric',
};
const formatter = new Intl.DateTimeFormat(locale, options);
return formatter.format(date);
};
export const generateYAxis = (revenue: Revenue[]) => {
// Calculate what labels we need to display on the y-axis
// based on highest record and in 1000s
const yAxisLabels = [];
const highestRecord = Math.max(...revenue.map((month) => month.revenue));
const topLabel = Math.ceil(highestRecord / 1000) * 1000;
for (let i = topLabel; i >= 0; i -= 1000) {
yAxisLabels.push(`$${i / 1000}K`);
}
return { yAxisLabels, topLabel };
};
export const generatePagination = (currentPage: number, totalPages: number) => {
// If the total number of pages is 7 or less,
// display all pages without any ellipsis.
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
// If the current page is among the first 3 pages,
// show the first 3, an ellipsis, and the last 2 pages.
if (currentPage <= 3) {
return [1, 2, 3, '...', totalPages - 1, totalPages];
}
// If the current page is among the last 3 pages,
// show the first 2, an ellipsis, and the last 3 pages.
if (currentPage >= totalPages - 2) {
return [1, 2, '...', totalPages - 2, totalPages - 1, totalPages];
}
// If the current page is somewhere in the middle,
// show the first page, an ellipsis, the current page and its neighbors,
// another ellipsis, and the last page.
return [
1,
'...',
currentPage - 1,
currentPage,
currentPage + 1,
'...',
totalPages,
];
};