8 Commits

Author SHA1 Message Date
Sebastien Castiel
6ff54b6d21 Split unevenly by amount 2024-01-08 12:03:53 -05:00
Sebastien Castiel
c9a92408d7 Redesign expense form 2024-01-08 10:24:41 -05:00
Sebastien Castiel
f4b31c805d Form validation 2024-01-08 08:28:18 -05:00
Sebastien Castiel
2b712cd69c Change field size 2024-01-08 08:28:18 -05:00
Sebastien Castiel
f42253149a Update balances based on shares 2024-01-08 08:28:18 -05:00
Sebastien Castiel
4decb5e6a3 Add splitmode and shares to expenses 2024-01-08 08:28:18 -05:00
Sebastien Castiel
0fb0c42ff5 Fix index 2023-12-26 12:24:42 +01:00
Sebastien Castiel
f881aff5f9 Revert "Use modal dialogs for expense creation & edition (#10)"
This reverts commit 1e66efe516.
2023-12-19 09:44:09 -05:00
26 changed files with 718 additions and 675 deletions

176
package-lock.json generated
View File

@@ -12,7 +12,7 @@
"@hookform/resolvers": "^3.3.2", "@hookform/resolvers": "^3.3.2",
"@prisma/client": "5.6.0", "@prisma/client": "5.6.0",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7", "@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
@@ -25,7 +25,7 @@
"clsx": "^2.0.0", "clsx": "^2.0.0",
"lucide-react": "^0.290.0", "lucide-react": "^0.290.0",
"nanoid": "^5.0.4", "nanoid": "^5.0.4",
"next": "14.0.1", "next": "^14.0.4",
"next-plausible": "^3.12.0", "next-plausible": "^3.12.0",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"next13-progressbar": "^1.1.1", "next13-progressbar": "^1.1.1",
@@ -35,8 +35,8 @@
"react-hook-form": "^7.47.0", "react-hook-form": "^7.47.0",
"tailwind-merge": "^1.14.0", "tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"ts-pattern": "^5.0.6",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"vaul": "^0.7.9",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
@@ -281,9 +281,9 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "14.0.1", "version": "14.0.4",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.0.4.tgz",
"integrity": "sha512-Ms8ZswqY65/YfcjrlcIwMPD7Rg/dVjdLapMcSHG26W6O67EJDF435ShW4H4LXi1xKO1oRc97tLXUpx8jpLe86A==" "integrity": "sha512-irQnbMLbUNQpP1wcE5NstJtbuA/69kRfzBrpAD7Gsn8zm/CY6YQYc3HQBz8QPxwISG26tIm5afvvVbu508oBeQ=="
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
"version": "14.0.4", "version": "14.0.4",
@@ -315,9 +315,9 @@
} }
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "14.0.1", "version": "14.0.4",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.0.4.tgz",
"integrity": "sha512-JyxnGCS4qT67hdOKQ0CkgFTp+PXub5W1wsGvIq98TNbF3YEIN7iDekYhYsZzc8Ov0pWEsghQt+tANdidITCLaw==", "integrity": "sha512-mF05E/5uPthWzyYDyptcwHptucf/jj09i2SXBPwNzbgBNc+XnwzrL0U6BmPjQeOL+FiB+iG1gwBeq7mlDjSRPg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -330,9 +330,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "14.0.1", "version": "14.0.4",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.0.4.tgz",
"integrity": "sha512-625Z7bb5AyIzswF9hvfZWa+HTwFZw+Jn3lOBNZB87lUS0iuCYDHqk3ujuHCkiyPtSC0xFBtYDLcrZ11mF/ap3w==", "integrity": "sha512-IZQ3C7Bx0k2rYtrZZxKKiusMTM9WWcK5ajyhOZkYYTCc8xytmwSzR1skU7qLgVT/EY9xtXDG0WhY6fyujnI3rw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -345,9 +345,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "14.0.1", "version": "14.0.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.0.4.tgz",
"integrity": "sha512-iVpn3KG3DprFXzVHM09kvb//4CNNXBQ9NB/pTm8LO+vnnnaObnzFdS5KM+w1okwa32xH0g8EvZIhoB3fI3mS1g==", "integrity": "sha512-VwwZKrBQo/MGb1VOrxJ6LrKvbpo7UbROuyMRvQKTFKhNaXjUmKTu7wxVkIuCARAfiI8JpaWAnKR+D6tzpCcM4w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -360,9 +360,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "14.0.1", "version": "14.0.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.0.4.tgz",
"integrity": "sha512-mVsGyMxTLWZXyD5sen6kGOTYVOO67lZjLApIj/JsTEEohDDt1im2nkspzfV5MvhfS7diDw6Rp/xvAQaWZTv1Ww==", "integrity": "sha512-8QftwPEW37XxXoAwsn+nXlodKWHfpMaSvt81W43Wh8dv0gkheD+30ezWMcFGHLI71KiWmHK5PSQbTQGUiidvLQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -375,9 +375,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "14.0.1", "version": "14.0.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.0.4.tgz",
"integrity": "sha512-wMqf90uDWN001NqCM/auRl3+qVVeKfjJdT9XW+RMIOf+rhUzadmYJu++tp2y+hUbb6GTRhT+VjQzcgg/QTD9NQ==", "integrity": "sha512-/s/Pme3VKfZAfISlYVq2hzFS8AcAIOTnoKupc/j4WlvF6GQ0VouS2Q2KEgPuO1eMBwakWPB1aYFIA4VNVh667A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -390,9 +390,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "14.0.1", "version": "14.0.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.0.4.tgz",
"integrity": "sha512-ol1X1e24w4j4QwdeNjfX0f+Nza25n+ymY0T2frTyalVczUmzkVD7QGgPTZMHfR1aLrO69hBs0G3QBYaj22J5GQ==", "integrity": "sha512-m8z/6Fyal4L9Bnlxde5g2Mfa1Z7dasMQyhEhskDATpqr+Y0mjOBZcXQ7G5U+vgL22cI4T7MfvgtrM2jdopqWaw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -405,9 +405,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "14.0.1", "version": "14.0.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.0.4.tgz",
"integrity": "sha512-WEmTEeWs6yRUEnUlahTgvZteh5RJc4sEjCQIodJlZZ5/VJwVP8p2L7l6VhzQhT4h7KvLx/Ed4UViBdne6zpIsw==", "integrity": "sha512-7Wv4PRiWIAWbm5XrGz3D8HUkCVDMMz9igffZG4NB1p4u1KoItwx9qjATHz88kwCEal/HXmbShucaslXCQXUM5w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -420,9 +420,9 @@
} }
}, },
"node_modules/@next/swc-win32-ia32-msvc": { "node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.0.1", "version": "14.0.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.0.4.tgz",
"integrity": "sha512-oFpHphN4ygAgZUKjzga7SoH2VGbEJXZa/KL8bHCAwCjDWle6R1SpiGOdUdA8EJ9YsG1TYWpzY6FTbUA+iAJeww==", "integrity": "sha512-zLeNEAPULsl0phfGb4kdzF/cAVIfaC7hY+kt0/d+y9mzcZHsMS3hAS829WbJ31DkSlVKQeHEjZHIdhN+Pg7Gyg==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -435,9 +435,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "14.0.1", "version": "14.0.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.0.1.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.0.4.tgz",
"integrity": "sha512-FFp3nOJ/5qSpeWT0BZQ+YE1pSMk4IMpkME/1DwKBwhg4mJLB9L+6EXuJi4JEwaJdl5iN+UUlmUD3IsR1kx5fAg==", "integrity": "sha512-yEh2+R8qDlDCjxVpzOTEpBLQTEFAcP2A8fUFLaWNap9GitYKkKv1//y2S6XY6zsR4rCOPRpU7plYDR+az2n30A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -629,6 +629,36 @@
} }
} }
}, },
"node_modules/@radix-ui/react-collapsible": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.0.3.tgz",
"integrity": "sha512-UBmVDkmR6IvDsloHVN+3rtx4Mi5TFvylYXpluuv0f37dtaz3H99bp8No0LGXRigVpl3UAT4l9j6bIchh42S/Gg==",
"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-id": "1.0.1",
"@radix-ui/react-presence": "1.0.1",
"@radix-ui/react-primitive": "1.0.3",
"@radix-ui/react-use-controllable-state": "1.0.1",
"@radix-ui/react-use-layout-effect": "1.0.1"
},
"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-collection": { "node_modules/@radix-ui/react-collection": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.3.tgz",
@@ -690,42 +720,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": { "node_modules/@radix-ui/react-direction": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.1.tgz",
@@ -4236,14 +4230,15 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/next": { "node_modules/next": {
"version": "14.0.1", "version": "14.0.4",
"resolved": "https://registry.npmjs.org/next/-/next-14.0.1.tgz", "resolved": "https://registry.npmjs.org/next/-/next-14.0.4.tgz",
"integrity": "sha512-s4YaLpE4b0gmb3ggtmpmV+wt+lPRuGtANzojMQ2+gmBpgX9w5fTbjsy6dXByBuENsdCX5pukZH/GxdFgO62+pA==", "integrity": "sha512-qbwypnM7327SadwFtxXnQdGiKpkuhaRLE2uq62/nRul9cj9KhQ5LhHmlziTNqUidZotw/Q1I9OjirBROdUJNgA==",
"dependencies": { "dependencies": {
"@next/env": "14.0.1", "@next/env": "14.0.4",
"@swc/helpers": "0.5.2", "@swc/helpers": "0.5.2",
"busboy": "1.6.0", "busboy": "1.6.0",
"caniuse-lite": "^1.0.30001406", "caniuse-lite": "^1.0.30001406",
"graceful-fs": "^4.2.11",
"postcss": "8.4.31", "postcss": "8.4.31",
"styled-jsx": "5.1.1", "styled-jsx": "5.1.1",
"watchpack": "2.4.0" "watchpack": "2.4.0"
@@ -4255,15 +4250,15 @@
"node": ">=18.17.0" "node": ">=18.17.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "14.0.1", "@next/swc-darwin-arm64": "14.0.4",
"@next/swc-darwin-x64": "14.0.1", "@next/swc-darwin-x64": "14.0.4",
"@next/swc-linux-arm64-gnu": "14.0.1", "@next/swc-linux-arm64-gnu": "14.0.4",
"@next/swc-linux-arm64-musl": "14.0.1", "@next/swc-linux-arm64-musl": "14.0.4",
"@next/swc-linux-x64-gnu": "14.0.1", "@next/swc-linux-x64-gnu": "14.0.4",
"@next/swc-linux-x64-musl": "14.0.1", "@next/swc-linux-x64-musl": "14.0.4",
"@next/swc-win32-arm64-msvc": "14.0.1", "@next/swc-win32-arm64-msvc": "14.0.4",
"@next/swc-win32-ia32-msvc": "14.0.1", "@next/swc-win32-ia32-msvc": "14.0.4",
"@next/swc-win32-x64-msvc": "14.0.1" "@next/swc-win32-x64-msvc": "14.0.4"
}, },
"peerDependencies": { "peerDependencies": {
"@opentelemetry/api": "^1.1.0", "@opentelemetry/api": "^1.1.0",
@@ -5786,6 +5781,11 @@
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/ts-pattern": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.0.6.tgz",
"integrity": "sha512-Y+jOjihlFriWzcBjncPCf2/am+Hgz7LtsWs77pWg5vQQKLQj07oNrJryo/wK2G0ndNaoVn2ownFMeoeAuReu3Q=="
},
"node_modules/tsconfig-paths": { "node_modules/tsconfig-paths": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz",
@@ -6037,18 +6037,6 @@
"uuid": "dist/bin/uuid" "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": { "node_modules/watchpack": {
"version": "2.4.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",

View File

@@ -13,7 +13,7 @@
"@hookform/resolvers": "^3.3.2", "@hookform/resolvers": "^3.3.2",
"@prisma/client": "5.6.0", "@prisma/client": "5.6.0",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7", "@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
@@ -26,7 +26,7 @@
"clsx": "^2.0.0", "clsx": "^2.0.0",
"lucide-react": "^0.290.0", "lucide-react": "^0.290.0",
"nanoid": "^5.0.4", "nanoid": "^5.0.4",
"next": "14.0.1", "next": "^14.0.4",
"next-plausible": "^3.12.0", "next-plausible": "^3.12.0",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"next13-progressbar": "^1.1.1", "next13-progressbar": "^1.1.1",
@@ -36,8 +36,8 @@
"react-hook-form": "^7.47.0", "react-hook-form": "^7.47.0",
"tailwind-merge": "^1.14.0", "tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"ts-pattern": "^5.0.6",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"vaul": "^0.7.9",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ExpensePaidFor" ADD COLUMN "shares" INTEGER NOT NULL DEFAULT 1;

View File

@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "SplitMode" AS ENUM ('EVENLY', 'BY_SHARES', 'BY_PERCENTAGE', 'BY_AMOUNT');
-- AlterTable
ALTER TABLE "Expense" ADD COLUMN "splitMode" "SplitMode" NOT NULL DEFAULT 'EVENLY';

View File

@@ -39,14 +39,23 @@ model Expense {
paidFor ExpensePaidFor[] paidFor ExpensePaidFor[]
groupId String groupId String
isReimbursement Boolean @default(false) isReimbursement Boolean @default(false)
splitMode SplitMode @default(EVENLY)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
} }
enum SplitMode {
EVENLY
BY_SHARES
BY_PERCENTAGE
BY_AMOUNT
}
model ExpensePaidFor { model ExpensePaidFor {
expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade) expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade)
participant Participant @relation(fields: [participantId], references: [id], onDelete: Cascade) participant Participant @relation(fields: [participantId], references: [id], onDelete: Cascade)
expenseId String expenseId String
participantId String participantId String
shares Int @default(1)
@@id([expenseId, participantId]) @@id([expenseId, participantId])
} }

View File

@@ -1,3 +0,0 @@
export default function Default() {
return null
}

View File

@@ -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
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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}
/>
)
}

View File

@@ -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')
}

View File

@@ -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} />
}

View File

@@ -33,9 +33,7 @@ export function ExpenseList({
expense.isReimbursement && 'italic', expense.isReimbursement && 'italic',
)} )}
onClick={() => { onClick={() => {
router.push(`/groups/${groupId}/expenses/${expense.id}/edit`, { router.push(`/groups/${groupId}/expenses/${expense.id}/edit`)
scroll: false,
})
}} }}
> >
<div> <div>
@@ -68,10 +66,7 @@ export function ExpenseList({
{currency} {(expense.amount / 100).toFixed(2)} {currency} {(expense.amount / 100).toFixed(2)}
</div> </div>
<Button size="icon" variant="link" className="-my-2" asChild> <Button size="icon" variant="link" className="-my-2" asChild>
<Link <Link href={`/groups/${groupId}/expenses/${expense.id}/edit`}>
href={`/groups/${groupId}/expenses/${expense.id}/edit`}
scroll={false}
>
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />
</Link> </Link>
</Button> </Button>

View File

@@ -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>
)
}

View File

@@ -1,9 +0,0 @@
import { ReactNode } from 'react'
export default function GroupExpensesLayout({
children,
}: {
children: ReactNode
}) {
return <>{children}</>
}

View File

@@ -35,7 +35,7 @@ export default async function GroupExpensesPage({
</CardHeader> </CardHeader>
<CardHeader> <CardHeader>
<Button asChild size="icon"> <Button asChild size="icon">
<Link href={`/groups/${groupId}/expenses/create`} scroll={false}> <Link href={`/groups/${groupId}/expenses/create`}>
<Plus /> <Plus />
</Link> </Link>
</Button> </Button>

View File

@@ -5,13 +5,12 @@ import { getGroup } from '@/lib/api'
import { Metadata } from 'next' import { Metadata } from 'next'
import Link from 'next/link' import Link from 'next/link'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { PropsWithChildren, ReactNode } from 'react' import { PropsWithChildren } from 'react'
type Props = { type Props = {
params: { params: {
groupId: string groupId: string
} }
modal: ReactNode
} }
export async function generateMetadata({ export async function generateMetadata({
@@ -29,7 +28,6 @@ export async function generateMetadata({
export default async function GroupLayout({ export default async function GroupLayout({
children, children,
modal,
params: { groupId }, params: { groupId },
}: PropsWithChildren<Props>) { }: PropsWithChildren<Props>) {
const group = await getGroup(groupId) const group = await getGroup(groupId)
@@ -49,7 +47,6 @@ export default async function GroupLayout({
</div> </div>
{children} {children}
{modal}
<SaveGroupLocally group={{ id: group.id, name: group.name }} /> <SaveGroupLocally group={{ id: group.id, name: group.name }} />
</> </>

View File

@@ -1,5 +0,0 @@
'use client'
export default function NotFound() {
return null
}

View File

@@ -37,7 +37,6 @@ export function ReimbursementList({
<Button variant="link" asChild className="-mx-4 -my-3"> <Button variant="link" asChild className="-mx-4 -my-3">
<Link <Link
href={`/groups/${groupId}/expenses/create?reimbursement=yes&from=${reimbursement.from}&to=${reimbursement.to}&amount=${reimbursement.amount}`} href={`/groups/${groupId}/expenses/create?reimbursement=yes&from=${reimbursement.from}&to=${reimbursement.to}&amount=${reimbursement.amount}`}
scroll={false}
> >
Mark as paid Mark as paid
</Link> </Link>

View File

@@ -12,6 +12,9 @@ import {
import Link from 'next/link' import Link from 'next/link'
import { ReactNode } from 'react' import { ReactNode } from 'react'
// FIX for https://github.com/vercel/next.js/issues/58615
export const dynamic = 'force-dynamic'
export default function HomePage() { export default function HomePage() {
return ( return (
<main> <main>

View File

@@ -1,13 +1,20 @@
'use client' 'use client'
import {
createExpenseAction,
deleteExpenseAction,
updateExpenseAction,
} from '@/app/groups/[groupId]/expenses/actions'
import { AsyncButton } from '@/components/async-button' import { AsyncButton } from '@/components/async-button'
import { SubmitButton } from '@/components/submit-button' import { SubmitButton } from '@/components/submit-button'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { import {
Form, Form,
FormControl, FormControl,
@@ -27,16 +34,21 @@ import {
} from '@/components/ui/select' } from '@/components/ui/select'
import { getExpense, getGroup } from '@/lib/api' import { getExpense, getGroup } from '@/lib/api'
import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas' import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas'
import { cn } from '@/lib/utils'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { useRouter, useSearchParams } from 'next/navigation' import { Save, Trash2 } from 'lucide-react'
import { useSearchParams } from 'next/navigation'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { match } from 'ts-pattern'
export type Props = { export type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>> group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
expense?: NonNullable<Awaited<ReturnType<typeof getExpense>>> 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 isCreate = expense === undefined
const searchParams = useSearchParams() const searchParams = useSearchParams()
const form = useForm<ExpenseFormValues>({ const form = useForm<ExpenseFormValues>({
@@ -46,7 +58,11 @@ export function ExpenseForm({ group, expense }: Props) {
title: expense.title, title: expense.title,
amount: String(expense.amount / 100) as unknown as number, // hack amount: String(expense.amount / 100) as unknown as number, // hack
paidBy: expense.paidById, paidBy: expense.paidById,
paidFor: expense.paidFor.map(({ participantId }) => participantId), paidFor: expense.paidFor.map(({ participantId, shares }) => ({
participant: participantId,
shares: String(shares / 100) as unknown as number,
})),
splitMode: expense.splitMode,
isReimbursement: expense.isReimbursement, isReimbursement: expense.isReimbursement,
} }
: searchParams.get('reimbursement') : searchParams.get('reimbursement')
@@ -56,211 +72,373 @@ export function ExpenseForm({ group, expense }: Props) {
(Number(searchParams.get('amount')) || 0) / 100, (Number(searchParams.get('amount')) || 0) / 100,
) as unknown as number, // hack ) as unknown as number, // hack
paidBy: searchParams.get('from') ?? undefined, paidBy: searchParams.get('from') ?? undefined,
paidFor: [searchParams.get('to') ?? undefined], paidFor: [
searchParams.get('to')
? { participant: searchParams.get('to')! }
: undefined,
],
isReimbursement: true, isReimbursement: true,
} }
: { title: '', amount: 0, paidFor: [], isReimbursement: false }, : {
title: '',
amount: 0,
paidFor: [],
isReimbursement: false,
splitMode: 'EVENLY',
},
}) })
const router = useRouter()
return ( return (
<Form {...form}> <Form {...form}>
<form <form onSubmit={form.handleSubmit((values) => onSubmit(values))}>
onSubmit={form.handleSubmit(async (values) => { <Card>
if (expense) { <CardHeader>
await updateExpenseAction(group.id, expense.id, values) <CardTitle>
} else { {isCreate ? <>Create expense</> : <>Edit expense</>}
await createExpenseAction(group.id, values) </CardTitle>
} </CardHeader>
router.back() <CardContent className="grid sm:grid-cols-2 gap-6">
})} <FormField
> control={form.control}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6"> name="title"
<FormField render={({ field }) => (
control={form.control} <FormItem className="">
name="title" <FormLabel>Expense title</FormLabel>
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>
<FormControl> <FormControl>
<Input <Input
className="text-base max-w-[120px]" placeholder="Monday evening restaurant"
type="number" className="text-base"
inputMode="decimal"
step={0.01}
placeholder="0.00"
{...field} {...field}
/> />
</FormControl> </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>
)}
/>
<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> <FormDescription>
Select who the expense was paid for. Enter a description for the expense.
</FormDescription> </FormDescription>
</div> <FormMessage />
{group.participants.map(({ id, name }) => ( </FormItem>
<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>
)}
/>
</div>
<div className="mt-6 flex gap-2"> <FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem className="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
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>
)}
/>
<FormField
control={form.control}
name="paidBy"
render={({ field }) => (
<FormItem className="sm:order-5">
<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>
)}
/>
</CardContent>
</Card>
<Card className="mt-4">
<CardHeader>
<CardTitle className="flex justify-between">
<span>Paid for</span>
<Button
variant="link"
type="button"
className="-my-2 -mx-4"
onClick={() => {
const paidFor = form.getValues().paidFor
const allSelected =
paidFor.length === group.participants.length
const newPaidFor = allSelected
? []
: group.participants.map((p) => ({
participant: p.id,
shares:
paidFor.find((pfor) => pfor.participant === p.id)
?.shares ?? ('1' as unknown as number),
}))
form.setValue('paidFor', newPaidFor, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
}}
>
{form.getValues().paidFor.length ===
group.participants.length ? (
<>Select none</>
) : (
<>Select all</>
)}
</Button>
</CardTitle>
<CardDescription>
Select who the expense was paid for.
</CardDescription>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="paidFor"
render={() => (
<FormItem className="sm:order-4 row-span-2 space-y-0">
{group.participants.map(({ id, name }) => (
<FormField
key={id}
control={form.control}
name="paidFor"
render={({ field }) => {
return (
<div
data-id={`${id}/${form.getValues().splitMode}/${
group.currency
}`}
className="flex items-center border-t last-of-type:border-b last-of-type:!mb-4 -mx-6 px-6 py-3"
>
<FormItem className="flex-1 flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value?.some(
({ participant }) => participant === id,
)}
onCheckedChange={(checked) => {
return checked
? field.onChange([
...field.value,
{
participant: id,
shares: '1',
},
])
: field.onChange(
field.value?.filter(
(value) => value.participant !== id,
),
)
}}
/>
</FormControl>
<FormLabel className="text-sm font-normal flex-1">
{name}
</FormLabel>
</FormItem>
{form.getValues().splitMode !== 'EVENLY' && (
<FormField
name={`paidFor[${field.value.findIndex(
({ participant }) => participant === id,
)}].shares`}
render={() => {
const sharesLabel = (
<span
className={cn('text-sm', {
'text-muted': !field.value?.some(
({ participant }) =>
participant === id,
),
})}
>
{match(form.getValues().splitMode)
.with('BY_SHARES', () => <>share(s)</>)
.with('BY_PERCENTAGE', () => <>%</>)
.with('BY_AMOUNT', () => (
<>{group.currency}</>
))
.otherwise(() => (
<></>
))}
</span>
)
return (
<div>
<div className="flex gap-1 items-baseline">
{form.getValues().splitMode ===
'BY_AMOUNT' && sharesLabel}
<FormControl>
<Input
key={String(
!field.value?.some(
({ participant }) =>
participant === id,
),
)}
className="text-base w-[80px] -my-2"
type="number"
disabled={
!field.value?.some(
({ participant }) =>
participant === id,
)
}
value={
field.value?.find(
({ participant }) =>
participant === id,
)?.shares
}
onChange={(event) =>
field.onChange(
field.value.map((p) =>
p.participant === id
? {
participant: id,
shares:
event.target.value,
}
: p,
),
)
}
inputMode="numeric"
step={1}
/>
</FormControl>
{[
'BY_SHARES',
'BY_PERCENTAGE',
].includes(
form.getValues().splitMode,
) && sharesLabel}
</div>
<FormMessage className="float-right" />
</div>
)
}}
/>
)}
</div>
)
}}
/>
))}
<FormMessage />
</FormItem>
)}
/>
<Collapsible className="mt-5">
<CollapsibleTrigger asChild>
<Button variant="link" className="-mx-4">
Advanced splitting options
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="grid sm:grid-cols-2 gap-6 pt-3">
<FormField
control={form.control}
name="splitMode"
render={({ field }) => (
<FormItem className="sm:order-2">
<FormLabel>Split mode</FormLabel>
<FormControl>
<Select
onValueChange={(value) => {
form.setValue('splitMode', value as any, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
}}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EVENLY">Evenly</SelectItem>
<SelectItem value="BY_SHARES">
Unevenly By shares
</SelectItem>
<SelectItem value="BY_PERCENTAGE">
Unevenly By percentage
</SelectItem>
<SelectItem value="BY_AMOUNT">
Unevenly By amount
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
Select how to split the expense.
</FormDescription>
</FormItem>
)}
/>
</div>
</CollapsibleContent>
</Collapsible>
</CardContent>
</Card>
<div className="flex mt-4 gap-2">
<SubmitButton <SubmitButton
loadingContent={isCreate ? <>Creating</> : <>Saving</>} loadingContent={isCreate ? <>Creating</> : <>Saving</>}
> >
<Save className="w-4 h-4 mr-2" />
{isCreate ? <>Create</> : <>Save</>} {isCreate ? <>Create</> : <>Save</>}
</SubmitButton> </SubmitButton>
{!isCreate && ( {!isCreate && onDelete && (
<AsyncButton <AsyncButton
type="button" type="button"
variant="destructive" variant="destructive"
loadingContent="Deleting…" loadingContent="Deleting…"
action={async () => { action={onDelete}
await deleteExpenseAction(group.id, expense.id)
router.back()
}}
> >
<Trash2 className="w-4 h-4 mr-2" />
Delete Delete
</AsyncButton> </AsyncButton>
)} )}

View File

@@ -0,0 +1,11 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -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,
}

View File

@@ -36,7 +36,7 @@ export async function createExpense(
for (const participant of [ for (const participant of [
expenseFormValues.paidBy, expenseFormValues.paidBy,
...expenseFormValues.paidFor, ...expenseFormValues.paidFor.map((p) => p.participant),
]) { ]) {
if (!group.participants.some((p) => p.id === participant)) if (!group.participants.some((p) => p.id === participant))
throw new Error(`Invalid participant ID: ${participant}`) throw new Error(`Invalid participant ID: ${participant}`)
@@ -50,10 +50,12 @@ export async function createExpense(
amount: expenseFormValues.amount, amount: expenseFormValues.amount,
title: expenseFormValues.title, title: expenseFormValues.title,
paidById: expenseFormValues.paidBy, paidById: expenseFormValues.paidBy,
splitMode: expenseFormValues.splitMode,
paidFor: { paidFor: {
createMany: { createMany: {
data: expenseFormValues.paidFor.map((paidFor) => ({ data: expenseFormValues.paidFor.map((paidFor) => ({
participantId: paidFor, participantId: paidFor.participant,
shares: paidFor.shares,
})), })),
}, },
}, },
@@ -84,12 +86,14 @@ export async function getGroupExpensesParticipants(groupId: string) {
export async function getGroups(groupIds: string[]) { export async function getGroups(groupIds: string[]) {
const prisma = await getPrisma() const prisma = await getPrisma()
return (await prisma.group.findMany({ return (
where: { id: { in: groupIds } }, await prisma.group.findMany({
include: { _count: { select: { participants: true } } }, where: { id: { in: groupIds } },
})).map(group => ({ include: { _count: { select: { participants: true } } },
})
).map((group) => ({
...group, ...group,
createdAt: group.createdAt.toISOString() createdAt: group.createdAt.toISOString(),
})) }))
} }
@@ -106,7 +110,7 @@ export async function updateExpense(
for (const participant of [ for (const participant of [
expenseFormValues.paidBy, expenseFormValues.paidBy,
...expenseFormValues.paidFor, ...expenseFormValues.paidFor.map((p) => p.participant),
]) { ]) {
if (!group.participants.some((p) => p.id === participant)) if (!group.participants.some((p) => p.id === participant))
throw new Error(`Invalid participant ID: ${participant}`) throw new Error(`Invalid participant ID: ${participant}`)
@@ -119,17 +123,34 @@ export async function updateExpense(
amount: expenseFormValues.amount, amount: expenseFormValues.amount,
title: expenseFormValues.title, title: expenseFormValues.title,
paidById: expenseFormValues.paidBy, paidById: expenseFormValues.paidBy,
splitMode: expenseFormValues.splitMode,
paidFor: { paidFor: {
connectOrCreate: expenseFormValues.paidFor.map((paidFor) => ({ create: expenseFormValues.paidFor
.filter(
(p) =>
!existingExpense.paidFor.some(
(pp) => pp.participantId === p.participant,
),
)
.map((paidFor) => ({
participantId: paidFor.participant,
shares: paidFor.shares,
})),
update: expenseFormValues.paidFor.map((paidFor) => ({
where: { where: {
expenseId_participantId: { expenseId, participantId: paidFor }, expenseId_participantId: {
expenseId,
participantId: paidFor.participant,
},
},
data: {
shares: paidFor.shares,
}, },
create: { participantId: paidFor },
})), })),
deleteMany: existingExpense.paidFor.filter( deleteMany: existingExpense.paidFor.filter(
(paidFor) => (paidFor) =>
!expenseFormValues.paidFor.some( !expenseFormValues.paidFor.some(
(pf) => pf === paidFor.participantId, (pf) => pf.participant === paidFor.participantId,
), ),
), ),
}, },

View File

@@ -1,5 +1,6 @@
import { getGroupExpenses } from '@/lib/api' import { getGroupExpenses } from '@/lib/api'
import { Participant } from '@prisma/client' import { Participant } from '@prisma/client'
import { match } from 'ts-pattern'
export type Balances = Record< export type Balances = Record<
Participant['id'], Participant['id'],
@@ -19,34 +20,42 @@ export function getBalances(
for (const expense of expenses) { for (const expense of expenses) {
const paidBy = expense.paidById const paidBy = expense.paidById
const paidFors = expense.paidFor.map((p) => p.participantId) const paidFors = expense.paidFor
if (!balances[paidBy]) balances[paidBy] = { paid: 0, paidFor: 0, total: 0 } if (!balances[paidBy]) balances[paidBy] = { paid: 0, paidFor: 0, total: 0 }
balances[paidBy].paid += expense.amount balances[paidBy].paid += expense.amount
balances[paidBy].total += expense.amount balances[paidBy].total += expense.amount
paidFors.forEach((paidFor, index) => {
if (!balances[paidFor])
balances[paidFor] = { paid: 0, paidFor: 0, total: 0 }
const dividedAmount = divide( const totalPaidForShares = paidFors.reduce(
expense.amount, (sum, paidFor) => sum + paidFor.shares,
paidFors.length, 0,
index === paidFors.length - 1, )
) let remaining = expense.amount
balances[paidFor].paidFor += dividedAmount paidFors.forEach((paidFor, index) => {
balances[paidFor].total -= dividedAmount if (!balances[paidFor.participantId])
balances[paidFor.participantId] = { paid: 0, paidFor: 0, total: 0 }
const isLast = index === paidFors.length - 1
const [shares, totalShares] = match(expense.splitMode)
.with('EVENLY', () => [1, paidFors.length])
.with('BY_SHARES', () => [paidFor.shares, totalPaidForShares])
.with('BY_PERCENTAGE', () => [paidFor.shares, totalPaidForShares])
.with('BY_AMOUNT', () => [paidFor.shares, totalPaidForShares])
.exhaustive()
const dividedAmount = isLast
? remaining
: Math.floor((expense.amount * shares) / totalShares)
remaining -= dividedAmount
balances[paidFor.participantId].paidFor += dividedAmount
balances[paidFor.participantId].total -= dividedAmount
}) })
} }
return balances return balances
} }
function divide(total: number, count: number, isLast: boolean): number {
if (!isLast) return Math.floor(total / count)
return total - divide(total, count, false) * (count - 1)
}
export function getSuggestedReimbursements( export function getSuggestedReimbursements(
balances: Balances, balances: Balances,
): Reimbursement[] { ): Reimbursement[] {

View File

@@ -1,3 +1,4 @@
import { SplitMode } from '@prisma/client'
import * as z from 'zod' import * as z from 'zod'
export const groupFormSchema = z export const groupFormSchema = z
@@ -38,36 +39,111 @@ export const groupFormSchema = z
export type GroupFormValues = z.infer<typeof groupFormSchema> export type GroupFormValues = z.infer<typeof groupFormSchema>
export const expenseFormSchema = z.object({ export const expenseFormSchema = z
title: z .object({
.string({ required_error: 'Please enter a title.' }) title: z
.min(2, 'Enter at least two characters.'), .string({ required_error: 'Please enter a title.' })
amount: z .min(2, 'Enter at least two characters.'),
.union( amount: z
[ .union(
z.number(), [
z.string().transform((value, ctx) => { z.number(),
const valueAsNumber = Number(value) z.string().transform((value, ctx) => {
if (Number.isNaN(valueAsNumber)) const valueAsNumber = Number(value)
if (Number.isNaN(valueAsNumber))
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid number.',
})
return Math.round(valueAsNumber * 100)
}),
],
{ required_error: 'You must enter an amount.' },
)
.refine((amount) => amount >= 1, 'The amount must be higher than 0.01.')
.refine(
(amount) => amount <= 10_000_000_00,
'The amount must be lower than 10,000,000.',
),
paidBy: z.string({ required_error: 'You must select a participant.' }),
paidFor: z
.array(
z.object({
participant: z.string(),
shares: z.union([
z.number(),
z.string().transform((value, ctx) => {
const valueAsNumber = Number(value)
if (Number.isNaN(valueAsNumber))
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid number.',
})
return Math.round(valueAsNumber * 100)
}),
]),
}),
)
.min(1, 'The expense must be paid for at least one participant.')
.superRefine((paidFor, ctx) => {
let sum = 0
for (const { shares } of paidFor) {
sum += shares
if (shares < 1) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: 'Invalid number.', message: 'All shares must be higher than 0.',
}) })
return Math.round(valueAsNumber * 100) }
}), }
], }),
{ required_error: 'You must enter an amount.' }, splitMode: z
) .enum<SplitMode, [SplitMode, ...SplitMode[]]>(
.refine((amount) => amount >= 1, 'The amount must be higher than 0.01.') Object.values(SplitMode) as any,
.refine( )
(amount) => amount <= 10_000_000_00, .default('EVENLY'),
'The amount must be lower than 10,000,000.', isReimbursement: z.boolean(),
), })
paidBy: z.string({ required_error: 'You must select a participant.' }), .superRefine((expense, ctx) => {
paidFor: z let sum = 0
.array(z.string()) for (const { shares } of expense.paidFor) {
.min(1, 'The expense must be paid for at least one participant.'), sum +=
isReimbursement: z.boolean(), typeof shares === 'number' ? shares : Math.round(Number(shares) * 100)
}) }
switch (expense.splitMode) {
case 'EVENLY':
break // noop
case 'BY_SHARES':
break // noop
case 'BY_AMOUNT': {
if (sum !== expense.amount) {
const detail =
sum < expense.amount
? `${((expense.amount - sum) / 100).toFixed(2)} missing`
: `${((sum - expense.amount) / 100).toFixed(2)} surplus`
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Sum of amounts must equal the expense amount (${detail}).`,
path: ['paidFor'],
})
}
break
}
case 'BY_PERCENTAGE': {
if (sum !== 10000) {
const detail =
sum < 10000
? `${((10000 - sum) / 100).toFixed(0)}% missing`
: `${((sum - 10000) / 100).toFixed(0)}% surplus`
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Sum of percentages must equal 100 (${detail})`,
path: ['paidFor'],
})
}
break
}
}
})
export type ExpenseFormValues = z.infer<typeof expenseFormSchema> export type ExpenseFormValues = z.infer<typeof expenseFormSchema>