diff --git a/package.json b/package.json index b9fb46b..0cd01c1 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "seed": "node -r dotenv/config ./src/bootstrap/db/seed.js" }, "dependencies": { + "@heroicons/react": "^2.1.5", "@radix-ui/react-icons": "^1.3.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -21,7 +22,8 @@ "reflect-metadata": "^0.2.2", "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7", - "tsyringe": "^4.8.0" + "tsyringe": "^4.8.0", + "zod": "^3.23.8" }, "devDependencies": { "@types/node": "^20", diff --git a/public/customers/amy-burns.png b/public/customers/amy-burns.png new file mode 100644 index 0000000..7b29d72 Binary files /dev/null and b/public/customers/amy-burns.png differ diff --git a/public/customers/balazs-orban.png b/public/customers/balazs-orban.png new file mode 100644 index 0000000..7fbc009 Binary files /dev/null and b/public/customers/balazs-orban.png differ diff --git a/public/customers/delba-de-oliveira.png b/public/customers/delba-de-oliveira.png new file mode 100644 index 0000000..08db1b8 Binary files /dev/null and b/public/customers/delba-de-oliveira.png differ diff --git a/public/customers/emil-kowalski.png b/public/customers/emil-kowalski.png new file mode 100644 index 0000000..8411e21 Binary files /dev/null and b/public/customers/emil-kowalski.png differ diff --git a/public/customers/evil-rabbit.png b/public/customers/evil-rabbit.png new file mode 100644 index 0000000..fe7990f Binary files /dev/null and b/public/customers/evil-rabbit.png differ diff --git a/public/customers/guillermo-rauch.png b/public/customers/guillermo-rauch.png new file mode 100644 index 0000000..e46c96f Binary files /dev/null and b/public/customers/guillermo-rauch.png differ diff --git a/public/customers/hector-simpson.png b/public/customers/hector-simpson.png new file mode 100644 index 0000000..2957557 Binary files /dev/null and b/public/customers/hector-simpson.png differ diff --git a/public/customers/jared-palmer.png b/public/customers/jared-palmer.png new file mode 100644 index 0000000..e495518 Binary files /dev/null and b/public/customers/jared-palmer.png differ diff --git a/public/customers/lee-robinson.png b/public/customers/lee-robinson.png new file mode 100644 index 0000000..633ae98 Binary files /dev/null and b/public/customers/lee-robinson.png differ diff --git a/public/customers/michael-novotny.png b/public/customers/michael-novotny.png new file mode 100644 index 0000000..96a13a6 Binary files /dev/null and b/public/customers/michael-novotny.png differ diff --git a/public/customers/steph-dietz.png b/public/customers/steph-dietz.png new file mode 100644 index 0000000..e99f15a Binary files /dev/null and b/public/customers/steph-dietz.png differ diff --git a/public/customers/steven-tey.png b/public/customers/steven-tey.png new file mode 100644 index 0000000..3f5bd7e Binary files /dev/null and b/public/customers/steven-tey.png differ diff --git a/src/app/dashboard/(overview)/page.tsx b/src/app/dashboard/(overview)/page.tsx index 60a12e0..6d632b5 100644 --- a/src/app/dashboard/(overview)/page.tsx +++ b/src/app/dashboard/(overview)/page.tsx @@ -1,3 +1,8 @@ +import { LatestInvoicesSkeleton, RevenueChartSkeleton } from "@/app/components/skeleton/skeletons"; +import CardWrapper from "@/app/dashboard/components/cards"; +import LatestInvoices from "@/app/dashboard/components/latest-invoices"; +import RevenueChart from "@/app/dashboard/components/revenue-chart"; +import { Suspense } from "react"; export default async function Dashboard() { @@ -7,15 +12,15 @@ export default async function Dashboard() { Dashboard
- {/* */} +
- {/* }> + }> - */} - {/* }> + + }> - */} +
) diff --git a/src/app/dashboard/components/cards.tsx b/src/app/dashboard/components/cards.tsx new file mode 100644 index 0000000..72bb37b --- /dev/null +++ b/src/app/dashboard/components/cards.tsx @@ -0,0 +1,62 @@ +import { + BanknotesIcon, + ClockIcon, + UserGroupIcon, + InboxIcon, +} from '@heroicons/react/24/outline'; +import { fetchCardData } from '@/app/lib/data'; + +const iconMap = { + collected: BanknotesIcon, + customers: UserGroupIcon, + pending: ClockIcon, + invoices: InboxIcon, +}; + +export default async function CardWrapper() { + const { + numberOfInvoices, + numberOfCustomers, + totalPaidInvoices, + totalPendingInvoices, + } = await fetchCardData(); + + return ( + <> + + + + + + ); +} + +export function Card({ + title, + value, + type, +}: { + title: string; + value: number | string; + type: 'invoices' | 'customers' | 'pending' | 'collected'; +}) { + const Icon = iconMap[type]; + + return ( +
+
+ {Icon ? : null} +

{title}

+
+

+ {value} +

+
+ ); +} diff --git a/src/app/dashboard/components/latest-invoices.tsx b/src/app/dashboard/components/latest-invoices.tsx new file mode 100644 index 0000000..5c7cd95 --- /dev/null +++ b/src/app/dashboard/components/latest-invoices.tsx @@ -0,0 +1,60 @@ +import { ArrowPathIcon } from '@heroicons/react/24/outline'; +import clsx from 'clsx'; +import Image from 'next/image'; +import { fetchLatestInvoices } from '@/app/lib/data'; +export default async function LatestInvoices() { + const latestInvoices = await fetchLatestInvoices(); + + return ( +
+

+ Latest Invoices +

+
+ +
+ {latestInvoices.map((invoice, i) => { + return ( +
+
+ {`${invoice.name}'s +
+

+ {invoice.name} +

+

+ {invoice.email} +

+
+
+

+ {invoice.amount} +

+
+ ); + })} +
+
+ +

Updated just now

+
+
+
+ ); +} diff --git a/src/app/dashboard/components/revenue-chart.tsx b/src/app/dashboard/components/revenue-chart.tsx new file mode 100644 index 0000000..49d888c --- /dev/null +++ b/src/app/dashboard/components/revenue-chart.tsx @@ -0,0 +1,53 @@ +import { generateYAxis } from '@/app/lib/utils'; +import { CalendarIcon } from '@heroicons/react/24/outline'; +import { fetchRevenue } from '@/app/lib/data'; + +export default async function RevenueChart() { + const revenue = await fetchRevenue(); + + const chartHeight = 350; + + const { yAxisLabels, topLabel } = generateYAxis(revenue); + + if (!revenue || revenue.length === 0) { + return

No data available.

; + } + + return ( +
+

+ Recent Revenue +

+
+
+
+ {yAxisLabels.map((label) => ( +

{label}

+ ))} +
+ + {revenue.map((month) => ( +
+
+

+ {month.month} +

+
+ ))} +
+
+ +

Last 12 months

+
+
+
+ ); +} diff --git a/src/app/lib/actions.ts b/src/app/lib/actions.ts new file mode 100644 index 0000000..617b4d7 --- /dev/null +++ b/src/app/lib/actions.ts @@ -0,0 +1,117 @@ +'use server'; + +import { z } from 'zod'; +import { revalidatePath } from 'next/cache'; +import { redirect } from 'next/navigation'; +import { sql } from '@/bootstrap/db/db'; + +const FormSchema = z.object({ + id: z.string(), + customerId: z.string({ + invalid_type_error: 'Please select a customer.', + }), + amount: z.coerce.number().gt(0, { message: 'Please enter an amount greater than $0.' }), + status: z.enum(['pending', 'paid'], { + invalid_type_error: 'Please select an invoice status.', + }), + date: z.string(), +}); +const CreateInvoice = FormSchema.omit({ id: true, date: true }); + + + +// This is temporary +export type State = { + errors?: { + customerId?: string[]; + amount?: string[]; + status?: string[]; + }; + message?: string | null; +}; + +export async function createInvoice(_prevState: State, formData: FormData) { + // Validate form fields using Zod + console.log('ffor', formData) + const validatedFields = CreateInvoice.safeParse({ + customerId: formData?.get('customerId') || undefined, + amount: formData?.get('amount') || undefined, + status: formData?.get('status') || undefined, + }); + + // 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; + const date = new Date().toISOString().split('T')[0]; + + // Insert data into the database + try { + await sql` + INSERT INTO invoices (customer_id, amount, status, date) + VALUES (${customerId}, ${amountInCents}, ${status}, ${date}) + `; + } catch { + // 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'); +} + +const UpdateInvoice = FormSchema.omit({ id: true, date: true }); +export async function updateInvoice( + id: string, + _prevState: State, + formData: FormData, +) { + const validatedFields = UpdateInvoice.safeParse({ + customerId: formData.get('customerId'), + amount: formData.get('amount'), + status: formData.get('status'), + }); + + if (!validatedFields.success) { + return { + errors: validatedFields.error.flatten().fieldErrors, + message: 'Missing Fields. Failed to Update Invoice.', + }; + } + + const { customerId, amount, status } = validatedFields.data; + const amountInCents = amount * 100; + + try { + await sql` + UPDATE invoices + SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status} + WHERE id = ${id} + `; + } catch { + return { message: 'Database Error: Failed to Update Invoice.' }; + } + + revalidatePath('/dashboard/invoices'); + redirect('/dashboard/invoices'); +} +export async function deleteInvoice(id: string) { + try { + await sql`DELETE FROM invoices WHERE id = ${id}`; + revalidatePath('/dashboard/invoices'); + return { message: 'Deleted Invoice.' }; + } catch { + return { message: 'Database Error: Failed to Delete Invoice.' }; + } +} + diff --git a/src/app/lib/data.ts b/src/app/lib/data.ts new file mode 100644 index 0000000..c655f10 --- /dev/null +++ b/src/app/lib/data.ts @@ -0,0 +1,254 @@ +import { sql } from '@/bootstrap/db/db'; +import { + CustomerField, + CustomersTableType, + InvoiceForm, + InvoicesTable, + User, + Revenue, + Invoice, + Customer, + LatestInvoice, +} from './definitions'; +import { formatCurrency } from './utils'; +import postgres from 'postgres'; +import { connection } from 'next/server'; + +export async function fetchRevenue() { + // This is equivalent to in fetch(..., {cache: 'no-store'}). + connection() + + 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`SELECT * FROM revenue`; + + console.log('Data fetch completed after 3 seconds.'); + + return data as postgres.RowList; + } catch (error) { + console.error('Database Error:', error); + throw new Error('Failed to fetch revenue data.'); + } +} + +export async function fetchLatestInvoices() { + // This is equivalent to in fetch(..., {cache: 'no-store'}). + connection() + + try { + const data = await sql` + 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` as postgres.RowList; + + const latestInvoices = data.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() { + // This is equivalent to in fetch(..., {cache: 'no-store'}). + connection() + + 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 invoices = data[0] as postgres.RowList + const customres = data[1] as postgres.RowList + const invoiceStatus = data[2] as postgres.RowList<({paid: string, pending: string})[]> + const numberOfInvoices = Number(invoices.count ?? '0'); + const numberOfCustomers = Number(customres.count ?? '0'); + const totalPaidInvoices = formatCurrency(Number(invoiceStatus.at(0)?.paid ?? '0')); + const totalPendingInvoices = formatCurrency(Number(invoiceStatus.at(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, +) { + // This is equivalent to in fetch(..., {cache: 'no-store'}). + connection() + + const offset = (currentPage - 1) * ITEMS_PER_PAGE; + + try { + const invoices = 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 ${ITEMS_PER_PAGE} OFFSET ${offset} + ` as postgres.RowList; + + return invoices; + } catch (error) { + console.error('Database Error:', error); + throw new Error('Failed to fetch invoices.'); + } +} + +export async function fetchInvoicesPages(query: string) { + // This is equivalent to in fetch(..., {cache: 'no-store'}). + connection() + 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.at(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) { + // This is equivalent to in fetch(..., {cache: 'no-store'}). + connection() + try { + const data = await sql` + SELECT + invoices.id, + invoices.customer_id, + invoices.amount, + invoices.status + FROM invoices + WHERE invoices.id = ${id}; + ` as postgres.RowList; + + const invoice = data.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() { + // This is equivalent to in fetch(..., {cache: 'no-store'}). + connection() + try { + const data = await sql` + SELECT + id, + name + FROM customers + ORDER BY name ASC + ` as postgres.RowList; + + return data; + } catch (err) { + console.error('Database Error:', err); + throw new Error('Failed to fetch all customers.'); + } +} + +export async function fetchFilteredCustomers(query: string) { + // This is equivalent to in fetch(..., {cache: 'no-store'}). + connection() + 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 + 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 + ` as postgres.RowList; + + const customers = data.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.at(0) as User; + } catch (error) { + console.error('Failed to fetch user:', error); + throw new Error('Failed to fetch user.'); + } +} diff --git a/src/app/lib/definitions.ts b/src/app/lib/definitions.ts new file mode 100644 index 0000000..e439b66 --- /dev/null +++ b/src/app/lib/definitions.ts @@ -0,0 +1,86 @@ +// 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; // Will be created on the database + customer_id: string; + amount: number; // Stored in cents + status: 'pending' | 'paid'; + date: string; +}; + +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 & { + 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'; +}; diff --git a/src/app/lib/placeholder-data.js b/src/app/lib/placeholder-data.js new file mode 100644 index 0000000..15a4156 --- /dev/null +++ b/src/app/lib/placeholder-data.js @@ -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, +}; diff --git a/src/app/lib/utils.ts b/src/app/lib/utils.ts new file mode 100644 index 0000000..b7f7cff --- /dev/null +++ b/src/app/lib/utils.ts @@ -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, + ]; +}; diff --git a/src/bootstrap/db/db.ts b/src/bootstrap/db/db.ts new file mode 100644 index 0000000..dbcd877 --- /dev/null +++ b/src/bootstrap/db/db.ts @@ -0,0 +1,13 @@ +import postgres from "postgres"; + + +const envs = process.env; +const dbConfigs = { + host: envs.POSTGRES_HOST, + port: Number(envs.POSTGRES_PORT), + username: envs.POSTGRES_USER, + password: envs.POSTGRES_PASS, + database: envs.POSTGRES_DB, +} + +export const sql = postgres(dbConfigs); \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 9b2101a..ee61d15 100644 --- a/yarn.lock +++ b/yarn.lock @@ -46,6 +46,11 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2" integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q== +"@heroicons/react@^2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@heroicons/react/-/react-2.1.5.tgz#1e13f34976cc542deae92353c01c8b3d7942e9ba" + integrity sha512-FuzFN+BsHa+7OxbvAERtgBTNeZpUjgM/MIizfVkSCL2/edriN0Hx/DWRCR//aPYwO5QX/YlgLGXk+E3PcfZwjA== + "@humanwhocodes/config-array@^0.13.0": version "0.13.0" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.13.0.tgz#fb907624df3256d04b9aa2df50d7aa97ec648748" @@ -3161,3 +3166,8 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zod@^3.23.8: + version "3.23.8" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" + integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==