mirror of
https://github.com/vercel/next-learn.git
synced 2026-06-11 09:51:47 +00:00
Code for Chapters 9-10 (#172)
This commit is contained in:
committed by
GitHub
parent
68be8ee164
commit
b0d832e2cf
1
dashboard/15-final/.gitignore
vendored
1
dashboard/15-final/.gitignore
vendored
@@ -26,6 +26,7 @@ yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
.env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
@@ -1,20 +1,102 @@
|
||||
import InvoiceForm from '@/app/ui/invoices/form';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { Invoice } from '@/app/lib/definitions';
|
||||
import { fetchInvoiceById } from '@/app/lib/data';
|
||||
import { fetchInvoiceById, fetchAllCustomers } from '@/app/lib/data';
|
||||
import { updateInvoice } from '@/app/lib/actions';
|
||||
|
||||
export default async function Page({ params }: { params: { id: string } }) {
|
||||
const id = params.id ? parseInt(params.id) : null;
|
||||
const invoiceData = await fetchInvoiceById(id);
|
||||
const invoice = invoiceData.rows[0] as Invoice;
|
||||
const id = params.id;
|
||||
const invoice = await fetchInvoiceById(id);
|
||||
const customers = await fetchAllCustomers();
|
||||
|
||||
if (!invoice) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<InvoiceForm type="edit" invoice={invoice} />
|
||||
</div>
|
||||
<main role="main">
|
||||
<div className="mx-auto max-w-sm rounded-lg border px-6 py-8 shadow-sm">
|
||||
<h2
|
||||
className="mb-6 text-xl font-semibold text-gray-900"
|
||||
role="heading"
|
||||
aria-level={2}
|
||||
>
|
||||
Edit Invoice
|
||||
</h2>
|
||||
<form action={updateInvoice}>
|
||||
<input type="hidden" name="id" value={id} />
|
||||
{/* Customer selection */}
|
||||
<div className="mb-4" role="group">
|
||||
<label
|
||||
htmlFor="customer"
|
||||
className="mb-2 block text-sm font-semibold"
|
||||
>
|
||||
Customer
|
||||
</label>
|
||||
<select
|
||||
id="customer"
|
||||
name="customerId"
|
||||
className="block w-full rounded-md border-0 py-1.5 pl-3 text-sm ring-1 ring-inset ring-gray-200 placeholder:text-gray-200 focus:ring-blue-200"
|
||||
defaultValue={invoice.name}
|
||||
aria-label="Select Customer"
|
||||
>
|
||||
{customers.map((customer) => (
|
||||
<option key={customer.id} value={customer.id}>
|
||||
{customer.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Invoice amount */}
|
||||
<div className="mb-4" role="group">
|
||||
<label className="mb-2 block text-sm font-semibold">Amount</label>
|
||||
<div className="relative mt-2 rounded-md shadow-sm">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span className="text-gray-600 sm:text-sm" aria-hidden="true">
|
||||
$
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
name="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
defaultValue={invoice.amount}
|
||||
placeholder="00.00"
|
||||
className="block w-full rounded-md border-0 py-1.5 pl-7 text-sm leading-6 ring-1 ring-inset ring-gray-200 placeholder:text-gray-400 focus:ring-blue-200"
|
||||
aria-label="Enter Amount"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoice status */}
|
||||
<div className="mb-4" role="group">
|
||||
<label
|
||||
className="mb-2 block text-sm font-semibold"
|
||||
htmlFor="status"
|
||||
>
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
className="block w-full rounded-md border-0 py-1.5 pl-3 text-sm ring-1 ring-inset ring-gray-200 placeholder:text-gray-200 focus:ring-blue-200"
|
||||
defaultValue={invoice.status}
|
||||
aria-label="Select Status"
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="paid">Paid</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-blue-500 px-4 py-2 text-center text-sm font-semibold text-white hover:bg-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
aria-label="Update Invoice"
|
||||
>
|
||||
Update Invoice
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,101 @@
|
||||
import InvoiceForm from '@/app/ui/invoices/form';
|
||||
import { createInvoice } from '@/app/lib/actions';
|
||||
import { fetchAllCustomers } from '@/app/lib/data';
|
||||
|
||||
export default function Page() {
|
||||
return <InvoiceForm type="new" />;
|
||||
export default async function Page() {
|
||||
const customers = await fetchAllCustomers();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-sm rounded-lg border px-6 py-8 shadow-sm">
|
||||
<h2 className="mb-6 text-xl font-semibold text-gray-900">
|
||||
Create Invoice
|
||||
</h2>
|
||||
|
||||
<form action={createInvoice}>
|
||||
{/* Customer */}
|
||||
<div className="mb-4">
|
||||
<label
|
||||
htmlFor="customer"
|
||||
className="mb-2 block text-sm font-semibold"
|
||||
aria-label="Select Customer"
|
||||
>
|
||||
Customer
|
||||
</label>
|
||||
<select
|
||||
id="customer"
|
||||
name="customerId"
|
||||
className="block w-full rounded-md border-0 py-1.5 pl-3 text-sm ring-1 ring-inset ring-gray-200 placeholder:text-gray-200"
|
||||
defaultValue=""
|
||||
aria-label="Select Customer"
|
||||
aria-required="true"
|
||||
required
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select a customer
|
||||
</option>
|
||||
{customers.map((customer) => (
|
||||
<option key={customer.id} value={customer.id}>
|
||||
{customer.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="mb-4">
|
||||
<label
|
||||
className="mb-2 block text-sm font-semibold"
|
||||
htmlFor="amount"
|
||||
id="amount-label"
|
||||
>
|
||||
Amount
|
||||
</label>
|
||||
<div className="relative mt-2 rounded-md shadow-sm">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span className="text-gray-600 sm:text-sm" aria-hidden="true">
|
||||
$
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id="amount"
|
||||
name="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="00.00"
|
||||
className="block w-full rounded-md border-0 py-1.5 pl-7 text-sm leading-6 ring-1 ring-inset ring-gray-200 placeholder:text-gray-400"
|
||||
aria-describedby="amount-label"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoice Status */}
|
||||
<div className="mb-4">
|
||||
<label
|
||||
className="mb-2 block text-sm font-semibold"
|
||||
htmlFor="status"
|
||||
id="status-label"
|
||||
>
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
className="block w-full rounded-md border-0 py-1.5 pl-3 text-sm ring-1 ring-inset ring-gray-200 placeholder:text-gray-200"
|
||||
defaultValue="pending"
|
||||
aria-describedby="status-label"
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="paid">Paid</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-blue-500 px-4 py-2 text-center text-sm font-semibold text-white hover:bg-blue-600 focus:border-blue-700 focus:outline-none focus:ring focus:ring-blue-200"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,38 @@
|
||||
import InvoicesTable from '@/app/ui/invoices/table';
|
||||
import Pagination from '@/app/ui/invoices/pagination';
|
||||
import Search from '@/app/ui/invoices/search';
|
||||
import { CreateInvoice } from '@/app/ui/invoices/buttons';
|
||||
import Table from '@/app/ui/invoices/table';
|
||||
import { fetchFilteredInvoices } from '@/app/lib/data';
|
||||
|
||||
export default function Page({
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: {
|
||||
query: string;
|
||||
page: string;
|
||||
};
|
||||
searchParams:
|
||||
| {
|
||||
query: string | undefined;
|
||||
page: string | undefined;
|
||||
}
|
||||
| undefined;
|
||||
}) {
|
||||
const query = searchParams?.query || '';
|
||||
const currentPage = query ? 1 : Number(searchParams?.page || '1');
|
||||
|
||||
const { invoices, totalPages } = await fetchFilteredInvoices(
|
||||
query,
|
||||
currentPage,
|
||||
);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<InvoicesTable searchParams={searchParams} />
|
||||
</main>
|
||||
<div className="w-full">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<h1 className="text-base font-semibold">Invoices</h1>
|
||||
<CreateInvoice />
|
||||
</div>
|
||||
<div className="mt-8 flex items-center justify-between gap-2">
|
||||
<Search />
|
||||
<Pagination totalPages={totalPages} currentPage={currentPage} />
|
||||
</div>
|
||||
<Table invoices={invoices} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,65 @@
|
||||
'use server';
|
||||
|
||||
export async function deleteInvoice(id: number) {
|
||||
// TO DO: Add delete invoice logic
|
||||
console.log('Delete invoice', id);
|
||||
import { z } from 'zod';
|
||||
import { sql } from '@vercel/postgres';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
const InvoiceSchema = z.object({
|
||||
id: z.string(),
|
||||
customerId: z.string(),
|
||||
amount: z.coerce.number(),
|
||||
status: z.enum(['pending', 'paid']),
|
||||
date: z.string(),
|
||||
});
|
||||
const CreateInvoice = InvoiceSchema.omit({ id: true, date: true });
|
||||
const UpdateInvoice = InvoiceSchema.omit({ date: true });
|
||||
const DeleteInvoice = InvoiceSchema.pick({ id: true });
|
||||
|
||||
export async function createInvoice(formData: FormData) {
|
||||
const { customerId, amount, status } = CreateInvoice.parse({
|
||||
customerId: formData.get('customerId'),
|
||||
amount: formData.get('amount'),
|
||||
status: formData.get('status'),
|
||||
});
|
||||
|
||||
const amountInCents = amount * 100;
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
|
||||
await sql`
|
||||
INSERT INTO invoices (customer_id, amount, status, date)
|
||||
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
|
||||
`;
|
||||
|
||||
revalidatePath('/dashboard/invoices');
|
||||
redirect('/dashboard/invoices');
|
||||
}
|
||||
|
||||
export async function addOrUpdateInvoice(formData: FormData) {
|
||||
// TO DO: Add create/update invoice logic
|
||||
console.log('Edit Invoice');
|
||||
export async function updateInvoice(formData: FormData) {
|
||||
const { id, customerId, amount, status } = UpdateInvoice.parse({
|
||||
id: formData.get('id'),
|
||||
customerId: formData.get('customerId'),
|
||||
amount: formData.get('amount'),
|
||||
status: formData.get('status'),
|
||||
});
|
||||
|
||||
const amountInCents = amount * 100;
|
||||
|
||||
await sql`
|
||||
UPDATE invoices
|
||||
SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
|
||||
WHERE id = ${id}
|
||||
`;
|
||||
|
||||
revalidatePath('/dashboard/invoices');
|
||||
redirect('/dashboard/invoices');
|
||||
}
|
||||
|
||||
export async function deleteInvoice(formData: FormData) {
|
||||
const { id } = DeleteInvoice.parse({
|
||||
id: formData.get('id'),
|
||||
});
|
||||
|
||||
await sql`DELETE FROM invoices WHERE id = ${id}`;
|
||||
revalidatePath('/dashboard/invoices');
|
||||
}
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
import { sql } from '@vercel/postgres';
|
||||
import { formatCurrency } from './utils';
|
||||
import { Revenue, LatestInvoice } from './definitions';
|
||||
import {
|
||||
Revenue,
|
||||
LatestInvoice,
|
||||
InvoicesTable,
|
||||
CustomersTable,
|
||||
} 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[]> {
|
||||
export async function fetchRevenue() {
|
||||
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));
|
||||
// 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.');
|
||||
const data = await sql`SELECT * FROM revenue`;
|
||||
|
||||
return revenueData.rows as Revenue[];
|
||||
// console.log('Data fetch complete after 3 seconds.');
|
||||
|
||||
return data.rows as Revenue[];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch revenue data:', error);
|
||||
console.error('Database Error:', error);
|
||||
throw new Error('Failed to fetch revenue data.');
|
||||
}
|
||||
}
|
||||
@@ -39,23 +35,23 @@ export async function fetchCounts() {
|
||||
|
||||
return { numberOfCustomers, numberOfInvoices };
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch counts:', error);
|
||||
console.error('Database Error:', error);
|
||||
throw new Error('Failed to fetch counts.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchTotalAmountByStatus() {
|
||||
try {
|
||||
const totalAmount = await sql`SELECT
|
||||
const data = 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);
|
||||
const totalPaidInvoices = formatCurrency(data.rows[0].paid);
|
||||
const totalPendingInvoices = formatCurrency(data.rows[0].pending);
|
||||
|
||||
return { totalPaidInvoices, totalPendingInvoices };
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch total amounts by status:', error);
|
||||
console.error('Database Error:', error);
|
||||
throw new Error('Failed to fetch total amounts by status.');
|
||||
}
|
||||
}
|
||||
@@ -68,65 +64,147 @@ export async function fetchLatestInvoices() {
|
||||
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);
|
||||
console.error('Database Error:', 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 fetchFilteredInvoices(
|
||||
query: string,
|
||||
currentPage: number,
|
||||
) {
|
||||
const itemsPerPage = 10;
|
||||
const offset = (currentPage - 1) * itemsPerPage;
|
||||
|
||||
try {
|
||||
const data = await sql`
|
||||
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 ${itemsPerPage} OFFSET ${offset}
|
||||
`;
|
||||
|
||||
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 totalRecords = Number(count.rows[0].count);
|
||||
const totalPages = Math.ceil(totalRecords / itemsPerPage);
|
||||
|
||||
return {
|
||||
invoices: data.rows as InvoicesTable[],
|
||||
totalPages,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Database Error:', error);
|
||||
throw new Error('Failed to fetch invoices.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchInvoiceById(id: string) {
|
||||
try {
|
||||
const data = await sql`
|
||||
SELECT
|
||||
invoices.id,
|
||||
invoices.amount,
|
||||
invoices.status,
|
||||
customers.name
|
||||
FROM invoices
|
||||
JOIN customers ON invoices.customer_id = customers.id
|
||||
WHERE invoices.id = ${id};
|
||||
`;
|
||||
|
||||
const invoice = data.rows.map((invoice) => ({
|
||||
...invoice,
|
||||
// Convert amount from cents to dollars
|
||||
amount: invoice.amount / 100,
|
||||
}));
|
||||
|
||||
return invoice[0] as {
|
||||
id: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
name: string;
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Database Error:', error);
|
||||
throw new Error('Failed to fetch invoice.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAllCustomers() {
|
||||
const customersData = await sql`SELECT * FROM customers`;
|
||||
return customersData.rows;
|
||||
try {
|
||||
const data = await sql`
|
||||
SELECT
|
||||
id,
|
||||
name
|
||||
FROM customers
|
||||
ORDER BY name ASC
|
||||
`;
|
||||
|
||||
const customers = data.rows;
|
||||
return customers as { id: string; name: string }[];
|
||||
} catch (err) {
|
||||
console.error('Database Error:', err);
|
||||
throw new Error('Failed to fetch all customers.');
|
||||
}
|
||||
}
|
||||
|
||||
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 fetchCustomersTable() {
|
||||
try {
|
||||
const data = await sql`
|
||||
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
|
||||
GROUP BY customers.id, customers.name, customers.email, customers.image_url
|
||||
ORDER BY customers.name ASC
|
||||
`;
|
||||
|
||||
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;
|
||||
}
|
||||
const customers = data.rows.map((customer) => ({
|
||||
...customer,
|
||||
total_pending: formatCurrency(customer.total_pending),
|
||||
total_paid: formatCurrency(customer.total_paid),
|
||||
}));
|
||||
|
||||
export async function fetchInvoiceById(id: number | null) {
|
||||
return await sql`SELECT * from INVOICES where id=${id}`;
|
||||
return customers as CustomersTable[];
|
||||
} catch (err) {
|
||||
console.error('Database Error:', err);
|
||||
throw new Error('Failed to fetch customer table.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// This file contains type definitions for you data.
|
||||
// These describe the shape of the data, and what data type each property should accept.
|
||||
|
||||
export type User = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -9,31 +8,52 @@ export type User = {
|
||||
};
|
||||
|
||||
export type Customer = {
|
||||
id: number;
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
image_url: string;
|
||||
};
|
||||
|
||||
export type Invoice = {
|
||||
id: number;
|
||||
customer_id: number;
|
||||
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';
|
||||
date: string;
|
||||
};
|
||||
|
||||
export type LatestInvoice = {
|
||||
id: number;
|
||||
name: string;
|
||||
image_url: string;
|
||||
email: string;
|
||||
amount: string;
|
||||
};
|
||||
|
||||
export type Revenue = {
|
||||
month: string;
|
||||
revenue: number;
|
||||
};
|
||||
|
||||
export type LatestInvoice = {
|
||||
id: string;
|
||||
name: string;
|
||||
image_url: string;
|
||||
email: string;
|
||||
amount: string;
|
||||
};
|
||||
|
||||
export type InvoicesTable = {
|
||||
id: string;
|
||||
customer_id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
image_url: string;
|
||||
date: string;
|
||||
amount: number;
|
||||
status: 'pending' | 'paid';
|
||||
};
|
||||
|
||||
export type CustomersTable = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
image_url: string;
|
||||
total_invoices: number;
|
||||
total_pending: string;
|
||||
total_paid: string;
|
||||
};
|
||||
|
||||
@@ -10,25 +10,25 @@ const users = [
|
||||
|
||||
const customers = [
|
||||
{
|
||||
id: 1,
|
||||
id: '93980f8c-a5e4-484c-a469-2d12ca8fdde3',
|
||||
name: 'Ada Lovelace',
|
||||
email: 'ada@lovelace.com',
|
||||
image_url: '/customers/ada-lovelace.png',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
id: 'e53120f8-0301-437b-924a-0288f4ec6040',
|
||||
name: 'Grace Hopper',
|
||||
email: 'grace@hopper.com',
|
||||
image_url: '/customers/grace-hopper.png',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
id: '030fab4c-18d7-4ed2-814c-4171cc67bca8',
|
||||
name: 'Hedy Lammar',
|
||||
email: 'hedy@lammar.com',
|
||||
image_url: '/customers/hedy-lammar.png',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
id: '3958dc9e-737f-4377-85e9-fec4b6a6442a',
|
||||
name: 'Margaret Hamilton',
|
||||
email: 'margaret@hamilton.com',
|
||||
image_url: '/customers/margaret-hamilton.png',
|
||||
@@ -37,106 +37,91 @@ const customers = [
|
||||
|
||||
const invoices = [
|
||||
{
|
||||
id: 1,
|
||||
customer_id: 1,
|
||||
customer_id: customers[0].id,
|
||||
amount: 15795,
|
||||
status: 'pending',
|
||||
date: '2023-12-06',
|
||||
date: '2022-12-06',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
customer_id: 2,
|
||||
customer_id: customers[1].id,
|
||||
amount: 20348,
|
||||
status: 'pending',
|
||||
date: '2023-11-14',
|
||||
date: '2022-11-14',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
customer_id: 3,
|
||||
customer_id: customers[2].id,
|
||||
amount: 3040,
|
||||
status: 'paid',
|
||||
date: '2023-10-29',
|
||||
date: '2022-10-29',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
customer_id: 4,
|
||||
customer_id: customers[3].id,
|
||||
amount: 44800,
|
||||
status: 'paid',
|
||||
date: '2023-09-10',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
customer_id: 1,
|
||||
customer_id: customers[0].id,
|
||||
amount: 34577,
|
||||
status: 'pending',
|
||||
date: '2023-08-05',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
customer_id: 2,
|
||||
customer_id: customers[1].id,
|
||||
amount: 54246,
|
||||
status: 'pending',
|
||||
date: '2023-07-16',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
customer_id: 3,
|
||||
customer_id: customers[2].id,
|
||||
amount: 8945,
|
||||
status: 'pending',
|
||||
date: '2023-06-27',
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
customer_id: 4,
|
||||
customer_id: customers[3].id,
|
||||
amount: 32545,
|
||||
status: 'paid',
|
||||
date: '2023-06-09',
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
customer_id: 3,
|
||||
customer_id: customers[2].id,
|
||||
amount: 1250,
|
||||
status: 'paid',
|
||||
date: '2023-06-17',
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
customer_id: 1,
|
||||
customer_id: customers[0].id,
|
||||
amount: 8945,
|
||||
status: 'paid',
|
||||
date: '2023-06-07',
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
customer_id: 2,
|
||||
customer_id: customers[1].id,
|
||||
amount: 500,
|
||||
status: 'paid',
|
||||
date: '2023-08-19',
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
customer_id: 3,
|
||||
customer_id: customers[2].id,
|
||||
amount: 8945,
|
||||
status: 'paid',
|
||||
date: '2023-06-03',
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
customer_id: 3,
|
||||
customer_id: customers[2].id,
|
||||
amount: 8945,
|
||||
status: 'paid',
|
||||
date: '2023-06-18',
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
customer_id: 4,
|
||||
customer_id: customers[3].id,
|
||||
amount: 8945,
|
||||
status: 'paid',
|
||||
date: '2023-10-04',
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
customer_id: 3,
|
||||
customer_id: customers[2].id,
|
||||
amount: 1000,
|
||||
status: 'paid',
|
||||
date: '2022-06-05',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Invoice, Revenue, Customer } from './definitions';
|
||||
import { Revenue } from './definitions';
|
||||
|
||||
export const formatCurrency = (amount: number) => {
|
||||
return (amount / 100).toLocaleString('en-US', {
|
||||
@@ -21,72 +21,6 @@ export const formatDateToLocal = (
|
||||
return formatter.format(date);
|
||||
};
|
||||
|
||||
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',
|
||||
) => {
|
||||
return invoices
|
||||
.filter((invoice) => !status || invoice.status === status)
|
||||
.reduce((total, invoice) => total + invoice.amount / 100, 0)
|
||||
.toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
});
|
||||
};
|
||||
|
||||
export const calculateCustomerInvoices = (
|
||||
invoices: Invoice[],
|
||||
status: 'pending' | 'paid',
|
||||
customerId: number,
|
||||
) => {
|
||||
return invoices
|
||||
.filter((invoice) => invoice.customer_id === customerId)
|
||||
.filter((invoice) => !status || invoice.status === status)
|
||||
.reduce((total, invoice) => total + invoice.amount / 100, 0)
|
||||
.toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
});
|
||||
};
|
||||
|
||||
export const countCustomerInvoices = (
|
||||
invoices: Invoice[],
|
||||
customerId: number,
|
||||
) => {
|
||||
return invoices.filter((invoice) => invoice.customer_id === customerId)
|
||||
.length;
|
||||
};
|
||||
|
||||
export const generateYAxis = (revenue: Revenue[]) => {
|
||||
// Calculate what labels we need to display on the y-axis
|
||||
// based on highest record and in 1000s
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function Page() {
|
||||
</a>
|
||||
</div>
|
||||
<div className="w-full sm:w-1/2">
|
||||
<Image src={HeroImage} alt="Dashboard Hero Image" placeholder="blur" />
|
||||
<Image src={HeroImage} alt="Dashboard Hero Image" />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import {
|
||||
countCustomerInvoices,
|
||||
calculateCustomerInvoices,
|
||||
} from '@/app/lib/utils';
|
||||
import { Customer, Invoice } from '@/app/lib/definitions';
|
||||
import { fetchAllCustomers, fetchAllInvoices } from '@/app/lib/data';
|
||||
import { fetchCustomersTable } from '@/app/lib/data';
|
||||
import Image from 'next/image';
|
||||
|
||||
export default async function CustomersTable() {
|
||||
const invoices = (await fetchAllInvoices()) as Invoice[];
|
||||
const customers = (await fetchAllCustomers()) as Customer[];
|
||||
const customers = await fetchCustomersTable();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
@@ -22,19 +16,19 @@ export default async function CustomersTable() {
|
||||
<table className="min-w-full divide-y divide-gray-300">
|
||||
<thead className="bg-gray-50 text-left text-sm">
|
||||
<tr>
|
||||
<th scope="col" className="px-3.5 py-3.5 sm:pl-6">
|
||||
<th scope="col" className="px-4 py-4 sm:pl-6">
|
||||
Name
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-3.5 font-semibold">
|
||||
<th scope="col" className="px-4 py-4 font-semibold">
|
||||
Email
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-3.5 font-semibold">
|
||||
<th scope="col" className="px-4 py-4 font-semibold">
|
||||
Total Invoices
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-3.5 font-semibold">
|
||||
<th scope="col" className="px-4 py-4 font-semibold">
|
||||
Total Pending
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-3.5 font-semibold">
|
||||
<th scope="col" className="px-4 py-4 font-semibold">
|
||||
Total Paid
|
||||
</th>
|
||||
</tr>
|
||||
@@ -55,25 +49,17 @@ export default async function CustomersTable() {
|
||||
<p>{customer.name}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm">
|
||||
<td className="whitespace-nowrap px-4 py-4 text-sm">
|
||||
{customer.email}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm">
|
||||
{countCustomerInvoices(invoices, customer.id)}
|
||||
<td className="whitespace-nowrap px-4 py-4 text-sm">
|
||||
{customer.total_invoices}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm">
|
||||
{calculateCustomerInvoices(
|
||||
invoices,
|
||||
'pending',
|
||||
customer.id,
|
||||
)}
|
||||
<td className="whitespace-nowrap px-4 py-4 text-sm">
|
||||
{customer.total_pending}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm">
|
||||
{calculateCustomerInvoices(
|
||||
invoices,
|
||||
'paid',
|
||||
customer.id,
|
||||
)}
|
||||
<td className="whitespace-nowrap px-4 py-4 text-sm">
|
||||
{customer.total_paid}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { generateYAxis } from '@/app/lib/utils';
|
||||
import { fetchRevenueDelayed } from '@/app/lib/data';
|
||||
import { fetchRevenue } from '@/app/lib/data';
|
||||
|
||||
// This component is representational only.
|
||||
// For data visualization UI, check out:
|
||||
@@ -8,7 +8,7 @@ import { fetchRevenueDelayed } from '@/app/lib/data';
|
||||
// https://airbnb.io/visx/
|
||||
|
||||
export default async function RevenueChart() {
|
||||
const revenue = await fetchRevenueDelayed();
|
||||
const revenue = await fetchRevenue();
|
||||
|
||||
const chartHeight = 350;
|
||||
const { yAxisLabels, topLabel } = generateYAxis(revenue);
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
export default function AddInvoice() {
|
||||
return (
|
||||
<Link
|
||||
href="/dashboard/invoices/create"
|
||||
className="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
Add Invoice
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
32
dashboard/15-final/app/ui/invoices/buttons.tsx
Normal file
32
dashboard/15-final/app/ui/invoices/buttons.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { PencilSquareIcon, TrashIcon } from '@heroicons/react/24/outline';
|
||||
import Link from 'next/link';
|
||||
import { deleteInvoice } from '@/app/lib/actions';
|
||||
|
||||
export function CreateInvoice() {
|
||||
return (
|
||||
<button className="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">
|
||||
<Link href="/dashboard/invoices/create">Create Invoice</Link>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateInvoice({ id }: { id: string }) {
|
||||
return (
|
||||
<button className="rounded-md border p-1">
|
||||
<Link href={`/dashboard/invoices/${id}/edit`}>
|
||||
<PencilSquareIcon className="w-4" />
|
||||
</Link>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeleteInvoice({ id }: { id: string }) {
|
||||
return (
|
||||
<form action={deleteInvoice}>
|
||||
<input type="hidden" name="id" value={id} />
|
||||
<button className="rounded-md border p-1">
|
||||
<TrashIcon className="w-4" />
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { TrashIcon } from '@heroicons/react/24/outline';
|
||||
import { useTransition } from 'react';
|
||||
import { deleteInvoice } from '@/app/lib/actions';
|
||||
|
||||
export default function DeleteInvoice({ id }: { id: number }) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => startTransition(() => deleteInvoice(id))}
|
||||
className="rounded-md border p-1"
|
||||
>
|
||||
<TrashIcon className="w-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { PencilSquareIcon } from '@heroicons/react/24/outline';
|
||||
import Link from 'next/link';
|
||||
export default function EditInvoice({ id }: { id: number }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/dashboard/invoices/${id}/edit`}
|
||||
className="rounded-md border p-1"
|
||||
>
|
||||
<PencilSquareIcon className="w-4" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Invoice } from '@/app/lib/definitions';
|
||||
import { customers } from '@/app/lib/dummy-data';
|
||||
import { useState, FormEvent } from 'react';
|
||||
|
||||
// import { addOrUpdateInvoice } from "@/app/lib/actions";
|
||||
// export const dynamic = "force-dynamic";
|
||||
|
||||
export default function InvoiceForm({
|
||||
type,
|
||||
invoice,
|
||||
}: {
|
||||
type: 'new' | 'edit';
|
||||
invoice?: Invoice;
|
||||
}) {
|
||||
// TO DO: Replace state and handleSubmit with a Server Action
|
||||
const customer = customers.find(
|
||||
(customer) => customer.id === invoice?.customer_id,
|
||||
);
|
||||
const initialCustomer = customer ? customer.id : 0;
|
||||
const initialAmount = invoice?.amount ? invoice.amount / 100 : 0;
|
||||
const initialStatus = invoice?.status || 'pending';
|
||||
|
||||
const [selectedCustomer, setSelectedCustomer] = useState(initialCustomer);
|
||||
const [amount, setAmount] = useState<number>(initialAmount);
|
||||
const [status, setStatus] = useState<'pending' | 'paid'>(initialStatus);
|
||||
|
||||
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (selectedCustomer && amount) {
|
||||
const newInvoice: Invoice = {
|
||||
customer_id: selectedCustomer,
|
||||
amount: amount * 100, // Convert to cents
|
||||
|
||||
// These would be generated on the server
|
||||
id: 1, // Record ID will be automatically incremented
|
||||
status: status, // Default status for a new invoice
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
};
|
||||
|
||||
// TODO: Add this invoice to the database
|
||||
console.log('New Invoice:', newInvoice);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-sm rounded-lg border px-6 py-8 shadow-sm">
|
||||
<h2 className="mb-6 text-xl font-semibold text-gray-900">
|
||||
{type === 'new' ? 'New Invoice' : 'Edit Invoice'}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label
|
||||
htmlFor="customer"
|
||||
className="mb-2 block text-sm font-semibold"
|
||||
>
|
||||
Customer
|
||||
</label>
|
||||
|
||||
<select
|
||||
id="customer"
|
||||
onChange={(e) => setSelectedCustomer(Number(e.target.value))}
|
||||
className="block w-full rounded-md border-0 py-1.5 pl-3 text-sm ring-1 ring-inset ring-gray-200 placeholder:text-gray-200"
|
||||
value={selectedCustomer}
|
||||
>
|
||||
{customers.map((customer) => (
|
||||
<option key={customer.id} value={customer.id}>
|
||||
{customer.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="mb-2 block text-sm font-semibold">Amount</label>
|
||||
<div className="relative mt-2 rounded-md shadow-sm">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span className="text-gray-600 sm:text-sm">$</span>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
value={amount}
|
||||
placeholder="00.00"
|
||||
onChange={(e) => setAmount(Number(e.target.value))}
|
||||
className="block w-full rounded-md border-0 py-1.5 pl-7 text-sm leading-6 ring-1 ring-inset ring-gray-200 placeholder:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{type === 'edit' ? (
|
||||
<div className="mb-4">
|
||||
<label
|
||||
className="mb-2 block text-sm font-semibold"
|
||||
htmlFor="status"
|
||||
>
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
className="block w-full rounded-md border-0 py-1.5 pl-3 text-sm ring-1 ring-inset ring-gray-200 placeholder:text-gray-200"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
|
||||
if (value === 'paid' || value === 'pending') {
|
||||
setStatus(value);
|
||||
}
|
||||
}}
|
||||
value={status}
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="paid">Paid</option>
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-blue-500 px-4 py-2 text-center text-sm font-semibold text-white hover:bg-blue-600"
|
||||
>
|
||||
Create Invoice
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import clsx from 'clsx';
|
||||
import Link from 'next/link';
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function PaginationButtons({
|
||||
totalPages,
|
||||
export default function Pagination({
|
||||
currentPage,
|
||||
searchParams,
|
||||
totalPages,
|
||||
}: {
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
searchParams: { query: string; page: string };
|
||||
totalPages: number;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const pageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
|
||||
const createPageUrl = (pageNumber: number) => {
|
||||
const newSearchParams = new URLSearchParams(searchParams.toString());
|
||||
newSearchParams.set('page', pageNumber.toString());
|
||||
return `${pathname}?${newSearchParams.toString()}`;
|
||||
};
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const allPages = Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
const PreviousPageTag = currentPage === 1 ? 'p' : Link;
|
||||
const NextPageTag = currentPage === totalPages ? 'p' : Link;
|
||||
|
||||
const createPageUrl = (pageNumber: number) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set('page', pageNumber.toString());
|
||||
return `${pathname}?${params.toString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="inline-flex -space-x-px">
|
||||
<PreviousPageTag
|
||||
@@ -41,7 +38,7 @@ export default function PaginationButtons({
|
||||
>
|
||||
<ChevronLeftIcon className="w-4" />
|
||||
</PreviousPageTag>
|
||||
{pageNumbers.map((page) => {
|
||||
{allPages.map((page) => {
|
||||
const PageTag = page === currentPage ? 'p' : Link;
|
||||
return (
|
||||
<PageTag
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
export default function Search({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { query: string; page: string };
|
||||
}) {
|
||||
export default function Search() {
|
||||
const searchParams = useSearchParams();
|
||||
const { replace } = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
function handleSearch(term: string) {
|
||||
const handleSearch = useDebouncedCallback((term) => {
|
||||
console.log(`Searching... ${term}`);
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (term) {
|
||||
params.set('query', term);
|
||||
@@ -19,7 +19,7 @@ export default function Search({
|
||||
params.delete('query');
|
||||
}
|
||||
replace(`${pathname}?${params.toString()}`);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return (
|
||||
<div className="relative max-w-md flex-grow">
|
||||
@@ -31,11 +31,11 @@ export default function Search({
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={searchParams.query || ''}
|
||||
className="absolute inset-0 w-full rounded-md border border-gray-300 bg-transparent p-2 pl-8 text-sm"
|
||||
onChange={(e) => {
|
||||
handleSearch(e.target.value);
|
||||
}}
|
||||
className="absolute inset-0 w-full rounded-md border border-gray-300 bg-transparent p-2 pl-8 text-sm"
|
||||
defaultValue={searchParams.get('query')?.toString()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,126 +1,77 @@
|
||||
import Image from 'next/image';
|
||||
import DeleteInvoice from '@/app/ui/invoices/delete-button';
|
||||
import EditInvoice from '@/app/ui/invoices/edit-button';
|
||||
import AddInvoice from '@/app/ui/invoices/add-button';
|
||||
import Search from '@/app/ui/invoices/search';
|
||||
import PaginationButtons from '@/app/ui/invoices/pagination';
|
||||
import { UpdateInvoice, DeleteInvoice } from '@/app/ui/invoices/buttons';
|
||||
import InvoiceStatus from '@/app/ui/invoices/status';
|
||||
import {
|
||||
fetchFilteredInvoices,
|
||||
fetchInvoiceCountBySearchTerm,
|
||||
} from '@/app/lib/data';
|
||||
import { formatDateToLocal, formatCurrency } from '@/app/lib/utils';
|
||||
import { InvoicesTable } from '@/app/lib/definitions';
|
||||
|
||||
export default async function InvoicesTable({
|
||||
searchParams,
|
||||
invoices,
|
||||
}: {
|
||||
searchParams: {
|
||||
query: string;
|
||||
page: string;
|
||||
};
|
||||
invoices: InvoicesTable[];
|
||||
}) {
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
const searchTerm = searchParams.query ?? '';
|
||||
let currentPage = 1;
|
||||
if (!searchTerm) {
|
||||
currentPage = parseInt(searchParams.page ?? '1');
|
||||
}
|
||||
|
||||
const invoices = await fetchFilteredInvoices(
|
||||
searchTerm,
|
||||
currentPage,
|
||||
ITEMS_PER_PAGE,
|
||||
);
|
||||
|
||||
const totalCount = await fetchInvoiceCountBySearchTerm(searchTerm);
|
||||
const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<h1 className="text-base font-semibold">Invoices</h1>
|
||||
<AddInvoice />
|
||||
</div>
|
||||
<div className="mt-8 flex items-center justify-between gap-2">
|
||||
<Search searchParams={searchParams} />
|
||||
<PaginationButtons
|
||||
searchParams={searchParams}
|
||||
totalPages={totalPages}
|
||||
currentPage={currentPage}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flow-root">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="inline-block min-w-full align-middle">
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<table className="min-w-full divide-y divide-gray-300">
|
||||
<thead className="bg-gray-50 text-left text-sm">
|
||||
<tr>
|
||||
<th scope="col" className="px-3.5 py-3.5 sm:pl-6">
|
||||
#
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-3.5 font-semibold">
|
||||
Customer
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-3.5 font-semibold">
|
||||
Email
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-3.5 font-semibold">
|
||||
Amount
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-3.5 font-semibold">
|
||||
Date
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-3.5 font-semibold">
|
||||
Status
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="relative py-3.5 pl-3 pr-4 sm:pr-6"
|
||||
>
|
||||
<span className="sr-only">Edit</span>
|
||||
</th>
|
||||
<div className="mt-4 flow-root">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="inline-block min-w-full align-middle">
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<table className="min-w-full divide-y divide-gray-300">
|
||||
<thead className="bg-gray-50 text-left text-sm">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 pr-4 font-semibold">
|
||||
Customer
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-4 font-semibold">
|
||||
Email
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-4 font-semibold">
|
||||
Amount
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-4 font-semibold">
|
||||
Date
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-4 font-semibold">
|
||||
Status
|
||||
</th>
|
||||
<th scope="col" className="relative py-4 pl-3 pr-6 sm:pr-6">
|
||||
<span className="sr-only">Edit</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 text-gray-500">
|
||||
{invoices?.map((invoice) => (
|
||||
<tr key={invoice.id}>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<Image
|
||||
src={invoice.image_url}
|
||||
className="rounded-full"
|
||||
alt="Customer Image"
|
||||
width={28}
|
||||
height={28}
|
||||
/>
|
||||
<p>{invoice.name}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm">
|
||||
{invoice.email}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm">
|
||||
{formatCurrency(invoice.amount)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm">
|
||||
{formatDateToLocal(invoice.date)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm">
|
||||
<InvoiceStatus status={invoice.status} />
|
||||
</td>
|
||||
<td className="flex justify-end gap-2 whitespace-nowrap px-6 py-4 text-sm">
|
||||
<UpdateInvoice id={invoice.id} />
|
||||
<DeleteInvoice id={invoice.id} />
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 text-gray-500">
|
||||
{invoices?.map((invoice) => (
|
||||
<tr key={invoice.id}>
|
||||
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-black sm:pl-6">
|
||||
{invoice.id}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<Image
|
||||
src={`${invoice.customer_image}`}
|
||||
className="rounded-full"
|
||||
alt="Customer Image"
|
||||
width={28}
|
||||
height={28}
|
||||
/>
|
||||
<p>{invoice.customer_name}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm">
|
||||
{invoice.customer_email}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm">
|
||||
{formatCurrency(invoice.amount)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm">
|
||||
{formatDateToLocal(invoice.date)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm">
|
||||
<InvoiceStatus status={invoice.status} />
|
||||
</td>
|
||||
<td className="flex justify-end gap-2 whitespace-nowrap py-4 pl-3 pr-6 text-sm">
|
||||
<EditInvoice id={invoice.id} />
|
||||
<DeleteInvoice id={invoice.id} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,6 @@ import Image from 'next/image';
|
||||
export default function LoginForm() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
1744
dashboard/15-final/package-lock.json
generated
1744
dashboard/15-final/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,9 @@
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"tailwindcss": "3.3.3",
|
||||
"typescript": "5.2.2"
|
||||
"typescript": "5.2.2",
|
||||
"use-debounce": "^9.0.4",
|
||||
"zod": "^3.22.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
|
||||
@@ -47,16 +47,18 @@ async function seedUsers() {
|
||||
|
||||
async function seedInvoices() {
|
||||
try {
|
||||
await sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`;
|
||||
|
||||
// Create the "invoices" table if it doesn't exist
|
||||
const createTable = await sql`
|
||||
CREATE TABLE IF NOT EXISTS invoices (
|
||||
id SERIAL PRIMARY KEY,
|
||||
customer_id INT NOT NULL,
|
||||
amount INT NOT NULL,
|
||||
status VARCHAR(255) NOT NULL,
|
||||
date DATE NOT NULL
|
||||
);
|
||||
`;
|
||||
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`);
|
||||
|
||||
@@ -64,8 +66,8 @@ async function seedInvoices() {
|
||||
const insertedInvoices = await Promise.all(
|
||||
invoices.map(
|
||||
(invoice) => sql`
|
||||
INSERT INTO invoices (id, customer_id, amount, status, date)
|
||||
VALUES (${invoice.id}, ${invoice.customer_id}, ${invoice.amount}, ${invoice.status}, ${invoice.date})
|
||||
INSERT INTO invoices (customer_id, amount, status, date)
|
||||
VALUES (${invoice.customer_id}, ${invoice.amount}, ${invoice.status}, ${invoice.date})
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
`,
|
||||
),
|
||||
@@ -85,10 +87,12 @@ async function seedInvoices() {
|
||||
|
||||
async function seedCustomers() {
|
||||
try {
|
||||
// Create the "invoices" table if it doesn't exist
|
||||
await sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`;
|
||||
|
||||
// Create the "customers" table if it doesn't exist
|
||||
const createTable = await sql`
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
image_url VARCHAR(255) NOT NULL
|
||||
|
||||
123
pnpm-lock.yaml
generated
123
pnpm-lock.yaml
generated
@@ -37,7 +37,7 @@ importers:
|
||||
version: 4.0.3
|
||||
next:
|
||||
specifier: latest
|
||||
version: 13.5.2(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
version: 13.5.3(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
react:
|
||||
specifier: 18.2.0
|
||||
version: 18.2.0
|
||||
@@ -55,7 +55,7 @@ importers:
|
||||
dependencies:
|
||||
next:
|
||||
specifier: latest
|
||||
version: 13.5.2(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
version: 13.5.3(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
react:
|
||||
specifier: 18.2.0
|
||||
version: 18.2.0
|
||||
@@ -73,7 +73,7 @@ importers:
|
||||
version: 4.0.3
|
||||
next:
|
||||
specifier: latest
|
||||
version: 13.5.2(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
version: 13.5.3(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
react:
|
||||
specifier: 18.2.0
|
||||
version: 18.2.0
|
||||
@@ -91,7 +91,7 @@ importers:
|
||||
dependencies:
|
||||
next:
|
||||
specifier: latest
|
||||
version: 13.5.2(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
version: 13.5.3(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
react:
|
||||
specifier: 18.2.0
|
||||
version: 18.2.0
|
||||
@@ -109,7 +109,7 @@ importers:
|
||||
version: 4.0.3
|
||||
next:
|
||||
specifier: latest
|
||||
version: 13.5.2(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
version: 13.5.3(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
react:
|
||||
specifier: 18.2.0
|
||||
version: 18.2.0
|
||||
@@ -130,7 +130,7 @@ importers:
|
||||
version: 4.0.3
|
||||
next:
|
||||
specifier: latest
|
||||
version: 13.5.2(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
version: 13.5.3(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
react:
|
||||
specifier: 18.2.0
|
||||
version: 18.2.0
|
||||
@@ -145,7 +145,7 @@ importers:
|
||||
version: 4.0.3
|
||||
next:
|
||||
specifier: latest
|
||||
version: 13.5.2(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
version: 13.5.3(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
react:
|
||||
specifier: 18.2.0
|
||||
version: 18.2.0
|
||||
@@ -157,7 +157,7 @@ importers:
|
||||
dependencies:
|
||||
next:
|
||||
specifier: latest
|
||||
version: 13.5.2(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
version: 13.5.3(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
react:
|
||||
specifier: 18.2.0
|
||||
version: 18.2.0
|
||||
@@ -169,7 +169,7 @@ importers:
|
||||
dependencies:
|
||||
next:
|
||||
specifier: latest
|
||||
version: 13.5.2(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
version: 13.5.3(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
react:
|
||||
specifier: 18.2.0
|
||||
version: 18.2.0
|
||||
@@ -242,10 +242,10 @@ importers:
|
||||
version: 2.0.0
|
||||
next:
|
||||
specifier: ^13.4.19
|
||||
version: 13.5.2(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
version: 13.5.3(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
next-auth:
|
||||
specifier: ^4.23.1
|
||||
version: 4.23.1(next@13.5.2)(react-dom@18.2.0)(react@18.2.0)
|
||||
version: 4.23.1(next@13.5.3)(react-dom@18.2.0)(react@18.2.0)
|
||||
postcss:
|
||||
specifier: 8.4.28
|
||||
version: 8.4.28
|
||||
@@ -261,6 +261,12 @@ importers:
|
||||
typescript:
|
||||
specifier: 5.2.2
|
||||
version: 5.2.2
|
||||
use-debounce:
|
||||
specifier: ^9.0.4
|
||||
version: 9.0.4(react@18.2.0)
|
||||
zod:
|
||||
specifier: ^3.22.2
|
||||
version: 3.22.2
|
||||
devDependencies:
|
||||
'@types/bcrypt':
|
||||
specifier: ^5.0.0
|
||||
@@ -279,7 +285,7 @@ importers:
|
||||
version: 4.17.21
|
||||
next:
|
||||
specifier: latest
|
||||
version: 13.5.2(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
version: 13.5.3(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
react:
|
||||
specifier: 18.2.0
|
||||
version: 18.2.0
|
||||
@@ -633,8 +639,8 @@ packages:
|
||||
resolution: {integrity: sha512-FsAT5x0jF2kkhNkKkukhsyYOrRqtSxrEhfliniIq0bwWbuXLgyt3Gv0Ml+b91XwjwArmuP7NxCiGd++GGKdNMQ==}
|
||||
dev: false
|
||||
|
||||
/@next/env@13.5.2:
|
||||
resolution: {integrity: sha512-dUseBIQVax+XtdJPzhwww4GetTjlkRSsXeQnisIJWBaHsnxYcN2RGzsPHi58D6qnkATjnhuAtQTJmR1hKYQQPg==}
|
||||
/@next/env@13.5.3:
|
||||
resolution: {integrity: sha512-X4te86vsbjsB7iO4usY9jLPtZ827Mbx+WcwNBGUOIuswuTAKQtzsuoxc/6KLxCMvogKG795MhrR1LDhYgDvasg==}
|
||||
dev: false
|
||||
|
||||
/@next/eslint-plugin-next@13.4.19:
|
||||
@@ -652,8 +658,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@next/swc-darwin-arm64@13.5.2:
|
||||
resolution: {integrity: sha512-7eAyunAWq6yFwdSQliWMmGhObPpHTesiKxMw4DWVxhm5yLotBj8FCR4PXGkpRP2tf8QhaWuVba+/fyAYggqfQg==}
|
||||
/@next/swc-darwin-arm64@13.5.3:
|
||||
resolution: {integrity: sha512-6hiYNJxJmyYvvKGrVThzo4nTcqvqUTA/JvKim7Auaj33NexDqSNwN5YrrQu+QhZJCIpv2tULSHt+lf+rUflLSw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
@@ -670,8 +676,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@next/swc-darwin-x64@13.5.2:
|
||||
resolution: {integrity: sha512-WxXYWE7zF1ch8rrNh5xbIWzhMVas6Vbw+9BCSyZvu7gZC5EEiyZNJsafsC89qlaSA7BnmsDXVWQmc+s1feSYbQ==}
|
||||
/@next/swc-darwin-x64@13.5.3:
|
||||
resolution: {integrity: sha512-UpBKxu2ob9scbpJyEq/xPgpdrgBgN3aLYlxyGqlYX5/KnwpJpFuIHU2lx8upQQ7L+MEmz+fA1XSgesoK92ppwQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
@@ -688,8 +694,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@next/swc-linux-arm64-gnu@13.5.2:
|
||||
resolution: {integrity: sha512-URSwhRYrbj/4MSBjLlefPTK3/tvg95TTm6mRaiZWBB6Za3hpHKi8vSdnCMw5D2aP6k0sQQIEG6Pzcfwm+C5vrg==}
|
||||
/@next/swc-linux-arm64-gnu@13.5.3:
|
||||
resolution: {integrity: sha512-5AzM7Yx1Ky+oLY6pHs7tjONTF22JirDPd5Jw/3/NazJ73uGB05NqhGhB4SbeCchg7SlVYVBeRMrMSZwJwq/xoA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
@@ -706,8 +712,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@next/swc-linux-arm64-musl@13.5.2:
|
||||
resolution: {integrity: sha512-HefiwAdIygFyNmyVsQeiJp+j8vPKpIRYDlmTlF9/tLdcd3qEL/UEBswa1M7cvO8nHcr27ZTKXz5m7dkd56/Esg==}
|
||||
/@next/swc-linux-arm64-musl@13.5.3:
|
||||
resolution: {integrity: sha512-A/C1shbyUhj7wRtokmn73eBksjTM7fFQoY2v/0rTM5wehpkjQRLOXI8WJsag2uLhnZ4ii5OzR1rFPwoD9cvOgA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
@@ -724,8 +730,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@next/swc-linux-x64-gnu@13.5.2:
|
||||
resolution: {integrity: sha512-htGVVroW0tdHgMYwKWkxWvVoG2RlAdDXRO1RQxYDvOBQsaV0nZsgKkw0EJJJ3urTYnwKskn/MXm305cOgRxD2w==}
|
||||
/@next/swc-linux-x64-gnu@13.5.3:
|
||||
resolution: {integrity: sha512-FubPuw/Boz8tKkk+5eOuDHOpk36F80rbgxlx4+xty/U71e3wZZxVYHfZXmf0IRToBn1Crb8WvLM9OYj/Ur815g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
@@ -742,8 +748,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@next/swc-linux-x64-musl@13.5.2:
|
||||
resolution: {integrity: sha512-UBD333GxbHVGi7VDJPPDD1bKnx30gn2clifNJbla7vo5nmBV+x5adyARg05RiT9amIpda6yzAEEUu+s774ldkw==}
|
||||
/@next/swc-linux-x64-musl@13.5.3:
|
||||
resolution: {integrity: sha512-DPw8nFuM1uEpbX47tM3wiXIR0Qa+atSzs9Q3peY1urkhofx44o7E1svnq+a5Q0r8lAcssLrwiM+OyJJgV/oj7g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
@@ -760,8 +766,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@next/swc-win32-arm64-msvc@13.5.2:
|
||||
resolution: {integrity: sha512-Em9ApaSFIQnWXRT3K6iFnr9uBXymixLc65Xw4eNt7glgH0eiXpg+QhjmgI2BFyc7k4ZIjglfukt9saNpEyolWA==}
|
||||
/@next/swc-win32-arm64-msvc@13.5.3:
|
||||
resolution: {integrity: sha512-zBPSP8cHL51Gub/YV8UUePW7AVGukp2D8JU93IHbVDu2qmhFAn9LWXiOOLKplZQKxnIPUkJTQAJDCWBWU4UWUA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
@@ -778,8 +784,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@next/swc-win32-ia32-msvc@13.5.2:
|
||||
resolution: {integrity: sha512-TBACBvvNYU+87X0yklSuAseqdpua8m/P79P0SG1fWUvWDDA14jASIg7kr86AuY5qix47nZLEJ5WWS0L20jAUNw==}
|
||||
/@next/swc-win32-ia32-msvc@13.5.3:
|
||||
resolution: {integrity: sha512-ONcL/lYyGUj4W37D4I2I450SZtSenmFAvapkJQNIJhrPMhzDU/AdfLkW98NvH1D2+7FXwe7yclf3+B7v28uzBQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
@@ -796,8 +802,8 @@ packages:
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@next/swc-win32-x64-msvc@13.5.2:
|
||||
resolution: {integrity: sha512-LfTHt+hTL8w7F9hnB3H4nRasCzLD/fP+h4/GUVBTxrkMJOnh/7OZ0XbYDKO/uuWwryJS9kZjhxcruBiYwc5UDw==}
|
||||
/@next/swc-win32-x64-msvc@13.5.3:
|
||||
resolution: {integrity: sha512-2Vz2tYWaLqJvLcWbbTlJ5k9AN6JD7a5CN2pAeIzpbecK8ZF/yobA39cXtv6e+Z8c5UJuVOmaTldEAIxvsIux/Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -3687,7 +3693,7 @@ packages:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
dev: true
|
||||
|
||||
/next-auth@4.23.1(next@13.5.2)(react-dom@18.2.0)(react@18.2.0):
|
||||
/next-auth@4.23.1(next@13.5.3)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-mL083z8KgRtlrIV6CDca2H1kduWJuK/3pTS0Fe2og15KOm4v2kkLGdSDfc2g+019aEBrJUT0pPW2Xx42ImN1WA==}
|
||||
peerDependencies:
|
||||
next: ^12.2.5 || ^13
|
||||
@@ -3702,11 +3708,11 @@ packages:
|
||||
'@panva/hkdf': 1.1.1
|
||||
cookie: 0.5.0
|
||||
jose: 4.14.6
|
||||
next: 13.5.2(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
next: 13.5.3(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0)
|
||||
oauth: 0.9.15
|
||||
openid-client: 5.5.0
|
||||
preact: 10.17.1
|
||||
preact-render-to-string: 5.2.6(preact@10.17.1)
|
||||
preact: 10.18.0
|
||||
preact-render-to-string: 5.2.6(preact@10.18.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
uuid: 8.3.2
|
||||
@@ -3752,8 +3758,8 @@ packages:
|
||||
- babel-plugin-macros
|
||||
dev: false
|
||||
|
||||
/next@13.5.2(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-vog4UhUaMYAzeqfiAAmgB/QWLW7p01/sg+2vn6bqc/CxHFYizMzLv6gjxKzl31EVFkfl/F+GbxlKizlkTE9RdA==}
|
||||
/next@13.5.3(@babel/core@7.22.15)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-4Nt4HRLYDW/yRpJ/QR2t1v63UOMS55A38dnWv3UDOWGezuY0ZyFO1ABNbD7mulVzs9qVhgy2+ppjdsANpKP1mg==}
|
||||
engines: {node: '>=16.14.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -3767,7 +3773,7 @@ packages:
|
||||
sass:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@next/env': 13.5.2
|
||||
'@next/env': 13.5.3
|
||||
'@swc/helpers': 0.5.2
|
||||
busboy: 1.6.0
|
||||
caniuse-lite: 1.0.30001527
|
||||
@@ -3778,15 +3784,15 @@ packages:
|
||||
watchpack: 2.4.0
|
||||
zod: 3.21.4
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 13.5.2
|
||||
'@next/swc-darwin-x64': 13.5.2
|
||||
'@next/swc-linux-arm64-gnu': 13.5.2
|
||||
'@next/swc-linux-arm64-musl': 13.5.2
|
||||
'@next/swc-linux-x64-gnu': 13.5.2
|
||||
'@next/swc-linux-x64-musl': 13.5.2
|
||||
'@next/swc-win32-arm64-msvc': 13.5.2
|
||||
'@next/swc-win32-ia32-msvc': 13.5.2
|
||||
'@next/swc-win32-x64-msvc': 13.5.2
|
||||
'@next/swc-darwin-arm64': 13.5.3
|
||||
'@next/swc-darwin-x64': 13.5.3
|
||||
'@next/swc-linux-arm64-gnu': 13.5.3
|
||||
'@next/swc-linux-arm64-musl': 13.5.3
|
||||
'@next/swc-linux-x64-gnu': 13.5.3
|
||||
'@next/swc-linux-x64-musl': 13.5.3
|
||||
'@next/swc-win32-arm64-msvc': 13.5.3
|
||||
'@next/swc-win32-ia32-msvc': 13.5.3
|
||||
'@next/swc-win32-x64-msvc': 13.5.3
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
@@ -4236,17 +4242,17 @@ packages:
|
||||
xtend: 4.0.2
|
||||
dev: false
|
||||
|
||||
/preact-render-to-string@5.2.6(preact@10.17.1):
|
||||
/preact-render-to-string@5.2.6(preact@10.18.0):
|
||||
resolution: {integrity: sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==}
|
||||
peerDependencies:
|
||||
preact: '>=10'
|
||||
dependencies:
|
||||
preact: 10.17.1
|
||||
preact: 10.18.0
|
||||
pretty-format: 3.8.0
|
||||
dev: false
|
||||
|
||||
/preact@10.17.1:
|
||||
resolution: {integrity: sha512-X9BODrvQ4Ekwv9GURm9AKAGaomqXmip7NQTZgY7gcNmr7XE83adOMJvd3N42id1tMFU7ojiynRsYnY6/BRFxLA==}
|
||||
/preact@10.18.0:
|
||||
resolution: {integrity: sha512-O4dGFmErPd3RNVDvXmCbOW6hetnve6vYtjx5qf51mCUmBS96s66MrNQkEII5UThDGoNF7953ptA+aNupiDxVeg==}
|
||||
dev: false
|
||||
|
||||
/prelude-ls@1.2.1:
|
||||
@@ -5192,6 +5198,15 @@ packages:
|
||||
punycode: 2.3.0
|
||||
dev: true
|
||||
|
||||
/use-debounce@9.0.4(react@18.2.0):
|
||||
resolution: {integrity: sha512-6X8H/mikbrt0XE8e+JXRtZ8yYVvKkdYRfmIhWZYsP8rcNs9hk3APV8Ua2mFkKRLcJKVdnX2/Vwrmg2GWKUQEaQ==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/utf-8-validate@6.0.3:
|
||||
resolution: {integrity: sha512-uIuGf9TWQ/y+0Lp+KGZCMuJWc3N9BHA+l/UmHd/oUHwJJDeysyTRxNQVkbzsIWfGFbRe3OcgML/i0mvVRPOyDA==}
|
||||
engines: {node: '>=6.14.2'}
|
||||
@@ -5385,6 +5400,10 @@ packages:
|
||||
resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==}
|
||||
dev: false
|
||||
|
||||
/zod@3.22.2:
|
||||
resolution: {integrity: sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg==}
|
||||
dev: false
|
||||
|
||||
/zwitch@2.0.4:
|
||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||
dev: false
|
||||
|
||||
Reference in New Issue
Block a user