git init
This commit is contained in:
13
app/ui/acme-logo.tsx
Normal file
13
app/ui/acme-logo.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { GlobeAltIcon } from '@heroicons/react/24/outline';
|
||||
import {lusitana} from "@/app/ui/fonts";
|
||||
|
||||
export default function AcmeLogo() {
|
||||
return (
|
||||
<div
|
||||
className={`${lusitana.className} flex flex-row items-center leading-none text-white`}
|
||||
>
|
||||
<GlobeAltIcon className="h-12 w-12 rotate-[15deg]" />
|
||||
<p className="text-[44px]">Acme</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
app/ui/button.tsx
Normal file
19
app/ui/button.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Button({ children, className, ...rest }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
className={clsx(
|
||||
'flex h-10 items-center rounded-lg bg-blue-500 px-4 text-sm font-medium text-white transition-colors hover:bg-blue-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 active:bg-blue-600 aria-disabled:cursor-not-allowed aria-disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
122
app/ui/customers/table.tsx
Normal file
122
app/ui/customers/table.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import Image from 'next/image';
|
||||
import { lusitana } from '@/app/ui/fonts';
|
||||
import Search from '@/app/ui/search';
|
||||
import {
|
||||
FormattedCustomersTable,
|
||||
} from '@/app/lib/definitions';
|
||||
|
||||
export default async function CustomersTable({
|
||||
customers,
|
||||
}: {
|
||||
customers: FormattedCustomersTable[];
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<h1 className={`${lusitana.className} mb-8 text-xl md:text-2xl`}>
|
||||
Customers
|
||||
</h1>
|
||||
<Search placeholder="Search customers..." />
|
||||
<div className="mt-6 flow-root">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="inline-block min-w-full align-middle">
|
||||
<div className="overflow-hidden rounded-md bg-gray-50 p-2 md:pt-0">
|
||||
<div className="md:hidden">
|
||||
{customers?.map((customer) => (
|
||||
<div
|
||||
key={customer.id}
|
||||
className="mb-2 w-full rounded-md bg-white p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b pb-4">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<Image
|
||||
src={customer.image_url}
|
||||
className="rounded-full"
|
||||
alt={`${customer.name}'s profile picture`}
|
||||
width={28}
|
||||
height={28}
|
||||
/>
|
||||
<p>{customer.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
{customer.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between border-b py-5">
|
||||
<div className="flex w-1/2 flex-col">
|
||||
<p className="text-xs">Pending</p>
|
||||
<p className="font-medium">{customer.total_pending}</p>
|
||||
</div>
|
||||
<div className="flex w-1/2 flex-col">
|
||||
<p className="text-xs">Paid</p>
|
||||
<p className="font-medium">{customer.total_paid}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-4 text-sm">
|
||||
<p>{customer.total_invoices} invoices</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<table className="hidden min-w-full rounded-md text-gray-900 md:table">
|
||||
<thead className="rounded-md bg-gray-50 text-left text-sm font-normal">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-5 font-medium sm:pl-6">
|
||||
Name
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-5 font-medium">
|
||||
Email
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-5 font-medium">
|
||||
Total Invoices
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-5 font-medium">
|
||||
Total Pending
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-5 font-medium">
|
||||
Total Paid
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody className="divide-y divide-gray-200 text-gray-900">
|
||||
{customers.map((customer) => (
|
||||
<tr key={customer.id} className="group">
|
||||
<td className="whitespace-nowrap bg-white py-5 pl-4 pr-3 text-sm text-black group-first-of-type:rounded-md group-last-of-type:rounded-md sm:pl-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Image
|
||||
src={customer.image_url}
|
||||
className="rounded-full"
|
||||
alt={`${customer.name}'s profile picture`}
|
||||
width={28}
|
||||
height={28}
|
||||
/>
|
||||
<p>{customer.name}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap bg-white px-4 py-5 text-sm">
|
||||
{customer.email}
|
||||
</td>
|
||||
<td className="whitespace-nowrap bg-white px-4 py-5 text-sm">
|
||||
{customer.total_invoices}
|
||||
</td>
|
||||
<td className="whitespace-nowrap bg-white px-4 py-5 text-sm">
|
||||
{customer.total_pending}
|
||||
</td>
|
||||
<td className="whitespace-nowrap bg-white px-4 py-5 text-sm group-first-of-type:rounded-md group-last-of-type:rounded-md">
|
||||
{customer.total_paid}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
app/ui/dashboard/cards.tsx
Normal file
66
app/ui/dashboard/cards.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
BanknotesIcon,
|
||||
ClockIcon,
|
||||
UserGroupIcon,
|
||||
InboxIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { lusitana } from '@/app/ui/fonts';
|
||||
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 (
|
||||
<>
|
||||
{/* NOTE: Uncomment this code in Chapter 9 */}
|
||||
|
||||
<Card title="Collected" value={totalPaidInvoices} type="collected" />
|
||||
<Card title="Pending" value={totalPendingInvoices} type="pending" />
|
||||
<Card title="Total Invoices" value={numberOfInvoices} type="invoices" />
|
||||
<Card
|
||||
title="Total Customers"
|
||||
value={numberOfCustomers}
|
||||
type="customers"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Card({
|
||||
title,
|
||||
value,
|
||||
type,
|
||||
}: {
|
||||
title: string;
|
||||
value: number | string;
|
||||
type: 'invoices' | 'customers' | 'pending' | 'collected';
|
||||
}) {
|
||||
const Icon = iconMap[type];
|
||||
|
||||
return (
|
||||
<div className="rounded-xl bg-gray-50 p-2 shadow-sm">
|
||||
<div className="flex p-4">
|
||||
{Icon ? <Icon className="h-5 w-5 text-gray-700" /> : null}
|
||||
<h3 className="ml-2 text-sm font-medium">{title}</h3>
|
||||
</div>
|
||||
<p
|
||||
className={`${lusitana.className}
|
||||
truncate rounded-xl bg-white px-4 py-8 text-center text-2xl`}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
app/ui/dashboard/latest-invoices.tsx
Normal file
63
app/ui/dashboard/latest-invoices.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { ArrowPathIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
import Image from 'next/image';
|
||||
|
||||
import {lusitana} from "@/app/ui/fonts";
|
||||
import {fetchLatestInvoices} from "@/app/lib/data";
|
||||
|
||||
export default async function LatestInvoices() {
|
||||
|
||||
const latestInvoices = await fetchLatestInvoices();
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col md:col-span-4">
|
||||
<h2 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>최근 송장</h2>
|
||||
<div className="flex grow flex-col justify-between rounded-xl bg-gray-50 p-4">
|
||||
{/* NOTE: Uncomment this code in Chapter 7 */}
|
||||
|
||||
<div className="bg-white px-6">
|
||||
{latestInvoices.map((invoice, i) => {
|
||||
return (
|
||||
<div
|
||||
key={invoice.id}
|
||||
className={clsx(
|
||||
'flex flex-row items-center justify-between py-4',
|
||||
{
|
||||
'border-t': i !== 0,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Image
|
||||
src={invoice.image_url}
|
||||
className="mr-4 rounded-full"
|
||||
width={32}
|
||||
height={32}
|
||||
alt="invoices"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold md:text-base">
|
||||
{invoice.name}
|
||||
</p>
|
||||
<p className="hidden text-sm text-gray-500 sm:block">
|
||||
{invoice.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
className={`${lusitana.className} truncate text-sm font-medium md:text-base`}
|
||||
>
|
||||
{invoice.amount}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center pb-2 pt-6">
|
||||
<ArrowPathIcon className="h-5 w-5 text-gray-500" />
|
||||
<h3 className="ml-2 text-sm text-gray-500 ">Updated just now</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
app/ui/dashboard/nav-links.tsx
Normal file
48
app/ui/dashboard/nav-links.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
UserGroupIcon,
|
||||
HomeIcon,
|
||||
DocumentDuplicateIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import Link from "next/link";
|
||||
import {usePathname} from 'next/navigation';
|
||||
import clsx from "clsx";
|
||||
|
||||
// Map of links to display in the side navigation.
|
||||
// Depending on the size of the application, this would be stored in a database.
|
||||
const links = [
|
||||
{ name: 'Home', href: '/dashboard', icon: HomeIcon },
|
||||
{
|
||||
name: 'Invoices',
|
||||
href: '/dashboard/invoices',
|
||||
icon: DocumentDuplicateIcon,
|
||||
},
|
||||
{ name: 'Customers', href: '/dashboard/customers', icon: UserGroupIcon },
|
||||
];
|
||||
|
||||
export default function NavLinks() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<>
|
||||
{links.map((link) => {
|
||||
const LinkIcon = link.icon;
|
||||
return (
|
||||
<Link
|
||||
key={link.name}
|
||||
href={link.href}
|
||||
className={clsx(
|
||||
'flex h-[48px] grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3',
|
||||
{
|
||||
'bg-sky-100 text-blue-600': pathname === link.href,
|
||||
},
|
||||
)} >
|
||||
<LinkIcon className="w-6" />
|
||||
<p className="hidden md:block">{link.name}</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
61
app/ui/dashboard/revenue-chart.tsx
Normal file
61
app/ui/dashboard/revenue-chart.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { generateYAxis } from '@/app/lib/utils';
|
||||
import { CalendarIcon } from '@heroicons/react/24/outline';
|
||||
import { lusitana } from '@/app/ui/fonts';
|
||||
import {fetchRevenue} from "@/app/lib/data";
|
||||
|
||||
// This component is representational only.
|
||||
// For data visualization UI, check out:
|
||||
// https://www.tremor.so/
|
||||
// https://www.chartjs.org/
|
||||
// https://airbnb.io/visx/
|
||||
|
||||
export default async function RevenueChart() {
|
||||
const revenue = await fetchRevenue();
|
||||
|
||||
const chartHeight = 350;
|
||||
const { yAxisLabels, topLabel } = generateYAxis(revenue);
|
||||
|
||||
if (!revenue || revenue.length === 0) {
|
||||
return <p className="mt-4 text-gray-400">No data available.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full md:col-span-4">
|
||||
<h2 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
|
||||
최근 매출액
|
||||
</h2>
|
||||
{/* NOTE: Uncomment this code in Chapter 7 */}
|
||||
|
||||
<div className="rounded-xl bg-gray-50 p-4">
|
||||
<div className="sm:grid-cols-13 mt-0 grid grid-cols-12 items-end gap-2 rounded-md bg-white p-4 md:gap-4">
|
||||
<div
|
||||
className="mb-6 hidden flex-col justify-between text-sm text-gray-400 sm:flex"
|
||||
style={{ height: `${chartHeight}px` }}
|
||||
>
|
||||
{yAxisLabels.map((label) => (
|
||||
<p key={label}>{label}</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{revenue.map((month) => (
|
||||
<div key={month.month} className="flex flex-col items-center gap-2">
|
||||
<div
|
||||
className="w-full rounded-md bg-blue-300"
|
||||
style={{
|
||||
height: `${(chartHeight / topLabel) * month.revenue}px`,
|
||||
}}
|
||||
></div>
|
||||
<p className="-rotate-90 text-sm text-gray-400 sm:rotate-0">
|
||||
{month.month}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center pb-2 pt-6">
|
||||
<CalendarIcon className="h-5 w-5 text-gray-500" />
|
||||
<h3 className="ml-2 text-sm text-gray-500 ">최근 12개월 동향</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
app/ui/dashboard/sidenav.tsx
Normal file
36
app/ui/dashboard/sidenav.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
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 (
|
||||
<div className="flex h-full flex-col px-3 py-4 md:px-2">
|
||||
<Link
|
||||
className="mb-2 flex h-20 items-end justify-start rounded-md bg-blue-600 p-4 md:h-40"
|
||||
href="/"
|
||||
>
|
||||
<div className="w-32 text-white md:w-40">
|
||||
<AcmeLogo/>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex grow flex-row justify-between space-x-2 md:flex-col md:space-x-0 md:space-y-2">
|
||||
<NavLinks/>
|
||||
<div className="hidden h-auto w-full grow rounded-md bg-gray-50 md:block"></div>
|
||||
<form
|
||||
action={async () => {
|
||||
'use server';
|
||||
await signOut();
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="flex h-[48px] w-full grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3">
|
||||
<PowerIcon className="w-6"/>
|
||||
<div className="hidden md:block">접속해제</div>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
app/ui/fonts.ts
Normal file
10
app/ui/fonts.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import {Inter, Lusitana} from 'next/font/google';
|
||||
|
||||
export const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
export const lusitana = Lusitana({
|
||||
weight: ['400', '700'],
|
||||
subsets: ['latin'],
|
||||
});
|
||||
18
app/ui/global.css
Normal file
18
app/ui/global.css
Normal file
@@ -0,0 +1,18 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
input[type='number'] {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
input[type='number']::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input[type='number']::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
7
app/ui/home.module.css
Normal file
7
app/ui/home.module.css
Normal file
@@ -0,0 +1,7 @@
|
||||
.shape {
|
||||
height: 0;
|
||||
width: 0;
|
||||
border-bottom: 30px solid black;
|
||||
border-left: 20px solid transparent;
|
||||
border-right: 20px solid transparent;
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
47
app/ui/invoices/buttons.tsx
Normal file
47
app/ui/invoices/buttons.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
|
||||
import Link from 'next/link';
|
||||
import {deleteInvoice} from "@/app/lib/actions";
|
||||
import {useActionState} from "react";
|
||||
|
||||
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/${id}/edit`}
|
||||
className="rounded-md border p-2 hover:bg-gray-100"
|
||||
>
|
||||
<PencilIcon className="w-5" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeleteInvoice({ id }: { id: string }) {
|
||||
const deleteInvoiceWithId = deleteInvoice.bind(null, id);
|
||||
const [state, formAction] = useActionState(deleteInvoiceWithId, null);
|
||||
console.log(state);
|
||||
|
||||
return (
|
||||
<form action={formAction}>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md border p-2 hover:bg-gray-100"
|
||||
>
|
||||
<span className="sr-only">삭제</span>
|
||||
<TrashIcon className="w-5"/>
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
149
app/ui/invoices/create-form.tsx
Normal file
149
app/ui/invoices/create-form.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
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';
|
||||
import {createInvoice, State} from "@/app/lib/actions";
|
||||
import {useActionState} from "react";
|
||||
|
||||
export default function Form({customers}: { customers: CustomerField[] }) {
|
||||
const initialState: State = { message: null, errors: {} }
|
||||
const [state, formAction] = useActionState(createInvoice, initialState);
|
||||
|
||||
return (
|
||||
<form action={formAction}>
|
||||
<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=""
|
||||
aria-describedby="customer-error"
|
||||
>
|
||||
<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>
|
||||
<div id="customer-error" aria-live="polite" aria-atomic="true">
|
||||
{state.errors?.customerId &&
|
||||
state.errors.customerId.map((error: string) => (
|
||||
<p className="mt-2 text-sm text-red-500" key={error}>
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</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"
|
||||
aria-describedby="amount-error"
|
||||
/>
|
||||
<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>
|
||||
<div id="amount-error" aria-live="polite" aria-atomic="true">
|
||||
{state.errors?.amount &&
|
||||
state.errors.amount.map((error: string) => (
|
||||
<p className="mt-2 text-sm text-red-500" key={error}>
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</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"
|
||||
aria-describedby="status-error"
|
||||
/>
|
||||
<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>
|
||||
<div id="status-error" aria-live="polite" aria-atomic="true">
|
||||
{state.errors?.status &&
|
||||
state.errors.status.map((error: string) => (
|
||||
<p className="mt-2 text-sm text-red-500" key={error}>
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
{state.message && <p className="mt-2 text-sm text-red-500">{state.message}</p>}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
159
app/ui/invoices/edit-form.tsx
Normal file
159
app/ui/invoices/edit-form.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
'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';
|
||||
import {State, updateInvoice} from '@/app/lib/actions';
|
||||
import {useActionState} from "react";
|
||||
|
||||
export default function EditInvoiceForm({
|
||||
invoice,
|
||||
customers,
|
||||
}: {
|
||||
invoice: InvoiceForm;
|
||||
customers: CustomerField[];
|
||||
}) {
|
||||
const initialState: State = { message: null, errors: {} };
|
||||
const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);
|
||||
const [state, formAction] = useActionState(updateInvoiceWithId, initialState);
|
||||
|
||||
return (
|
||||
<form action={formAction}>
|
||||
<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}
|
||||
aria-describedby="customer-error"
|
||||
>
|
||||
<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>
|
||||
<div id="customer-error" aria-live="polite" aria-atomic="true">
|
||||
{state.errors?.customerId &&
|
||||
state.errors.customerId.map((error: string) => (
|
||||
<p className="mt-2 text-sm text-red-500" key={error}>
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</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"
|
||||
aria-describedby="amount-error"
|
||||
/>
|
||||
<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>
|
||||
<div id="amount-error" aria-live="polite" aria-atomic="true">
|
||||
{state.errors?.amount &&
|
||||
state.errors.amount.map((error: string) => (
|
||||
<p className="mt-2 text-sm text-red-500" key={error}>
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</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"
|
||||
aria-describedby="status-error"
|
||||
/>
|
||||
<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>
|
||||
<div id="status-error" aria-live="polite" aria-atomic="true">
|
||||
{state.errors?.status &&
|
||||
state.errors.status.map((error: string) => (
|
||||
<p className="mt-2 text-sm text-red-500" key={error}>
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
{state.message && <p className="mt-2 text-sm text-red-500">{state.message}</p>}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
129
app/ui/invoices/pagination.tsx
Normal file
129
app/ui/invoices/pagination.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'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';
|
||||
import {usePathname, useSearchParams} from "next/navigation";
|
||||
|
||||
export default function Pagination({totalPages}: { totalPages: number }) {
|
||||
// NOTE: Uncomment this code in Chapter 11
|
||||
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const currentPage = Number(searchParams.get('page')) || 1;
|
||||
const allPages = generatePagination(currentPage, totalPages);
|
||||
|
||||
const createPageURL = (pageNumber: number | string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set('page', pageNumber.toString());
|
||||
return `${pathname}?${params.toString()}`;
|
||||
}
|
||||
|
||||
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: number | string, index) => {
|
||||
let position: 'first' | 'last' | 'single' | 'middle' | undefined;
|
||||
|
||||
if (index === 0) position = 'first';
|
||||
if (index === allPages.length - 1) position = 'last';
|
||||
if (allPages.length === 1) position = 'single';
|
||||
if (page === '...') position = 'middle';
|
||||
|
||||
return (
|
||||
<PaginationNumber
|
||||
key={page}
|
||||
href={createPageURL(page)}
|
||||
page={page}
|
||||
position={position}
|
||||
isActive={currentPage === page}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<PaginationArrow
|
||||
direction="right"
|
||||
href={createPageURL(currentPage + 1)}
|
||||
isDisabled={currentPage >= totalPages}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationNumber({
|
||||
page,
|
||||
href,
|
||||
isActive,
|
||||
position,
|
||||
}: {
|
||||
page: number | string;
|
||||
href: string;
|
||||
position?: 'first' | 'last' | 'middle' | 'single';
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const className = clsx(
|
||||
'flex h-10 w-10 items-center justify-center text-sm border',
|
||||
{
|
||||
'rounded-l-md': position === 'first' || position === 'single',
|
||||
'rounded-r-md': position === 'last' || position === 'single',
|
||||
'z-10 bg-blue-600 border-blue-600 text-white': isActive,
|
||||
'hover:bg-gray-100': !isActive && position !== 'middle',
|
||||
'text-gray-300': position === 'middle',
|
||||
},
|
||||
);
|
||||
|
||||
return isActive || position === 'middle' ? (
|
||||
<div className={className}>{page}</div>
|
||||
) : (
|
||||
<Link href={href} className={className}>
|
||||
{page}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationArrow({
|
||||
href,
|
||||
direction,
|
||||
isDisabled,
|
||||
}: {
|
||||
href: string;
|
||||
direction: 'left' | 'right';
|
||||
isDisabled?: boolean;
|
||||
}) {
|
||||
const className = clsx(
|
||||
'flex h-10 w-10 items-center justify-center rounded-md border',
|
||||
{
|
||||
'pointer-events-none text-gray-300': isDisabled,
|
||||
'hover:bg-gray-100': !isDisabled,
|
||||
'mr-2 md:mr-4': direction === 'left',
|
||||
'ml-2 md:ml-4': direction === 'right',
|
||||
},
|
||||
);
|
||||
|
||||
const icon =
|
||||
direction === 'left' ? (
|
||||
<ArrowLeftIcon className="w-4"/>
|
||||
) : (
|
||||
<ArrowRightIcon className="w-4"/>
|
||||
);
|
||||
|
||||
return isDisabled ? (
|
||||
<div className={className}>{icon}</div>
|
||||
) : (
|
||||
<Link className={className} href={href}>
|
||||
{icon}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
87
app/ui/login-form.tsx
Normal file
87
app/ui/login-form.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import { lusitana } from '@/app/ui/fonts';
|
||||
import {
|
||||
AtSymbolIcon,
|
||||
KeyIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import {ArrowRightIcon, ExclamationCircleIcon} from '@heroicons/react/20/solid';
|
||||
import { Button } from './button';
|
||||
import {useActionState} from "react";
|
||||
import {authenticate} from "@/app/lib/actions";
|
||||
|
||||
export default function LoginForm() {
|
||||
const [errorMessage, formAction, isPending] = useActionState(
|
||||
authenticate,
|
||||
undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="space-y-3">
|
||||
<div className="flex-1 rounded-lg bg-gray-50 px-6 pb-4 pt-8">
|
||||
<h1 className={`${lusitana.className} mb-3 text-2xl`}>계속하려면 로그인하세요.</h1>
|
||||
<div className="w-full">
|
||||
<div>
|
||||
<label
|
||||
className="mb-3 mt-5 block text-xs font-medium text-gray-900"
|
||||
htmlFor="email"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="Enter your email address"
|
||||
defaultValue="user@nextmail.com"
|
||||
required
|
||||
/>
|
||||
<AtSymbolIcon
|
||||
className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900"/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label
|
||||
className="mb-3 mt-5 block text-xs font-medium text-gray-900"
|
||||
htmlFor="password"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Enter password"
|
||||
required
|
||||
minLength={6}
|
||||
defaultValue="123456"
|
||||
/>
|
||||
<KeyIcon
|
||||
className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="mt-4 w-full" aria-disabled={isPending}>
|
||||
로그인 <ArrowRightIcon className="ml-auto h-5 w-5 text-gray-50"/>
|
||||
</Button>
|
||||
<div
|
||||
className="flex h-8 items-end space-x-1"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{errorMessage && (
|
||||
<>
|
||||
|
||||
<ExclamationCircleIcon className="h-5 w-5 text-red-500"/>
|
||||
<p className="test-sm test-red-500">{errorMessage}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
43
app/ui/search.tsx
Normal file
43
app/ui/search.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import {MagnifyingGlassIcon} from '@heroicons/react/24/outline';
|
||||
import {usePathname, useRouter, useSearchParams} from "next/navigation";
|
||||
import {useDebouncedCallback} from "use-debounce";
|
||||
|
||||
export default function Search({placeholder}: { placeholder: string }) {
|
||||
|
||||
const pathname = usePathname();
|
||||
const { replace } = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const handleSearch = useDebouncedCallback((term) => {
|
||||
console.log(`searching for ${term}`)
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set('page', '1');
|
||||
|
||||
if (term) {
|
||||
params.set('query', term);
|
||||
} else {
|
||||
params.delete('query');
|
||||
}
|
||||
|
||||
replace(`${pathname}?${params.toString()}`); // params.toString() translates this input into a URL-friendly format
|
||||
}, 500);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-1 flex-shrink-0">
|
||||
<label htmlFor="search" className="sr-only">
|
||||
Search
|
||||
</label>
|
||||
<input
|
||||
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
defaultValue={searchParams.get('query')?.toString()}
|
||||
/>
|
||||
<MagnifyingGlassIcon
|
||||
className="absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900"/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
218
app/ui/skeletons.tsx
Normal file
218
app/ui/skeletons.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
// Loading animation
|
||||
const shimmer =
|
||||
'before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_2s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/60 before:to-transparent';
|
||||
|
||||
export function CardSkeleton() {
|
||||
return (
|
||||
<div
|
||||
className={`${shimmer} relative overflow-hidden rounded-xl bg-gray-100 p-2 shadow-sm`}
|
||||
>
|
||||
<div className="flex p-4">
|
||||
<div className="h-5 w-5 rounded-md bg-gray-200" />
|
||||
<div className="ml-2 h-6 w-16 rounded-md bg-gray-200 text-sm font-medium" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center truncate rounded-xl bg-white px-4 py-8">
|
||||
<div className="h-7 w-20 rounded-md bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardsSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function RevenueChartSkeleton() {
|
||||
return (
|
||||
<div className={`${shimmer} relative w-full overflow-hidden md:col-span-4`}>
|
||||
<div className="mb-4 h-8 w-36 rounded-md bg-gray-100" />
|
||||
<div className="rounded-xl bg-gray-100 p-4">
|
||||
<div className="sm:grid-cols-13 mt-0 grid h-[410px] grid-cols-12 items-end gap-2 rounded-md bg-white p-4 md:gap-4" />
|
||||
<div className="flex items-center pb-2 pt-6">
|
||||
<div className="h-5 w-5 rounded-full bg-gray-200" />
|
||||
<div className="ml-2 h-4 w-20 rounded-md bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InvoiceSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-row items-center justify-between border-b border-gray-100 py-4">
|
||||
<div className="flex items-center">
|
||||
<div className="mr-2 h-8 w-8 rounded-full bg-gray-200" />
|
||||
<div className="min-w-0">
|
||||
<div className="h-5 w-40 rounded-md bg-gray-200" />
|
||||
<div className="mt-2 h-4 w-12 rounded-md bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 h-4 w-12 rounded-md bg-gray-200" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LatestInvoicesSkeleton() {
|
||||
return (
|
||||
<div
|
||||
className={`${shimmer} relative flex w-full flex-col overflow-hidden md:col-span-4`}
|
||||
>
|
||||
<div className="mb-4 h-8 w-36 rounded-md bg-gray-100" />
|
||||
<div className="flex grow flex-col justify-between rounded-xl bg-gray-100 p-4">
|
||||
<div className="bg-white px-6">
|
||||
<InvoiceSkeleton />
|
||||
<InvoiceSkeleton />
|
||||
<InvoiceSkeleton />
|
||||
<InvoiceSkeleton />
|
||||
<InvoiceSkeleton />
|
||||
</div>
|
||||
<div className="flex items-center pb-2 pt-6">
|
||||
<div className="h-5 w-5 rounded-full bg-gray-200" />
|
||||
<div className="ml-2 h-4 w-20 rounded-md bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`${shimmer} relative mb-4 h-8 w-36 overflow-hidden rounded-md bg-gray-100`}
|
||||
/>
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
|
||||
<RevenueChartSkeleton />
|
||||
<LatestInvoicesSkeleton />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableRowSkeleton() {
|
||||
return (
|
||||
<tr className="w-full border-b border-gray-100 last-of-type:border-none [&:first-child>td:first-child]:rounded-tl-lg [&:first-child>td:last-child]:rounded-tr-lg [&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg">
|
||||
{/* Customer Name and Image */}
|
||||
<td className="relative overflow-hidden whitespace-nowrap py-3 pl-6 pr-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-full bg-gray-100"></div>
|
||||
<div className="h-6 w-24 rounded bg-gray-100"></div>
|
||||
</div>
|
||||
</td>
|
||||
{/* Email */}
|
||||
<td className="whitespace-nowrap px-3 py-3">
|
||||
<div className="h-6 w-32 rounded bg-gray-100"></div>
|
||||
</td>
|
||||
{/* Amount */}
|
||||
<td className="whitespace-nowrap px-3 py-3">
|
||||
<div className="h-6 w-16 rounded bg-gray-100"></div>
|
||||
</td>
|
||||
{/* Date */}
|
||||
<td className="whitespace-nowrap px-3 py-3">
|
||||
<div className="h-6 w-16 rounded bg-gray-100"></div>
|
||||
</td>
|
||||
{/* Status */}
|
||||
<td className="whitespace-nowrap px-3 py-3">
|
||||
<div className="h-6 w-16 rounded bg-gray-100"></div>
|
||||
</td>
|
||||
{/* Actions */}
|
||||
<td className="whitespace-nowrap py-3 pl-6 pr-3">
|
||||
<div className="flex justify-end gap-3">
|
||||
<div className="h-[38px] w-[38px] rounded bg-gray-100"></div>
|
||||
<div className="h-[38px] w-[38px] rounded bg-gray-100"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function InvoicesMobileSkeleton() {
|
||||
return (
|
||||
<div className="mb-2 w-full rounded-md bg-white p-4">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 pb-8">
|
||||
<div className="flex items-center">
|
||||
<div className="mr-2 h-8 w-8 rounded-full bg-gray-100"></div>
|
||||
<div className="h-6 w-16 rounded bg-gray-100"></div>
|
||||
</div>
|
||||
<div className="h-6 w-16 rounded bg-gray-100"></div>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between pt-4">
|
||||
<div>
|
||||
<div className="h-6 w-16 rounded bg-gray-100"></div>
|
||||
<div className="mt-2 h-6 w-24 rounded bg-gray-100"></div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<div className="h-10 w-10 rounded bg-gray-100"></div>
|
||||
<div className="h-10 w-10 rounded bg-gray-100"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InvoicesTableSkeleton() {
|
||||
return (
|
||||
<div className="mt-6 flow-root">
|
||||
<div className="inline-block min-w-full align-middle">
|
||||
<div className="rounded-lg bg-gray-50 p-2 md:pt-0">
|
||||
<div className="md:hidden">
|
||||
<InvoicesMobileSkeleton />
|
||||
<InvoicesMobileSkeleton />
|
||||
<InvoicesMobileSkeleton />
|
||||
<InvoicesMobileSkeleton />
|
||||
<InvoicesMobileSkeleton />
|
||||
<InvoicesMobileSkeleton />
|
||||
</div>
|
||||
<table className="hidden min-w-full text-gray-900 md:table">
|
||||
<thead className="rounded-lg text-left text-sm font-normal">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-5 font-medium sm:pl-6">
|
||||
Customer
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-5 font-medium">
|
||||
Email
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-5 font-medium">
|
||||
Amount
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-5 font-medium">
|
||||
Date
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-5 font-medium">
|
||||
Status
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="relative pb-4 pl-3 pr-6 pt-2 sm:pr-6"
|
||||
>
|
||||
<span className="sr-only">Edit</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white">
|
||||
<TableRowSkeleton />
|
||||
<TableRowSkeleton />
|
||||
<TableRowSkeleton />
|
||||
<TableRowSkeleton />
|
||||
<TableRowSkeleton />
|
||||
<TableRowSkeleton />
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user