aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--package-lock.json139
-rw-r--r--package.json4
-rw-r--r--src/app/groups/[groupId]/@modal/default.tsx3
-rw-r--r--src/app/groups/[groupId]/@modal/expense-modal.tsx83
-rw-r--r--src/app/groups/[groupId]/@modal/expenses/[expenseId]/edit/page.tsx26
-rw-r--r--src/app/groups/[groupId]/@modal/expenses/create/page.tsx24
-rw-r--r--src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx42
-rw-r--r--src/app/groups/[groupId]/expenses/actions.ts28
-rw-r--r--src/app/groups/[groupId]/expenses/create/page.tsx27
-rw-r--r--src/app/groups/[groupId]/expenses/expense-list.tsx9
-rw-r--r--src/app/groups/[groupId]/expenses/expense-page.tsx19
-rw-r--r--src/app/groups/[groupId]/expenses/layout.tsx9
-rw-r--r--src/app/groups/[groupId]/expenses/page.tsx2
-rw-r--r--src/app/groups/[groupId]/layout.tsx5
-rw-r--r--src/app/groups/[groupId]/not-found.tsx5
-rw-r--r--src/app/groups/[groupId]/reimbursement-list.tsx1
-rw-r--r--src/components/expense-form.tsx388
-rw-r--r--src/components/ui/dialog.tsx122
18 files changed, 312 insertions, 624 deletions
diff --git a/package-lock.json b/package-lock.json
index 6595acb..8ffbc2a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,7 +12,6 @@
"@hookform/resolvers": "^3.3.2",
"@prisma/client": "5.6.0",
"@radix-ui/react-checkbox": "^1.0.4",
- "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-icons": "^1.3.0",
@@ -25,7 +24,7 @@
"clsx": "^2.0.0",
"lucide-react": "^0.290.0",
"nanoid": "^5.0.4",
- "next": "14.0.1",
+ "next": "^14.0.4",
"next-plausible": "^3.12.0",
"next-themes": "^0.2.1",
"next13-progressbar": "^1.1.1",
@@ -36,7 +35,6 @@
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
"uuid": "^9.0.1",
- "vaul": "^0.7.9",
"zod": "^3.22.4"
},
"devDependencies": {
@@ -281,9 +279,9 @@
}
},
"node_modules/@next/env": {
- "version": "14.0.1",
- "resolved": "https://registry.npmjs.org/@next/env/-/env-14.0.1.tgz",
- "integrity": "sha512-Ms8ZswqY65/YfcjrlcIwMPD7Rg/dVjdLapMcSHG26W6O67EJDF435ShW4H4LXi1xKO1oRc97tLXUpx8jpLe86A=="
+ "version": "14.0.4",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-14.0.4.tgz",
+ "integrity": "sha512-irQnbMLbUNQpP1wcE5NstJtbuA/69kRfzBrpAD7Gsn8zm/CY6YQYc3HQBz8QPxwISG26tIm5afvvVbu508oBeQ=="
},
"node_modules/@next/eslint-plugin-next": {
"version": "14.0.4",
@@ -315,9 +313,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
- "version": "14.0.1",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.0.1.tgz",
- "integrity": "sha512-JyxnGCS4qT67hdOKQ0CkgFTp+PXub5W1wsGvIq98TNbF3YEIN7iDekYhYsZzc8Ov0pWEsghQt+tANdidITCLaw==",
+ "version": "14.0.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.0.4.tgz",
+ "integrity": "sha512-mF05E/5uPthWzyYDyptcwHptucf/jj09i2SXBPwNzbgBNc+XnwzrL0U6BmPjQeOL+FiB+iG1gwBeq7mlDjSRPg==",
"cpu": [
"arm64"
],
@@ -330,9 +328,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
- "version": "14.0.1",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.0.1.tgz",
- "integrity": "sha512-625Z7bb5AyIzswF9hvfZWa+HTwFZw+Jn3lOBNZB87lUS0iuCYDHqk3ujuHCkiyPtSC0xFBtYDLcrZ11mF/ap3w==",
+ "version": "14.0.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.0.4.tgz",
+ "integrity": "sha512-IZQ3C7Bx0k2rYtrZZxKKiusMTM9WWcK5ajyhOZkYYTCc8xytmwSzR1skU7qLgVT/EY9xtXDG0WhY6fyujnI3rw==",
"cpu": [
"x64"
],
@@ -345,9 +343,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
- "version": "14.0.1",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.0.1.tgz",
- "integrity": "sha512-iVpn3KG3DprFXzVHM09kvb//4CNNXBQ9NB/pTm8LO+vnnnaObnzFdS5KM+w1okwa32xH0g8EvZIhoB3fI3mS1g==",
+ "version": "14.0.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.0.4.tgz",
+ "integrity": "sha512-VwwZKrBQo/MGb1VOrxJ6LrKvbpo7UbROuyMRvQKTFKhNaXjUmKTu7wxVkIuCARAfiI8JpaWAnKR+D6tzpCcM4w==",
"cpu": [
"arm64"
],
@@ -360,9 +358,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
- "version": "14.0.1",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.0.1.tgz",
- "integrity": "sha512-mVsGyMxTLWZXyD5sen6kGOTYVOO67lZjLApIj/JsTEEohDDt1im2nkspzfV5MvhfS7diDw6Rp/xvAQaWZTv1Ww==",
+ "version": "14.0.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.0.4.tgz",
+ "integrity": "sha512-8QftwPEW37XxXoAwsn+nXlodKWHfpMaSvt81W43Wh8dv0gkheD+30ezWMcFGHLI71KiWmHK5PSQbTQGUiidvLQ==",
"cpu": [
"arm64"
],
@@ -375,9 +373,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
- "version": "14.0.1",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.0.1.tgz",
- "integrity": "sha512-wMqf90uDWN001NqCM/auRl3+qVVeKfjJdT9XW+RMIOf+rhUzadmYJu++tp2y+hUbb6GTRhT+VjQzcgg/QTD9NQ==",
+ "version": "14.0.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.0.4.tgz",
+ "integrity": "sha512-/s/Pme3VKfZAfISlYVq2hzFS8AcAIOTnoKupc/j4WlvF6GQ0VouS2Q2KEgPuO1eMBwakWPB1aYFIA4VNVh667A==",
"cpu": [
"x64"
],
@@ -390,9 +388,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
- "version": "14.0.1",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.0.1.tgz",
- "integrity": "sha512-ol1X1e24w4j4QwdeNjfX0f+Nza25n+ymY0T2frTyalVczUmzkVD7QGgPTZMHfR1aLrO69hBs0G3QBYaj22J5GQ==",
+ "version": "14.0.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.0.4.tgz",
+ "integrity": "sha512-m8z/6Fyal4L9Bnlxde5g2Mfa1Z7dasMQyhEhskDATpqr+Y0mjOBZcXQ7G5U+vgL22cI4T7MfvgtrM2jdopqWaw==",
"cpu": [
"x64"
],
@@ -405,9 +403,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
- "version": "14.0.1",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.0.1.tgz",
- "integrity": "sha512-WEmTEeWs6yRUEnUlahTgvZteh5RJc4sEjCQIodJlZZ5/VJwVP8p2L7l6VhzQhT4h7KvLx/Ed4UViBdne6zpIsw==",
+ "version": "14.0.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.0.4.tgz",
+ "integrity": "sha512-7Wv4PRiWIAWbm5XrGz3D8HUkCVDMMz9igffZG4NB1p4u1KoItwx9qjATHz88kwCEal/HXmbShucaslXCQXUM5w==",
"cpu": [
"arm64"
],
@@ -420,9 +418,9 @@
}
},
"node_modules/@next/swc-win32-ia32-msvc": {
- "version": "14.0.1",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.0.1.tgz",
- "integrity": "sha512-oFpHphN4ygAgZUKjzga7SoH2VGbEJXZa/KL8bHCAwCjDWle6R1SpiGOdUdA8EJ9YsG1TYWpzY6FTbUA+iAJeww==",
+ "version": "14.0.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.0.4.tgz",
+ "integrity": "sha512-zLeNEAPULsl0phfGb4kdzF/cAVIfaC7hY+kt0/d+y9mzcZHsMS3hAS829WbJ31DkSlVKQeHEjZHIdhN+Pg7Gyg==",
"cpu": [
"ia32"
],
@@ -435,9 +433,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
- "version": "14.0.1",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.0.1.tgz",
- "integrity": "sha512-FFp3nOJ/5qSpeWT0BZQ+YE1pSMk4IMpkME/1DwKBwhg4mJLB9L+6EXuJi4JEwaJdl5iN+UUlmUD3IsR1kx5fAg==",
+ "version": "14.0.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.0.4.tgz",
+ "integrity": "sha512-yEh2+R8qDlDCjxVpzOTEpBLQTEFAcP2A8fUFLaWNap9GitYKkKv1//y2S6XY6zsR4rCOPRpU7plYDR+az2n30A==",
"cpu": [
"x64"
],
@@ -690,42 +688,6 @@
}
}
},
- "node_modules/@radix-ui/react-dialog": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz",
- "integrity": "sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==",
- "dependencies": {
- "@babel/runtime": "^7.13.10",
- "@radix-ui/primitive": "1.0.1",
- "@radix-ui/react-compose-refs": "1.0.1",
- "@radix-ui/react-context": "1.0.1",
- "@radix-ui/react-dismissable-layer": "1.0.5",
- "@radix-ui/react-focus-guards": "1.0.1",
- "@radix-ui/react-focus-scope": "1.0.4",
- "@radix-ui/react-id": "1.0.1",
- "@radix-ui/react-portal": "1.0.4",
- "@radix-ui/react-presence": "1.0.1",
- "@radix-ui/react-primitive": "1.0.3",
- "@radix-ui/react-slot": "1.0.2",
- "@radix-ui/react-use-controllable-state": "1.0.1",
- "aria-hidden": "^1.1.1",
- "react-remove-scroll": "2.5.5"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0",
- "react-dom": "^16.8 || ^17.0 || ^18.0"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-direction": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.1.tgz",
@@ -4236,14 +4198,15 @@
"license": "MIT"
},
"node_modules/next": {
- "version": "14.0.1",
- "resolved": "https://registry.npmjs.org/next/-/next-14.0.1.tgz",
- "integrity": "sha512-s4YaLpE4b0gmb3ggtmpmV+wt+lPRuGtANzojMQ2+gmBpgX9w5fTbjsy6dXByBuENsdCX5pukZH/GxdFgO62+pA==",
+ "version": "14.0.4",
+ "resolved": "https://registry.npmjs.org/next/-/next-14.0.4.tgz",
+ "integrity": "sha512-qbwypnM7327SadwFtxXnQdGiKpkuhaRLE2uq62/nRul9cj9KhQ5LhHmlziTNqUidZotw/Q1I9OjirBROdUJNgA==",
"dependencies": {
- "@next/env": "14.0.1",
+ "@next/env": "14.0.4",
"@swc/helpers": "0.5.2",
"busboy": "1.6.0",
"caniuse-lite": "^1.0.30001406",
+ "graceful-fs": "^4.2.11",
"postcss": "8.4.31",
"styled-jsx": "5.1.1",
"watchpack": "2.4.0"
@@ -4255,15 +4218,15 @@
"node": ">=18.17.0"
},
"optionalDependencies": {
- "@next/swc-darwin-arm64": "14.0.1",
- "@next/swc-darwin-x64": "14.0.1",
- "@next/swc-linux-arm64-gnu": "14.0.1",
- "@next/swc-linux-arm64-musl": "14.0.1",
- "@next/swc-linux-x64-gnu": "14.0.1",
- "@next/swc-linux-x64-musl": "14.0.1",
- "@next/swc-win32-arm64-msvc": "14.0.1",
- "@next/swc-win32-ia32-msvc": "14.0.1",
- "@next/swc-win32-x64-msvc": "14.0.1"
+ "@next/swc-darwin-arm64": "14.0.4",
+ "@next/swc-darwin-x64": "14.0.4",
+ "@next/swc-linux-arm64-gnu": "14.0.4",
+ "@next/swc-linux-arm64-musl": "14.0.4",
+ "@next/swc-linux-x64-gnu": "14.0.4",
+ "@next/swc-linux-x64-musl": "14.0.4",
+ "@next/swc-win32-arm64-msvc": "14.0.4",
+ "@next/swc-win32-ia32-msvc": "14.0.4",
+ "@next/swc-win32-x64-msvc": "14.0.4"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
@@ -6037,18 +6000,6 @@
"uuid": "dist/bin/uuid"
}
},
- "node_modules/vaul": {
- "version": "0.7.9",
- "resolved": "https://registry.npmjs.org/vaul/-/vaul-0.7.9.tgz",
- "integrity": "sha512-RrcnGOHOq/cEU3YpyyZrnjh0H79xMpF3IrHZs9ichvHlpKjLDc4Vwjn4VkuGzeUGrmQ3wamfm/cpdKWpvBIgQw==",
- "dependencies": {
- "@radix-ui/react-dialog": "^1.0.4"
- },
- "peerDependencies": {
- "react": "^16.8 || ^17.0 || ^18.0",
- "react-dom": "^16.8 || ^17.0 || ^18.0"
- }
- },
"node_modules/watchpack": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
diff --git a/package.json b/package.json
index 5e69948..7fe84fc 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,6 @@
"@hookform/resolvers": "^3.3.2",
"@prisma/client": "5.6.0",
"@radix-ui/react-checkbox": "^1.0.4",
- "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-icons": "^1.3.0",
@@ -26,7 +25,7 @@
"clsx": "^2.0.0",
"lucide-react": "^0.290.0",
"nanoid": "^5.0.4",
- "next": "14.0.1",
+ "next": "^14.0.4",
"next-plausible": "^3.12.0",
"next-themes": "^0.2.1",
"next13-progressbar": "^1.1.1",
@@ -37,7 +36,6 @@
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
"uuid": "^9.0.1",
- "vaul": "^0.7.9",
"zod": "^3.22.4"
},
"devDependencies": {
diff --git a/src/app/groups/[groupId]/@modal/default.tsx b/src/app/groups/[groupId]/@modal/default.tsx
deleted file mode 100644
index 86b9e9a..0000000
--- a/src/app/groups/[groupId]/@modal/default.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function Default() {
- return null
-}
diff --git a/src/app/groups/[groupId]/@modal/expense-modal.tsx b/src/app/groups/[groupId]/@modal/expense-modal.tsx
deleted file mode 100644
index 17bb302..0000000
--- a/src/app/groups/[groupId]/@modal/expense-modal.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-'use client'
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
-import { useRouter } from 'next/navigation'
-import { ReactNode, useEffect, useState } from 'react'
-import { Drawer } from 'vaul'
-
-type Props = {
- children: ReactNode
- title: ReactNode
-}
-
-export function ExpenseModal(props: Props) {
- const size = useTailwindBreakpoint()
- if (size === 'xs') {
- return <ExpenseVaul {...props} />
- } else {
- return <ExpenseDialog {...props} />
- }
-}
-
-export function ExpenseDialog({ children, title }: Props) {
- const router = useRouter()
-
- return (
- <Dialog open onOpenChange={() => router.back()}>
- <DialogContent className="w-full max-w-screen-sm">
- <DialogHeader>
- <DialogTitle>{title}</DialogTitle>
- </DialogHeader>
- {children}
- </DialogContent>
- </Dialog>
- )
-}
-
-export function ExpenseVaul({ children, title }: Props) {
- const router = useRouter()
- return (
- <Drawer.Root open onClose={() => router.back()}>
- <Drawer.Portal>
- <Drawer.Title>{title}</Drawer.Title>
- <Drawer.Overlay className="fixed inset-0 bg-background/80 backdrop-blur-sm" />
- <Drawer.Content className="bg-background border flex flex-col rounded-t-[10px] max-h-[90dvh] mt-24 fixed bottom-0 left-0 right-0 z-50">
- <div className="mx-auto w-12 h-1.5 flex-shrink-0 rounded-full bg-gray-300 dark:bg-gray-700 mt-4"></div>
- <div className="text-xl font-bold p-4">{title}</div>
- <div className="flex-1 overflow-y-auto p-4 pt-0">{children}</div>
- </Drawer.Content>
- </Drawer.Portal>
- </Drawer.Root>
- )
-}
-
-export function useTailwindBreakpoint() {
- const [size, setSize] = useState<'xs' | 'sm' | 'md' | 'lg'>('xs')
-
- useEffect(() => {
- const handleBreakpointChange = () => {
- if (window.innerWidth >= 1200) {
- setSize('lg')
- } else if (window.innerWidth >= 768) {
- setSize('md')
- } else if (window.innerWidth >= 640) {
- setSize('sm')
- } else {
- setSize('xs')
- }
- }
-
- window.addEventListener('resize', handleBreakpointChange)
- handleBreakpointChange()
-
- return () => {
- window.removeEventListener('resize', handleBreakpointChange)
- }
- }, [])
-
- return size
-}
diff --git a/src/app/groups/[groupId]/@modal/expenses/[expenseId]/edit/page.tsx b/src/app/groups/[groupId]/@modal/expenses/[expenseId]/edit/page.tsx
deleted file mode 100644
index fb70f45..0000000
--- a/src/app/groups/[groupId]/@modal/expenses/[expenseId]/edit/page.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import { ExpenseModal } from '@/app/groups/[groupId]/@modal/expense-modal'
-import { ExpenseForm } from '@/components/expense-form'
-import { getExpense, getGroup } from '@/lib/api'
-import { Metadata } from 'next'
-import { notFound } from 'next/navigation'
-
-export const metadata: Metadata = {
- title: 'Edit expense',
-}
-
-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()
-
- return (
- <ExpenseModal title="Edit expense">
- <ExpenseForm group={group} expense={expense} />
- </ExpenseModal>
- )
-}
diff --git a/src/app/groups/[groupId]/@modal/expenses/create/page.tsx b/src/app/groups/[groupId]/@modal/expenses/create/page.tsx
deleted file mode 100644
index 93cc413..0000000
--- a/src/app/groups/[groupId]/@modal/expenses/create/page.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import { ExpenseModal } from '@/app/groups/[groupId]/@modal/expense-modal'
-import { ExpenseForm } from '@/components/expense-form'
-import { getGroup } from '@/lib/api'
-import { Metadata } from 'next'
-import { notFound } from 'next/navigation'
-
-export const metadata: Metadata = {
- title: 'Create expense',
-}
-
-export default async function ExpensePage({
- params: { groupId },
-}: {
- params: { groupId: string }
-}) {
- const group = await getGroup(groupId)
- if (!group) notFound()
-
- return (
- <ExpenseModal title="Create expense">
- <ExpenseForm group={group} />
- </ExpenseModal>
- )
-}
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..188d08f
--- /dev/null
+++ b/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx
@@ -0,0 +1,42 @@
+import { ExpenseForm } from '@/components/expense-form'
+import { deleteExpense, getExpense, getGroup, updateExpense } from '@/lib/api'
+import { expenseFormSchema } from '@/lib/schemas'
+import { Metadata } from 'next'
+import { notFound, redirect } from 'next/navigation'
+
+export const metadata: Metadata = {
+ title: 'Edit expense',
+}
+
+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}`)
+ }
+
+ async function deleteExpenseAction() {
+ 'use server'
+ await deleteExpense(expenseId)
+ redirect(`/groups/${groupId}`)
+ }
+
+ return (
+ <ExpenseForm
+ group={group}
+ expense={expense}
+ onSubmit={updateExpenseAction}
+ onDelete={deleteExpenseAction}
+ />
+ )
+}
diff --git a/src/app/groups/[groupId]/expenses/actions.ts b/src/app/groups/[groupId]/expenses/actions.ts
deleted file mode 100644
index 31bb2e7..0000000
--- a/src/app/groups/[groupId]/expenses/actions.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-'use server'
-import { createExpense, deleteExpense, updateExpense } from '@/lib/api'
-import { expenseFormSchema } from '@/lib/schemas'
-import { revalidatePath } from 'next/cache'
-
-export async function createExpenseAction(groupId: string, values: unknown) {
- 'use server'
- const expenseFormValues = expenseFormSchema.parse(values)
- await createExpense(expenseFormValues, groupId)
- revalidatePath(`/groups/${groupId}`, 'layout')
-}
-
-export async function updateExpenseAction(
- groupId: string,
- expenseId: string,
- values: unknown,
-) {
- 'use server'
- const expenseFormValues = expenseFormSchema.parse(values)
- await updateExpense(groupId, expenseId, expenseFormValues)
- revalidatePath(`/groups/${groupId}`, 'layout')
-}
-
-export async function deleteExpenseAction(groupId: string, expenseId: string) {
- 'use server'
- await deleteExpense(expenseId)
- revalidatePath(`/groups/${groupId}`, 'layout')
-}
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..e603e59
--- /dev/null
+++ b/src/app/groups/[groupId]/expenses/create/page.tsx
@@ -0,0 +1,27 @@
+import { ExpenseForm } from '@/components/expense-form'
+import { createExpense, getGroup } from '@/lib/api'
+import { expenseFormSchema } from '@/lib/schemas'
+import { Metadata } from 'next'
+import { notFound, redirect } from 'next/navigation'
+
+export const metadata: Metadata = {
+ title: 'Create expense',
+}
+
+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 <ExpenseForm group={group} onSubmit={createExpenseAction} />
+}
diff --git a/src/app/groups/[groupId]/expenses/expense-list.tsx b/src/app/groups/[groupId]/expenses/expense-list.tsx
index adc6e7a..798a5f8 100644
--- a/src/app/groups/[groupId]/expenses/expense-list.tsx
+++ b/src/app/groups/[groupId]/expenses/expense-list.tsx
@@ -33,9 +33,7 @@ export function ExpenseList({
expense.isReimbursement && 'italic',
)}
onClick={() => {
- router.push(`/groups/${groupId}/expenses/${expense.id}/edit`, {
- scroll: false,
- })
+ router.push(`/groups/${groupId}/expenses/${expense.id}/edit`)
}}
>
<div>
@@ -68,10 +66,7 @@ export function ExpenseList({
{currency} {(expense.amount / 100).toFixed(2)}
</div>
<Button size="icon" variant="link" className="-my-2" asChild>
- <Link
- href={`/groups/${groupId}/expenses/${expense.id}/edit`}
- scroll={false}
- >
+ <Link href={`/groups/${groupId}/expenses/${expense.id}/edit`}>
<ChevronRight className="w-4 h-4" />
</Link>
</Button>
diff --git a/src/app/groups/[groupId]/expenses/expense-page.tsx b/src/app/groups/[groupId]/expenses/expense-page.tsx
deleted file mode 100644
index 281c38b..0000000
--- a/src/app/groups/[groupId]/expenses/expense-page.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { ReactNode } from 'react'
-
-export function ExpensePage({
- children,
- title,
-}: {
- children: ReactNode
- title: ReactNode
-}) {
- return (
- <Card>
- <CardHeader>
- <CardTitle>{title}</CardTitle>
- </CardHeader>
- <CardContent>{children}</CardContent>
- </Card>
- )
-}
diff --git a/src/app/groups/[groupId]/expenses/layout.tsx b/src/app/groups/[groupId]/expenses/layout.tsx
deleted file mode 100644
index cd65df9..0000000
--- a/src/app/groups/[groupId]/expenses/layout.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-import { ReactNode } from 'react'
-
-export default function GroupExpensesLayout({
- children,
-}: {
- children: ReactNode
-}) {
- return <>{children}</>
-}
diff --git a/src/app/groups/[groupId]/expenses/page.tsx b/src/app/groups/[groupId]/expenses/page.tsx
index 64987a3..5285cb8 100644
--- a/src/app/groups/[groupId]/expenses/page.tsx
+++ b/src/app/groups/[groupId]/expenses/page.tsx
@@ -35,7 +35,7 @@ export default async function GroupExpensesPage({
</CardHeader>
<CardHeader>
<Button asChild size="icon">
- <Link href={`/groups/${groupId}/expenses/create`} scroll={false}>
+ <Link href={`/groups/${groupId}/expenses/create`}>
<Plus />
</Link>
</Button>
diff --git a/src/app/groups/[groupId]/layout.tsx b/src/app/groups/[groupId]/layout.tsx
index 40f6155..3e649e7 100644
--- a/src/app/groups/[groupId]/layout.tsx
+++ b/src/app/groups/[groupId]/layout.tsx
@@ -5,13 +5,12 @@ import { getGroup } from '@/lib/api'
import { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
-import { PropsWithChildren, ReactNode } from 'react'
+import { PropsWithChildren } from 'react'
type Props = {
params: {
groupId: string
}
- modal: ReactNode
}
export async function generateMetadata({
@@ -29,7 +28,6 @@ export async function generateMetadata({
export default async function GroupLayout({
children,
- modal,
params: { groupId },
}: PropsWithChildren<Props>) {
const group = await getGroup(groupId)
@@ -49,7 +47,6 @@ export default async function GroupLayout({
</div>
{children}
- {modal}
<SaveGroupLocally group={{ id: group.id, name: group.name }} />
</>
diff --git a/src/app/groups/[groupId]/not-found.tsx b/src/app/groups/[groupId]/not-found.tsx
deleted file mode 100644
index af3788b..0000000
--- a/src/app/groups/[groupId]/not-found.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-'use client'
-
-export default function NotFound() {
- return null
-}
diff --git a/src/app/groups/[groupId]/reimbursement-list.tsx b/src/app/groups/[groupId]/reimbursement-list.tsx
index 1153b59..54ee9d7 100644
--- a/src/app/groups/[groupId]/reimbursement-list.tsx
+++ b/src/app/groups/[groupId]/reimbursement-list.tsx
@@ -37,7 +37,6 @@ export function ReimbursementList({
<Button variant="link" asChild className="-mx-4 -my-3">
<Link
href={`/groups/${groupId}/expenses/create?reimbursement=yes&from=${reimbursement.from}&to=${reimbursement.to}&amount=${reimbursement.amount}`}
- scroll={false}
>
Mark as paid
</Link>
diff --git a/src/components/expense-form.tsx b/src/components/expense-form.tsx
index 0f677e6..bdee9d0 100644
--- a/src/components/expense-form.tsx
+++ b/src/components/expense-form.tsx
@@ -1,12 +1,14 @@
'use client'
-import {
- createExpenseAction,
- deleteExpenseAction,
- updateExpenseAction,
-} from '@/app/groups/[groupId]/expenses/actions'
import { AsyncButton } from '@/components/async-button'
import { SubmitButton } from '@/components/submit-button'
import { Button } from '@/components/ui/button'
+import {
+ Card,
+ CardContent,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import {
Form,
@@ -28,15 +30,17 @@ import {
import { getExpense, getGroup } from '@/lib/api'
import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas'
import { zodResolver } from '@hookform/resolvers/zod'
-import { useRouter, useSearchParams } from 'next/navigation'
+import { useSearchParams } from 'next/navigation'
import { useForm } from 'react-hook-form'
export type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
expense?: NonNullable<Awaited<ReturnType<typeof getExpense>>>
+ onSubmit: (values: ExpenseFormValues) => Promise<void>
+ onDelete?: () => Promise<void>
}
-export function ExpenseForm({ group, expense }: Props) {
+export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
const isCreate = expense === undefined
const searchParams = useSearchParams()
const form = useForm<ExpenseFormValues>({
@@ -61,210 +65,204 @@ export function ExpenseForm({ group, expense }: Props) {
}
: { title: '', amount: 0, paidFor: [], isReimbursement: false },
})
- const router = useRouter()
return (
<Form {...form}>
- <form
- onSubmit={form.handleSubmit(async (values) => {
- if (expense) {
- await updateExpenseAction(group.id, expense.id, values)
- } else {
- await createExpenseAction(group.id, values)
- }
- router.back()
- })}
- >
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
- <FormField
- control={form.control}
- name="title"
- render={({ field }) => (
- <FormItem className="order-1">
- <FormLabel>Expense title</FormLabel>
- <FormControl>
- <Input
- placeholder="Monday evening restaurant"
- className="text-base"
- {...field}
- />
- </FormControl>
- <FormDescription>
- Enter a description for the expense.
- </FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="paidBy"
- render={({ field }) => (
- <FormItem className="order-3 sm:order-2">
- <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 className="order-2 sm:order-3">
- <FormLabel>Amount</FormLabel>
- <div className="flex items-baseline gap-2">
- <span>{group.currency}</span>
+ <form onSubmit={form.handleSubmit((values) => onSubmit(values))}>
+ <Card>
+ <CardHeader>
+ <CardTitle>
+ {isCreate ? <>Create expense</> : <>Edit expense</>}
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="grid grid-cols-1 sm:grid-cols-2 gap-6">
+ <FormField
+ control={form.control}
+ name="title"
+ render={({ field }) => (
+ <FormItem className="order-1">
+ <FormLabel>Expense title</FormLabel>
<FormControl>
<Input
- className="text-base max-w-[120px]"
- type="number"
- inputMode="decimal"
- step={0.01}
- placeholder="0.00"
+ placeholder="Monday evening restaurant"
+ className="text-base"
{...field}
/>
</FormControl>
- </div>
- <FormMessage />
-
- <FormField
- control={form.control}
- name="isReimbursement"
- render={({ field }) => (
- <FormItem className="flex flex-row gap-2 items-center space-y-0 pt-2">
- <FormControl>
- <Checkbox
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- <div>
- <FormLabel>This is a reimbursement</FormLabel>
- </div>
- </FormItem>
- )}
- />
- </FormItem>
- )}
- />
+ <FormDescription>
+ Enter a description for the expense.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
- <FormField
- control={form.control}
- name="paidFor"
- render={() => (
- <FormItem className="order-5">
- <div className="mb-4">
- <FormLabel>
- Paid for
- <Button
- variant="link"
- type="button"
- className="-m-2"
- onClick={() => {
- const paidFor = form.getValues().paidFor
- const allSelected =
- paidFor.length === group.participants.length
- const newPairFor = allSelected
- ? []
- : group.participants.map((p) => p.id)
- form.setValue('paidFor', newPairFor, {
- shouldDirty: true,
- shouldTouch: true,
- shouldValidate: true,
- })
- }}
- >
- {form.getValues().paidFor.length ===
- group.participants.length ? (
- <>Select none</>
- ) : (
- <>Select all</>
- )}
- </Button>
- </FormLabel>
+ <FormField
+ control={form.control}
+ name="paidBy"
+ render={({ field }) => (
+ <FormItem className="order-3 sm:order-2">
+ <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 who the expense was paid for.
+ Select the participant who paid the expense.
</FormDescription>
- </div>
- {group.participants.map(({ id, name }) => (
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="amount"
+ render={({ field }) => (
+ <FormItem className="order-2 sm:order-3">
+ <FormLabel>Amount</FormLabel>
+ <div className="flex items-baseline gap-2">
+ <span>{group.currency}</span>
+ <FormControl>
+ <Input
+ className="text-base max-w-[120px]"
+ type="number"
+ inputMode="decimal"
+ step={0.01}
+ placeholder="0.00"
+ {...field}
+ />
+ </FormControl>
+ </div>
+ <FormMessage />
+
<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>
- )
- }}
+ name="isReimbursement"
+ render={({ field }) => (
+ <FormItem className="flex flex-row gap-2 items-center space-y-0 pt-2">
+ <FormControl>
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ <div>
+ <FormLabel>This is a reimbursement</FormLabel>
+ </div>
+ </FormItem>
+ )}
/>
- ))}
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
+ </FormItem>
+ )}
+ />
- <div className="mt-6 flex gap-2">
- <SubmitButton
- loadingContent={isCreate ? <>Creating…</> : <>Saving…</>}
- >
- {isCreate ? <>Create</> : <>Save</>}
- </SubmitButton>
- {!isCreate && (
- <AsyncButton
- type="button"
- variant="destructive"
- loadingContent="Deleting…"
- action={async () => {
- await deleteExpenseAction(group.id, expense.id)
- router.back()
- }}
+ <FormField
+ control={form.control}
+ name="paidFor"
+ render={() => (
+ <FormItem className="order-5">
+ <div className="mb-4">
+ <FormLabel>
+ Paid for
+ <Button
+ variant="link"
+ type="button"
+ className="-m-2"
+ onClick={() => {
+ const paidFor = form.getValues().paidFor
+ const allSelected =
+ paidFor.length === group.participants.length
+ const newPairFor = allSelected
+ ? []
+ : group.participants.map((p) => p.id)
+ form.setValue('paidFor', newPairFor, {
+ shouldDirty: true,
+ shouldTouch: true,
+ shouldValidate: true,
+ })
+ }}
+ >
+ {form.getValues().paidFor.length ===
+ group.participants.length ? (
+ <>Select none</>
+ ) : (
+ <>Select all</>
+ )}
+ </Button>
+ </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 className="gap-2">
+ <SubmitButton
+ loadingContent={isCreate ? <>Creating…</> : <>Saving…</>}
>
- Delete
- </AsyncButton>
- )}
- </div>
+ {isCreate ? <>Create</> : <>Save</>}
+ </SubmitButton>
+ {!isCreate && onDelete && (
+ <AsyncButton
+ type="button"
+ variant="destructive"
+ loadingContent="Deleting…"
+ action={onDelete}
+ >
+ Delete
+ </AsyncButton>
+ )}
+ </CardFooter>
+ </Card>
</form>
</Form>
)
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
deleted file mode 100644
index cad6f58..0000000
--- a/src/components/ui/dialog.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-"use client"
-
-import * as React from "react"
-import * as DialogPrimitive from "@radix-ui/react-dialog"
-import { X } from "lucide-react"
-
-import { cn } from "@/lib/utils"
-
-const Dialog = DialogPrimitive.Root
-
-const DialogTrigger = DialogPrimitive.Trigger
-
-const DialogPortal = DialogPrimitive.Portal
-
-const DialogClose = DialogPrimitive.Close
-
-const DialogOverlay = React.forwardRef<
- React.ElementRef<typeof DialogPrimitive.Overlay>,
- React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
->(({ className, ...props }, ref) => (
- <DialogPrimitive.Overlay
- ref={ref}
- className={cn(
- "fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
- className
- )}
- {...props}
- />
-))
-DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
-
-const DialogContent = React.forwardRef<
- React.ElementRef<typeof DialogPrimitive.Content>,
- React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
->(({ className, children, ...props }, ref) => (
- <DialogPortal>
- <DialogOverlay />
- <DialogPrimitive.Content
- ref={ref}
- className={cn(
- "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
- className
- )}
- {...props}
- >
- {children}
- <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
- <X className="h-4 w-4" />
- <span className="sr-only">Close</span>
- </DialogPrimitive.Close>
- </DialogPrimitive.Content>
- </DialogPortal>
-))
-DialogContent.displayName = DialogPrimitive.Content.displayName
-
-const DialogHeader = ({
- className,
- ...props
-}: React.HTMLAttributes<HTMLDivElement>) => (
- <div
- className={cn(
- "flex flex-col space-y-1.5 text-center sm:text-left",
- className
- )}
- {...props}
- />
-)
-DialogHeader.displayName = "DialogHeader"
-
-const DialogFooter = ({
- className,
- ...props
-}: React.HTMLAttributes<HTMLDivElement>) => (
- <div
- className={cn(
- "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
- className
- )}
- {...props}
- />
-)
-DialogFooter.displayName = "DialogFooter"
-
-const DialogTitle = React.forwardRef<
- React.ElementRef<typeof DialogPrimitive.Title>,
- React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
->(({ className, ...props }, ref) => (
- <DialogPrimitive.Title
- ref={ref}
- className={cn(
- "text-lg font-semibold leading-none tracking-tight",
- className
- )}
- {...props}
- />
-))
-DialogTitle.displayName = DialogPrimitive.Title.displayName
-
-const DialogDescription = React.forwardRef<
- React.ElementRef<typeof DialogPrimitive.Description>,
- React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
->(({ className, ...props }, ref) => (
- <DialogPrimitive.Description
- ref={ref}
- className={cn("text-sm text-muted-foreground", className)}
- {...props}
- />
-))
-DialogDescription.displayName = DialogPrimitive.Description.displayName
-
-export {
- Dialog,
- DialogPortal,
- DialogOverlay,
- DialogClose,
- DialogTrigger,
- DialogContent,
- DialogHeader,
- DialogFooter,
- DialogTitle,
- DialogDescription,
-}