@ -0,0 +1,36 @@ |
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. |
|||
|
|||
# dependencies |
|||
/node_modules |
|||
/.pnp |
|||
.pnp.js |
|||
|
|||
# testing |
|||
/coverage |
|||
|
|||
# next.js |
|||
/.next/ |
|||
/out/ |
|||
|
|||
# production |
|||
/build |
|||
|
|||
# misc |
|||
.DS_Store |
|||
*.pem |
|||
|
|||
# debug |
|||
npm-debug.log* |
|||
yarn-debug.log* |
|||
yarn-error.log* |
|||
|
|||
# local env files |
|||
.env*.local |
|||
.env |
|||
|
|||
# vercel |
|||
.vercel |
|||
|
|||
# typescript |
|||
*.tsbuildinfo |
|||
next-env.d.ts |
|||
@ -0,0 +1,5 @@ |
|||
## Next.js App Router Course - Starter |
|||
|
|||
This is the starter template for the Next.js App Router Course. It contains the starting code for the dashboard application. |
|||
|
|||
For more information, see the [course curriculum](https://nextjs.org/learn) on the Next.js Website. |
|||
@ -0,0 +1,11 @@ |
|||
export default function RootLayout({ |
|||
children, |
|||
}: { |
|||
children: React.ReactNode; |
|||
}) { |
|||
return ( |
|||
<html lang="en"> |
|||
<body>{children}</body> |
|||
</html> |
|||
); |
|||
} |
|||
@ -0,0 +1,231 @@ |
|||
import { sql } from '@vercel/postgres'; |
|||
import { |
|||
CustomerField, |
|||
CustomersTableType, |
|||
InvoiceForm, |
|||
InvoicesTable, |
|||
LatestInvoiceRaw, |
|||
User, |
|||
Revenue, |
|||
} from './definitions'; |
|||
import { formatCurrency } from './utils'; |
|||
|
|||
export async function fetchRevenue() { |
|||
// Add noStore() here to prevent the response from being cached.
|
|||
// This is equivalent to in fetch(..., {cache: 'no-store'}).
|
|||
|
|||
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 customer table.'); |
|||
} |
|||
} |
|||
|
|||
export async function getUser(email: string) { |
|||
try { |
|||
const user = await sql`SELECT * FROM users WHERE email=${email}`; |
|||
return user.rows[0] as User; |
|||
} catch (error) { |
|||
console.error('Failed to fetch user:', error); |
|||
throw new Error('Failed to fetch user.'); |
|||
} |
|||
} |
|||
@ -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'; |
|||
}; |
|||
@ -0,0 +1,188 @@ |
|||
// 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: '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: '3958dc9e-737f-4377-85e9-fec4b6a6442a', |
|||
name: 'Hector Simpson', |
|||
email: 'hector@simpson.com', |
|||
image_url: '/customers/hector-simpson.png', |
|||
}, |
|||
{ |
|||
id: '50ca3e18-62cd-11ee-8c99-0242ac120002', |
|||
name: 'Steven Tey', |
|||
email: 'steven@tey.com', |
|||
image_url: '/customers/steven-tey.png', |
|||
}, |
|||
{ |
|||
id: '3958dc9e-787f-4377-85e9-fec4b6a6442a', |
|||
name: 'Steph Dietz', |
|||
email: 'steph@dietz.com', |
|||
image_url: '/customers/steph-dietz.png', |
|||
}, |
|||
{ |
|||
id: '76d65c26-f784-44a2-ac19-586678f7c2f2', |
|||
name: 'Michael Novotny', |
|||
email: 'michael@novotny.com', |
|||
image_url: '/customers/michael-novotny.png', |
|||
}, |
|||
{ |
|||
id: 'd6e15727-9fe1-4961-8c5b-ea44a9bd81aa', |
|||
name: 'Evil Rabbit', |
|||
email: 'evil@rabbit.com', |
|||
image_url: '/customers/evil-rabbit.png', |
|||
}, |
|||
{ |
|||
id: '126eed9c-c90c-4ef6-a4a8-fcf7408d3c66', |
|||
name: 'Emil Kowalski', |
|||
email: 'emil@kowalski.com', |
|||
image_url: '/customers/emil-kowalski.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[7].id, |
|||
amount: 54246, |
|||
status: 'pending', |
|||
date: '2023-07-16', |
|||
}, |
|||
{ |
|||
customer_id: customers[6].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: 8945, |
|||
status: 'paid', |
|||
date: '2023-06-18', |
|||
}, |
|||
{ |
|||
customer_id: customers[0].id, |
|||
amount: 8945, |
|||
status: 'paid', |
|||
date: '2023-10-04', |
|||
}, |
|||
{ |
|||
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 }, |
|||
]; |
|||
|
|||
module.exports = { |
|||
users, |
|||
customers, |
|||
invoices, |
|||
revenue, |
|||
}; |
|||
@ -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, |
|||
]; |
|||
}; |
|||
@ -0,0 +1,33 @@ |
|||
import AcmeLogo from '@/app/ui/acme-logo'; |
|||
import { ArrowRightIcon } from '@heroicons/react/24/outline'; |
|||
import Link from 'next/link'; |
|||
|
|||
export default function Page() { |
|||
return ( |
|||
<main className="flex min-h-screen flex-col p-6"> |
|||
<div className="flex h-20 shrink-0 items-end rounded-lg bg-blue-500 p-4 md:h-52"> |
|||
{/* <AcmeLogo /> */} |
|||
</div> |
|||
<div className="mt-4 flex grow flex-col gap-4 md:flex-row"> |
|||
<div className="flex flex-col justify-center gap-6 rounded-lg bg-gray-50 px-6 py-10 md:w-2/5 md:px-20"> |
|||
<p className={`text-xl text-gray-800 md:text-3xl md:leading-normal`}> |
|||
<strong>Welcome to Acme.</strong> This is the example for the{' '} |
|||
<a href="https://nextjs.org/learn/" className="text-blue-500"> |
|||
Next.js Learn Course |
|||
</a> |
|||
, brought to you by Vercel. |
|||
</p> |
|||
<Link |
|||
href="/login" |
|||
className="flex items-center gap-5 self-start rounded-lg bg-blue-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-blue-400 md:text-base" |
|||
> |
|||
<span>Log in</span> <ArrowRightIcon className="w-5 md:w-6" /> |
|||
</Link> |
|||
</div> |
|||
<div className="flex items-center justify-center p-6 md:w-3/5 md:px-28 md:py-12"> |
|||
{/* Add Hero Images Here */} |
|||
</div> |
|||
</div> |
|||
</main> |
|||
); |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
import { GlobeAltIcon } from '@heroicons/react/24/outline'; |
|||
import { lusitana } from '@/app/ui/fonts'; |
|||
|
|||
export default function AcmeLogo() { |
|||
return ( |
|||
<div |
|||
className={`${lusitana.className} flex flex-row items-center leading-none text-white`} |
|||
> |
|||
<GlobeAltIcon className="h-12 w-12 rotate-[15deg]" /> |
|||
<p className="text-[44px]">Acme</p> |
|||
</div> |
|||
); |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
import clsx from 'clsx'; |
|||
|
|||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { |
|||
children: React.ReactNode; |
|||
} |
|||
|
|||
export function Button({ children, className, ...rest }: ButtonProps) { |
|||
return ( |
|||
<button |
|||
{...rest} |
|||
className={clsx( |
|||
'flex h-10 items-center rounded-lg bg-blue-500 px-4 text-sm font-medium text-white transition-colors hover:bg-blue-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 active:bg-blue-600 aria-disabled:cursor-not-allowed aria-disabled:opacity-50', |
|||
className, |
|||
)} |
|||
> |
|||
{children} |
|||
</button> |
|||
); |
|||
} |
|||
@ -0,0 +1,123 @@ |
|||
import Image from 'next/image'; |
|||
import { lusitana } from '@/app/ui/fonts'; |
|||
import Search from '@/app/ui/search'; |
|||
import { |
|||
CustomersTableType, |
|||
FormattedCustomersTable, |
|||
} from '@/app/lib/definitions'; |
|||
|
|||
export default async function CustomersTable({ |
|||
customers, |
|||
}: { |
|||
customers: FormattedCustomersTable[]; |
|||
}) { |
|||
return ( |
|||
<div className="w-full"> |
|||
<h1 className={`${lusitana.className} mb-8 text-xl md:text-2xl`}> |
|||
Customers |
|||
</h1> |
|||
<Search placeholder="Search customers..." /> |
|||
<div className="mt-6 flow-root"> |
|||
<div className="overflow-x-auto"> |
|||
<div className="inline-block min-w-full align-middle"> |
|||
<div className="overflow-hidden rounded-md bg-gray-50 p-2 md:pt-0"> |
|||
<div className="md:hidden"> |
|||
{customers?.map((customer) => ( |
|||
<div |
|||
key={customer.id} |
|||
className="mb-2 w-full rounded-md bg-white p-4" |
|||
> |
|||
<div className="flex items-center justify-between border-b pb-4"> |
|||
<div> |
|||
<div className="mb-2 flex items-center"> |
|||
<div className="flex items-center gap-3"> |
|||
<Image |
|||
src={customer.image_url} |
|||
className="rounded-full" |
|||
alt={`${customer.name}'s profile picture`} |
|||
width={28} |
|||
height={28} |
|||
/> |
|||
<p>{customer.name}</p> |
|||
</div> |
|||
</div> |
|||
<p className="text-sm text-gray-500"> |
|||
{customer.email} |
|||
</p> |
|||
</div> |
|||
</div> |
|||
<div className="flex w-full items-center justify-between border-b py-5"> |
|||
<div className="flex w-1/2 flex-col"> |
|||
<p className="text-xs">Pending</p> |
|||
<p className="font-medium">{customer.total_pending}</p> |
|||
</div> |
|||
<div className="flex w-1/2 flex-col"> |
|||
<p className="text-xs">Paid</p> |
|||
<p className="font-medium">{customer.total_paid}</p> |
|||
</div> |
|||
</div> |
|||
<div className="pt-4 text-sm"> |
|||
<p>{customer.total_invoices} invoices</p> |
|||
</div> |
|||
</div> |
|||
))} |
|||
</div> |
|||
<table className="hidden min-w-full rounded-md text-gray-900 md:table"> |
|||
<thead className="rounded-md bg-gray-50 text-left text-sm font-normal"> |
|||
<tr> |
|||
<th scope="col" className="px-4 py-5 font-medium sm:pl-6"> |
|||
Name |
|||
</th> |
|||
<th scope="col" className="px-3 py-5 font-medium"> |
|||
Email |
|||
</th> |
|||
<th scope="col" className="px-3 py-5 font-medium"> |
|||
Total Invoices |
|||
</th> |
|||
<th scope="col" className="px-3 py-5 font-medium"> |
|||
Total Pending |
|||
</th> |
|||
<th scope="col" className="px-4 py-5 font-medium"> |
|||
Total Paid |
|||
</th> |
|||
</tr> |
|||
</thead> |
|||
|
|||
<tbody className="divide-y divide-gray-200 text-gray-900"> |
|||
{customers.map((customer) => ( |
|||
<tr key={customer.id} className="group"> |
|||
<td className="whitespace-nowrap bg-white py-5 pl-4 pr-3 text-sm text-black group-first-of-type:rounded-md group-last-of-type:rounded-md sm:pl-6"> |
|||
<div className="flex items-center gap-3"> |
|||
<Image |
|||
src={customer.image_url} |
|||
className="rounded-full" |
|||
alt={`${customer.name}'s profile picture`} |
|||
width={28} |
|||
height={28} |
|||
/> |
|||
<p>{customer.name}</p> |
|||
</div> |
|||
</td> |
|||
<td className="whitespace-nowrap bg-white px-4 py-5 text-sm"> |
|||
{customer.email} |
|||
</td> |
|||
<td className="whitespace-nowrap bg-white px-4 py-5 text-sm"> |
|||
{customer.total_invoices} |
|||
</td> |
|||
<td className="whitespace-nowrap bg-white px-4 py-5 text-sm"> |
|||
{customer.total_pending} |
|||
</td> |
|||
<td className="whitespace-nowrap bg-white px-4 py-5 text-sm group-first-of-type:rounded-md group-last-of-type:rounded-md"> |
|||
{customer.total_paid} |
|||
</td> |
|||
</tr> |
|||
))} |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
@ -0,0 +1,58 @@ |
|||
import { |
|||
BanknotesIcon, |
|||
ClockIcon, |
|||
UserGroupIcon, |
|||
InboxIcon, |
|||
} from '@heroicons/react/24/outline'; |
|||
import { lusitana } from '@/app/ui/fonts'; |
|||
|
|||
const iconMap = { |
|||
collected: BanknotesIcon, |
|||
customers: UserGroupIcon, |
|||
pending: ClockIcon, |
|||
invoices: InboxIcon, |
|||
}; |
|||
|
|||
export default async function CardWrapper() { |
|||
return ( |
|||
<> |
|||
{/* NOTE: comment in this code when you get to this point in the course */} |
|||
|
|||
{/* <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" |
|||
/> */} |
|||
</> |
|||
); |
|||
} |
|||
|
|||
export function Card({ |
|||
title, |
|||
value, |
|||
type, |
|||
}: { |
|||
title: string; |
|||
value: number | string; |
|||
type: 'invoices' | 'customers' | 'pending' | 'collected'; |
|||
}) { |
|||
const Icon = iconMap[type]; |
|||
|
|||
return ( |
|||
<div className="rounded-xl bg-gray-50 p-2 shadow-sm"> |
|||
<div className="flex p-4"> |
|||
{Icon ? <Icon className="h-5 w-5 text-gray-700" /> : null} |
|||
<h3 className="ml-2 text-sm font-medium">{title}</h3> |
|||
</div> |
|||
<p |
|||
className={`${lusitana.className} |
|||
truncate rounded-xl bg-white px-4 py-8 text-center text-2xl`}
|
|||
> |
|||
{value} |
|||
</p> |
|||
</div> |
|||
); |
|||
} |
|||
@ -0,0 +1,64 @@ |
|||
import { ArrowPathIcon } from '@heroicons/react/24/outline'; |
|||
import clsx from 'clsx'; |
|||
import Image from 'next/image'; |
|||
import { lusitana } from '@/app/ui/fonts'; |
|||
import { LatestInvoice } from '@/app/lib/definitions'; |
|||
export default async function LatestInvoices({ |
|||
latestInvoices, |
|||
}: { |
|||
latestInvoices: LatestInvoice[]; |
|||
}) { |
|||
return ( |
|||
<div className="flex w-full flex-col md:col-span-4"> |
|||
<h2 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}> |
|||
Latest Invoices |
|||
</h2> |
|||
<div className="flex grow flex-col justify-between rounded-xl bg-gray-50 p-4"> |
|||
{/* NOTE: comment in this code when you get to this point in the course */} |
|||
|
|||
{/* <div className="bg-white px-6"> |
|||
{latestInvoices.map((invoice, i) => { |
|||
return ( |
|||
<div |
|||
key={invoice.id} |
|||
className={clsx( |
|||
'flex flex-row items-center justify-between py-4', |
|||
{ |
|||
'border-t': i !== 0, |
|||
}, |
|||
)} |
|||
> |
|||
<div className="flex items-center"> |
|||
<Image |
|||
src={invoice.image_url} |
|||
alt={`${invoice.name}'s profile picture`} |
|||
className="mr-4 rounded-full" |
|||
width={32} |
|||
height={32} |
|||
/> |
|||
<div className="min-w-0"> |
|||
<p className="truncate text-sm font-semibold md:text-base"> |
|||
{invoice.name} |
|||
</p> |
|||
<p className="hidden text-sm text-gray-500 sm:block"> |
|||
{invoice.email} |
|||
</p> |
|||
</div> |
|||
</div> |
|||
<p |
|||
className={`${lusitana.className} truncate text-sm font-medium md:text-base`} |
|||
> |
|||
{invoice.amount} |
|||
</p> |
|||
</div> |
|||
); |
|||
})} |
|||
</div> */} |
|||
<div className="flex items-center pb-2 pt-6"> |
|||
<ArrowPathIcon className="h-5 w-5 text-gray-500" /> |
|||
<h3 className="ml-2 text-sm text-gray-500 ">Updated just now</h3> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
@ -0,0 +1,37 @@ |
|||
import { |
|||
UserGroupIcon, |
|||
HomeIcon, |
|||
DocumentDuplicateIcon, |
|||
} from '@heroicons/react/24/outline'; |
|||
|
|||
// Map of links to display in the side navigation.
|
|||
// Depending on the size of the application, this would be stored in a database.
|
|||
const links = [ |
|||
{ name: 'Home', href: '/dashboard', icon: HomeIcon }, |
|||
{ |
|||
name: 'Invoices', |
|||
href: '/dashboard/invoices', |
|||
icon: DocumentDuplicateIcon, |
|||
}, |
|||
{ name: 'Customers', href: '/dashboard/customers', icon: UserGroupIcon }, |
|||
]; |
|||
|
|||
export default function NavLinks() { |
|||
return ( |
|||
<> |
|||
{links.map((link) => { |
|||
const LinkIcon = link.icon; |
|||
return ( |
|||
<a |
|||
key={link.name} |
|||
href={link.href} |
|||
className="flex h-[48px] grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3" |
|||
> |
|||
<LinkIcon className="w-6" /> |
|||
<p className="hidden md:block">{link.name}</p> |
|||
</a> |
|||
); |
|||
})} |
|||
</> |
|||
); |
|||
} |
|||
@ -0,0 +1,65 @@ |
|||
import { generateYAxis } from '@/app/lib/utils'; |
|||
import { CalendarIcon } from '@heroicons/react/24/outline'; |
|||
import { lusitana } from '@/app/ui/fonts'; |
|||
import { Revenue } from '@/app/lib/definitions'; |
|||
|
|||
// This component is representational only.
|
|||
// For data visualization UI, check out:
|
|||
// https://www.tremor.so/
|
|||
// https://www.chartjs.org/
|
|||
// https://airbnb.io/visx/
|
|||
|
|||
export default async function RevenueChart({ |
|||
revenue, |
|||
}: { |
|||
revenue: Revenue[]; |
|||
}) { |
|||
const chartHeight = 350; |
|||
// NOTE: comment in this code when you get to this point in the course
|
|||
|
|||
// const { yAxisLabels, topLabel } = generateYAxis(revenue);
|
|||
|
|||
// if (!revenue || revenue.length === 0) {
|
|||
// return <p className="mt-4 text-gray-400">No data available.</p>;
|
|||
// }
|
|||
|
|||
return ( |
|||
<div className="w-full md:col-span-4"> |
|||
<h2 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}> |
|||
Recent Revenue |
|||
</h2> |
|||
{/* NOTE: comment in this code when you get to this point in the course */} |
|||
|
|||
{/* <div className="rounded-xl bg-gray-50 p-4"> |
|||
<div className="sm:grid-cols-13 mt-0 grid grid-cols-12 items-end gap-2 rounded-md bg-white p-4 md:gap-4"> |
|||
<div |
|||
className="mb-6 hidden flex-col justify-between text-sm text-gray-400 sm:flex" |
|||
style={{ height: `${chartHeight}px` }} |
|||
> |
|||
{yAxisLabels.map((label) => ( |
|||
<p key={label}>{label}</p> |
|||
))} |
|||
</div> |
|||
|
|||
{revenue.map((month) => ( |
|||
<div key={month.month} className="flex flex-col items-center gap-2"> |
|||
<div |
|||
className="w-full rounded-md bg-blue-300" |
|||
style={{ |
|||
height: `${(chartHeight / topLabel) * month.revenue}px`, |
|||
}} |
|||
></div> |
|||
<p className="-rotate-90 text-sm text-gray-400 sm:rotate-0"> |
|||
{month.month} |
|||
</p> |
|||
</div> |
|||
))} |
|||
</div> |
|||
<div className="flex items-center pb-2 pt-6"> |
|||
<CalendarIcon className="h-5 w-5 text-gray-500" /> |
|||
<h3 className="ml-2 text-sm text-gray-500 ">Last 12 months</h3> |
|||
</div> |
|||
</div> */} |
|||
</div> |
|||
); |
|||
} |
|||
@ -0,0 +1,29 @@ |
|||
import Link from 'next/link'; |
|||
import NavLinks from '@/app/ui/dashboard/nav-links'; |
|||
import AcmeLogo from '@/app/ui/acme-logo'; |
|||
import { PowerIcon } from '@heroicons/react/24/outline'; |
|||
|
|||
export default function SideNav() { |
|||
return ( |
|||
<div className="flex h-full flex-col px-3 py-4 md:px-2"> |
|||
<Link |
|||
className="mb-2 flex h-20 items-end justify-start rounded-md bg-blue-600 p-4 md:h-40" |
|||
href="/" |
|||
> |
|||
<div className="w-32 text-white md:w-40"> |
|||
<AcmeLogo /> |
|||
</div> |
|||
</Link> |
|||
<div className="flex grow flex-row justify-between space-x-2 md:flex-col md:space-x-0 md:space-y-2"> |
|||
<NavLinks /> |
|||
<div className="hidden h-auto w-full grow rounded-md bg-gray-50 md:block"></div> |
|||
<form> |
|||
<button className="flex h-[48px] w-full grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3"> |
|||
<PowerIcon className="w-6" /> |
|||
<div className="hidden md:block">Sign Out</div> |
|||
</button> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
@ -0,0 +1,18 @@ |
|||
@tailwind base; |
|||
@tailwind components; |
|||
@tailwind utilities; |
|||
|
|||
input[type='number'] { |
|||
-moz-appearance: textfield; |
|||
appearance: textfield; |
|||
} |
|||
|
|||
input[type='number']::-webkit-inner-spin-button { |
|||
-webkit-appearance: none; |
|||
margin: 0; |
|||
} |
|||
|
|||
input[type='number']::-webkit-outer-spin-button { |
|||
-webkit-appearance: none; |
|||
margin: 0; |
|||
} |
|||
@ -0,0 +1,36 @@ |
|||
import { clsx } from 'clsx'; |
|||
import Link from 'next/link'; |
|||
import { lusitana } from '@/app/ui/fonts'; |
|||
|
|||
interface Breadcrumb { |
|||
label: string; |
|||
href: string; |
|||
active?: boolean; |
|||
} |
|||
|
|||
export default function Breadcrumbs({ |
|||
breadcrumbs, |
|||
}: { |
|||
breadcrumbs: Breadcrumb[]; |
|||
}) { |
|||
return ( |
|||
<nav aria-label="Breadcrumb" className="mb-6 block"> |
|||
<ol className={clsx(lusitana.className, 'flex text-xl md:text-2xl')}> |
|||
{breadcrumbs.map((breadcrumb, index) => ( |
|||
<li |
|||
key={breadcrumb.href} |
|||
aria-current={breadcrumb.active} |
|||
className={clsx( |
|||
breadcrumb.active ? 'text-gray-900' : 'text-gray-500', |
|||
)} |
|||
> |
|||
<Link href={breadcrumb.href}>{breadcrumb.label}</Link> |
|||
{index < breadcrumbs.length - 1 ? ( |
|||
<span className="mx-3 inline-block">/</span> |
|||
) : null} |
|||
</li> |
|||
))} |
|||
</ol> |
|||
</nav> |
|||
); |
|||
} |
|||
@ -0,0 +1,36 @@ |
|||
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline'; |
|||
import Link from 'next/link'; |
|||
|
|||
export function CreateInvoice() { |
|||
return ( |
|||
<Link |
|||
href="/dashboard/invoices/create" |
|||
className="flex h-10 items-center rounded-lg bg-blue-600 px-4 text-sm font-medium text-white transition-colors hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" |
|||
> |
|||
<span className="hidden md:block">Create Invoice</span>{' '} |
|||
<PlusIcon className="h-5 md:ml-4" /> |
|||
</Link> |
|||
); |
|||
} |
|||
|
|||
export function UpdateInvoice({ id }: { id: string }) { |
|||
return ( |
|||
<Link |
|||
href="/dashboard/invoices" |
|||
className="rounded-md border p-2 hover:bg-gray-100" |
|||
> |
|||
<PencilIcon className="w-5" /> |
|||
</Link> |
|||
); |
|||
} |
|||
|
|||
export function DeleteInvoice({ id }: { id: string }) { |
|||
return ( |
|||
<> |
|||
<button className="rounded-md border p-2 hover:bg-gray-100"> |
|||
<span className="sr-only">Delete</span> |
|||
<TrashIcon className="w-5" /> |
|||
</button> |
|||
</> |
|||
); |
|||
} |
|||
@ -0,0 +1,112 @@ |
|||
import { CustomerField } from '@/app/lib/definitions'; |
|||
import Link from 'next/link'; |
|||
import { |
|||
CheckIcon, |
|||
ClockIcon, |
|||
CurrencyDollarIcon, |
|||
UserCircleIcon, |
|||
} from '@heroicons/react/24/outline'; |
|||
import { Button } from '@/app/ui/button'; |
|||
|
|||
export default function Form({ customers }: { customers: CustomerField[] }) { |
|||
return ( |
|||
<form> |
|||
<div className="rounded-md bg-gray-50 p-4 md:p-6"> |
|||
{/* Customer Name */} |
|||
<div className="mb-4"> |
|||
<label htmlFor="customer" className="mb-2 block text-sm font-medium"> |
|||
Choose customer |
|||
</label> |
|||
<div className="relative"> |
|||
<select |
|||
id="customer" |
|||
name="customerId" |
|||
className="peer block w-full cursor-pointer rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500" |
|||
defaultValue="" |
|||
> |
|||
<option value="" disabled> |
|||
Select a customer |
|||
</option> |
|||
{customers.map((customer) => ( |
|||
<option key={customer.id} value={customer.id}> |
|||
{customer.name} |
|||
</option> |
|||
))} |
|||
</select> |
|||
<UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500" /> |
|||
</div> |
|||
</div> |
|||
|
|||
{/* Invoice Amount */} |
|||
<div className="mb-4"> |
|||
<label htmlFor="amount" className="mb-2 block text-sm font-medium"> |
|||
Choose an amount |
|||
</label> |
|||
<div className="relative mt-2 rounded-md"> |
|||
<div className="relative"> |
|||
<input |
|||
id="amount" |
|||
name="amount" |
|||
type="number" |
|||
step="0.01" |
|||
placeholder="Enter USD amount" |
|||
className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500" |
|||
/> |
|||
<CurrencyDollarIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" /> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
{/* Invoice Status */} |
|||
<fieldset> |
|||
<legend className="mb-2 block text-sm font-medium"> |
|||
Set the invoice status |
|||
</legend> |
|||
<div className="rounded-md border border-gray-200 bg-white px-[14px] py-3"> |
|||
<div className="flex gap-4"> |
|||
<div className="flex items-center"> |
|||
<input |
|||
id="pending" |
|||
name="status" |
|||
type="radio" |
|||
value="pending" |
|||
className="h-4 w-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2" |
|||
/> |
|||
<label |
|||
htmlFor="pending" |
|||
className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-600" |
|||
> |
|||
Pending <ClockIcon className="h-4 w-4" /> |
|||
</label> |
|||
</div> |
|||
<div className="flex items-center"> |
|||
<input |
|||
id="paid" |
|||
name="status" |
|||
type="radio" |
|||
value="paid" |
|||
className="h-4 w-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2" |
|||
/> |
|||
<label |
|||
htmlFor="paid" |
|||
className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-green-500 px-3 py-1.5 text-xs font-medium text-white" |
|||
> |
|||
Paid <CheckIcon className="h-4 w-4" /> |
|||
</label> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</fieldset> |
|||
</div> |
|||
<div className="mt-6 flex justify-end gap-4"> |
|||
<Link |
|||
href="/dashboard/invoices" |
|||
className="flex h-10 items-center rounded-lg bg-gray-100 px-4 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-200" |
|||
> |
|||
Cancel |
|||
</Link> |
|||
<Button type="submit">Create Invoice</Button> |
|||
</div> |
|||
</form> |
|||
); |
|||
} |
|||
@ -0,0 +1,123 @@ |
|||
'use client'; |
|||
|
|||
import { CustomerField, InvoiceForm } from '@/app/lib/definitions'; |
|||
import { |
|||
CheckIcon, |
|||
ClockIcon, |
|||
CurrencyDollarIcon, |
|||
UserCircleIcon, |
|||
} from '@heroicons/react/24/outline'; |
|||
import Link from 'next/link'; |
|||
import { Button } from '@/app/ui/button'; |
|||
|
|||
export default function EditInvoiceForm({ |
|||
invoice, |
|||
customers, |
|||
}: { |
|||
invoice: InvoiceForm; |
|||
customers: CustomerField[]; |
|||
}) { |
|||
return ( |
|||
<form> |
|||
<div className="rounded-md bg-gray-50 p-4 md:p-6"> |
|||
{/* Customer Name */} |
|||
<div className="mb-4"> |
|||
<label htmlFor="customer" className="mb-2 block text-sm font-medium"> |
|||
Choose customer |
|||
</label> |
|||
<div className="relative"> |
|||
<select |
|||
id="customer" |
|||
name="customerId" |
|||
className="peer block w-full cursor-pointer rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500" |
|||
defaultValue={invoice.customer_id} |
|||
> |
|||
<option value="" disabled> |
|||
Select a customer |
|||
</option> |
|||
{customers.map((customer) => ( |
|||
<option key={customer.id} value={customer.id}> |
|||
{customer.name} |
|||
</option> |
|||
))} |
|||
</select> |
|||
<UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500" /> |
|||
</div> |
|||
</div> |
|||
|
|||
{/* Invoice Amount */} |
|||
<div className="mb-4"> |
|||
<label htmlFor="amount" className="mb-2 block text-sm font-medium"> |
|||
Choose an amount |
|||
</label> |
|||
<div className="relative mt-2 rounded-md"> |
|||
<div className="relative"> |
|||
<input |
|||
id="amount" |
|||
name="amount" |
|||
type="number" |
|||
step="0.01" |
|||
defaultValue={invoice.amount} |
|||
placeholder="Enter USD amount" |
|||
className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500" |
|||
/> |
|||
<CurrencyDollarIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" /> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
{/* Invoice Status */} |
|||
<fieldset> |
|||
<legend className="mb-2 block text-sm font-medium"> |
|||
Set the invoice status |
|||
</legend> |
|||
<div className="rounded-md border border-gray-200 bg-white px-[14px] py-3"> |
|||
<div className="flex gap-4"> |
|||
<div className="flex items-center"> |
|||
<input |
|||
id="pending" |
|||
name="status" |
|||
type="radio" |
|||
value="pending" |
|||
defaultChecked={invoice.status === 'pending'} |
|||
className="h-4 w-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2" |
|||
/> |
|||
<label |
|||
htmlFor="pending" |
|||
className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-600" |
|||
> |
|||
Pending <ClockIcon className="h-4 w-4" /> |
|||
</label> |
|||
</div> |
|||
<div className="flex items-center"> |
|||
<input |
|||
id="paid" |
|||
name="status" |
|||
type="radio" |
|||
value="paid" |
|||
defaultChecked={invoice.status === 'paid'} |
|||
className="h-4 w-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2" |
|||
/> |
|||
<label |
|||
htmlFor="paid" |
|||
className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-green-500 px-3 py-1.5 text-xs font-medium text-white" |
|||
> |
|||
Paid <CheckIcon className="h-4 w-4" /> |
|||
</label> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</fieldset> |
|||
</div> |
|||
<div className="mt-6 flex justify-end gap-4"> |
|||
<Link |
|||
href="/dashboard/invoices" |
|||
className="flex h-10 items-center rounded-lg bg-gray-100 px-4 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-200" |
|||
> |
|||
Cancel |
|||
</Link> |
|||
<Button type="submit">Edit Invoice</Button> |
|||
</div> |
|||
</form> |
|||
); |
|||
} |
|||
@ -0,0 +1,119 @@ |
|||
'use client'; |
|||
|
|||
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline'; |
|||
import clsx from 'clsx'; |
|||
import Link from 'next/link'; |
|||
import { generatePagination } from '@/app/lib/utils'; |
|||
|
|||
export default function Pagination({ totalPages }: { totalPages: number }) { |
|||
// NOTE: comment in this code when you get to this point in the course
|
|||
|
|||
// const allPages = generatePagination(currentPage, totalPages);
|
|||
|
|||
return ( |
|||
<> |
|||
{/* NOTE: comment in this code when you get to this point in the course */} |
|||
|
|||
{/* <div className="inline-flex"> |
|||
<PaginationArrow |
|||
direction="left" |
|||
href={createPageURL(currentPage - 1)} |
|||
isDisabled={currentPage <= 1} |
|||
/> |
|||
|
|||
<div className="flex -space-x-px"> |
|||
{allPages.map((page, index) => { |
|||
let position: 'first' | 'last' | 'single' | 'middle' | undefined; |
|||
|
|||
if (index === 0) position = 'first'; |
|||
if (index === allPages.length - 1) position = 'last'; |
|||
if (allPages.length === 1) position = 'single'; |
|||
if (page === '...') position = 'middle'; |
|||
|
|||
return ( |
|||
<PaginationNumber |
|||
key={page} |
|||
href={createPageURL(page)} |
|||
page={page} |
|||
position={position} |
|||
isActive={currentPage === page} |
|||
/> |
|||
); |
|||
})} |
|||
</div> |
|||
|
|||
<PaginationArrow |
|||
direction="right" |
|||
href={createPageURL(currentPage + 1)} |
|||
isDisabled={currentPage >= totalPages} |
|||
/> |
|||
</div> */} |
|||
</> |
|||
); |
|||
} |
|||
|
|||
function PaginationNumber({ |
|||
page, |
|||
href, |
|||
isActive, |
|||
position, |
|||
}: { |
|||
page: number | string; |
|||
href: string; |
|||
position?: 'first' | 'last' | 'middle' | 'single'; |
|||
isActive: boolean; |
|||
}) { |
|||
const className = clsx( |
|||
'flex h-10 w-10 items-center justify-center text-sm border', |
|||
{ |
|||
'rounded-l-md': position === 'first' || position === 'single', |
|||
'rounded-r-md': position === 'last' || position === 'single', |
|||
'z-10 bg-blue-600 border-blue-600 text-white': isActive, |
|||
'hover:bg-gray-100': !isActive && position !== 'middle', |
|||
'text-gray-300': position === 'middle', |
|||
}, |
|||
); |
|||
|
|||
return isActive || position === 'middle' ? ( |
|||
<div className={className}>{page}</div> |
|||
) : ( |
|||
<Link href={href} className={className}> |
|||
{page} |
|||
</Link> |
|||
); |
|||
} |
|||
|
|||
function PaginationArrow({ |
|||
href, |
|||
direction, |
|||
isDisabled, |
|||
}: { |
|||
href: string; |
|||
direction: 'left' | 'right'; |
|||
isDisabled?: boolean; |
|||
}) { |
|||
const className = clsx( |
|||
'flex h-10 w-10 items-center justify-center rounded-md border', |
|||
{ |
|||
'pointer-events-none text-gray-300': isDisabled, |
|||
'hover:bg-gray-100': !isDisabled, |
|||
'mr-2 md:mr-4': direction === 'left', |
|||
'ml-2 md:ml-4': direction === 'right', |
|||
}, |
|||
); |
|||
|
|||
const icon = |
|||
direction === 'left' ? ( |
|||
<ArrowLeftIcon className="w-4" /> |
|||
) : ( |
|||
<ArrowRightIcon className="w-4" /> |
|||
); |
|||
|
|||
return isDisabled ? ( |
|||
<div className={className}>{icon}</div> |
|||
) : ( |
|||
<Link className={className} href={href}> |
|||
{icon} |
|||
</Link> |
|||
); |
|||
} |
|||
@ -0,0 +1,29 @@ |
|||
import { CheckIcon, ClockIcon } from '@heroicons/react/24/outline'; |
|||
import clsx from 'clsx'; |
|||
|
|||
export default function InvoiceStatus({ status }: { status: string }) { |
|||
return ( |
|||
<span |
|||
className={clsx( |
|||
'inline-flex items-center rounded-full px-2 py-1 text-xs', |
|||
{ |
|||
'bg-gray-100 text-gray-500': status === 'pending', |
|||
'bg-green-500 text-white': status === 'paid', |
|||
}, |
|||
)} |
|||
> |
|||
{status === 'pending' ? ( |
|||
<> |
|||
Pending |
|||
<ClockIcon className="ml-1 w-4 text-gray-500" /> |
|||
</> |
|||
) : null} |
|||
{status === 'paid' ? ( |
|||
<> |
|||
Paid |
|||
<CheckIcon className="ml-1 w-4 text-white" /> |
|||
</> |
|||
) : null} |
|||
</span> |
|||
); |
|||
} |
|||
@ -0,0 +1,124 @@ |
|||
import Image from 'next/image'; |
|||
import { UpdateInvoice, DeleteInvoice } from '@/app/ui/invoices/buttons'; |
|||
import InvoiceStatus from '@/app/ui/invoices/status'; |
|||
import { formatDateToLocal, formatCurrency } from '@/app/lib/utils'; |
|||
import { fetchFilteredInvoices } from '@/app/lib/data'; |
|||
|
|||
export default async function InvoicesTable({ |
|||
query, |
|||
currentPage, |
|||
}: { |
|||
query: string; |
|||
currentPage: number; |
|||
}) { |
|||
const invoices = await fetchFilteredInvoices(query, currentPage); |
|||
|
|||
return ( |
|||
<div className="mt-6 flow-root"> |
|||
<div className="inline-block min-w-full align-middle"> |
|||
<div className="rounded-lg bg-gray-50 p-2 md:pt-0"> |
|||
<div className="md:hidden"> |
|||
{invoices?.map((invoice) => ( |
|||
<div |
|||
key={invoice.id} |
|||
className="mb-2 w-full rounded-md bg-white p-4" |
|||
> |
|||
<div className="flex items-center justify-between border-b pb-4"> |
|||
<div> |
|||
<div className="mb-2 flex items-center"> |
|||
<Image |
|||
src={invoice.image_url} |
|||
className="mr-2 rounded-full" |
|||
width={28} |
|||
height={28} |
|||
alt={`${invoice.name}'s profile picture`} |
|||
/> |
|||
<p>{invoice.name}</p> |
|||
</div> |
|||
<p className="text-sm text-gray-500">{invoice.email}</p> |
|||
</div> |
|||
<InvoiceStatus status={invoice.status} /> |
|||
</div> |
|||
<div className="flex w-full items-center justify-between pt-4"> |
|||
<div> |
|||
<p className="text-xl font-medium"> |
|||
{formatCurrency(invoice.amount)} |
|||
</p> |
|||
<p>{formatDateToLocal(invoice.date)}</p> |
|||
</div> |
|||
<div className="flex justify-end gap-2"> |
|||
<UpdateInvoice id={invoice.id} /> |
|||
<DeleteInvoice id={invoice.id} /> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
))} |
|||
</div> |
|||
<table className="hidden min-w-full text-gray-900 md:table"> |
|||
<thead className="rounded-lg text-left text-sm font-normal"> |
|||
<tr> |
|||
<th scope="col" className="px-4 py-5 font-medium sm:pl-6"> |
|||
Customer |
|||
</th> |
|||
<th scope="col" className="px-3 py-5 font-medium"> |
|||
Email |
|||
</th> |
|||
<th scope="col" className="px-3 py-5 font-medium"> |
|||
Amount |
|||
</th> |
|||
<th scope="col" className="px-3 py-5 font-medium"> |
|||
Date |
|||
</th> |
|||
<th scope="col" className="px-3 py-5 font-medium"> |
|||
Status |
|||
</th> |
|||
<th scope="col" className="relative py-3 pl-6 pr-3"> |
|||
<span className="sr-only">Edit</span> |
|||
</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody className="bg-white"> |
|||
{invoices?.map((invoice) => ( |
|||
<tr |
|||
key={invoice.id} |
|||
className="w-full border-b py-3 text-sm last-of-type:border-none [&:first-child>td:first-child]:rounded-tl-lg [&:first-child>td:last-child]:rounded-tr-lg [&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg" |
|||
> |
|||
<td className="whitespace-nowrap py-3 pl-6 pr-3"> |
|||
<div className="flex items-center gap-3"> |
|||
<Image |
|||
src={invoice.image_url} |
|||
className="rounded-full" |
|||
width={28} |
|||
height={28} |
|||
alt={`${invoice.name}'s profile picture`} |
|||
/> |
|||
<p>{invoice.name}</p> |
|||
</div> |
|||
</td> |
|||
<td className="whitespace-nowrap px-3 py-3"> |
|||
{invoice.email} |
|||
</td> |
|||
<td className="whitespace-nowrap px-3 py-3"> |
|||
{formatCurrency(invoice.amount)} |
|||
</td> |
|||
<td className="whitespace-nowrap px-3 py-3"> |
|||
{formatDateToLocal(invoice.date)} |
|||
</td> |
|||
<td className="whitespace-nowrap px-3 py-3"> |
|||
<InvoiceStatus status={invoice.status} /> |
|||
</td> |
|||
<td className="whitespace-nowrap py-3 pl-6 pr-3"> |
|||
<div className="flex justify-end gap-3"> |
|||
<UpdateInvoice id={invoice.id} /> |
|||
<DeleteInvoice id={invoice.id} /> |
|||
</div> |
|||
</td> |
|||
</tr> |
|||
))} |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
@ -0,0 +1,73 @@ |
|||
import { lusitana } from '@/app/ui/fonts'; |
|||
import { |
|||
AtSymbolIcon, |
|||
KeyIcon, |
|||
ExclamationCircleIcon, |
|||
} from '@heroicons/react/24/outline'; |
|||
import { ArrowRightIcon } from '@heroicons/react/20/solid'; |
|||
import { Button } from './button'; |
|||
|
|||
export default function LoginForm() { |
|||
return ( |
|||
<form className="space-y-3"> |
|||
<div className="flex-1 rounded-lg bg-gray-50 px-6 pb-4 pt-8"> |
|||
<h1 className={`${lusitana.className} mb-3 text-2xl`}> |
|||
Please log in to continue. |
|||
</h1> |
|||
<div className="w-full"> |
|||
<div> |
|||
<label |
|||
className="mb-3 mt-5 block text-xs font-medium text-gray-900" |
|||
htmlFor="email" |
|||
> |
|||
Email |
|||
</label> |
|||
<div className="relative"> |
|||
<input |
|||
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500" |
|||
id="email" |
|||
type="email" |
|||
name="email" |
|||
placeholder="Enter your email address" |
|||
required |
|||
/> |
|||
<AtSymbolIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" /> |
|||
</div> |
|||
</div> |
|||
<div className="mt-4"> |
|||
<label |
|||
className="mb-3 mt-5 block text-xs font-medium text-gray-900" |
|||
htmlFor="password" |
|||
> |
|||
Password |
|||
</label> |
|||
<div className="relative"> |
|||
<input |
|||
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500" |
|||
id="password" |
|||
type="password" |
|||
name="password" |
|||
placeholder="Enter password" |
|||
required |
|||
minLength={6} |
|||
/> |
|||
<KeyIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" /> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<LoginButton /> |
|||
<div className="flex h-8 items-end space-x-1"> |
|||
{/* Add form errors here */} |
|||
</div> |
|||
</div> |
|||
</form> |
|||
); |
|||
} |
|||
|
|||
function LoginButton() { |
|||
return ( |
|||
<Button className="mt-4 w-full"> |
|||
Log in <ArrowRightIcon className="ml-auto h-5 w-5 text-gray-50" /> |
|||
</Button> |
|||
); |
|||
} |
|||
@ -0,0 +1,18 @@ |
|||
'use client'; |
|||
|
|||
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; |
|||
|
|||
export default function Search({ placeholder }: { placeholder: string }) { |
|||
return ( |
|||
<div className="relative flex flex-1 flex-shrink-0"> |
|||
<label htmlFor="search" className="sr-only"> |
|||
Search |
|||
</label> |
|||
<input |
|||
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500" |
|||
placeholder={placeholder} |
|||
/> |
|||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" /> |
|||
</div> |
|||
); |
|||
} |
|||
@ -0,0 +1,218 @@ |
|||
// 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 overflow-hidden rounded-xl bg-gray-100 p-2 shadow-sm`} |
|||
> |
|||
<div className="flex p-4"> |
|||
<div className="h-5 w-5 rounded-md bg-gray-200" /> |
|||
<div className="ml-2 h-6 w-16 rounded-md bg-gray-200 text-sm font-medium" /> |
|||
</div> |
|||
<div className="flex items-center justify-center truncate rounded-xl bg-white px-4 py-8"> |
|||
<div className="h-7 w-20 rounded-md bg-gray-200" /> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
export function CardsSkeleton() { |
|||
return ( |
|||
<> |
|||
<CardSkeleton /> |
|||
<CardSkeleton /> |
|||
<CardSkeleton /> |
|||
<CardSkeleton /> |
|||
</> |
|||
); |
|||
} |
|||
|
|||
export function RevenueChartSkeleton() { |
|||
return ( |
|||
<div className={`${shimmer} relative w-full overflow-hidden md:col-span-4`}> |
|||
<div className="mb-4 h-8 w-36 rounded-md bg-gray-100" /> |
|||
<div className="rounded-xl bg-gray-100 p-4"> |
|||
<div className="mt-0 grid h-[410px] grid-cols-12 items-end gap-2 rounded-md bg-white p-4 sm:grid-cols-13 md:gap-4" /> |
|||
<div className="flex items-center pb-2 pt-6"> |
|||
<div className="h-5 w-5 rounded-full bg-gray-200" /> |
|||
<div className="ml-2 h-4 w-20 rounded-md bg-gray-200" /> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
export function InvoiceSkeleton() { |
|||
return ( |
|||
<div className="flex flex-row items-center justify-between border-b border-gray-100 py-4"> |
|||
<div className="flex items-center"> |
|||
<div className="mr-2 h-8 w-8 rounded-full bg-gray-200" /> |
|||
<div className="min-w-0"> |
|||
<div className="h-5 w-40 rounded-md bg-gray-200" /> |
|||
<div className="mt-2 h-4 w-12 rounded-md bg-gray-200" /> |
|||
</div> |
|||
</div> |
|||
<div className="mt-2 h-4 w-12 rounded-md bg-gray-200" /> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
export function LatestInvoicesSkeleton() { |
|||
return ( |
|||
<div |
|||
className={`${shimmer} relative flex w-full flex-col overflow-hidden md:col-span-4`} |
|||
> |
|||
<div className="mb-4 h-8 w-36 rounded-md bg-gray-100" /> |
|||
<div className="flex grow flex-col justify-between rounded-xl bg-gray-100 p-4"> |
|||
<div className="bg-white px-6"> |
|||
<InvoiceSkeleton /> |
|||
<InvoiceSkeleton /> |
|||
<InvoiceSkeleton /> |
|||
<InvoiceSkeleton /> |
|||
<InvoiceSkeleton /> |
|||
<div className="flex items-center pb-2 pt-6"> |
|||
<div className="h-5 w-5 rounded-full bg-gray-200" /> |
|||
<div className="ml-2 h-4 w-20 rounded-md bg-gray-200" /> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
export default function DashboardSkeleton() { |
|||
return ( |
|||
<> |
|||
<div |
|||
className={`${shimmer} relative mb-4 h-8 w-36 overflow-hidden rounded-md bg-gray-100`} |
|||
/> |
|||
<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> |
|||
</> |
|||
); |
|||
} |
|||
|
|||
export function TableRowSkeleton() { |
|||
return ( |
|||
<tr className="w-full border-b border-gray-100 last-of-type:border-none [&:first-child>td:first-child]:rounded-tl-lg [&:first-child>td:last-child]:rounded-tr-lg [&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg"> |
|||
{/* Customer Name and Image */} |
|||
<td className="relative overflow-hidden whitespace-nowrap py-3 pl-6 pr-3"> |
|||
<div className="flex items-center gap-3"> |
|||
<div className="h-8 w-8 rounded-full bg-gray-100"></div> |
|||
<div className="h-6 w-24 rounded bg-gray-100"></div> |
|||
</div> |
|||
</td> |
|||
{/* Email */} |
|||
<td className="whitespace-nowrap px-3 py-3"> |
|||
<div className="h-6 w-32 rounded bg-gray-100"></div> |
|||
</td> |
|||
{/* Amount */} |
|||
<td className="whitespace-nowrap px-3 py-3"> |
|||
<div className="h-6 w-16 rounded bg-gray-100"></div> |
|||
</td> |
|||
{/* Date */} |
|||
<td className="whitespace-nowrap px-3 py-3"> |
|||
<div className="h-6 w-16 rounded bg-gray-100"></div> |
|||
</td> |
|||
{/* Status */} |
|||
<td className="whitespace-nowrap px-3 py-3"> |
|||
<div className="h-6 w-16 rounded bg-gray-100"></div> |
|||
</td> |
|||
{/* Actions */} |
|||
<td className="whitespace-nowrap py-3 pl-6 pr-3"> |
|||
<div className="flex justify-end gap-3"> |
|||
<div className="h-[38px] w-[38px] rounded bg-gray-100"></div> |
|||
<div className="h-[38px] w-[38px] rounded bg-gray-100"></div> |
|||
</div> |
|||
</td> |
|||
</tr> |
|||
); |
|||
} |
|||
|
|||
export function InvoicesMobileSkeleton() { |
|||
return ( |
|||
<div className="mb-2 w-full rounded-md bg-white p-4"> |
|||
<div className="flex items-center justify-between border-b border-gray-100 pb-8"> |
|||
<div className="flex items-center"> |
|||
<div className="mr-2 h-8 w-8 rounded-full bg-gray-100"></div> |
|||
<div className="h-6 w-16 rounded bg-gray-100"></div> |
|||
</div> |
|||
<div className="h-6 w-16 rounded bg-gray-100"></div> |
|||
</div> |
|||
<div className="flex w-full items-center justify-between pt-4"> |
|||
<div> |
|||
<div className="h-6 w-16 rounded bg-gray-100"></div> |
|||
<div className="mt-2 h-6 w-24 rounded bg-gray-100"></div> |
|||
</div> |
|||
<div className="flex justify-end gap-2"> |
|||
<div className="h-10 w-10 rounded bg-gray-100"></div> |
|||
<div className="h-10 w-10 rounded bg-gray-100"></div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
export function InvoicesTableSkeleton() { |
|||
return ( |
|||
<div className="mt-6 flow-root"> |
|||
<div className="inline-block min-w-full align-middle"> |
|||
<div className="rounded-lg bg-gray-50 p-2 md:pt-0"> |
|||
<div className="md:hidden"> |
|||
<InvoicesMobileSkeleton /> |
|||
<InvoicesMobileSkeleton /> |
|||
<InvoicesMobileSkeleton /> |
|||
<InvoicesMobileSkeleton /> |
|||
<InvoicesMobileSkeleton /> |
|||
<InvoicesMobileSkeleton /> |
|||
</div> |
|||
<table className="hidden min-w-full text-gray-900 md:table"> |
|||
<thead className="rounded-lg text-left text-sm font-normal"> |
|||
<tr> |
|||
<th scope="col" className="px-4 py-5 font-medium sm:pl-6"> |
|||
Customer |
|||
</th> |
|||
<th scope="col" className="px-3 py-5 font-medium"> |
|||
Email |
|||
</th> |
|||
<th scope="col" className="px-3 py-5 font-medium"> |
|||
Amount |
|||
</th> |
|||
<th scope="col" className="px-3 py-5 font-medium"> |
|||
Date |
|||
</th> |
|||
<th scope="col" className="px-3 py-5 font-medium"> |
|||
Status |
|||
</th> |
|||
<th |
|||
scope="col" |
|||
className="relative pb-4 pl-3 pr-6 pt-2 sm:pr-6" |
|||
> |
|||
<span className="sr-only">Edit</span> |
|||
</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody className="bg-white"> |
|||
<TableRowSkeleton /> |
|||
<TableRowSkeleton /> |
|||
<TableRowSkeleton /> |
|||
<TableRowSkeleton /> |
|||
<TableRowSkeleton /> |
|||
<TableRowSkeleton /> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
@ -0,0 +1,4 @@ |
|||
/** @type {import('next').NextConfig} */ |
|||
const nextConfig = {}; |
|||
|
|||
module.exports = nextConfig; |
|||
@ -0,0 +1,41 @@ |
|||
{ |
|||
"private": true, |
|||
"scripts": { |
|||
"build": "next build", |
|||
"dev": "next dev", |
|||
"prettier": "prettier --write --ignore-unknown .", |
|||
"prettier:check": "prettier --check --ignore-unknown .", |
|||
"start": "next start" |
|||
}, |
|||
"dependencies": { |
|||
"@heroicons/react": "^2.0.18", |
|||
"@tailwindcss/forms": "^0.5.7", |
|||
"@types/node": "20.5.7", |
|||
"@vercel/postgres": "^0.5.0", |
|||
"autoprefixer": "10.4.15", |
|||
"bcrypt": "^5.1.1", |
|||
"clsx": "^2.0.0", |
|||
"next": "^14.0.2", |
|||
"postcss": "8.4.31", |
|||
"react": "18.2.0", |
|||
"react-dom": "18.2.0", |
|||
"tailwindcss": "3.3.3", |
|||
"typescript": "5.2.2", |
|||
"zod": "^3.22.2" |
|||
}, |
|||
"devDependencies": { |
|||
"@types/bcrypt": "^5.0.1", |
|||
"@types/react": "18.2.21", |
|||
"@types/react-dom": "18.2.14", |
|||
"@vercel/style-guide": "^5.0.1", |
|||
"dotenv": "^16.3.1", |
|||
"eslint": "^8.52.0", |
|||
"eslint-config-next": "^14.0.0", |
|||
"eslint-config-prettier": "9.0.0", |
|||
"prettier": "^3.0.3", |
|||
"prettier-plugin-tailwindcss": "0.5.4" |
|||
}, |
|||
"engines": { |
|||
"node": ">=18.17.0" |
|||
} |
|||
} |
|||
@ -0,0 +1,6 @@ |
|||
module.exports = { |
|||
plugins: { |
|||
tailwindcss: {}, |
|||
autoprefixer: {}, |
|||
}, |
|||
}; |
|||
@ -0,0 +1,6 @@ |
|||
const styleguide = require('@vercel/style-guide/prettier'); |
|||
|
|||
module.exports = { |
|||
...styleguide, |
|||
plugins: [...styleguide.plugins, 'prettier-plugin-tailwindcss'], |
|||
}; |
|||
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 1019 B |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 487 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 238 KiB |
@ -0,0 +1,179 @@ |
|||
const { db } = require('@vercel/postgres'); |
|||
const { |
|||
invoices, |
|||
customers, |
|||
revenue, |
|||
users, |
|||
} = require('../app/lib/placeholder-data.js'); |
|||
const bcrypt = require('bcrypt'); |
|||
|
|||
async function seedUsers(client) { |
|||
try { |
|||
await client.sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`; |
|||
// Create the "users" table if it doesn't exist
|
|||
const createTable = await client.sql` |
|||
CREATE TABLE IF NOT EXISTS users ( |
|||
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, |
|||
name VARCHAR(255) NOT NULL, |
|||
email TEXT NOT NULL UNIQUE, |
|||
password TEXT NOT NULL |
|||
); |
|||
`;
|
|||
|
|||
console.log(`Created "users" table`); |
|||
|
|||
// Insert data into the "users" table
|
|||
const insertedUsers = await Promise.all( |
|||
users.map(async (user) => { |
|||
const hashedPassword = await bcrypt.hash(user.password, 10); |
|||
return client.sql` |
|||
INSERT INTO users (id, name, email, password) |
|||
VALUES (${user.id}, ${user.name}, ${user.email}, ${hashedPassword}) |
|||
ON CONFLICT (id) DO NOTHING; |
|||
`;
|
|||
}), |
|||
); |
|||
|
|||
console.log(`Seeded ${insertedUsers.length} users`); |
|||
|
|||
return { |
|||
createTable, |
|||
users: insertedUsers, |
|||
}; |
|||
} catch (error) { |
|||
console.error('Error seeding users:', error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
async function seedInvoices(client) { |
|||
try { |
|||
await client.sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`; |
|||
|
|||
// Create the "invoices" table if it doesn't exist
|
|||
const createTable = await client.sql` |
|||
CREATE TABLE IF NOT EXISTS invoices ( |
|||
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, |
|||
customer_id UUID NOT NULL, |
|||
amount INT NOT NULL, |
|||
status VARCHAR(255) NOT NULL, |
|||
date DATE NOT NULL |
|||
); |
|||
`;
|
|||
|
|||
console.log(`Created "invoices" table`); |
|||
|
|||
// Insert data into the "invoices" table
|
|||
const insertedInvoices = await Promise.all( |
|||
invoices.map( |
|||
(invoice) => client.sql` |
|||
INSERT INTO invoices (customer_id, amount, status, date) |
|||
VALUES (${invoice.customer_id}, ${invoice.amount}, ${invoice.status}, ${invoice.date}) |
|||
ON CONFLICT (id) DO NOTHING; |
|||
`,
|
|||
), |
|||
); |
|||
|
|||
console.log(`Seeded ${insertedInvoices.length} invoices`); |
|||
|
|||
return { |
|||
createTable, |
|||
invoices: insertedInvoices, |
|||
}; |
|||
} catch (error) { |
|||
console.error('Error seeding invoices:', error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
async function seedCustomers(client) { |
|||
try { |
|||
await client.sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`; |
|||
|
|||
// Create the "customers" table if it doesn't exist
|
|||
const createTable = await client.sql` |
|||
CREATE TABLE IF NOT EXISTS customers ( |
|||
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, |
|||
name VARCHAR(255) NOT NULL, |
|||
email VARCHAR(255) NOT NULL, |
|||
image_url VARCHAR(255) NOT NULL |
|||
); |
|||
`;
|
|||
|
|||
console.log(`Created "customers" table`); |
|||
|
|||
// Insert data into the "customers" table
|
|||
const insertedCustomers = await Promise.all( |
|||
customers.map( |
|||
(customer) => client.sql` |
|||
INSERT INTO customers (id, name, email, image_url) |
|||
VALUES (${customer.id}, ${customer.name}, ${customer.email}, ${customer.image_url}) |
|||
ON CONFLICT (id) DO NOTHING; |
|||
`,
|
|||
), |
|||
); |
|||
|
|||
console.log(`Seeded ${insertedCustomers.length} customers`); |
|||
|
|||
return { |
|||
createTable, |
|||
customers: insertedCustomers, |
|||
}; |
|||
} catch (error) { |
|||
console.error('Error seeding customers:', error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
async function seedRevenue(client) { |
|||
try { |
|||
// Create the "revenue" table if it doesn't exist
|
|||
const createTable = await client.sql` |
|||
CREATE TABLE IF NOT EXISTS revenue ( |
|||
month VARCHAR(4) NOT NULL UNIQUE, |
|||
revenue INT NOT NULL |
|||
); |
|||
`;
|
|||
|
|||
console.log(`Created "revenue" table`); |
|||
|
|||
// Insert data into the "revenue" table
|
|||
const insertedRevenue = await Promise.all( |
|||
revenue.map( |
|||
(rev) => client.sql` |
|||
INSERT INTO revenue (month, revenue) |
|||
VALUES (${rev.month}, ${rev.revenue}) |
|||
ON CONFLICT (month) DO NOTHING; |
|||
`,
|
|||
), |
|||
); |
|||
|
|||
console.log(`Seeded ${insertedRevenue.length} revenue`); |
|||
|
|||
return { |
|||
createTable, |
|||
revenue: insertedRevenue, |
|||
}; |
|||
} catch (error) { |
|||
console.error('Error seeding revenue:', error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
async function main() { |
|||
const client = await db.connect(); |
|||
|
|||
await seedUsers(client); |
|||
await seedCustomers(client); |
|||
await seedInvoices(client); |
|||
await seedRevenue(client); |
|||
|
|||
await client.end(); |
|||
} |
|||
|
|||
main().catch((err) => { |
|||
console.error( |
|||
'An error occurred while attempting to seed the database:', |
|||
err, |
|||
); |
|||
}); |
|||
@ -0,0 +1,32 @@ |
|||
import type { Config } from 'tailwindcss'; |
|||
|
|||
const config: Config = { |
|||
content: [ |
|||
'./pages/**/*.{js,ts,jsx,tsx,mdx}', |
|||
'./components/**/*.{js,ts,jsx,tsx,mdx}', |
|||
'./app/**/*.{js,ts,jsx,tsx,mdx}', |
|||
], |
|||
theme: { |
|||
extend: { |
|||
gridTemplateColumns: { |
|||
'13': 'repeat(13, minmax(0, 1fr))', |
|||
}, |
|||
colors: { |
|||
blue: { |
|||
400: '#2589FE', |
|||
500: '#0070F3', |
|||
600: '#2F6FEB', |
|||
}, |
|||
}, |
|||
}, |
|||
keyframes: { |
|||
shimmer: { |
|||
'100%': { |
|||
transform: 'translateX(100%)', |
|||
}, |
|||
}, |
|||
}, |
|||
}, |
|||
plugins: [require('@tailwindcss/forms')], |
|||
}; |
|||
export default config; |
|||
@ -0,0 +1,34 @@ |
|||
{ |
|||
"compilerOptions": { |
|||
"target": "es5", |
|||
"lib": ["dom", "dom.iterable", "esnext"], |
|||
"allowJs": true, |
|||
"skipLibCheck": true, |
|||
"strict": true, |
|||
"noEmit": true, |
|||
"esModuleInterop": true, |
|||
"module": "esnext", |
|||
"moduleResolution": "bundler", |
|||
"resolveJsonModule": true, |
|||
"isolatedModules": true, |
|||
"jsx": "preserve", |
|||
"incremental": true, |
|||
"plugins": [ |
|||
{ |
|||
"name": "next" |
|||
} |
|||
], |
|||
"paths": { |
|||
"@/*": ["./*"] |
|||
} |
|||
}, |
|||
"include": [ |
|||
"next-env.d.ts", |
|||
"**/*.ts", |
|||
"**/*.tsx", |
|||
".next/types/**/*.ts", |
|||
"app/lib/placeholder-data.js", |
|||
"scripts/seed.js" |
|||
], |
|||
"exclude": ["node_modules"] |
|||
} |
|||