diff options
Diffstat (limited to 'src')
27 files changed, 1722 insertions, 130 deletions
diff --git a/src/app/globals.css b/src/app/globals.css index fd81e88..8abdb15 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,26 +2,75 @@ @tailwind components; @tailwind utilities; -:root { - --foreground-rgb: 0, 0, 0; - --background-start-rgb: 214, 219, 220; - --background-end-rgb: 255, 255, 255; -} - -@media (prefers-color-scheme: dark) { +@layer base { :root { - --foreground-rgb: 255, 255, 255; - --background-start-rgb: 0, 0, 0; - --background-end-rgb: 0, 0, 0; + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; } } -body { - color: rgb(var(--foreground-rgb)); - background: linear-gradient( - to bottom, - transparent, - rgb(var(--background-end-rgb)) - ) - rgb(var(--background-start-rgb)); +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } } diff --git a/src/app/groups/[groupId]/edit/page.tsx b/src/app/groups/[groupId]/edit/page.tsx new file mode 100644 index 0000000..23bd865 --- /dev/null +++ b/src/app/groups/[groupId]/edit/page.tsx @@ -0,0 +1,36 @@ +import { GroupForm } from '@/components/group-form' +import { Button } from '@/components/ui/button' +import { getGroup, updateGroup } from '@/lib/api' +import { groupFormSchema } from '@/lib/schemas' +import { ChevronLeft } from 'lucide-react' +import Link from 'next/link' +import { notFound, redirect } from 'next/navigation' + +export default async function EditGroupPage({ + params: { groupId }, +}: { + params: { groupId: string } +}) { + const group = await getGroup(groupId) + if (!group) notFound() + + async function updateGroupAction(values: unknown) { + 'use server' + const groupFormValues = groupFormSchema.parse(values) + const group = await updateGroup(groupId, groupFormValues) + redirect(`/groups/${group.id}`) + } + + return ( + <main> + <div className="mb-4"> + <Button variant="ghost" asChild> + <Link href={`/groups/${groupId}`}> + <ChevronLeft className="w-4 h-4 mr-2" /> Back to group + </Link> + </Button> + </div> + <GroupForm group={group} onSubmit={updateGroupAction} /> + </main> + ) +} diff --git a/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx b/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx new file mode 100644 index 0000000..f22ac08 --- /dev/null +++ b/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx @@ -0,0 +1,42 @@ +import { ExpenseForm } from '@/components/expense-form' +import { Button } from '@/components/ui/button' +import { getExpense, getGroup, updateExpense } from '@/lib/api' +import { expenseFormSchema } from '@/lib/schemas' +import { ChevronLeft } from 'lucide-react' +import Link from 'next/link' +import { notFound, redirect } from 'next/navigation' + +export default async function EditExpensePage({ + params: { groupId, expenseId }, +}: { + params: { groupId: string; expenseId: string } +}) { + const group = await getGroup(groupId) + if (!group) notFound() + const expense = await getExpense(groupId, expenseId) + if (!expense) notFound() + + async function updateExpenseAction(values: unknown) { + 'use server' + const expenseFormValues = expenseFormSchema.parse(values) + await updateExpense(groupId, expenseId, expenseFormValues) + redirect(`/groups/${groupId}`) + } + + return ( + <main> + <div className="mb-4"> + <Button variant="ghost" asChild> + <Link href={`/groups/${groupId}`}> + <ChevronLeft className="w-4 h-4 mr-2" /> Back to group + </Link> + </Button> + </div> + <ExpenseForm + group={group} + expense={expense} + onSubmit={updateExpenseAction} + /> + </main> + ) +} diff --git a/src/app/groups/[groupId]/expenses/create/page.tsx b/src/app/groups/[groupId]/expenses/create/page.tsx new file mode 100644 index 0000000..f8d0ff9 --- /dev/null +++ b/src/app/groups/[groupId]/expenses/create/page.tsx @@ -0,0 +1,37 @@ +import { ExpenseForm } from '@/components/expense-form' +import { Button } from '@/components/ui/button' +import { createExpense, getGroup } from '@/lib/api' +import { expenseFormSchema } from '@/lib/schemas' +import { ChevronLeft } from 'lucide-react' +import Link from 'next/link' +import { notFound, redirect } from 'next/navigation' + +export default async function ExpensePage({ + params: { groupId }, +}: { + params: { groupId: string } +}) { + const group = await getGroup(groupId) + if (!group) notFound() + + async function createExpenseAction(values: unknown) { + 'use server' + const expenseFormValues = expenseFormSchema.parse(values) + await createExpense(expenseFormValues, groupId) + redirect(`/groups/${groupId}`) + } + + return ( + <main> + <div className="mb-4"> + <Button variant="ghost" asChild> + <Link href={`/groups/${groupId}`}> + <ChevronLeft className="w-4 h-4 mr-2" /> Back to group + </Link> + </Button> + </div> + + <ExpenseForm group={group} onSubmit={createExpenseAction} /> + </main> + ) +} diff --git a/src/app/groups/[groupId]/page.tsx b/src/app/groups/[groupId]/page.tsx new file mode 100644 index 0000000..1577d7a --- /dev/null +++ b/src/app/groups/[groupId]/page.tsx @@ -0,0 +1,141 @@ +import { SaveGroupLocally } from '@/app/groups/[groupId]/save-recent-group' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { getGroup, getGroupExpenses } from '@/lib/api' +import { ChevronLeft, ChevronRight, Edit, Plus } from 'lucide-react' +import Link from 'next/link' +import { notFound } from 'next/navigation' + +export default async function GroupPage({ + params: { groupId }, +}: { + params: { groupId: string } +}) { + const group = await getGroup(groupId) + if (!group) notFound() + + const expenses = await getGroupExpenses(groupId) + + return ( + <main> + <div className="mb-4 flex justify-between"> + <Button variant="ghost" asChild> + <Link href="/groups"> + <ChevronLeft className="w-4 h-4 mr-2" /> Back to recent groups + </Link> + </Button> + <Button variant="secondary" asChild> + <Link href={`/groups/${groupId}/edit`}> + <Edit className="w-4 h-4 mr-2" /> Edit group settings + </Link> + </Button> + </div> + + <h1 className="font-bold mb-4">{group.name}</h1> + + <Card className="mb-4"> + <div className="flex flex-1"> + <CardHeader className="flex-1"> + <CardTitle>Expenses</CardTitle> + <CardDescription> + Here are the expenses that you created for your group. + </CardDescription> + </CardHeader> + <CardHeader> + <Button asChild size="icon"> + <Link href={`/groups/${groupId}/expenses/create`}> + <Plus /> + </Link> + </Button> + </CardHeader> + </div> + <CardContent className="p-0"> + <Table className=""> + <TableHeader> + <TableRow> + <TableHead>Title</TableHead> + <TableHead>Paid by</TableHead> + <TableHead>Paid for</TableHead> + <TableHead className="text-right">Amount</TableHead> + <TableHead className="w-0"></TableHead> + </TableRow> + </TableHeader> + <TableBody> + {expenses.map((expense) => ( + <TableRow key={expense.id}> + <TableCell>{expense.title}</TableCell> + <TableCell> + <Badge variant="secondary"> + { + group.participants.find( + (p) => p.id === expense.paidById, + )?.name + } + </Badge> + </TableCell> + <TableCell className="flex flex-wrap gap-1"> + {expense.paidFor.map((paidFor, index) => ( + <Badge variant="secondary" key={index}> + { + group.participants.find( + (p) => p.id === paidFor.participantId, + )?.name + } + </Badge> + ))} + </TableCell> + <TableCell className="text-right tabular-nums"> + $ {expense.amount.toFixed(2)} + </TableCell> + <TableCell> + <Button + size="icon" + variant="link" + className="-my-2" + asChild + > + <Link + href={`/groups/${groupId}/expenses/${expense.id}/edit`} + > + <ChevronRight className="w-4 h-4" /> + </Link> + </Button> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </CardContent> + </Card> + + <Card className="mb-4"> + <CardHeader> + <CardTitle>Participants</CardTitle> + </CardHeader> + <CardContent> + <ul> + {group.participants.map((participant) => ( + <li key={participant.id}>{participant.name}</li> + ))} + </ul> + </CardContent> + </Card> + <SaveGroupLocally group={{ id: group.id, name: group.name }} /> + </main> + ) +} diff --git a/src/app/groups/[groupId]/save-recent-group.tsx b/src/app/groups/[groupId]/save-recent-group.tsx new file mode 100644 index 0000000..e55d7c8 --- /dev/null +++ b/src/app/groups/[groupId]/save-recent-group.tsx @@ -0,0 +1,18 @@ +'use client' +import { + RecentGroup, + saveRecentGroup, +} from '@/app/groups/recent-groups-helpers' +import { useEffect } from 'react' + +type Props = { + group: RecentGroup +} + +export function SaveGroupLocally({ group }: Props) { + useEffect(() => { + saveRecentGroup(group) + }, []) + + return null +} diff --git a/src/app/groups/create/page.tsx b/src/app/groups/create/page.tsx new file mode 100644 index 0000000..4f220d2 --- /dev/null +++ b/src/app/groups/create/page.tsx @@ -0,0 +1,29 @@ +import { GroupForm } from '@/components/group-form' +import { Button } from '@/components/ui/button' +import { createGroup } from '@/lib/api' +import { groupFormSchema } from '@/lib/schemas' +import { ChevronLeft } from 'lucide-react' +import Link from 'next/link' +import { redirect } from 'next/navigation' + +export default function CreateGroupPage() { + async function createGroupAction(values: unknown) { + 'use server' + const groupFormValues = groupFormSchema.parse(values) + const group = await createGroup(groupFormValues) + redirect(`/groups/${group.id}`) + } + + return ( + <main> + <div className="mb-4"> + <Button variant="ghost" asChild> + <Link href="/groups"> + <ChevronLeft className="w-4 h-4 mr-2" /> Back to recent groups + </Link> + </Button> + </div> + <GroupForm onSubmit={createGroupAction} /> + </main> + ) +} diff --git a/src/app/groups/page.tsx b/src/app/groups/page.tsx new file mode 100644 index 0000000..7e4f484 --- /dev/null +++ b/src/app/groups/page.tsx @@ -0,0 +1,14 @@ +import { RecentGroupList } from '@/app/groups/recent-group-list' +import { Button } from '@/components/ui/button' +import Link from 'next/link' + +export default async function GroupsPage() { + return ( + <main> + <Button asChild> + <Link href="/groups/create">New group</Link> + </Button> + <RecentGroupList /> + </main> + ) +} diff --git a/src/app/groups/recent-group-list.tsx b/src/app/groups/recent-group-list.tsx new file mode 100644 index 0000000..e728193 --- /dev/null +++ b/src/app/groups/recent-group-list.tsx @@ -0,0 +1,43 @@ +'use client' +import { getRecentGroups } from '@/app/groups/recent-groups-helpers' +import { Button } from '@/components/ui/button' +import Link from 'next/link' +import { useEffect, useState } from 'react' +import { z } from 'zod' + +const recentGroupsSchema = z.array( + z.object({ + id: z.string().min(1), + name: z.string(), + }), +) +type RecentGroups = z.infer<typeof recentGroupsSchema> + +type State = { status: 'pending' } | { status: 'success'; groups: RecentGroups } + +export function RecentGroupList() { + const [state, setState] = useState<State>({ status: 'pending' }) + + useEffect(() => { + const groupsInStorage = getRecentGroups() + setState({ status: 'success', groups: groupsInStorage }) + }, []) + + return ( + <ul className="flex flex-col gap-2 mt-2"> + {state.status === 'pending' ? ( + <li> + <em>Loading recent groups…</em> + </li> + ) : ( + state.groups.map(({ id, name }) => ( + <li key={id}> + <Button asChild variant="outline"> + <Link href={`/groups/${id}`}>{name}</Link> + </Button> + </li> + )) + )} + </ul> + ) +} diff --git a/src/app/groups/recent-groups-helpers.ts b/src/app/groups/recent-groups-helpers.ts new file mode 100644 index 0000000..5d52141 --- /dev/null +++ b/src/app/groups/recent-groups-helpers.ts @@ -0,0 +1,30 @@ +import { z } from 'zod' + +export const recentGroupsSchema = z.array( + z.object({ + id: z.string().min(1), + name: z.string(), + }), +) + +export type RecentGroups = z.infer<typeof recentGroupsSchema> +export type RecentGroup = RecentGroups[number] + +const STORAGE_KEY = 'recentGroups' + +export function getRecentGroups() { + const groupsInStorageJson = localStorage.getItem(STORAGE_KEY) + const groupsInStorageRaw = groupsInStorageJson + ? JSON.parse(groupsInStorageJson) + : [] + const parseResult = recentGroupsSchema.safeParse(groupsInStorageRaw) + return parseResult.success ? parseResult.data : [] +} + +export function saveRecentGroup(group: RecentGroup) { + const recentGroups = getRecentGroups() + localStorage.setItem( + STORAGE_KEY, + JSON.stringify([group, ...recentGroups.filter((rg) => rg.id !== group.id)]), + ) +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 40e027f..4a83b57 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,9 +1,6 @@ import type { Metadata } from 'next' -import { Inter } from 'next/font/google' import './globals.css' -const inter = Inter({ subsets: ['latin'] }) - export const metadata: Metadata = { title: 'Create Next App', description: 'Generated by create next app', @@ -16,7 +13,9 @@ export default function RootLayout({ }) { return ( <html lang="en"> - <body className={inter.className}>{children}</body> + <body> + <div className="max-w-screen-md mx-auto p-4">{children}</div> + </body> </html> ) } diff --git a/src/app/page.tsx b/src/app/page.tsx index e38c626..a94e2d5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,113 +1,12 @@ -import Image from 'next/image' +import { Button } from '@/components/ui/button' +import Link from 'next/link' -export default function Home() { +export default function HomePage() { return ( - <main className="flex min-h-screen flex-col items-center justify-between p-24"> - <div className="z-10 max-w-5xl w-full items-center justify-between font-mono text-sm lg:flex"> - <p className="fixed left-0 top-0 flex w-full justify-center border-b border-gray-300 bg-gradient-to-b from-zinc-200 pb-6 pt-8 backdrop-blur-2xl dark:border-neutral-800 dark:bg-zinc-800/30 dark:from-inherit lg:static lg:w-auto lg:rounded-xl lg:border lg:bg-gray-200 lg:p-4 lg:dark:bg-zinc-800/30"> - Get started by editing - <code className="font-mono font-bold">src/app/page.tsx</code> - </p> - <div className="fixed bottom-0 left-0 flex h-48 w-full items-end justify-center bg-gradient-to-t from-white via-white dark:from-black dark:via-black lg:static lg:h-auto lg:w-auto lg:bg-none"> - <a - className="pointer-events-none flex place-items-center gap-2 p-8 lg:pointer-events-auto lg:p-0" - href="https://vercel.com?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app" - target="_blank" - rel="noopener noreferrer" - > - By{' '} - <Image - src="/vercel.svg" - alt="Vercel Logo" - className="dark:invert" - width={100} - height={24} - priority - /> - </a> - </div> - </div> - - <div className="relative flex place-items-center before:absolute before:h-[300px] before:w-[480px] before:-translate-x-1/2 before:rounded-full before:bg-gradient-radial before:from-white before:to-transparent before:blur-2xl before:content-[''] after:absolute after:-z-20 after:h-[180px] after:w-[240px] after:translate-x-1/3 after:bg-gradient-conic after:from-sky-200 after:via-blue-200 after:blur-2xl after:content-[''] before:dark:bg-gradient-to-br before:dark:from-transparent before:dark:to-blue-700 before:dark:opacity-10 after:dark:from-sky-900 after:dark:via-[#0141ff] after:dark:opacity-40 before:lg:h-[360px] z-[-1]"> - <Image - className="relative dark:drop-shadow-[0_0_0.3rem_#ffffff70] dark:invert" - src="/next.svg" - alt="Next.js Logo" - width={180} - height={37} - priority - /> - </div> - - <div className="mb-32 grid text-center lg:max-w-5xl lg:w-full lg:mb-0 lg:grid-cols-4 lg:text-left"> - <a - href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app" - className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30" - target="_blank" - rel="noopener noreferrer" - > - <h2 className={`mb-3 text-2xl font-semibold`}> - Docs{' '} - <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none"> - -> - </span> - </h2> - <p className={`m-0 max-w-[30ch] text-sm opacity-50`}> - Find in-depth information about Next.js features and API. - </p> - </a> - - <a - href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" - className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30" - target="_blank" - rel="noopener noreferrer" - > - <h2 className={`mb-3 text-2xl font-semibold`}> - Learn{' '} - <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none"> - -> - </span> - </h2> - <p className={`m-0 max-w-[30ch] text-sm opacity-50`}> - Learn about Next.js in an interactive course with quizzes! - </p> - </a> - - <a - href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app" - className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30" - target="_blank" - rel="noopener noreferrer" - > - <h2 className={`mb-3 text-2xl font-semibold`}> - Templates{' '} - <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none"> - -> - </span> - </h2> - <p className={`m-0 max-w-[30ch] text-sm opacity-50`}> - Explore the Next.js 13 playground. - </p> - </a> - - <a - href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app" - className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30" - target="_blank" - rel="noopener noreferrer" - > - <h2 className={`mb-3 text-2xl font-semibold`}> - Deploy{' '} - <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none"> - -> - </span> - </h2> - <p className={`m-0 max-w-[30ch] text-sm opacity-50`}> - Instantly deploy your Next.js site to a shareable URL with Vercel. - </p> - </a> - </div> + <main> + <Button asChild variant="link"> + <Link href="/groups">My groups</Link> + </Button> </main> ) } diff --git a/src/components/expense-form.tsx b/src/components/expense-form.tsx new file mode 100644 index 0000000..056d25e --- /dev/null +++ b/src/components/expense-form.tsx @@ -0,0 +1,186 @@ +'use client' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Checkbox } from '@/components/ui/checkbox' +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { getExpense, getGroup } from '@/lib/api' +import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas' +import { zodResolver } from '@hookform/resolvers/zod' +import { useForm } from 'react-hook-form' + +export type Props = { + group: NonNullable<Awaited<ReturnType<typeof getGroup>>> + expense?: NonNullable<Awaited<ReturnType<typeof getExpense>>> + onSubmit: (values: ExpenseFormValues) => void +} + +export function ExpenseForm({ group, expense, onSubmit }: Props) { + const form = useForm<ExpenseFormValues>({ + resolver: zodResolver(expenseFormSchema), + defaultValues: expense + ? { + title: expense.title, + amount: expense.amount, + paidBy: expense.paidById, + paidFor: expense.paidFor.map(({ participantId }) => participantId), + } + : { title: '', amount: 0, paidFor: [] }, + }) + + return ( + <Form {...form}> + <form onSubmit={form.handleSubmit((values) => onSubmit(values))}> + <Card> + <CardHeader> + <CardTitle>Expense information</CardTitle> + </CardHeader> + <CardContent className="grid grid-cols-2 gap-6"> + <FormField + control={form.control} + name="title" + render={({ field }) => ( + <FormItem> + <FormLabel>Expense title</FormLabel> + <FormControl> + <Input placeholder="Monday evening restaurant" {...field} /> + </FormControl> + <FormDescription> + Enter a description for the expense. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="paidBy" + render={({ field }) => ( + <FormItem> + <FormLabel>Paid by</FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + > + <SelectTrigger> + <SelectValue placeholder="Select a participant" /> + </SelectTrigger> + <SelectContent> + {group.participants.map(({ id, name }) => ( + <SelectItem key={id} value={id}> + {name} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormDescription> + Select the participant who paid the expense. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="amount" + render={({ field }) => ( + <FormItem> + <FormLabel>Amount</FormLabel> + <FormControl> + <Input + type="number" + min={0.01} + step={0.01} + placeholder="0.00" + {...field} + /> + </FormControl> + <FormDescription>Enter the expense amount.</FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="paidFor" + render={() => ( + <FormItem> + <div className="mb-4"> + <FormLabel>Paid for</FormLabel> + <FormDescription> + Select who the expense was paid for. + </FormDescription> + </div> + {group.participants.map(({ id, name }) => ( + <FormField + key={id} + control={form.control} + name="paidFor" + render={({ field }) => { + return ( + <FormItem + key={id} + className="flex flex-row items-start space-x-3 space-y-0" + > + <FormControl> + <Checkbox + checked={field.value?.includes(id)} + onCheckedChange={(checked) => { + return checked + ? field.onChange([...field.value, id]) + : field.onChange( + field.value?.filter( + (value) => value !== id, + ), + ) + }} + /> + </FormControl> + <FormLabel className="text-sm font-normal"> + {name} + </FormLabel> + </FormItem> + ) + }} + /> + ))} + <FormMessage /> + </FormItem> + )} + /> + </CardContent> + + <CardFooter> + <Button variant="secondary" type="submit"> + Submit + </Button> + </CardFooter> + </Card> + </form> + </Form> + ) +} diff --git a/src/components/group-form.tsx b/src/components/group-form.tsx new file mode 100644 index 0000000..0b6e560 --- /dev/null +++ b/src/components/group-form.tsx @@ -0,0 +1,136 @@ +'use client' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { getGroup } from '@/lib/api' +import { GroupFormValues, groupFormSchema } from '@/lib/schemas' +import { zodResolver } from '@hookform/resolvers/zod' +import { useFieldArray, useForm } from 'react-hook-form' + +export type Props = { + group?: NonNullable<Awaited<ReturnType<typeof getGroup>>> + onSubmit: (groupFormValues: GroupFormValues) => void +} + +export function GroupForm({ group, onSubmit }: Props) { + const form = useForm<GroupFormValues>({ + resolver: zodResolver(groupFormSchema), + defaultValues: group + ? { + name: group.name, + participants: group.participants, + } + : { + name: '', + participants: [{ name: 'John' }, { name: 'Jane' }, { name: 'Jack' }], + }, + }) + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: 'participants', + }) + + return ( + <Form {...form}> + <form + onSubmit={form.handleSubmit((values) => { + onSubmit(values) + })} + className="space-y-8" + > + <Card> + <CardHeader> + <CardTitle>Group information</CardTitle> + </CardHeader> + <CardContent> + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>Group name</FormLabel> + <FormControl> + <Input placeholder="Summer vacations" {...field} /> + </FormControl> + <FormDescription> + Enter a name for your group. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + </CardContent> + </Card> + <Card> + <CardHeader> + <CardTitle>Participants</CardTitle> + <CardDescription> + Enter the name for each participant + </CardDescription> + </CardHeader> + <CardContent> + <ul className="flex flex-col gap-4"> + {fields.map((item, index) => ( + <li key={item.id}> + <FormField + control={form.control} + name={`participants.${index}.name`} + render={({ field }) => ( + <FormItem> + <FormLabel className="sr-only"> + Participant #{index + 1} + </FormLabel> + <FormControl> + <div className="flex gap-2"> + <Input {...field} /> + <Button + variant="destructive" + onClick={() => remove(index)} + type="button" + > + Remove + </Button> + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </li> + ))} + </ul> + </CardContent> + <CardFooter> + <Button + variant="secondary" + onClick={() => { + append({ name: 'New' }) + }} + type="button" + > + Add participant + </Button> + </CardFooter> + </Card> + + <Button type="submit">Submit</Button> + </form> + </Form> + ) +} diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..f000e3e --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes<HTMLDivElement>, + VariantProps<typeof badgeVariants> {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( + <div className={cn(badgeVariants({ variant }), className)} {...props} /> + ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..0ba4277 --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes<HTMLButtonElement>, + VariantProps<typeof buttonVariants> { + asChild?: boolean +} + +const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + <Comp + className={cn(buttonVariants({ variant, size, className }))} + ref={ref} + {...props} + /> + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..afa13ec --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn( + "rounded-lg border bg-card text-card-foreground shadow-sm", + className + )} + {...props} + /> +)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("flex flex-col space-y-1.5 p-6", className)} + {...props} + /> +)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLHeadingElement> +>(({ className, ...props }, ref) => ( + <h3 + ref={ref} + className={cn( + "text-2xl font-semibold leading-none tracking-tight", + className + )} + {...props} + /> +)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> +>(({ className, ...props }, ref) => ( + <p + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> +)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> +)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("flex items-center p-6 pt-0", className)} + {...props} + /> +)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..df61a13 --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,30 @@ +"use client" + +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef<typeof CheckboxPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> +>(({ className, ...props }, ref) => ( + <CheckboxPrimitive.Root + ref={ref} + className={cn( + "peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground", + className + )} + {...props} + > + <CheckboxPrimitive.Indicator + className={cn("flex items-center justify-center text-current")} + > + <Check className="h-4 w-4" /> + </CheckboxPrimitive.Indicator> + </CheckboxPrimitive.Root> +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 0000000..4603f8b --- /dev/null +++ b/src/components/ui/form.tsx @@ -0,0 +1,176 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> +> = { + name: TName +} + +const FormFieldContext = React.createContext<FormFieldContextValue>( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> +>({ + ...props +}: ControllerProps<TFieldValues, TName>) => { + return ( + <FormFieldContext.Provider value={{ name: props.name }}> + <Controller {...props} /> + </FormFieldContext.Provider> + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within <FormField>") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext<FormItemContextValue>( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + <FormItemContext.Provider value={{ id }}> + <div ref={ref} className={cn("space-y-2", className)} {...props} /> + </FormItemContext.Provider> + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef<typeof LabelPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( + <Label + ref={ref} + className={cn(error && "text-destructive", className)} + htmlFor={formItemId} + {...props} + /> + ) +}) +FormLabel.displayName = "FormLabel" + +const FormControl = React.forwardRef< + React.ElementRef<typeof Slot>, + React.ComponentPropsWithoutRef<typeof Slot> +>(({ ...props }, ref) => { + const { error, formItemId, formDescriptionId, formMessageId } = useFormField() + + return ( + <Slot + ref={ref} + id={formItemId} + aria-describedby={ + !error + ? `${formDescriptionId}` + : `${formDescriptionId} ${formMessageId}` + } + aria-invalid={!!error} + {...props} + /> + ) +}) +FormControl.displayName = "FormControl" + +const FormDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> +>(({ className, ...props }, ref) => { + const { formDescriptionId } = useFormField() + + return ( + <p + ref={ref} + id={formDescriptionId} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> + ) +}) +FormDescription.displayName = "FormDescription" + +const FormMessage = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> +>(({ className, children, ...props }, ref) => { + const { error, formMessageId } = useFormField() + const body = error ? String(error?.message) : children + + if (!body) { + return null + } + + return ( + <p + ref={ref} + id={formMessageId} + className={cn("text-sm font-medium text-destructive", className)} + {...props} + > + {body} + </p> + ) +}) +FormMessage.displayName = "FormMessage" + +export { + useFormField, + Form, + FormItem, + FormLabel, + FormControl, + FormDescription, + FormMessage, + FormField, +} diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000..677d05f --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,25 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +export interface InputProps + extends React.InputHTMLAttributes<HTMLInputElement> {} + +const Input = React.forwardRef<HTMLInputElement, InputProps>( + ({ className, type, ...props }, ref) => { + return ( + <input + type={type} + className={cn( + "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", + className + )} + ref={ref} + {...props} + /> + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..5341821 --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,26 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +) + +const Label = React.forwardRef< + React.ElementRef<typeof LabelPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & + VariantProps<typeof labelVariants> +>(({ className, ...props }, ref) => ( + <LabelPrimitive.Root + ref={ref} + className={cn(labelVariants(), className)} + {...props} + /> +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 0000000..cbe5a36 --- /dev/null +++ b/src/components/ui/select.tsx @@ -0,0 +1,160 @@ +"use client" + +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> +>(({ className, children, ...props }, ref) => ( + <SelectPrimitive.Trigger + ref={ref} + className={cn( + "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", + className + )} + {...props} + > + {children} + <SelectPrimitive.Icon asChild> + <ChevronDown className="h-4 w-4 opacity-50" /> + </SelectPrimitive.Icon> + </SelectPrimitive.Trigger> +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.ScrollUpButton + ref={ref} + className={cn( + "flex cursor-default items-center justify-center py-1", + className + )} + {...props} + > + <ChevronUp className="h-4 w-4" /> + </SelectPrimitive.ScrollUpButton> +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.ScrollDownButton + ref={ref} + className={cn( + "flex cursor-default items-center justify-center py-1", + className + )} + {...props} + > + <ChevronDown className="h-4 w-4" /> + </SelectPrimitive.ScrollDownButton> +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> +>(({ className, children, position = "popper", ...props }, ref) => ( + <SelectPrimitive.Portal> + <SelectPrimitive.Content + ref={ref} + className={cn( + "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + position === "popper" && + "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", + className + )} + position={position} + {...props} + > + <SelectScrollUpButton /> + <SelectPrimitive.Viewport + className={cn( + "p-1", + position === "popper" && + "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]" + )} + > + {children} + </SelectPrimitive.Viewport> + <SelectScrollDownButton /> + </SelectPrimitive.Content> + </SelectPrimitive.Portal> +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.Label + ref={ref} + className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} + {...props} + /> +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> +>(({ className, children, ...props }, ref) => ( + <SelectPrimitive.Item + ref={ref} + className={cn( + "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className + )} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <SelectPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </SelectPrimitive.ItemIndicator> + </span> + + <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> + </SelectPrimitive.Item> +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.Separator + ref={ref} + className={cn("-mx-1 my-1 h-px bg-muted", className)} + {...props} + /> +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx new file mode 100644 index 0000000..7f3502f --- /dev/null +++ b/src/components/ui/table.tsx @@ -0,0 +1,117 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes<HTMLTableElement> +>(({ className, ...props }, ref) => ( + <div className="relative w-full overflow-auto"> + <table + ref={ref} + className={cn("w-full caption-bottom text-sm", className)} + {...props} + /> + </div> +)) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes<HTMLTableSectionElement> +>(({ className, ...props }, ref) => ( + <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} /> +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes<HTMLTableSectionElement> +>(({ className, ...props }, ref) => ( + <tbody + ref={ref} + className={cn("[&_tr:last-child]:border-0", className)} + {...props} + /> +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes<HTMLTableSectionElement> +>(({ className, ...props }, ref) => ( + <tfoot + ref={ref} + className={cn( + "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", + className + )} + {...props} + /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes<HTMLTableRowElement> +>(({ className, ...props }, ref) => ( + <tr + ref={ref} + className={cn( + "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", + className + )} + {...props} + /> +)) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes<HTMLTableCellElement> +>(({ className, ...props }, ref) => ( + <th + ref={ref} + className={cn( + "h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0", + className + )} + {...props} + /> +)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes<HTMLTableCellElement> +>(({ className, ...props }, ref) => ( + <td + ref={ref} + className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)} + {...props} + /> +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes<HTMLTableCaptionElement> +>(({ className, ...props }, ref) => ( + <caption + ref={ref} + className={cn("mt-4 text-sm text-muted-foreground", className)} + {...props} + /> +)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..6ea26f4 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,155 @@ +import { getPrisma } from '@/lib/prisma' +import { ExpenseFormValues, GroupFormValues } from '@/lib/schemas' +import { Expense } from '@prisma/client' +import { v4 as uuidv4 } from 'uuid' + +export async function createGroup(groupFormValues: GroupFormValues) { + return getPrisma().group.create({ + data: { + id: uuidv4(), + name: groupFormValues.name, + participants: { + createMany: { + data: groupFormValues.participants.map(({ name }) => ({ + id: uuidv4(), + name, + })), + }, + }, + }, + include: { participants: true }, + }) +} + +export async function createExpense( + expenseFormValues: ExpenseFormValues, + groupId: string, +): Promise<Expense> { + const group = await getGroup(groupId) + if (!group) throw new Error(`Invalid group ID: ${groupId}`) + + for (const participant of [ + expenseFormValues.paidBy, + ...expenseFormValues.paidFor, + ]) { + if (!group.participants.some((p) => p.id === participant)) + throw new Error(`Invalid participant ID: ${participant}`) + } + + return getPrisma().expense.create({ + data: { + id: uuidv4(), + groupId, + amount: expenseFormValues.amount, + title: expenseFormValues.title, + paidById: expenseFormValues.paidBy, + paidFor: { + createMany: { + data: expenseFormValues.paidFor.map((paidFor) => ({ + participantId: paidFor, + })), + }, + }, + }, + }) +} + +export async function updateExpense( + groupId: string, + expenseId: string, + expenseFormValues: ExpenseFormValues, +) { + const group = await getGroup(groupId) + if (!group) throw new Error(`Invalid group ID: ${groupId}`) + + const existingExpense = await getExpense(groupId, expenseId) + if (!existingExpense) throw new Error(`Invalid expense ID: ${expenseId}`) + + for (const participant of [ + expenseFormValues.paidBy, + ...expenseFormValues.paidFor, + ]) { + if (!group.participants.some((p) => p.id === participant)) + throw new Error(`Invalid participant ID: ${participant}`) + } + + return getPrisma().expense.update({ + where: { id: expenseId }, + data: { + amount: expenseFormValues.amount, + title: expenseFormValues.title, + paidById: expenseFormValues.paidBy, + paidFor: { + connectOrCreate: expenseFormValues.paidFor.map((paidFor) => ({ + where: { + expenseId_participantId: { expenseId, participantId: paidFor }, + }, + create: { participantId: paidFor }, + })), + deleteMany: existingExpense.paidFor.filter( + (paidFor) => + !expenseFormValues.paidFor.some( + (pf) => pf === paidFor.participantId, + ), + ), + }, + }, + }) +} + +export async function updateGroup( + groupId: string, + groupFormValues: GroupFormValues, +) { + const existingGroup = await getGroup(groupId) + if (!existingGroup) throw new Error('Invalid group ID') + + return getPrisma().group.update({ + where: { id: groupId }, + data: { + name: groupFormValues.name, + participants: { + deleteMany: existingGroup.participants.filter( + (p) => !groupFormValues.participants.some((p2) => p2.id === p.id), + ), + updateMany: groupFormValues.participants + .filter((participant) => participant.id !== undefined) + .map((participant) => ({ + where: { id: participant.id }, + data: { + name: participant.name, + }, + })), + createMany: { + data: groupFormValues.participants + .filter((participant) => participant.id === undefined) + .map((participant) => ({ + id: uuidv4(), + name: participant.name, + })), + }, + }, + }, + }) +} + +export async function getGroup(groupId: string) { + return getPrisma().group.findUnique({ + where: { id: groupId }, + include: { participants: true }, + }) +} + +export async function getGroupExpenses(groupId: string) { + return getPrisma().expense.findMany({ + where: { groupId }, + include: { paidFor: { include: { participant: true } }, paidBy: true }, + }) +} + +export async function getExpense(groupId: string, expenseId: string) { + return getPrisma().expense.findUnique({ + where: { id: expenseId }, + include: { paidBy: true, paidFor: true }, + }) +} diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts new file mode 100644 index 0000000..773547e --- /dev/null +++ b/src/lib/prisma.ts @@ -0,0 +1,17 @@ +import { PrismaClient } from '@prisma/client' + +let prisma: PrismaClient + +export function getPrisma() { + if (!prisma) { + if (process.env.NODE_ENV === 'production') { + prisma = new PrismaClient() + } else { + if (!(global as any).prisma) { + ;(global as any).prisma = new PrismaClient() + } + prisma = (global as any).prisma + } + } + return prisma +} diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts new file mode 100644 index 0000000..0219491 --- /dev/null +++ b/src/lib/schemas.ts @@ -0,0 +1,50 @@ +import * as z from 'zod' + +export const groupFormSchema = z + .object({ + name: z + .string() + .min(2, 'Enter at least two characters.') + .max(50, 'Enter at most 50 characters.'), + participants: z + .array( + z.object({ + id: z.string().optional(), + name: z + .string() + .min(2, 'Enter at least two characters.') + .max(50, 'Enter at most 50 characters.'), + }), + ) + .min(1), + }) + .superRefine(({ participants }, ctx) => { + participants.forEach((participant, i) => { + participants.slice(0, i).forEach((otherParticipant) => { + if (otherParticipant.name === participant.name) { + ctx.addIssue({ + code: 'custom', + message: 'Another participant already has this name.', + path: ['participants', i, 'name'], + }) + } + }) + }) + }) + +export type GroupFormValues = z.infer<typeof groupFormSchema> + +export const expenseFormSchema = z.object({ + title: z + .string({ required_error: 'Please enter a title.' }) + .min(2, 'Enter at least two characters.'), + amount: z.coerce + .number({ required_error: 'You must enter an amount.' }) + .min(0.01, 'The amount must be higher than 0.01.'), + paidBy: z.string({ required_error: 'You must select a participant.' }), + paidFor: z + .array(z.string()) + .min(1, 'The expense must be paid for at least 1 participant.'), +}) + +export type ExpenseFormValues = z.infer<typeof expenseFormSchema> diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..ec79801 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} |
