diff options
| -rw-r--r-- | package-lock.json | 139 | ||||
| -rw-r--r-- | package.json | 4 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/@modal/default.tsx | 3 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/@modal/expense-modal.tsx | 83 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/@modal/expenses/[expenseId]/edit/page.tsx | 26 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/@modal/expenses/create/page.tsx | 24 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx | 42 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/expenses/actions.ts | 28 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/expenses/create/page.tsx | 27 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/expenses/expense-list.tsx | 9 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/expenses/expense-page.tsx | 19 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/expenses/layout.tsx | 9 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/expenses/page.tsx | 2 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/layout.tsx | 5 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/not-found.tsx | 5 | ||||
| -rw-r--r-- | src/app/groups/[groupId]/reimbursement-list.tsx | 1 | ||||
| -rw-r--r-- | src/components/expense-form.tsx | 388 | ||||
| -rw-r--r-- | src/components/ui/dialog.tsx | 122 |
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, -} |
