mirror of
https://github.com/spliit-app/spliit.git
synced 2025-12-06 01:19:29 +01:00
First version
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -27,6 +27,7 @@ yarn-error.log*
|
|||||||
|
|
||||||
# local env files
|
# local env files
|
||||||
.env*.local
|
.env*.local
|
||||||
|
.env
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
@@ -34,3 +35,6 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# db
|
||||||
|
postgres-data
|
||||||
|
|||||||
5
.prettierrc
Normal file
5
.prettierrc
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"plugins": ["prettier-plugin-organize-imports"]
|
||||||
|
}
|
||||||
16
components.json
Normal file
16
components.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils"
|
||||||
|
}
|
||||||
|
}
|
||||||
5551
package-lock.json
generated
Normal file
5551
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
32
package.json
@@ -6,22 +6,42 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"postinstall": "prisma migrate deploy && prisma generate"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^3.3.2",
|
||||||
|
"@prisma/client": "5.6.0",
|
||||||
|
"@radix-ui/react-checkbox": "^1.0.4",
|
||||||
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"lucide-react": "^0.290.0",
|
||||||
|
"next": "14.0.0",
|
||||||
|
"prisma": "^5.6.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"next": "14.0.0"
|
"react-hook-form": "^7.47.0",
|
||||||
|
"tailwind-merge": "^1.14.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5",
|
"@total-typescript/ts-reset": "^0.5.1",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
|
"@types/uuid": "^9.0.6",
|
||||||
"autoprefixer": "^10",
|
"autoprefixer": "^10",
|
||||||
"postcss": "^8",
|
|
||||||
"tailwindcss": "^3",
|
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "14.0.0"
|
"eslint-config-next": "14.0.0",
|
||||||
|
"postcss": "^8",
|
||||||
|
"prettier": "^3.0.3",
|
||||||
|
"prettier-plugin-organize-imports": "^3.2.3",
|
||||||
|
"tailwindcss": "^3",
|
||||||
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
46
prisma/migrations/20231205211834_create_models/migration.sql
Normal file
46
prisma/migrations/20231205211834_create_models/migration.sql
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Group" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Group_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Participant" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"groupId" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Participant_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Expense" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"amount" DECIMAL(65,30) NOT NULL,
|
||||||
|
"paidById" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Expense_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ExpensePaidFor" (
|
||||||
|
"expenseId" TEXT NOT NULL,
|
||||||
|
"participantId" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ExpensePaidFor_pkey" PRIMARY KEY ("expenseId","participantId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Participant" ADD CONSTRAINT "Participant_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Expense" ADD CONSTRAINT "Expense_paidById_fkey" FOREIGN KEY ("paidById") REFERENCES "Participant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ExpensePaidFor" ADD CONSTRAINT "ExpensePaidFor_expenseId_fkey" FOREIGN KEY ("expenseId") REFERENCES "Expense"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ExpensePaidFor" ADD CONSTRAINT "ExpensePaidFor_participantId_fkey" FOREIGN KEY ("participantId") REFERENCES "Participant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Added the required column `groupId` to the `Expense` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Expense" ADD COLUMN "groupId" TEXT NOT NULL;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Expense" ADD CONSTRAINT "Expense_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to alter the column `amount` on the `Expense` table. The data in that column could be lost. The data in that column will be cast from `Decimal(65,30)` to `Integer`.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Expense" ALTER COLUMN "amount" SET DATA TYPE INTEGER;
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (i.e. Git)
|
||||||
|
provider = "postgresql"
|
||||||
48
prisma/schema.prisma
Normal file
48
prisma/schema.prisma
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("POSTGRES_PRISMA_URL") // uses connection pooling
|
||||||
|
directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection
|
||||||
|
}
|
||||||
|
|
||||||
|
model Group {
|
||||||
|
id String @id
|
||||||
|
name String
|
||||||
|
participants Participant[]
|
||||||
|
expenses Expense[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Participant {
|
||||||
|
id String @id
|
||||||
|
name String
|
||||||
|
group Group @relation(fields: [groupId], references: [id])
|
||||||
|
groupId String
|
||||||
|
expensesPaidBy Expense[]
|
||||||
|
expensesPaidFor ExpensePaidFor[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Expense {
|
||||||
|
id String @id
|
||||||
|
group Group @relation(fields: [groupId], references: [id])
|
||||||
|
title String
|
||||||
|
amount Int
|
||||||
|
paidBy Participant @relation(fields: [paidById], references: [id])
|
||||||
|
paidById String
|
||||||
|
paidFor ExpensePaidFor[]
|
||||||
|
groupId String
|
||||||
|
}
|
||||||
|
|
||||||
|
model ExpensePaidFor {
|
||||||
|
expense Expense @relation(fields: [expenseId], references: [id])
|
||||||
|
participant Participant @relation(fields: [participantId], references: [id])
|
||||||
|
expenseId String
|
||||||
|
participantId String
|
||||||
|
|
||||||
|
@@id([expenseId, participantId])
|
||||||
|
}
|
||||||
1
reset.d.ts
vendored
Normal file
1
reset.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import '@total-typescript/ts-reset'
|
||||||
@@ -2,26 +2,75 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
@layer base {
|
||||||
--foreground-rgb: 0, 0, 0;
|
|
||||||
--background-start-rgb: 214, 219, 220;
|
|
||||||
--background-end-rgb: 255, 255, 255;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
:root {
|
||||||
--foreground-rgb: 255, 255, 255;
|
--background: 0 0% 100%;
|
||||||
--background-start-rgb: 0, 0, 0;
|
--foreground: 222.2 84% 4.9%;
|
||||||
--background-end-rgb: 0, 0, 0;
|
|
||||||
|
--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 {
|
@layer base {
|
||||||
color: rgb(var(--foreground-rgb));
|
* {
|
||||||
background: linear-gradient(
|
@apply border-border;
|
||||||
to bottom,
|
}
|
||||||
transparent,
|
body {
|
||||||
rgb(var(--background-end-rgb))
|
@apply bg-background text-foreground;
|
||||||
)
|
}
|
||||||
rgb(var(--background-start-rgb));
|
|
||||||
}
|
}
|
||||||
|
|||||||
36
src/app/groups/[groupId]/edit/page.tsx
Normal file
36
src/app/groups/[groupId]/edit/page.tsx
Normal file
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx
Normal file
42
src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx
Normal file
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
src/app/groups/[groupId]/expenses/create/page.tsx
Normal file
37
src/app/groups/[groupId]/expenses/create/page.tsx
Normal file
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
141
src/app/groups/[groupId]/page.tsx
Normal file
141
src/app/groups/[groupId]/page.tsx
Normal file
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
src/app/groups/[groupId]/save-recent-group.tsx
Normal file
18
src/app/groups/[groupId]/save-recent-group.tsx
Normal file
@@ -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
|
||||||
|
}
|
||||||
29
src/app/groups/create/page.tsx
Normal file
29
src/app/groups/create/page.tsx
Normal file
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
14
src/app/groups/page.tsx
Normal file
14
src/app/groups/page.tsx
Normal file
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
src/app/groups/recent-group-list.tsx
Normal file
43
src/app/groups/recent-group-list.tsx
Normal file
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
src/app/groups/recent-groups-helpers.ts
Normal file
30
src/app/groups/recent-groups-helpers.ts
Normal file
@@ -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)]),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import { Inter } from 'next/font/google'
|
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
|
||||||
const inter = Inter({ subsets: ['latin'] })
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Create Next App',
|
title: 'Create Next App',
|
||||||
description: 'Generated by create next app',
|
description: 'Generated by create next app',
|
||||||
@@ -16,7 +13,9 @@ export default function RootLayout({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={inter.className}>{children}</body>
|
<body>
|
||||||
|
<div className="max-w-screen-md mx-auto p-4">{children}</div>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
115
src/app/page.tsx
115
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 (
|
return (
|
||||||
<main className="flex min-h-screen flex-col items-center justify-between p-24">
|
<main>
|
||||||
<div className="z-10 max-w-5xl w-full items-center justify-between font-mono text-sm lg:flex">
|
<Button asChild variant="link">
|
||||||
<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">
|
<Link href="/groups">My groups</Link>
|
||||||
Get started by editing
|
</Button>
|
||||||
<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>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
186
src/components/expense-form.tsx
Normal file
186
src/components/expense-form.tsx
Normal file
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
136
src/components/group-form.tsx
Normal file
136
src/components/group-form.tsx
Normal file
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal file
@@ -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 }
|
||||||
56
src/components/ui/button.tsx
Normal file
56
src/components/ui/button.tsx
Normal file
@@ -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 }
|
||||||
79
src/components/ui/card.tsx
Normal file
79
src/components/ui/card.tsx
Normal file
@@ -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 }
|
||||||
30
src/components/ui/checkbox.tsx
Normal file
30
src/components/ui/checkbox.tsx
Normal file
@@ -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 }
|
||||||
176
src/components/ui/form.tsx
Normal file
176
src/components/ui/form.tsx
Normal file
@@ -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,
|
||||||
|
}
|
||||||
25
src/components/ui/input.tsx
Normal file
25
src/components/ui/input.tsx
Normal file
@@ -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 }
|
||||||
26
src/components/ui/label.tsx
Normal file
26
src/components/ui/label.tsx
Normal file
@@ -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 }
|
||||||
160
src/components/ui/select.tsx
Normal file
160
src/components/ui/select.tsx
Normal file
@@ -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,
|
||||||
|
}
|
||||||
117
src/components/ui/table.tsx
Normal file
117
src/components/ui/table.tsx
Normal file
@@ -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,
|
||||||
|
}
|
||||||
155
src/lib/api.ts
Normal file
155
src/lib/api.ts
Normal file
@@ -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 },
|
||||||
|
})
|
||||||
|
}
|
||||||
17
src/lib/prisma.ts
Normal file
17
src/lib/prisma.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
50
src/lib/schemas.ts
Normal file
50
src/lib/schemas.ts
Normal file
@@ -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>
|
||||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
11
start-local-db.sh
Executable file
11
start-local-db.sh
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
result=$(docker ps | grep postgres)
|
||||||
|
if [ $? -eq 0 ];
|
||||||
|
then
|
||||||
|
echo "postgres is already running, doing nothing"
|
||||||
|
else
|
||||||
|
echo "postgres is not running, starting it"
|
||||||
|
docker rm postgres --force
|
||||||
|
mkdir -p postgres-data
|
||||||
|
docker run --name postgres -d -p 5432:5432 -e POSTGRES_PASSWORD=1234 -v ./postgres-data:/var/lib/postgresql/data postgres
|
||||||
|
sleep 5 # Wait for postgres to start
|
||||||
|
fi
|
||||||
76
tailwind.config.js
Normal file
76
tailwind.config.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: [
|
||||||
|
'./pages/**/*.{ts,tsx}',
|
||||||
|
'./components/**/*.{ts,tsx}',
|
||||||
|
'./app/**/*.{ts,tsx}',
|
||||||
|
'./src/**/*.{ts,tsx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: "2rem",
|
||||||
|
screens: {
|
||||||
|
"2xl": "1400px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"accordion-down": {
|
||||||
|
from: { height: 0 },
|
||||||
|
to: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
},
|
||||||
|
"accordion-up": {
|
||||||
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
to: { height: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate")],
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user