diff --git a/.env.example b/.env.example deleted file mode 100644 index 8a85ba7..0000000 --- a/.env.example +++ /dev/null @@ -1,13 +0,0 @@ -# Copy from .env.local on the Vercel dashboard -# https://nextjs.org/learn/dashboard-app/setting-up-your-database#create-a-postgres-database -POSTGRES_URL= -POSTGRES_PRISMA_URL= -POSTGRES_URL_NON_POOLING= -POSTGRES_USER= -POSTGRES_HOST= -POSTGRES_PASSWORD= -POSTGRES_DATABASE= - -# `openssl rand -base64 32` -AUTH_SECRET= -AUTH_URL=http://localhost:3000/api/auth \ No newline at end of file diff --git a/app/dashboard/(overview)/loading.tsx b/app/dashboard/(overview)/loading.tsx new file mode 100644 index 0000000..6b56116 --- /dev/null +++ b/app/dashboard/(overview)/loading.tsx @@ -0,0 +1,5 @@ +import DashboardSkeleton from '@/app/ui/skeletons'; + +export default function Loading() { + return ; + } \ No newline at end of file diff --git a/app/dashboard/(overview)/page.tsx b/app/dashboard/(overview)/page.tsx new file mode 100644 index 0000000..6996f12 --- /dev/null +++ b/app/dashboard/(overview)/page.tsx @@ -0,0 +1,33 @@ +import { Card } from '@/app/ui/dashboard/cards'; +import RevenueChart from '@/app/ui/dashboard/revenue-chart'; +import LatestInvoices from '@/app/ui/dashboard/latest-invoices'; +import { lusitana } from '@/app/ui/fonts'; +import { Suspense } from 'react'; +import { RevenueChartSkeleton, LatestInvoicesSkeleton, CardSkeleton } from '@/app/ui/skeletons'; +import CardWrapper from '@/app/ui/dashboard/cards'; + +export default async function Page() { + + return ( +
+

+ Dashboard +

+
+
+ }> + + +
+
+
+ }> + + + } > + + +
+
+ ); +} \ No newline at end of file diff --git a/app/dashboard/invoices/[id]/edit/not-found.tsx b/app/dashboard/invoices/[id]/edit/not-found.tsx new file mode 100644 index 0000000..1373306 --- /dev/null +++ b/app/dashboard/invoices/[id]/edit/not-found.tsx @@ -0,0 +1,18 @@ +import Link from 'next/link'; +import { FaceFrownIcon } from '@heroicons/react/24/outline'; + +export default function NotFound() { + return ( +
+ +

404 Not Found

+

Could not find the requested invoice.

+ + Go Back + +
+ ); +} \ No newline at end of file diff --git a/app/dashboard/invoices/[id]/edit/page.tsx b/app/dashboard/invoices/[id]/edit/page.tsx new file mode 100644 index 0000000..2a3517c --- /dev/null +++ b/app/dashboard/invoices/[id]/edit/page.tsx @@ -0,0 +1,33 @@ +import Form from '@/app/ui/invoices/edit-form'; +import Breadcrumbs from '@/app/ui/invoices/breadcrumbs'; +import { fetchInvoiceById, fetchCustomers } from '@/app/lib/data'; +import { notFound } from 'next/navigation'; + + +export default async function Page({ params }: { params: { id: string } }) { + const id = params.id; + const [invoice, customers] = await Promise.all([ + fetchInvoiceById(id), + fetchCustomers(), + ]); + + if (!invoice) { + notFound() + } + + return ( +
+ +
+
+ ); +} diff --git a/app/dashboard/invoices/create/page.tsx b/app/dashboard/invoices/create/page.tsx new file mode 100644 index 0000000..45feed0 --- /dev/null +++ b/app/dashboard/invoices/create/page.tsx @@ -0,0 +1,23 @@ +import Form from '@/app/ui/invoices/create-form'; +import Breadcrumbs from '@/app/ui/invoices/breadcrumbs'; +import { fetchCustomers } from '@/app/lib/data'; + +export default async function Page() { + const customers = await fetchCustomers(); + + return ( +
+ + +
+ ); +} \ No newline at end of file diff --git a/app/dashboard/invoices/error.tsx b/app/dashboard/invoices/error.tsx new file mode 100644 index 0000000..e285214 --- /dev/null +++ b/app/dashboard/invoices/error.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { useEffect } from 'react'; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + // Optionally log the error to an error reporting service + console.error(error); + }, [error]); + + return ( +
+

Something went wrong!

+ +
+ ); +} \ No newline at end of file diff --git a/app/dashboard/invoices/page.tsx b/app/dashboard/invoices/page.tsx index f0a0073..ca4678d 100644 --- a/app/dashboard/invoices/page.tsx +++ b/app/dashboard/invoices/page.tsx @@ -1,3 +1,44 @@ -export default function Page() { - return

Invoices Page

; + +import Pagination from '@/app/ui/invoices/pagination'; +import Search from '@/app/ui/search'; +import Table from '@/app/ui/invoices/table'; + + +import { CreateInvoice } from '@/app/ui/invoices/buttons'; +import { fetchInvoicesPages } from '@/app/lib/data'; +import { lusitana } from '@/app/ui/fonts'; +import { InvoicesTableSkeleton } from '@/app/ui/skeletons'; +import { Suspense } from 'react'; + +export default async function Page({ + searchParams, +}: { + searchParams?: { + query?: string; + page?: string; + }; +}) { + + const query = searchParams?.query || ''; + const currentPage = Number(searchParams?.page) || 1; + const totalPages = await fetchInvoicesPages(query); + + + return ( +
+
+

Invoices

+
+
+ + +
+ }> + + +
+ +
+ + ); } \ No newline at end of file diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx deleted file mode 100644 index 60f2c6d..0000000 --- a/app/dashboard/page.tsx +++ /dev/null @@ -1,4 +0,0 @@ - -export default function Page() { - return

Dashboard Page

; -} \ No newline at end of file diff --git a/app/lib/actions.ts b/app/lib/actions.ts new file mode 100644 index 0000000..8cc337a --- /dev/null +++ b/app/lib/actions.ts @@ -0,0 +1,146 @@ +'use server'; +import { z } from 'zod'; +import { sql } from '@vercel/postgres'; +import { revalidatePath } from 'next/cache'; +import { redirect } from 'next/navigation'; +import { signIn } from '@/auth'; +import { AuthError } from 'next-auth'; + + +export async function authenticate( + prevState: string | undefined, + formData: FormData, +) { + try { + await signIn('credentials', FormData); + } catch (error) { + if (error instanceof AuthError) { + switch (error.type) { + case 'CredentialsSignin': + return 'Invalid credentials.'; + default: + return 'Something went wrong.'; + } + } + throw error; + } +} + +const FormSchema = z.object({ + id: z.string(), + customerId: z.string({ + invalid_type_error: 'Please select a customer.' + }), + amount: z.coerce + .number() + .gt(0, { message: 'Amount must be greater than 0.' }) + , + status: z.enum(['pending', 'paid'], { + invalid_type_error: 'Please select an invoice status.' + }), + date: z.string(), +}); + + + +export type State = { + errors?: { + customerId?: string[]; + amount?: string[]; + status?: string[]; + }; + message?: string | null; +}; + + +// Use Zod to update the expected types +const UpdateInvoice = FormSchema.omit({ id: true, date: true }); +const CreateInvoice = FormSchema.omit({ id: true, data: true }) + +// ... +export async function createInvoice(prevState: State, formData: FormData) { + const date = new Date().toISOString().split('T')[0]; + // Validate form using Zod + const validatedFields = CreateInvoice.safeParse({ + customerId: formData.get('customerId'), + amount: formData.get('amount'), + status: formData.get('status'), + date: date, + }); + + // If form validation fails, return errors early. Otherwise, continue. + if (!validatedFields.success) { + return { + errors: validatedFields.error.flatten().fieldErrors, + message: 'Missing Fields. Failed to Create Invoice.', + }; + } + + // Prepare data for insertion into the database + const { customerId, amount, status } = validatedFields.data; + const amountInCents = amount * 100; + + // Insert data into the database + try { + await sql` + INSERT INTO invoices (customer_id, amount, status, date) + VALUES (${customerId}, ${amountInCents}, ${status}, ${date}) + `; + } catch (error) { + // If a database error occurs, return a more specific error. + return { + message: 'Database Error: Failed to Create Invoice.', + }; + } + + // Revalidate the cache for the invoices page and redirect the user. + revalidatePath('/dashboard/invoices'); + redirect('/dashboard/invoices'); +} + +export async function updateInvoice( + id: string, + prevState: State, + formData: FormData, +) { + const validatedFields = UpdateInvoice.safeParse({ + customerId: formData.get('customerId'), + amount: formData.get('amount'), + status: formData.get('status'), + }); + + if (!validatedFields.success) { + return { + errors: validatedFields.error.flatten().fieldErrors, + message: 'Missing Fields. Failed to Update Invoice.', + }; + } + + const { customerId, amount, status } = validatedFields.data; + const amountInCents = amount * 100; + + try { + await sql` + UPDATE invoices + SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status} + WHERE id = ${id} + `; + } catch (error) { + return { message: 'Database Error: Failed to Update Invoice.' }; + } + + revalidatePath('/dashboard/invoices'); + redirect('/dashboard/invoices'); +} + + +export async function deleteInvoice(id: string) { + + try { + await sql`DELETE FROM invoices WHERE id = ${id}`; + } catch (error) { + return { message: 'Database Error: Failed to delete invoice.' } + } + + revalidatePath('/dashboard/invoices'); +} diff --git a/app/lib/data.ts b/app/lib/data.ts index 3e32426..f987b5e 100644 --- a/app/lib/data.ts +++ b/app/lib/data.ts @@ -1,4 +1,5 @@ import { sql } from '@vercel/postgres'; + import { CustomerField, CustomersTableType, @@ -11,6 +12,7 @@ import { import { formatCurrency } from './utils'; export async function fetchRevenue() { + await new Promise((resolve) => setTimeout(resolve, 3000)); // Add noStore() here to prevent the response from being cached. // This is equivalent to in fetch(..., {cache: 'no-store'}). @@ -34,6 +36,7 @@ export async function fetchRevenue() { export async function fetchLatestInvoices() { try { + await new Promise((resolve) => setTimeout(resolve, 5000)); const data = await sql` SELECT invoices.amount, customers.name, customers.image_url, customers.email, invoices.id FROM invoices diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..d65e4b0 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,18 @@ +'use client' +import AcmeLogo from '@/app/ui/acme-logo'; +import LoginForm from '@/app/ui/login-form'; + +export default function LoginPage() { + return ( +
+
+
+
+ +
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/app/ui/dashboard/cards.tsx b/app/ui/dashboard/cards.tsx index 0ee0286..ef8c390 100644 --- a/app/ui/dashboard/cards.tsx +++ b/app/ui/dashboard/cards.tsx @@ -5,6 +5,7 @@ import { InboxIcon, } from '@heroicons/react/24/outline'; import { lusitana } from '@/app/ui/fonts'; +import { fetchCardData } from '@/app/lib/data'; const iconMap = { collected: BanknotesIcon, @@ -14,18 +15,24 @@ const iconMap = { }; export default async function CardWrapper() { + const { + numberOfInvoices, + numberOfCustomers, + totalPaidInvoices, + totalPendingInvoices, + } = await fetchCardData(); return ( <> {/* NOTE: comment in this code when you get to this point in the course */} - {/* + */} + /> ); } diff --git a/app/ui/dashboard/latest-invoices.tsx b/app/ui/dashboard/latest-invoices.tsx index 92962ee..9bdecf9 100644 --- a/app/ui/dashboard/latest-invoices.tsx +++ b/app/ui/dashboard/latest-invoices.tsx @@ -2,12 +2,10 @@ 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[]; -}) { +import { fetchLatestInvoices } from '@/app/lib/data'; + +export default async function LatestInvoices() { + const latestInvoices = await fetchLatestInvoices(); return (

@@ -16,7 +14,7 @@ export default async function LatestInvoices({
{/* NOTE: comment in this code when you get to this point in the course */} - {/*
+
{latestInvoices.map((invoice, i) => { return (
); })} -
*/} +

Updated just now

diff --git a/app/ui/dashboard/revenue-chart.tsx b/app/ui/dashboard/revenue-chart.tsx index 7ccc409..16ab4ca 100644 --- a/app/ui/dashboard/revenue-chart.tsx +++ b/app/ui/dashboard/revenue-chart.tsx @@ -1,7 +1,7 @@ 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'; +import {fetchRevenue} from '@/app/lib/data'; // This component is representational only. // For data visualization UI, check out: @@ -9,19 +9,16 @@ import { Revenue } from '@/app/lib/definitions'; // https://www.chartjs.org/ // https://airbnb.io/visx/ -export default async function RevenueChart({ - revenue, -}: { - revenue: Revenue[]; -}) { +export default async function RevenueChart() { + const revenue = await fetchRevenue(); const chartHeight = 350; // NOTE: comment in this code when you get to this point in the course - // const { yAxisLabels, topLabel } = generateYAxis(revenue); + const { yAxisLabels, topLabel } = generateYAxis(revenue); - // if (!revenue || revenue.length === 0) { - // return

No data available.

; - // } + if (!revenue || revenue.length === 0) { + return

No data available.

; + } return (
@@ -30,7 +27,7 @@ export default async function RevenueChart({

{/* NOTE: comment in this code when you get to this point in the course */} - {/*
+

Last 12 months

-
*/} +
); } diff --git a/app/ui/dashboard/sidenav.tsx b/app/ui/dashboard/sidenav.tsx index 3d55b46..e979374 100644 --- a/app/ui/dashboard/sidenav.tsx +++ b/app/ui/dashboard/sidenav.tsx @@ -2,6 +2,8 @@ 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'; +import { signOut } from '@/auth'; + export default function SideNav() { return ( @@ -17,7 +19,12 @@ export default function SideNav() {
- + { + 'use server' + await signOut(); + } + }> - + ); } diff --git a/app/ui/invoices/create-form.tsx b/app/ui/invoices/create-form.tsx index 35099ce..c24c461 100644 --- a/app/ui/invoices/create-form.tsx +++ b/app/ui/invoices/create-form.tsx @@ -1,4 +1,6 @@ +'use client'; import { CustomerField } from '@/app/lib/definitions'; +import { useFormState } from 'react-dom'; import Link from 'next/link'; import { CheckIcon, @@ -7,10 +9,16 @@ import { UserCircleIcon, } from '@heroicons/react/24/outline'; import { Button } from '@/app/ui/button'; +import { createInvoice } from '@/app/lib/actions'; +import { date } from 'zod'; export default function Form({ customers }: { customers: CustomerField[] }) { + const initialState = { message: null, errors: {} }; + const [state, dispatch] = useFormState(createInvoice, initialState); + console.log(state) + return ( -
+
{/* Customer Name */}
@@ -23,6 +31,7 @@ export default function Form({ customers }: { customers: CustomerField[] }) { 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="" + aria-describedby="customer-error" >
+
+ {state.errors?.customerId && + state.errors.customerId.map((error: string) => ( +

+ {error} +

+ ))} +
{/* Invoice Amount */} @@ -51,9 +68,18 @@ export default function Form({ customers }: { customers: CustomerField[] }) { 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" + aria-describedby="amount-error" />
+
+ {state.errors?.amount && + state.errors.amount.map((error: string) => ( +

+ {error} +

+ ))} +
@@ -71,6 +97,7 @@ export default function Form({ customers }: { customers: CustomerField[] }) { type="radio" value="pending" className="h-4 w-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2" + aria-describedby="status-error" />