Add initial Next.js dashboard files
This commit is contained in:
36
app/ui/invoices/breadcrumbs.tsx
Normal file
36
app/ui/invoices/breadcrumbs.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { clsx } from 'clsx';
|
||||
import Link from 'next/link';
|
||||
import { lusitana } from '@/app/ui/fonts';
|
||||
|
||||
interface Breadcrumb {
|
||||
label: string;
|
||||
href: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export default function Breadcrumbs({
|
||||
breadcrumbs,
|
||||
}: {
|
||||
breadcrumbs: Breadcrumb[];
|
||||
}) {
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className="mb-6 block">
|
||||
<ol className={clsx(lusitana.className, 'flex text-xl md:text-2xl')}>
|
||||
{breadcrumbs.map((breadcrumb, index) => (
|
||||
<li
|
||||
key={breadcrumb.href}
|
||||
aria-current={breadcrumb.active}
|
||||
className={clsx(
|
||||
breadcrumb.active ? 'text-gray-900' : 'text-gray-500',
|
||||
)}
|
||||
>
|
||||
<Link href={breadcrumb.href}>{breadcrumb.label}</Link>
|
||||
{index < breadcrumbs.length - 1 ? (
|
||||
<span className="mx-3 inline-block">/</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
36
app/ui/invoices/buttons.tsx
Normal file
36
app/ui/invoices/buttons.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function CreateInvoice() {
|
||||
return (
|
||||
<Link
|
||||
href="/dashboard/invoices/create"
|
||||
className="flex h-10 items-center rounded-lg bg-blue-600 px-4 text-sm font-medium text-white transition-colors hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
<span className="hidden md:block">Create Invoice</span>{' '}
|
||||
<PlusIcon className="h-5 md:ml-4" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateInvoice({ id }: { id: string }) {
|
||||
return (
|
||||
<Link
|
||||
href="/dashboard/invoices"
|
||||
className="rounded-md border p-2 hover:bg-gray-100"
|
||||
>
|
||||
<PencilIcon className="w-5" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeleteInvoice({ id }: { id: string }) {
|
||||
return (
|
||||
<>
|
||||
<button type="submit" className="rounded-md border p-2 hover:bg-gray-100">
|
||||
<span className="sr-only">Delete</span>
|
||||
<TrashIcon className="w-5" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
112
app/ui/invoices/create-form.tsx
Normal file
112
app/ui/invoices/create-form.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import { CustomerField } from '@/app/lib/definitions';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
CheckIcon,
|
||||
ClockIcon,
|
||||
CurrencyDollarIcon,
|
||||
UserCircleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { Button } from '@/app/ui/button';
|
||||
|
||||
export default function Form({ customers }: { customers: CustomerField[] }) {
|
||||
return (
|
||||
<form>
|
||||
<div className="rounded-md bg-gray-50 p-4 md:p-6">
|
||||
{/* Customer Name */}
|
||||
<div className="mb-4">
|
||||
<label htmlFor="customer" className="mb-2 block text-sm font-medium">
|
||||
Choose customer
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
id="customer"
|
||||
name="customerId"
|
||||
className="peer block w-full cursor-pointer rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select a customer
|
||||
</option>
|
||||
{customers.map((customer) => (
|
||||
<option key={customer.id} value={customer.id}>
|
||||
{customer.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoice Amount */}
|
||||
<div className="mb-4">
|
||||
<label htmlFor="amount" className="mb-2 block text-sm font-medium">
|
||||
Choose an amount
|
||||
</label>
|
||||
<div className="relative mt-2 rounded-md">
|
||||
<div className="relative">
|
||||
<input
|
||||
id="amount"
|
||||
name="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="Enter USD amount"
|
||||
className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
|
||||
/>
|
||||
<CurrencyDollarIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoice Status */}
|
||||
<fieldset>
|
||||
<legend className="mb-2 block text-sm font-medium">
|
||||
Set the invoice status
|
||||
</legend>
|
||||
<div className="rounded-md border border-gray-200 bg-white px-[14px] py-3">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="pending"
|
||||
name="status"
|
||||
type="radio"
|
||||
value="pending"
|
||||
className="h-4 w-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2"
|
||||
/>
|
||||
<label
|
||||
htmlFor="pending"
|
||||
className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-600"
|
||||
>
|
||||
Pending <ClockIcon className="h-4 w-4" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="paid"
|
||||
name="status"
|
||||
type="radio"
|
||||
value="paid"
|
||||
className="h-4 w-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2"
|
||||
/>
|
||||
<label
|
||||
htmlFor="paid"
|
||||
className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-green-500 px-3 py-1.5 text-xs font-medium text-white"
|
||||
>
|
||||
Paid <CheckIcon className="h-4 w-4" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-4">
|
||||
<Link
|
||||
href="/dashboard/invoices"
|
||||
className="flex h-10 items-center rounded-lg bg-gray-100 px-4 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button type="submit">Create Invoice</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
123
app/ui/invoices/edit-form.tsx
Normal file
123
app/ui/invoices/edit-form.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import { CustomerField, InvoiceForm } from '@/app/lib/definitions';
|
||||
import {
|
||||
CheckIcon,
|
||||
ClockIcon,
|
||||
CurrencyDollarIcon,
|
||||
UserCircleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/app/ui/button';
|
||||
|
||||
export default function EditInvoiceForm({
|
||||
invoice,
|
||||
customers,
|
||||
}: {
|
||||
invoice: InvoiceForm;
|
||||
customers: CustomerField[];
|
||||
}) {
|
||||
return (
|
||||
<form>
|
||||
<div className="rounded-md bg-gray-50 p-4 md:p-6">
|
||||
{/* Customer Name */}
|
||||
<div className="mb-4">
|
||||
<label htmlFor="customer" className="mb-2 block text-sm font-medium">
|
||||
Choose customer
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
id="customer"
|
||||
name="customerId"
|
||||
className="peer block w-full cursor-pointer rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
|
||||
defaultValue={invoice.customer_id}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select a customer
|
||||
</option>
|
||||
{customers.map((customer) => (
|
||||
<option key={customer.id} value={customer.id}>
|
||||
{customer.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoice Amount */}
|
||||
<div className="mb-4">
|
||||
<label htmlFor="amount" className="mb-2 block text-sm font-medium">
|
||||
Choose an amount
|
||||
</label>
|
||||
<div className="relative mt-2 rounded-md">
|
||||
<div className="relative">
|
||||
<input
|
||||
id="amount"
|
||||
name="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
defaultValue={invoice.amount}
|
||||
placeholder="Enter USD amount"
|
||||
className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
|
||||
/>
|
||||
<CurrencyDollarIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoice Status */}
|
||||
<fieldset>
|
||||
<legend className="mb-2 block text-sm font-medium">
|
||||
Set the invoice status
|
||||
</legend>
|
||||
<div className="rounded-md border border-gray-200 bg-white px-[14px] py-3">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="pending"
|
||||
name="status"
|
||||
type="radio"
|
||||
value="pending"
|
||||
defaultChecked={invoice.status === 'pending'}
|
||||
className="h-4 w-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2"
|
||||
/>
|
||||
<label
|
||||
htmlFor="pending"
|
||||
className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-600"
|
||||
>
|
||||
Pending <ClockIcon className="h-4 w-4" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="paid"
|
||||
name="status"
|
||||
type="radio"
|
||||
value="paid"
|
||||
defaultChecked={invoice.status === 'paid'}
|
||||
className="h-4 w-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2"
|
||||
/>
|
||||
<label
|
||||
htmlFor="paid"
|
||||
className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-green-500 px-3 py-1.5 text-xs font-medium text-white"
|
||||
>
|
||||
Paid <CheckIcon className="h-4 w-4" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-4">
|
||||
<Link
|
||||
href="/dashboard/invoices"
|
||||
className="flex h-10 items-center rounded-lg bg-gray-100 px-4 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button type="submit">Edit Invoice</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
119
app/ui/invoices/pagination.tsx
Normal file
119
app/ui/invoices/pagination.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
import Link from 'next/link';
|
||||
import { generatePagination } from '@/app/lib/utils';
|
||||
|
||||
export default function Pagination({ totalPages }: { totalPages: number }) {
|
||||
// NOTE: Uncomment this code in Chapter 11
|
||||
|
||||
// const allPages = generatePagination(currentPage, totalPages);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* NOTE: Uncomment this code in Chapter 11 */}
|
||||
|
||||
{/* <div className="inline-flex">
|
||||
<PaginationArrow
|
||||
direction="left"
|
||||
href={createPageURL(currentPage - 1)}
|
||||
isDisabled={currentPage <= 1}
|
||||
/>
|
||||
|
||||
<div className="flex -space-x-px">
|
||||
{allPages.map((page, index) => {
|
||||
let position: 'first' | 'last' | 'single' | 'middle' | undefined;
|
||||
|
||||
if (index === 0) position = 'first';
|
||||
if (index === allPages.length - 1) position = 'last';
|
||||
if (allPages.length === 1) position = 'single';
|
||||
if (page === '...') position = 'middle';
|
||||
|
||||
return (
|
||||
<PaginationNumber
|
||||
key={`${page}-${index}`}
|
||||
href={createPageURL(page)}
|
||||
page={page}
|
||||
position={position}
|
||||
isActive={currentPage === page}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<PaginationArrow
|
||||
direction="right"
|
||||
href={createPageURL(currentPage + 1)}
|
||||
isDisabled={currentPage >= totalPages}
|
||||
/>
|
||||
</div> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationNumber({
|
||||
page,
|
||||
href,
|
||||
isActive,
|
||||
position,
|
||||
}: {
|
||||
page: number | string;
|
||||
href: string;
|
||||
position?: 'first' | 'last' | 'middle' | 'single';
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const className = clsx(
|
||||
'flex h-10 w-10 items-center justify-center text-sm border',
|
||||
{
|
||||
'rounded-l-md': position === 'first' || position === 'single',
|
||||
'rounded-r-md': position === 'last' || position === 'single',
|
||||
'z-10 bg-blue-600 border-blue-600 text-white': isActive,
|
||||
'hover:bg-gray-100': !isActive && position !== 'middle',
|
||||
'text-gray-300': position === 'middle',
|
||||
},
|
||||
);
|
||||
|
||||
return isActive || position === 'middle' ? (
|
||||
<div className={className}>{page}</div>
|
||||
) : (
|
||||
<Link href={href} className={className}>
|
||||
{page}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationArrow({
|
||||
href,
|
||||
direction,
|
||||
isDisabled,
|
||||
}: {
|
||||
href: string;
|
||||
direction: 'left' | 'right';
|
||||
isDisabled?: boolean;
|
||||
}) {
|
||||
const className = clsx(
|
||||
'flex h-10 w-10 items-center justify-center rounded-md border',
|
||||
{
|
||||
'pointer-events-none text-gray-300': isDisabled,
|
||||
'hover:bg-gray-100': !isDisabled,
|
||||
'mr-2 md:mr-4': direction === 'left',
|
||||
'ml-2 md:ml-4': direction === 'right',
|
||||
},
|
||||
);
|
||||
|
||||
const icon =
|
||||
direction === 'left' ? (
|
||||
<ArrowLeftIcon className="w-4" />
|
||||
) : (
|
||||
<ArrowRightIcon className="w-4" />
|
||||
);
|
||||
|
||||
return isDisabled ? (
|
||||
<div className={className}>{icon}</div>
|
||||
) : (
|
||||
<Link className={className} href={href}>
|
||||
{icon}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
29
app/ui/invoices/status.tsx
Normal file
29
app/ui/invoices/status.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { CheckIcon, ClockIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function InvoiceStatus({ status }: { status: string }) {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center rounded-full px-2 py-1 text-xs',
|
||||
{
|
||||
'bg-gray-100 text-gray-500': status === 'pending',
|
||||
'bg-green-500 text-white': status === 'paid',
|
||||
},
|
||||
)}
|
||||
>
|
||||
{status === 'pending' ? (
|
||||
<>
|
||||
Pending
|
||||
<ClockIcon className="ml-1 w-4 text-gray-500" />
|
||||
</>
|
||||
) : null}
|
||||
{status === 'paid' ? (
|
||||
<>
|
||||
Paid
|
||||
<CheckIcon className="ml-1 w-4 text-white" />
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
124
app/ui/invoices/table.tsx
Normal file
124
app/ui/invoices/table.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
import Image from 'next/image';
|
||||
import { UpdateInvoice, DeleteInvoice } from '@/app/ui/invoices/buttons';
|
||||
import InvoiceStatus from '@/app/ui/invoices/status';
|
||||
import { formatDateToLocal, formatCurrency } from '@/app/lib/utils';
|
||||
import { fetchFilteredInvoices } from '@/app/lib/data';
|
||||
|
||||
export default async function InvoicesTable({
|
||||
query,
|
||||
currentPage,
|
||||
}: {
|
||||
query: string;
|
||||
currentPage: number;
|
||||
}) {
|
||||
const invoices = await fetchFilteredInvoices(query, currentPage);
|
||||
|
||||
return (
|
||||
<div className="mt-6 flow-root">
|
||||
<div className="inline-block min-w-full align-middle">
|
||||
<div className="rounded-lg bg-gray-50 p-2 md:pt-0">
|
||||
<div className="md:hidden">
|
||||
{invoices?.map((invoice) => (
|
||||
<div
|
||||
key={invoice.id}
|
||||
className="mb-2 w-full rounded-md bg-white p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b pb-4">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center">
|
||||
<Image
|
||||
src={invoice.image_url}
|
||||
className="mr-2 rounded-full"
|
||||
width={28}
|
||||
height={28}
|
||||
alt={`${invoice.name}'s profile picture`}
|
||||
/>
|
||||
<p>{invoice.name}</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">{invoice.email}</p>
|
||||
</div>
|
||||
<InvoiceStatus status={invoice.status} />
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between pt-4">
|
||||
<div>
|
||||
<p className="text-xl font-medium">
|
||||
{formatCurrency(invoice.amount)}
|
||||
</p>
|
||||
<p>{formatDateToLocal(invoice.date)}</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<UpdateInvoice id={invoice.id} />
|
||||
<DeleteInvoice id={invoice.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<table className="hidden min-w-full text-gray-900 md:table">
|
||||
<thead className="rounded-lg text-left text-sm font-normal">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-5 font-medium sm:pl-6">
|
||||
Customer
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-5 font-medium">
|
||||
Email
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-5 font-medium">
|
||||
Amount
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-5 font-medium">
|
||||
Date
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-5 font-medium">
|
||||
Status
|
||||
</th>
|
||||
<th scope="col" className="relative py-3 pl-6 pr-3">
|
||||
<span className="sr-only">Edit</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white">
|
||||
{invoices?.map((invoice) => (
|
||||
<tr
|
||||
key={invoice.id}
|
||||
className="w-full border-b py-3 text-sm last-of-type:border-none [&:first-child>td:first-child]:rounded-tl-lg [&:first-child>td:last-child]:rounded-tr-lg [&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg"
|
||||
>
|
||||
<td className="whitespace-nowrap py-3 pl-6 pr-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Image
|
||||
src={invoice.image_url}
|
||||
className="rounded-full"
|
||||
width={28}
|
||||
height={28}
|
||||
alt={`${invoice.name}'s profile picture`}
|
||||
/>
|
||||
<p>{invoice.name}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-3">
|
||||
{invoice.email}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-3">
|
||||
{formatCurrency(invoice.amount)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-3">
|
||||
{formatDateToLocal(invoice.date)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-3">
|
||||
<InvoiceStatus status={invoice.status} />
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-3 pl-6 pr-3">
|
||||
<div className="flex justify-end gap-3">
|
||||
<UpdateInvoice id={invoice.id} />
|
||||
<DeleteInvoice id={invoice.id} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user