Compare commits
142 Commits
split-unev
...
api
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b72fd2000 | ||
|
|
a7873fda02 | ||
|
|
c1c75fa260 | ||
|
|
9bdc8a715c | ||
|
|
35bbb04b9d | ||
|
|
5d96cdc1c2 | ||
|
|
56b4010b91 | ||
|
|
62cfad1a32 | ||
|
|
4db788680e | ||
|
|
39c1a2ffc6 | ||
|
|
f5154393e2 | ||
|
|
e9d583113a | ||
|
|
21d0c02687 | ||
|
|
2281316d58 | ||
|
|
210c12b7ef | ||
|
|
66e15e419e | ||
|
|
727803ea5c | ||
|
|
7add7efea2 | ||
|
|
a7c80f65c3 | ||
|
|
1e4edf7504 | ||
|
|
24053ca5ab | ||
|
|
343363d54f | ||
|
|
8742bd59da | ||
|
|
8eea062218 | ||
|
|
9a5674e239 | ||
|
|
50b3a2e431 | ||
|
|
e8d46cd4f3 | ||
|
|
8f896f7412 | ||
|
|
504631454a | ||
|
|
345f3716c9 | ||
|
|
5fff8da08d | ||
|
|
07e24f7fcb | ||
|
|
5dfe03b3f1 | ||
|
|
26bed11116 | ||
|
|
972bb9dadb | ||
|
|
4f5e124ff0 | ||
|
|
c392c06b39 | ||
|
|
002e867bc4 | ||
|
|
9b8f716a6a | ||
|
|
853f1791d2 | ||
|
|
7145cb6f30 | ||
|
|
e990e00a75 | ||
|
|
0c05499107 | ||
|
|
3887efd9ee | ||
|
|
e619c1a5b4 | ||
|
|
10e13d1f6b | ||
|
|
f9d915378b | ||
|
|
74465c0565 | ||
|
|
d3fd8027a5 | ||
|
|
833237b613 | ||
|
|
1cd2b273f9 | ||
|
|
1ad470309b | ||
|
|
2fd38aadd9 | ||
|
|
b61d1836ea | ||
|
|
c3903849ec | ||
|
|
b67a0be0dd | ||
|
|
e07d237218 | ||
|
|
cc37083389 | ||
|
|
552953151a | ||
|
|
b227401dd6 | ||
|
|
6a5efc5f3f | ||
|
|
4c5f8a6aa5 | ||
|
|
c2b591349b | ||
|
|
56c1865264 | ||
|
|
2f991e680b | ||
|
|
2af0660383 | ||
|
|
50525ad881 | ||
|
|
f7a13a0436 | ||
|
|
5b65b8f049 | ||
|
|
0e6a2bdc6c | ||
|
|
be0964d9e1 | ||
|
|
fb49fb596a | ||
|
|
10fd69404a | ||
|
|
6dd631b03a | ||
|
|
08d75fd75c | ||
|
|
e6467b41fc | ||
|
|
4a9bf575bd | ||
|
|
9e300e0ff0 | ||
|
|
3847a67a19 | ||
|
|
7695ffd62d | ||
|
|
091cd02c06 | ||
|
|
9876d7045f | ||
|
|
9759f61e0e | ||
|
|
d43e731fe1 | ||
|
|
11d2e298e8 | ||
|
|
0647000a77 | ||
|
|
2228415323 | ||
|
|
58ee685e22 | ||
|
|
545cf75e99 | ||
|
|
7956156d70 | ||
|
|
2f58e466da | ||
|
|
89ee5ae247 | ||
|
|
1bd3f99d38 | ||
|
|
e32a12ce41 | ||
|
|
49218e8e9d | ||
|
|
23eedcb619 | ||
|
|
ba4107e440 | ||
|
|
ae7cb2ccc8 | ||
|
|
3735509fea | ||
|
|
1b1ebf015e | ||
|
|
c138afadb9 | ||
|
|
18ac2142a8 | ||
|
|
875b9787d0 | ||
|
|
4d86c8c727 | ||
|
|
23524cb943 | ||
|
|
f9040f8bed | ||
|
|
395c86666c | ||
|
|
2728f24989 | ||
|
|
314eba284b | ||
|
|
92156b29cb | ||
|
|
c4de3f605c | ||
|
|
ff6b84ff88 | ||
|
|
6b6d58e95e | ||
|
|
d809e10d19 | ||
|
|
36cc4f1ef7 | ||
|
|
1141501edb | ||
|
|
28902ad0ea | ||
|
|
8abdcb7d6f | ||
|
|
43f7ca700b | ||
|
|
beae336666 | ||
|
|
2dcb80f954 | ||
|
|
c7fb810f80 | ||
|
|
45ee9cdba4 | ||
|
|
057f3e9c53 | ||
|
|
76427c9f13 | ||
|
|
ddce4d0bdb | ||
|
|
cf41048aea | ||
|
|
f20ebd5bdd | ||
|
|
9c728530c9 | ||
|
|
323b0ea128 | ||
|
|
5ce96aef30 | ||
|
|
a258e85fae | ||
|
|
1b9e624004 | ||
|
|
6bd3299331 | ||
|
|
a942369193 | ||
|
|
e891d259a5 | ||
|
|
d9aeb45c83 | ||
|
|
76befff481 | ||
|
|
55883ce414 | ||
|
|
bec1dd270a | ||
|
|
4566900f9c | ||
|
|
0a8e56f800 |
42
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,42 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node-postgres
|
||||
{
|
||||
"name": "spliit",
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
"service": "app",
|
||||
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {
|
||||
// "ghcr.io/frntn/devcontainers-features/prism:1": {}
|
||||
// },
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "cp container.env.example .env && npm install",
|
||||
"postAttachCommand": {
|
||||
"npm": "npm run dev"
|
||||
},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// This can be used to network with other containers or with the host.
|
||||
"forwardPorts": [3000, 5432],
|
||||
"portsAttributes": {
|
||||
"3000": {
|
||||
"label": "App"
|
||||
},
|
||||
"5432": {
|
||||
"label": "PostgreSQL"
|
||||
}
|
||||
},
|
||||
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
"codespaces": {
|
||||
"openFiles": [
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
33
.devcontainer/docker-compose.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
image: mcr.microsoft.com/devcontainers/typescript-node:latest
|
||||
|
||||
volumes:
|
||||
- ../..:/workspaces:cached
|
||||
|
||||
# Overrides default command so things don't shut down after the process ends.
|
||||
command: sleep infinity
|
||||
|
||||
# Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function.
|
||||
network_mode: service:db
|
||||
|
||||
# Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
|
||||
# (Adding the "ports" property to this file will not forward from a Codespace.)
|
||||
|
||||
db:
|
||||
image: postgres:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_PASSWORD: 1234
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: postgres
|
||||
|
||||
# Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally.
|
||||
# (Adding the "ports" property to this file will not forward from a Codespace.)
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
@@ -1,3 +1,2 @@
|
||||
POSTGRES_PRISMA_URL=postgresql://postgres:1234@localhost
|
||||
POSTGRES_URL_NON_POOLING=postgresql://postgres:1234@localhost
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
POSTGRES_URL_NON_POOLING=postgresql://postgres:1234@localhost
|
||||
13
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: ['scastiel']
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
custom: ['https://donate.stripe.com/28o3eh96G7hH8k89Ba']
|
||||
36
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
pull_request:
|
||||
branches: ['main']
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Generate Prisma client
|
||||
run: npx prisma generate
|
||||
|
||||
- name: Check TypeScript types
|
||||
run: npm run check-types
|
||||
|
||||
- name: Check ESLint
|
||||
run: npm run lint
|
||||
|
||||
- name: Check Prettier formatting
|
||||
run: npm run check-formatting
|
||||
5
.gitignore
vendored
@@ -27,7 +27,8 @@ yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
.env
|
||||
*.env
|
||||
!scripts/build.env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
@@ -38,3 +39,5 @@ next-env.d.ts
|
||||
|
||||
# db
|
||||
postgres-data
|
||||
|
||||
/dist
|
||||
|
||||
1
.prettierignore
Normal file
@@ -0,0 +1 @@
|
||||
src/components/ui
|
||||
85
README.md
@@ -1,6 +1,8 @@
|
||||
[<img alt="Spliit" height="60" src="https://github.com/scastiel/spliit2/blob/main/public/logo-with-text.png?raw=true" />](https://spliit.app)
|
||||
[<img alt="Spliit" height="60" src="https://github.com/spliit-app/spliit/blob/main/public/logo-with-text.png?raw=true" />](https://spliit.app)
|
||||
|
||||
Spliit is a free and open source alternative to Splitwise. I created it back in 2022 as a side project to learn the Go language, but rewrote it with Next.js since.
|
||||
Spliit is a free and open source alternative to Splitwise. You can either use the official instance at [Spliit.app](https://spliit.app), or deploy your own instance:
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fspliit-app%2Fspliit&project-name=my-spliit-instance&repository-name=my-spliit-instance&stores=%5B%7B%22type%22%3A%22postgres%22%7D%5D&)
|
||||
|
||||
## Features
|
||||
|
||||
@@ -10,12 +12,18 @@ Spliit is a free and open source alternative to Splitwise. I created it back in
|
||||
- [x] Create reimbursement expenses
|
||||
- [x] Progressive Web App
|
||||
- [x] Select all/no participant for expenses
|
||||
- [x] Split expenses unevenly [(#6)](https://github.com/spliit-app/spliit/issues/6)
|
||||
- [x] Mark a group as favorite [(#29)](https://github.com/spliit-app/spliit/issues/29)
|
||||
- [x] Tell the application who you are when opening a group [(#7)](https://github.com/spliit-app/spliit/issues/7)
|
||||
- [x] Assign a category to expenses [(#35)](https://github.com/spliit-app/spliit/issues/35)
|
||||
- [x] Search for expenses in a group [(#51)](https://github.com/spliit-app/spliit/issues/51)
|
||||
- [x] Upload and attach images to expenses [(#63)](https://github.com/spliit-app/spliit/issues/63)
|
||||
- [x] Create expense by scanning a receipt [(#23)](https://github.com/spliit-app/spliit/issues/23)
|
||||
|
||||
### Possible incoming features
|
||||
|
||||
- [ ] Tell the application who you are when opening a group [(#7)](https://github.com/scastiel/spliit2/issues/7)
|
||||
- [ ] Ability to create recurring expenses [(#5)](https://github.com/scastiel/spliit2/issues/5)
|
||||
- [ ] Ability to split expenses unevenly [(#6)](https://github.com/scastiel/spliit2/issues/6)
|
||||
- [ ] Ability to create recurring expenses [(#5)](https://github.com/spliit-app/spliit/issues/5)
|
||||
- [ ] Import expenses from Splitwise [(#22)](https://github.com/spliit-app/spliit/issues/22)
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -29,13 +37,72 @@ Spliit is a free and open source alternative to Splitwise. I created it back in
|
||||
|
||||
The project is open to contributions. Feel free to open an issue or even a pull-request!
|
||||
|
||||
If you want to contribute financially and help us keep the application free and without ads, you can also:
|
||||
|
||||
- 💜 [Sponsor me (Sebastien)](https://github.com/sponsors/scastiel), or
|
||||
- 💙 [Make a small one-time donation](https://donate.stripe.com/28o3eh96G7hH8k89Ba).
|
||||
|
||||
## Run locally
|
||||
|
||||
1. Clone the repository (or fork it if you intend to contribute)
|
||||
2. `npm install`
|
||||
3. Start a PostgreSQL server. You can run `./start-local-db.sh` if you don’t have a server already.
|
||||
4. Copy the file `.env.example` as `.env`
|
||||
5. `npm run dev`
|
||||
2. Start a PostgreSQL server. You can run `./scripts/start-local-db.sh` if you don’t have a server already.
|
||||
3. Copy the file `.env.example` as `.env`
|
||||
4. Run `npm install` to install dependencies. This will also apply database migrations and update Prisma Client.
|
||||
5. Run `npm run dev` to start the development server
|
||||
|
||||
## Run in a container
|
||||
|
||||
1. Run `npm run build-image` to build the docker image from the Dockerfile
|
||||
2. Copy the file `container.env.example` as `container.env`
|
||||
3. Run `npm run start-container` to start the postgres and the spliit2 containers
|
||||
4. You can access the app by browsing to http://localhost:3000
|
||||
|
||||
## Opt-in features
|
||||
|
||||
### Expense documents
|
||||
|
||||
Spliit offers users to upload images (to an AWS S3 bucket) and attach them to expenses. To enable this feature:
|
||||
|
||||
- Follow the instructions in the _S3 bucket_ and _IAM user_ sections of [next-s3-upload](https://next-s3-upload.codingvalue.com/setup#s3-bucket) to create and set up an S3 bucket where images will be stored.
|
||||
- Update your environments variables with appropriate values:
|
||||
|
||||
```.env
|
||||
NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS=true
|
||||
S3_UPLOAD_KEY=AAAAAAAAAAAAAAAAAAAA
|
||||
S3_UPLOAD_SECRET=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||
S3_UPLOAD_BUCKET=name-of-s3-bucket
|
||||
S3_UPLOAD_REGION=us-east-1
|
||||
```
|
||||
|
||||
You can also use other S3 providers by providing a custom endpoint:
|
||||
|
||||
```.env
|
||||
S3_UPLOAD_ENDPOINT=http://localhost:9000
|
||||
```
|
||||
|
||||
### Create expense from receipt
|
||||
|
||||
You can offer users to create expense by uploading a receipt. This feature relies on [OpenAI GPT-4 with Vision](https://platform.openai.com/docs/guides/vision) and a public S3 storage endpoint.
|
||||
|
||||
To enable the feature:
|
||||
|
||||
- You must enable expense documents feature as well (see section above). That might change in the future, but for now we need to store images to make receipt scanning work.
|
||||
- Subscribe to OpenAI API and get access to GPT 4 with Vision (you might need to buy credits in advance).
|
||||
- Update your environment variables with appropriate values:
|
||||
|
||||
```.env
|
||||
NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT=true
|
||||
OPENAI_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
```
|
||||
|
||||
### Deduce category from title
|
||||
|
||||
You can offer users to automatically deduce the expense category from the title. Since this feature relies on a OpenAI subscription, follow the signup instructions above and configure the following environment variables:
|
||||
|
||||
```.env
|
||||
NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT=true
|
||||
OPENAI_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
11
eslint.config.mjs
Normal file
@@ -0,0 +1,11 @@
|
||||
import pluginJs from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default [
|
||||
{ files: ['**/*.{js,mjs,cjs,ts}'] },
|
||||
{ languageOptions: { globals: globals.browser } },
|
||||
pluginJs.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
]
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {}
|
||||
|
||||
const { withPlausibleProxy } = require('next-plausible')
|
||||
module.exports = withPlausibleProxy()(nextConfig)
|
||||
4671
package-lock.json
generated
65
package.json
@@ -1,60 +1,53 @@
|
||||
{
|
||||
"name": "spliit2",
|
||||
"name": "spliit-api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"postinstall": "prisma migrate deploy && prisma generate"
|
||||
"lint": "eslint",
|
||||
"check-types": "tsc --noEmit",
|
||||
"check-formatting": "prettier -c src",
|
||||
"prettier": "prettier -w src",
|
||||
"postinstall": "prisma generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formatjs/intl-localematcher": "^0.5.4",
|
||||
"@hookform/resolvers": "^3.3.2",
|
||||
"@prisma/client": "5.6.0",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-hover-card": "^1.0.7",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@prisma/client": "^5.6.0",
|
||||
"@trpc/client": "^11.0.0-rc.586",
|
||||
"@trpc/react-query": "^11.0.0-rc.586",
|
||||
"@trpc/server": "^11.0.0-rc.586",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"lucide-react": "^0.290.0",
|
||||
"content-disposition": "^0.5.4",
|
||||
"dayjs": "^1.11.10",
|
||||
"nanoid": "^5.0.4",
|
||||
"next": "^14.0.4",
|
||||
"next-plausible": "^3.12.0",
|
||||
"next-themes": "^0.2.1",
|
||||
"next13-progressbar": "^1.1.1",
|
||||
"negotiator": "^0.6.3",
|
||||
"openai": "^4.25.0",
|
||||
"pg": "^8.11.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.47.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"prisma": "^5.7.0",
|
||||
"sharp": "^0.33.2",
|
||||
"superjson": "^2.2.1",
|
||||
"ts-pattern": "^5.0.6",
|
||||
"uuid": "^9.0.1",
|
||||
"zod": "^3.22.4"
|
||||
"vaul": "^0.8.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.13.0",
|
||||
"@total-typescript/ts-reset": "^0.5.1",
|
||||
"@types/content-disposition": "^0.5.8",
|
||||
"@types/negotiator": "^0.6.3",
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.10.9",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/uuid": "^9.0.6",
|
||||
"autoprefixer": "^10",
|
||||
"dotenv": "^16.3.1",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "^14.0.4",
|
||||
"postcss": "^8",
|
||||
"eslint": "^8.57.1",
|
||||
"globals": "^15.11.0",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier-plugin-organize-imports": "^3.2.3",
|
||||
"prisma": "^5.7.0",
|
||||
"tailwindcss": "^3",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.3.3"
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^8.11.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ExpensePaidFor" ADD COLUMN "shares" INTEGER NOT NULL DEFAULT 1;
|
||||
@@ -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';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Expense" ADD COLUMN "expenseDate" DATE NOT NULL DEFAULT CURRENT_DATE;
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `categoryId` to the `Expense` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Expense" ADD COLUMN "categoryId" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Category" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"grouping" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Insert categories
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (0, 'Uncategorized', 'General');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (1, 'Uncategorized', 'Payment');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (2, 'Entertainment', 'Entertainment');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (3, 'Entertainment', 'Games');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (4, 'Entertainment', 'Movies');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (5, 'Entertainment', 'Music');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (6, 'Entertainment', 'Sports');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (7, 'Food and Drink', 'Food and Drink');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (8, 'Food and Drink', 'Dining Out');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (9, 'Food and Drink', 'Groceries');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (10, 'Food and Drink', 'Liquor');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (11, 'Home', 'Home');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (12, 'Home', 'Electronics');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (13, 'Home', 'Furniture');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (14, 'Home', 'Household Supplies');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (15, 'Home', 'Maintenance');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (16, 'Home', 'Mortgage');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (17, 'Home', 'Pets');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (18, 'Home', 'Rent');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (19, 'Home', 'Services');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (20, 'Life', 'Childcare');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (21, 'Life', 'Clothing');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (22, 'Life', 'Education');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (23, 'Life', 'Gifts');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (24, 'Life', 'Insurance');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (25, 'Life', 'Medical Expenses');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (26, 'Life', 'Taxes');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (27, 'Transportation', 'Transportation');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (28, 'Transportation', 'Bicycle');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (29, 'Transportation', 'Bus/Train');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (30, 'Transportation', 'Car');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (31, 'Transportation', 'Gas/Fuel');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (32, 'Transportation', 'Hotel');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (33, 'Transportation', 'Parking');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (34, 'Transportation', 'Plane');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (35, 'Transportation', 'Taxi');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (36, 'Utilities', 'Utilities');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (37, 'Utilities', 'Cleaning');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (38, 'Utilities', 'Electricity');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (39, 'Utilities', 'Heat/Gas');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (40, 'Utilities', 'Trash');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (41, 'Utilities', 'TV/Phone/Internet');
|
||||
INSERT INTO "Category" ("id", "grouping", "name") VALUES (42, 'Utilities', 'Water');
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Expense" ADD CONSTRAINT "Expense_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Expense" ADD COLUMN "documentUrls" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The `documentUrls` column on the `Expense` table would be dropped and recreated. This will lead to data loss if there is data in the column.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Expense" DROP COLUMN "documentUrls",
|
||||
ADD COLUMN "documentUrls" JSONB[] DEFAULT ARRAY[]::JSONB[];
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `documentUrls` on the `Expense` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Expense" DROP COLUMN "documentUrls";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ExpenseDocument" (
|
||||
"id" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"expenseId" TEXT,
|
||||
|
||||
CONSTRAINT "ExpenseDocument_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ExpenseDocument" ADD CONSTRAINT "ExpenseDocument_expenseId_fkey" FOREIGN KEY ("expenseId") REFERENCES "Expense"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
10
prisma/migrations/20240128202400_add_doc_info/migration.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `height` to the `ExpenseDocument` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `width` to the `ExpenseDocument` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "ExpenseDocument" ADD COLUMN "height" INTEGER NOT NULL,
|
||||
ADD COLUMN "width" INTEGER NOT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Expense" ADD COLUMN "notes" TEXT;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ActivityType" AS ENUM ('UPDATE_GROUP', 'CREATE_EXPENSE', 'UPDATE_EXPENSE', 'DELETE_EXPENSE');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Activity" (
|
||||
"id" TEXT NOT NULL,
|
||||
"groupId" TEXT NOT NULL,
|
||||
"time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"activityType" "ActivityType" NOT NULL,
|
||||
"participantId" TEXT,
|
||||
"expenseId" TEXT,
|
||||
"data" TEXT,
|
||||
|
||||
CONSTRAINT "Activity_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Group" ADD COLUMN "information" TEXT;
|
||||
@@ -14,9 +14,11 @@ datasource db {
|
||||
model Group {
|
||||
id String @id
|
||||
name String
|
||||
information String? @db.Text
|
||||
currency String @default("$")
|
||||
participants Participant[]
|
||||
expenses Expense[]
|
||||
activities Activity[]
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
@@ -29,17 +31,46 @@ model Participant {
|
||||
expensesPaidFor ExpensePaidFor[]
|
||||
}
|
||||
|
||||
model Category {
|
||||
id Int @id @default(autoincrement())
|
||||
grouping String
|
||||
name String
|
||||
Expense Expense[]
|
||||
}
|
||||
|
||||
model Expense {
|
||||
id String @id
|
||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
id String @id
|
||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
expenseDate DateTime @default(dbgenerated("CURRENT_DATE")) @db.Date
|
||||
title String
|
||||
category Category? @relation(fields: [categoryId], references: [id])
|
||||
categoryId Int @default(0)
|
||||
amount Int
|
||||
paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade)
|
||||
paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade)
|
||||
paidById String
|
||||
paidFor ExpensePaidFor[]
|
||||
groupId String
|
||||
isReimbursement Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
isReimbursement Boolean @default(false)
|
||||
splitMode SplitMode @default(EVENLY)
|
||||
createdAt DateTime @default(now())
|
||||
documents ExpenseDocument[]
|
||||
notes String?
|
||||
}
|
||||
|
||||
model ExpenseDocument {
|
||||
id String @id
|
||||
url String
|
||||
width Int
|
||||
height Int
|
||||
Expense Expense? @relation(fields: [expenseId], references: [id])
|
||||
expenseId String?
|
||||
}
|
||||
|
||||
enum SplitMode {
|
||||
EVENLY
|
||||
BY_SHARES
|
||||
BY_PERCENTAGE
|
||||
BY_AMOUNT
|
||||
}
|
||||
|
||||
model ExpensePaidFor {
|
||||
@@ -47,6 +78,25 @@ model ExpensePaidFor {
|
||||
participant Participant @relation(fields: [participantId], references: [id], onDelete: Cascade)
|
||||
expenseId String
|
||||
participantId String
|
||||
shares Int @default(1)
|
||||
|
||||
@@id([expenseId, participantId])
|
||||
}
|
||||
|
||||
model Activity {
|
||||
id String @id
|
||||
group Group @relation(fields: [groupId], references: [id])
|
||||
groupId String
|
||||
time DateTime @default(now())
|
||||
activityType ActivityType
|
||||
participantId String?
|
||||
expenseId String?
|
||||
data String?
|
||||
}
|
||||
|
||||
enum ActivityType {
|
||||
UPDATE_GROUP
|
||||
CREATE_EXPENSE
|
||||
UPDATE_EXPENSE
|
||||
DELETE_EXPENSE
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 52 KiB |
@@ -1 +0,0 @@
|
||||
<?xml version="1.0" ?><svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:#ccc;}.cls-2{fill:#ed5167;}.cls-3{fill:#72d3b8;}.cls-4{fill:#55bc9c;}.cls-5{fill:#e8e8e8;}.cls-6{fill:#333538;}</style></defs><title/><g data-name="1 funnel" id="_1_funnel"><path class="cls-1" d="M261.25,134.9h0a76.62,76.62,0,0,1,76.62,76.62V423.22a0,0,0,0,1,0,0H261.25a0,0,0,0,1,0,0V134.9a0,0,0,0,1,0,0Z" transform="translate(285.06 -130.08) rotate(45)"/><path class="cls-2" d="M161.87,470.93l75.07-75.08a17.06,17.06,0,0,0,0-24.14l-40.19-40.19a17.06,17.06,0,0,0-24.14,0l-10.8,10.8a91,91,0,0,0-129.58.92c-34.17,35.14-34.17,91.69,0,126.83a91,91,0,0,0,129.58.92ZM57,447.11a57.21,57.21,0,1,1,80.9,0A57.22,57.22,0,0,1,57,447.11Z"/><rect class="cls-3" height="253.49" rx="47.61" transform="translate(511.66 282.22) rotate(180)" width="344.78" x="83.44" y="14.36"/><path class="cls-4" d="M126,184.86V97.36a40.46,40.46,0,0,0,40.46-40.45H345.23a40.44,40.44,0,0,0,40.44,40.45v87.5a40.44,40.44,0,0,0-40.44,40.44H166.44A40.46,40.46,0,0,0,126,184.86Z"/><circle class="cls-3" cx="255.83" cy="141.11" r="60.11"/><circle class="cls-3" cx="349.72" cy="141.11" r="21.88"/><circle class="cls-3" cx="161.94" cy="141.11" r="21.88"/><path class="cls-5" d="M174.13,134.9h76.62a0,0,0,0,1,0,0V346.61a76.62,76.62,0,0,1-76.62,76.62h0a0,0,0,0,1,0,0V134.9a0,0,0,0,1,0,0Z" transform="translate(559.99 326.17) rotate(135)"/><path class="cls-2" d="M350.19,471a91,91,0,0,0,129.58-.92c34.17-35.14,34.17-91.69,0-126.83a91,91,0,0,0-129.58-.92l-10.8-10.8a17.06,17.06,0,0,0-24.14,0l-40.19,40.19a17.06,17.06,0,0,0,0,24.14l75.07,75.08Zm23.89-23.88a57.21,57.21,0,1,1,80.9,0A57.2,57.2,0,0,1,374.08,447.11Z"/><circle class="cls-6" cx="256" cy="322.62" r="20.01"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
12
scripts/build-image.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
|
||||
SPLIIT_APP_NAME=$(node -p -e "require('./package.json').name")
|
||||
SPLIIT_VERSION=$(node -p -e "require('./package.json').version")
|
||||
|
||||
# we need to set dummy data for POSTGRES env vars in order for build not to fail
|
||||
docker buildx build \
|
||||
-t ${SPLIIT_APP_NAME}:${SPLIIT_VERSION} \
|
||||
-t ${SPLIIT_APP_NAME}:latest \
|
||||
.
|
||||
|
||||
docker image prune -f
|
||||
22
scripts/build.env
Normal file
@@ -0,0 +1,22 @@
|
||||
# build file that contains all possible env vars with mocked values
|
||||
# as most of them are used at build time in order to have the production build to work properly
|
||||
|
||||
# db
|
||||
POSTGRES_PASSWORD=1234
|
||||
|
||||
# app
|
||||
POSTGRES_PRISMA_URL=postgresql://postgres:${POSTGRES_PASSWORD}@db
|
||||
POSTGRES_URL_NON_POOLING=postgresql://postgres:${POSTGRES_PASSWORD}@db
|
||||
|
||||
# app-minio
|
||||
NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS=false
|
||||
S3_UPLOAD_KEY=AAAAAAAAAAAAAAAAAAAA
|
||||
S3_UPLOAD_SECRET=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||
S3_UPLOAD_BUCKET=spliit
|
||||
S3_UPLOAD_REGION=eu-north-1
|
||||
S3_UPLOAD_ENDPOINT=s3://minio.example.com
|
||||
|
||||
# app-openai
|
||||
NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT=false
|
||||
OPENAI_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT=false
|
||||
6
scripts/container-entrypoint.sh
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
npx prisma migrate deploy
|
||||
npm run start
|
||||
@@ -1,4 +1,4 @@
|
||||
result=$(docker ps | grep postgres)
|
||||
result=$(docker ps | grep spliit-db)
|
||||
if [ $? -eq 0 ];
|
||||
then
|
||||
echo "postgres is already running, doing nothing"
|
||||
@@ -6,6 +6,6 @@ else
|
||||
echo "postgres is not running, starting it"
|
||||
docker rm postgres --force
|
||||
mkdir -p postgres-data
|
||||
docker run --name postgres -d -p 5432:5432 -e POSTGRES_PASSWORD=1234 -v ./postgres-data:/var/lib/postgresql/data postgres
|
||||
docker run --name spliit-db -d -p 5432:5432 -e POSTGRES_PASSWORD=1234 -v "/$(pwd)/postgres-data:/var/lib/postgresql/data" postgres
|
||||
sleep 5 # Wait for postgres to start
|
||||
fi
|
||||
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,67 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 163 94% 24%;
|
||||
--primary-foreground: 0 100% 100%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 142.1 76.2% 36.3%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 20 14.3% 4.1%;
|
||||
--foreground: 0 0% 95%;
|
||||
--card: 24 9.8% 10%;
|
||||
--card-foreground: 0 0% 95%;
|
||||
--popover: 0 0% 9%;
|
||||
--popover-foreground: 0 0% 95%;
|
||||
--primary: 161 90% 45%;
|
||||
--primary-foreground: 144.9 80.4% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 15%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 12 6.5% 15.1%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 85.7% 97.3%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 142.4 71.8% 29.2%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
.landing-header {
|
||||
@apply bg-gradient-to-br from-emerald-800 to-emerald-600 dark:from-emerald-300 dark:to-emerald-600 bg-clip-text;
|
||||
}
|
||||
|
||||
.landing-header strong {
|
||||
@apply font-bold text-transparent;
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Balances } from '@/lib/balances'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Participant } from '@prisma/client'
|
||||
|
||||
type Props = {
|
||||
balances: Balances
|
||||
participants: Participant[]
|
||||
currency: string
|
||||
}
|
||||
|
||||
export function BalancesList({ balances, participants, currency }: Props) {
|
||||
const maxBalance = Math.max(
|
||||
...Object.values(balances).map((b) => Math.abs(b.total)),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="text-sm">
|
||||
{participants.map((participant) => {
|
||||
const balance = balances[participant.id]?.total ?? 0
|
||||
const isLeft = balance >= 0
|
||||
return (
|
||||
<div
|
||||
key={participant.id}
|
||||
className={cn('flex', isLeft || 'flex-row-reverse')}
|
||||
>
|
||||
<div className={cn('w-1/2 p-2', isLeft && 'text-right')}>
|
||||
{participant.name}
|
||||
</div>
|
||||
<div className={cn('w-1/2 relative', isLeft || 'text-right')}>
|
||||
<div className="absolute inset-0 p-2 z-20">
|
||||
{currency} {(balance / 100).toFixed(2)}
|
||||
</div>
|
||||
{balance !== 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-1 h-7 z-10',
|
||||
isLeft
|
||||
? 'bg-green-200 dark:bg-green-800 left-0 rounded-r-lg border border-green-300 dark:border-green-700'
|
||||
: 'bg-red-200 dark:bg-red-800 right-0 rounded-l-lg border border-red-300 dark:border-red-700',
|
||||
)}
|
||||
style={{
|
||||
width: (Math.abs(balance) / maxBalance) * 100 + '%',
|
||||
}}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { BalancesList } from '@/app/groups/[groupId]/balances-list'
|
||||
import { ReimbursementList } from '@/app/groups/[groupId]/reimbursement-list'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { getGroup, getGroupExpenses } from '@/lib/api'
|
||||
import { getBalances, getSuggestedReimbursements } from '@/lib/balances'
|
||||
import { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Balances',
|
||||
}
|
||||
|
||||
export default async function GroupPage({
|
||||
params: { groupId },
|
||||
}: {
|
||||
params: { groupId: string }
|
||||
}) {
|
||||
const group = await getGroup(groupId)
|
||||
if (!group) notFound()
|
||||
|
||||
const expenses = await getGroupExpenses(groupId)
|
||||
const balances = getBalances(expenses)
|
||||
const reimbursements = getSuggestedReimbursements(balances)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Balances</CardTitle>
|
||||
<CardDescription>
|
||||
This is the amount that each participant paid or was paid for.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BalancesList
|
||||
balances={balances}
|
||||
participants={group.participants}
|
||||
currency={group.currency}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Suggested reimbursements</CardTitle>
|
||||
<CardDescription>
|
||||
Here are suggestions for optimized reimbursements between
|
||||
participants.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<ReimbursementList
|
||||
reimbursements={reimbursements}
|
||||
participants={group.participants}
|
||||
currency={group.currency}
|
||||
groupId={groupId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { GroupForm } from '@/components/group-form'
|
||||
import { getGroup, getGroupExpensesParticipants, updateGroup } from '@/lib/api'
|
||||
import { groupFormSchema } from '@/lib/schemas'
|
||||
import { Metadata } from 'next'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Settings',
|
||||
}
|
||||
|
||||
export default async function EditGroupPage({
|
||||
params: { groupId },
|
||||
}: {
|
||||
params: { groupId: string }
|
||||
}) {
|
||||
const group = await getGroup(groupId)
|
||||
if (!group) notFound()
|
||||
|
||||
async function updateGroupAction(values: unknown) {
|
||||
'use server'
|
||||
const groupFormValues = groupFormSchema.parse(values)
|
||||
const group = await updateGroup(groupId, groupFormValues)
|
||||
redirect(`/groups/${group.id}`)
|
||||
}
|
||||
|
||||
const protectedParticipantIds = await getGroupExpensesParticipants(groupId)
|
||||
return (
|
||||
<GroupForm
|
||||
group={group}
|
||||
onSubmit={updateGroupAction}
|
||||
protectedParticipantIds={protectedParticipantIds}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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} />
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
'use client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getGroupExpenses } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Participant } from '@prisma/client'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Fragment } from 'react'
|
||||
|
||||
type Props = {
|
||||
expenses: Awaited<ReturnType<typeof getGroupExpenses>>
|
||||
participants: Participant[]
|
||||
currency: string
|
||||
groupId: string
|
||||
}
|
||||
|
||||
export function ExpenseList({
|
||||
expenses,
|
||||
currency,
|
||||
participants,
|
||||
groupId,
|
||||
}: Props) {
|
||||
const getParticipant = (id: string) => participants.find((p) => p.id === id)
|
||||
const router = useRouter()
|
||||
|
||||
return expenses.length > 0 ? (
|
||||
expenses.map((expense) => (
|
||||
<div
|
||||
key={expense.id}
|
||||
className={cn(
|
||||
'border-t flex justify-between pl-6 pr-2 py-4 text-sm cursor-pointer hover:bg-accent',
|
||||
expense.isReimbursement && 'italic',
|
||||
)}
|
||||
onClick={() => {
|
||||
router.push(`/groups/${groupId}/expenses/${expense.id}/edit`)
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className={cn('mb-1', expense.isReimbursement && 'italic')}>
|
||||
{expense.title}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Paid by <strong>{getParticipant(expense.paidById)?.name}</strong>{' '}
|
||||
for{' '}
|
||||
{expense.paidFor.map((paidFor, index) => (
|
||||
<Fragment key={index}>
|
||||
{index !== 0 && <>, </>}
|
||||
<strong>
|
||||
{
|
||||
participants.find((p) => p.id === paidFor.participantId)
|
||||
?.name
|
||||
}
|
||||
</strong>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={cn(
|
||||
'tabular-nums whitespace-nowrap',
|
||||
expense.isReimbursement ? 'italic' : 'font-bold',
|
||||
)}
|
||||
>
|
||||
{currency} {(expense.amount / 100).toFixed(2)}
|
||||
</div>
|
||||
<Button size="icon" variant="link" className="-my-2" asChild>
|
||||
<Link href={`/groups/${groupId}/expenses/${expense.id}/edit`}>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="px-6 text-sm py-6">
|
||||
Your group doesn’t contain any expense yet.{' '}
|
||||
<Button variant="link" asChild className="-m-4">
|
||||
<Link href={`/groups/${groupId}/expenses/create`}>
|
||||
Create the first one
|
||||
</Link>
|
||||
</Button>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { ExpenseList } from '@/app/groups/[groupId]/expenses/expense-list'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { getGroup, getGroupExpenses } from '@/lib/api'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Expenses',
|
||||
}
|
||||
|
||||
export default async function GroupExpensesPage({
|
||||
params: { groupId },
|
||||
}: {
|
||||
params: { groupId: string }
|
||||
}) {
|
||||
return (
|
||||
<Card className="mb-4">
|
||||
<div className="flex flex-1">
|
||||
<CardHeader className="flex-1">
|
||||
<CardTitle>Expenses</CardTitle>
|
||||
<CardDescription>
|
||||
Here are the expenses that you created for your group.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardHeader>
|
||||
<Button asChild size="icon">
|
||||
<Link href={`/groups/${groupId}/expenses/create`}>
|
||||
<Plus />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
</div>
|
||||
|
||||
<CardContent className="p-0">
|
||||
<Suspense
|
||||
fallback={[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-t flex justify-between items-center px-6 py-4 text-sm"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-4 w-16 rounded-full" />
|
||||
<Skeleton className="h-4 w-32 rounded-full" />
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className="h-4 w-16 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
>
|
||||
<Expenses groupId={groupId} />
|
||||
</Suspense>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
async function Expenses({ groupId }: { groupId: string }) {
|
||||
const group = await getGroup(groupId)
|
||||
if (!group) notFound()
|
||||
const expenses = await getGroupExpenses(group.id)
|
||||
|
||||
return (
|
||||
<ExpenseList
|
||||
expenses={expenses}
|
||||
groupId={group.id}
|
||||
currency={group.currency}
|
||||
participants={group.participants}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
'use client'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
|
||||
type Props = {
|
||||
groupId: string
|
||||
}
|
||||
|
||||
export function GroupTabs({ groupId }: Props) {
|
||||
const pathname = usePathname()
|
||||
const value =
|
||||
pathname.replace(/\/groups\/[^\/]+\/([^/]+).*/, '$1') || 'expenses'
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={value}
|
||||
className="[&>*]:border"
|
||||
onValueChange={(value) => {
|
||||
router.push(`/groups/${groupId}/${value}`)
|
||||
}}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="expenses">Expenses</TabsTrigger>
|
||||
<TabsTrigger value="balances">Balances</TabsTrigger>
|
||||
<TabsTrigger value="edit">Settings</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { GroupTabs } from '@/app/groups/[groupId]/group-tabs'
|
||||
import { SaveGroupLocally } from '@/app/groups/[groupId]/save-recent-group'
|
||||
import { ShareButton } from '@/app/groups/[groupId]/share-button'
|
||||
import { getGroup } from '@/lib/api'
|
||||
import { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
type Props = {
|
||||
params: {
|
||||
groupId: string
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params: { groupId },
|
||||
}: Props): Promise<Metadata> {
|
||||
const group = await getGroup(groupId)
|
||||
|
||||
return {
|
||||
title: {
|
||||
default: group?.name ?? '',
|
||||
template: `%s · ${group?.name} · Spliit`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function GroupLayout({
|
||||
children,
|
||||
params: { groupId },
|
||||
}: PropsWithChildren<Props>) {
|
||||
const group = await getGroup(groupId)
|
||||
if (!group) notFound()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-3">
|
||||
<h1 className="font-bold text-2xl">
|
||||
<Link href={`/groups/${groupId}`}>{group.name}</Link>
|
||||
</h1>
|
||||
|
||||
<div className="flex gap-2 justify-between">
|
||||
<GroupTabs groupId={groupId} />
|
||||
<ShareButton group={group} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
|
||||
<SaveGroupLocally group={{ id: group.id, name: group.name }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default async function GroupPage({
|
||||
params: { groupId },
|
||||
}: {
|
||||
params: { groupId: string }
|
||||
}) {
|
||||
redirect(`/groups/${groupId}/expenses`)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Reimbursement } from '@/lib/balances'
|
||||
import { Participant } from '@prisma/client'
|
||||
import Link from 'next/link'
|
||||
|
||||
type Props = {
|
||||
reimbursements: Reimbursement[]
|
||||
participants: Participant[]
|
||||
currency: string
|
||||
groupId: string
|
||||
}
|
||||
|
||||
export function ReimbursementList({
|
||||
reimbursements,
|
||||
participants,
|
||||
currency,
|
||||
groupId,
|
||||
}: Props) {
|
||||
if (reimbursements.length === 0) {
|
||||
return (
|
||||
<p className="px-6 text-sm pb-6">
|
||||
It looks like your group doesn’t need any reimbursement 😁
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const getParticipant = (id: string) => participants.find((p) => p.id === id)
|
||||
return (
|
||||
<div className="text-sm">
|
||||
{reimbursements.map((reimbursement, index) => (
|
||||
<div className="border-t px-6 py-4 flex justify-between" key={index}>
|
||||
<div className="flex flex-col gap-1 items-start sm:flex-row sm:items-baseline sm:gap-4">
|
||||
<div>
|
||||
<strong>{getParticipant(reimbursement.from)?.name}</strong> owes{' '}
|
||||
<strong>{getParticipant(reimbursement.to)?.name}</strong>
|
||||
</div>
|
||||
<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}`}
|
||||
>
|
||||
Mark as paid
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
{currency} {(reimbursement.amount / 100).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
'use client'
|
||||
import {
|
||||
RecentGroup,
|
||||
saveRecentGroup,
|
||||
} from '@/app/groups/recent-groups-helpers'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
type Props = {
|
||||
group: RecentGroup
|
||||
}
|
||||
|
||||
export function SaveGroupLocally({ group }: Props) {
|
||||
useEffect(() => {
|
||||
saveRecentGroup(group)
|
||||
}, [group])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
import { ShareUrlButton } from '@/components/share-url-button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { env } from '@/lib/env'
|
||||
import { Group } from '@prisma/client'
|
||||
import { Share } from 'lucide-react'
|
||||
|
||||
type Props = {
|
||||
group: Group
|
||||
}
|
||||
|
||||
export function ShareButton({ group }: Props) {
|
||||
const url = `${env.NEXT_PUBLIC_BASE_URL}/groups/${group.id}/expenses?ref=share`
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="icon">
|
||||
<Share className="w-4 h-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="[&_p]:text-sm flex flex-col gap-3">
|
||||
<p>
|
||||
For other participants to see the group and add expenses, share its
|
||||
URL with them.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input className="flex-1" defaultValue={url} readOnly />
|
||||
<CopyButton text={url} />
|
||||
<ShareUrlButton
|
||||
text={`Join my group ${group.name} on Spliit`}
|
||||
url={url}
|
||||
/>
|
||||
</div>
|
||||
<p>
|
||||
<strong>Warning!</strong> Every person with the group URL will be able
|
||||
to see and edit expenses. Share with caution!
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
'use server'
|
||||
import { getGroups } from "@/lib/api"
|
||||
|
||||
export async function getGroupsAction(groupIds: string[]) {
|
||||
'use server'
|
||||
return getGroups(groupIds)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { GroupForm } from '@/components/group-form'
|
||||
import { createGroup } from '@/lib/api'
|
||||
import { groupFormSchema } from '@/lib/schemas'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function CreateGroupPage() {
|
||||
async function createGroupAction(values: unknown) {
|
||||
'use server'
|
||||
const groupFormValues = groupFormSchema.parse(values)
|
||||
const group = await createGroup(groupFormValues)
|
||||
redirect(`/groups/${group.id}`)
|
||||
}
|
||||
|
||||
return <GroupForm onSubmit={createGroupAction} />
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
export default function GroupsLayout({ children }: PropsWithChildren<{}>) {
|
||||
return (
|
||||
<main className="flex-1 max-w-screen-md w-full mx-auto px-4 py-6 flex flex-col gap-6">
|
||||
{children}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>This group does not exist.</p>
|
||||
<p>
|
||||
<Button asChild variant="secondary">
|
||||
<Link href="/groups">Go to recently visited groups</Link>
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { RecentGroupList } from '@/app/groups/recent-group-list'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Recently visited groups',
|
||||
}
|
||||
|
||||
export default async function GroupsPage() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 items-start">
|
||||
<h1 className="font-bold text-2xl">
|
||||
<Link href="/groups">Recently visited groups</Link>
|
||||
</h1>
|
||||
<Button asChild>
|
||||
<Link href="/groups/create">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create group
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<RecentGroupList />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
'use client'
|
||||
import { getGroupsAction } from '@/app/groups/actions'
|
||||
import { getRecentGroups } from '@/app/groups/recent-groups-helpers'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { getGroups } from '@/lib/api'
|
||||
import { Calendar, Loader2, Users } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
|
||||
const recentGroupsSchema = z.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string(),
|
||||
}),
|
||||
)
|
||||
type RecentGroups = z.infer<typeof recentGroupsSchema>
|
||||
|
||||
type State =
|
||||
| { status: 'pending' }
|
||||
| { status: 'partial'; groups: RecentGroups }
|
||||
| {
|
||||
status: 'complete'
|
||||
groups: RecentGroups
|
||||
groupsDetails: Awaited<ReturnType<typeof getGroups>>
|
||||
}
|
||||
|
||||
type Props = {
|
||||
getGroupsAction: (groupIds: string[]) => ReturnType<typeof getGroups>
|
||||
}
|
||||
|
||||
export function RecentGroupList() {
|
||||
const [state, setState] = useState<State>({ status: 'pending' })
|
||||
|
||||
useEffect(() => {
|
||||
const groupsInStorage = getRecentGroups()
|
||||
setState({ status: 'partial', groups: groupsInStorage })
|
||||
getGroupsAction(groupsInStorage.map((g) => g.id)).then((groupsDetails) => {
|
||||
setState({ status: 'complete', groups: groupsInStorage, groupsDetails })
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (state.status === 'pending') {
|
||||
return (
|
||||
<p>
|
||||
<Loader2 className="w-4 m-4 mr-2 inline animate-spin" /> Loading recent
|
||||
groups…
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.groups.length === 0) {
|
||||
return (
|
||||
<div className="text-sm space-y-2">
|
||||
<p>You have not visited any group recently.</p>
|
||||
<p>
|
||||
<Button variant="link" asChild className="-m-4">
|
||||
<Link href={`/groups/create`}>Create one</Link>
|
||||
</Button>{' '}
|
||||
or ask a friend to send you the link to an existing one.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{state.groups.map((group) => {
|
||||
const details =
|
||||
state.status === 'complete'
|
||||
? state.groupsDetails.find((d) => d.id === group.id)
|
||||
: null
|
||||
return (
|
||||
<li key={group.id}>
|
||||
<Button variant="outline" className="h-fit w-full py-3" asChild>
|
||||
<Link href={`/groups/${group.id}`} className="text-base">
|
||||
<div className="w-full flex flex-col gap-1">
|
||||
<div className="text-base">{group.name}</div>
|
||||
<div className="text-muted-foreground font-normal text-xs">
|
||||
{details ? (
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Users className="w-3 h-3 inline mr-1" />
|
||||
<span>{details._count.participants}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-3 h-3 inline mx-1" />
|
||||
<span>
|
||||
{new Date(details.createdAt).toLocaleDateString(
|
||||
'en-US',
|
||||
{
|
||||
dateStyle: 'medium',
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-4 w-6 rounded-full" />
|
||||
<Skeleton className="h-4 w-24 rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</Button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const recentGroupsSchema = z.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export type RecentGroups = z.infer<typeof recentGroupsSchema>
|
||||
export type RecentGroup = RecentGroups[number]
|
||||
|
||||
const STORAGE_KEY = 'recentGroups'
|
||||
|
||||
export function getRecentGroups() {
|
||||
const groupsInStorageJson = localStorage.getItem(STORAGE_KEY)
|
||||
const groupsInStorageRaw = groupsInStorageJson
|
||||
? JSON.parse(groupsInStorageJson)
|
||||
: []
|
||||
const parseResult = recentGroupsSchema.safeParse(groupsInStorageRaw)
|
||||
return parseResult.success ? parseResult.data : []
|
||||
}
|
||||
|
||||
export function saveRecentGroup(group: RecentGroup) {
|
||||
const recentGroups = getRecentGroups()
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([group, ...recentGroups.filter((rg) => rg.id !== group.id)]),
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<?xml version="1.0" ?><svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:#ccc;}.cls-2{fill:#ed5167;}.cls-3{fill:#72d3b8;}.cls-4{fill:#55bc9c;}.cls-5{fill:#e8e8e8;}.cls-6{fill:#333538;}</style></defs><title/><g data-name="1 funnel" id="_1_funnel"><path class="cls-1" d="M261.25,134.9h0a76.62,76.62,0,0,1,76.62,76.62V423.22a0,0,0,0,1,0,0H261.25a0,0,0,0,1,0,0V134.9a0,0,0,0,1,0,0Z" transform="translate(285.06 -130.08) rotate(45)"/><path class="cls-2" d="M161.87,470.93l75.07-75.08a17.06,17.06,0,0,0,0-24.14l-40.19-40.19a17.06,17.06,0,0,0-24.14,0l-10.8,10.8a91,91,0,0,0-129.58.92c-34.17,35.14-34.17,91.69,0,126.83a91,91,0,0,0,129.58.92ZM57,447.11a57.21,57.21,0,1,1,80.9,0A57.22,57.22,0,0,1,57,447.11Z"/><rect class="cls-3" height="253.49" rx="47.61" transform="translate(511.66 282.22) rotate(180)" width="344.78" x="83.44" y="14.36"/><path class="cls-4" d="M126,184.86V97.36a40.46,40.46,0,0,0,40.46-40.45H345.23a40.44,40.44,0,0,0,40.44,40.45v87.5a40.44,40.44,0,0,0-40.44,40.44H166.44A40.46,40.46,0,0,0,126,184.86Z"/><circle class="cls-3" cx="255.83" cy="141.11" r="60.11"/><circle class="cls-3" cx="349.72" cy="141.11" r="21.88"/><circle class="cls-3" cx="161.94" cy="141.11" r="21.88"/><path class="cls-5" d="M174.13,134.9h76.62a0,0,0,0,1,0,0V346.61a76.62,76.62,0,0,1-76.62,76.62h0a0,0,0,0,1,0,0V134.9a0,0,0,0,1,0,0Z" transform="translate(559.99 326.17) rotate(135)"/><path class="cls-2" d="M350.19,471a91,91,0,0,0,129.58-.92c34.17-35.14,34.17-91.69,0-126.83a91,91,0,0,0-129.58-.92l-10.8-10.8a17.06,17.06,0,0,0-24.14,0l-40.19,40.19a17.06,17.06,0,0,0,0,24.14l75.07,75.08Zm23.89-23.88a57.21,57.21,0,1,1,80.9,0A57.2,57.2,0,0,1,374.08,447.11Z"/><circle class="cls-6" cx="256" cy="322.62" r="20.01"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1,139 +0,0 @@
|
||||
import { ProgressBar } from '@/components/progress-bar'
|
||||
import { ThemeProvider } from '@/components/theme-provider'
|
||||
import { ThemeToggle } from '@/components/theme-toggle'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { env } from '@/lib/env'
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import PlausibleProvider from 'next-plausible'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(env.NEXT_PUBLIC_BASE_URL),
|
||||
title: {
|
||||
default: 'Spliit · Share Expenses with Friends & Family',
|
||||
template: '%s · Spliit',
|
||||
},
|
||||
description:
|
||||
'Spliit is a minimalist web application to share expenses with friends and family. No ads, no account, no problem.',
|
||||
openGraph: {
|
||||
title: 'Spliit · Share Expenses with Friends & Family',
|
||||
description:
|
||||
'Spliit is a minimalist web application to share expenses with friends and family. No ads, no account, no problem.',
|
||||
images: `/banner.png`,
|
||||
type: 'website',
|
||||
url: '/',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
creator: '@scastiel',
|
||||
site: '@scastiel',
|
||||
images: `/banner.png`,
|
||||
title: 'Spliit · Share Expenses with Friends & Family',
|
||||
description:
|
||||
'Spliit is a minimalist web application to share expenses with friends and family. No ads, no account, no problem.',
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: 'Spliit',
|
||||
},
|
||||
applicationName: 'Spliit',
|
||||
icons: [
|
||||
{
|
||||
url: '/android-chrome-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
url: '/android-chrome-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#047857',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
{env.PLAUSIBLE_DOMAIN && (
|
||||
<PlausibleProvider domain={env.PLAUSIBLE_DOMAIN} trackOutboundLinks />
|
||||
)}
|
||||
<body className="pt-16 min-h-[100dvh] flex flex-col items-stretch bg-slate-50 bg-opacity-30 dark:bg-background">
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<ProgressBar />
|
||||
<header className="fixed top-0 left-0 right-0 h-16 flex justify-between bg-white dark:bg-gray-950 bg-opacity-50 dark:bg-opacity-50 p-2 border-b backdrop-blur-sm">
|
||||
<Link
|
||||
className="flex items-center gap-2 hover:scale-105 transition-transform"
|
||||
href="/"
|
||||
>
|
||||
<h1>
|
||||
<Image
|
||||
src="/logo-with-text.png"
|
||||
className="m-1 h-auto"
|
||||
width={(35 * 522) / 180}
|
||||
height={35}
|
||||
alt="Spliit"
|
||||
/>
|
||||
</h1>
|
||||
</Link>
|
||||
<div role="navigation" aria-label="Menu" className="flex">
|
||||
<ul className="flex items-center text-sm">
|
||||
<li>
|
||||
<Button
|
||||
variant="ghost"
|
||||
asChild
|
||||
className="-my-3 text-primary"
|
||||
>
|
||||
<Link href="/groups">Groups</Link>
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<ThemeToggle />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{children}
|
||||
|
||||
<footer className="sm:p-8 md:p-16 sm:mt-16 sm:text-sm md:text-base md:mt-32 bg-slate-50 dark:bg-card border-t p-6 mt-8 flex flex-col space-y-2 text-xs [&_a]:underline">
|
||||
<div className="sm:text-lg font-semibold text-base flex space-x-2 items-center">
|
||||
<Link className="flex items-center gap-2" href="/">
|
||||
<Image
|
||||
src="/logo-with-text.png"
|
||||
className="m-1 h-auto"
|
||||
width={(35 * 522) / 180}
|
||||
height={35}
|
||||
alt="Spliit"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col space-y a--no-underline-text-white">
|
||||
<span>Made in Montréal, Québec 🇨🇦</span>
|
||||
<span>
|
||||
Built by{' '}
|
||||
<a href="https://scastiel.dev" target="_blank" rel="noopener">
|
||||
Sebastien Castiel
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { MetadataRoute } from 'next'
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: 'Spliit',
|
||||
short_name: 'Spliit',
|
||||
description:
|
||||
'A minimalist web application to share expenses with friends and family. No ads, no account, no problem.',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#fff',
|
||||
theme_color: '#047857',
|
||||
icons: [
|
||||
{
|
||||
src: '/android-chrome-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/android-chrome-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/logo-512x512-maskable.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
144
src/app/page.tsx
@@ -1,144 +0,0 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
BarChartHorizontalBig,
|
||||
CircleDollarSign,
|
||||
Github,
|
||||
List,
|
||||
LucideIcon,
|
||||
Share,
|
||||
ShieldX,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
// FIX for https://github.com/vercel/next.js/issues/58615
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main>
|
||||
<section className="py-16 md:py-24 lg:py-32">
|
||||
<div className="container flex max-w-screen-md flex-col items-center gap-4 text-center">
|
||||
<h1 className="!leading-none font-bold text-3xl sm:text-5xl md:text-6xl lg:text-7xl landing-header py-2">
|
||||
Share <strong>Expenses</strong> <br /> with <strong>Friends</strong>{' '}
|
||||
& <strong>Family</strong>
|
||||
</h1>
|
||||
<p className="max-w-[42rem] leading-normal text-muted-foreground sm:text-xl sm:leading-8">
|
||||
No ads. No account. <br className="sm:hidden" /> Open Source.
|
||||
Forever Free.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button asChild size="lg">
|
||||
<Link
|
||||
className="inline-flex items-center justify-center text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 h-11 px-8 rounded-md"
|
||||
href="/groups/create"
|
||||
>
|
||||
Create a group
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-slate-50 dark:bg-card py-16 md:py-24 lg:py-32">
|
||||
<div className="p-4 flex mx-auto max-w-screen-md flex-col items-center text-center">
|
||||
<h2 className="font-bold text-3xl leading-[1.1] sm:text-3xl md:text-6xl">
|
||||
Features
|
||||
</h2>
|
||||
<p
|
||||
className="mt-2 md:mt-3 leading-normal text-muted-foreground sm:text-lg sm:leading-7"
|
||||
style={{ textWrap: 'balance' } as any}
|
||||
>
|
||||
Spliit is a minimalist application to track and share expenses with
|
||||
your friends and family.
|
||||
</p>
|
||||
<div className="mt-8 md:mt-6 w-full grid grid-cols-2 sm:grid-cols-3 gap-2 sm:gap-4 text-left">
|
||||
<Feature
|
||||
Icon={Users}
|
||||
name="Groups"
|
||||
description="Create a group for a travel, an event, a gift…"
|
||||
/>
|
||||
<Feature
|
||||
Icon={List}
|
||||
name="Expenses"
|
||||
description="Create and list expenses in your group."
|
||||
/>
|
||||
<Feature
|
||||
Icon={Share}
|
||||
name="Share"
|
||||
description="Send the group link to participants."
|
||||
/>
|
||||
<Feature
|
||||
Icon={BarChartHorizontalBig}
|
||||
name="Balances"
|
||||
description="Visualize how much each participant spent."
|
||||
/>
|
||||
<Feature
|
||||
Icon={CircleDollarSign}
|
||||
name="Reimbursements"
|
||||
description="Optimize money transfers between participants."
|
||||
/>
|
||||
<Feature
|
||||
Icon={ShieldX}
|
||||
name="No ads"
|
||||
description="No account. No limitation. No problem."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-16 md:py-24 lg:py-32">
|
||||
<div className="container flex max-w-screen-md flex-col items-center text-center">
|
||||
<h2 className="font-bold text-3xl leading-[1.1] sm:text-3xl md:text-6xl">
|
||||
Proudly Open Source
|
||||
</h2>
|
||||
<p
|
||||
className="mt-2 leading-normal text-muted-foreground sm:text-lg sm:leading-7"
|
||||
style={{ textWrap: 'balance' } as any}
|
||||
>
|
||||
Spliit is open source and powered by open source software. Feel free
|
||||
to contribute!
|
||||
</p>
|
||||
<div className="mt-4 md:mt-6">
|
||||
<Button asChild variant="secondary" size="lg">
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://github.com/scastiel/spliit2"
|
||||
>
|
||||
<Github className="w-4 h-4 mr-2" />
|
||||
GitHub
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function Feature({
|
||||
name,
|
||||
Icon,
|
||||
description,
|
||||
}: {
|
||||
name: ReactNode
|
||||
Icon: LucideIcon
|
||||
description: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-card border rounded-md p-4 flex flex-col gap-2">
|
||||
<Icon className="w-8 h-8" />
|
||||
<div>
|
||||
<strong>{name}</strong>
|
||||
</div>
|
||||
<div
|
||||
className="text-sm text-muted-foreground"
|
||||
style={{ textWrap: 'balance' } as any}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { env } from '@/lib/env'
|
||||
import { MetadataRoute } from 'next'
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: '/groups/',
|
||||
},
|
||||
sitemap: `${env.NEXT_PUBLIC_BASE_URL}/sitemap.xml`,
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { env } from '@/lib/env'
|
||||
import { MetadataRoute } from 'next'
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
{
|
||||
url: env.NEXT_PUBLIC_BASE_URL,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'yearly',
|
||||
priority: 1,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
'use client'
|
||||
import { Button, ButtonProps } from '@/components/ui/button'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { ReactNode, useState } from 'react'
|
||||
|
||||
type Props = ButtonProps & {
|
||||
action?: () => Promise<void>
|
||||
loadingContent?: ReactNode
|
||||
}
|
||||
|
||||
export function AsyncButton({
|
||||
action,
|
||||
children,
|
||||
loadingContent,
|
||||
...props
|
||||
}: Props) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
return (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
await action?.()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />{' '}
|
||||
{loadingContent ?? children}
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
'use client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
type Props = { text: string }
|
||||
|
||||
export function CopyButton({ text }: Props) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (copied) {
|
||||
let timeout = setTimeout(() => setCopied(false), 1000)
|
||||
return () => {
|
||||
setCopied(false)
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}, [copied])
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
}}
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
'use client'
|
||||
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,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { getExpense, getGroup } from '@/lib/api'
|
||||
import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { 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, onSubmit, onDelete }: Props) {
|
||||
const isCreate = expense === undefined
|
||||
const searchParams = useSearchParams()
|
||||
const form = useForm<ExpenseFormValues>({
|
||||
resolver: zodResolver(expenseFormSchema),
|
||||
defaultValues: expense
|
||||
? {
|
||||
title: expense.title,
|
||||
amount: String(expense.amount / 100) as unknown as number, // hack
|
||||
paidBy: expense.paidById,
|
||||
paidFor: expense.paidFor.map(({ participantId }) => participantId),
|
||||
isReimbursement: expense.isReimbursement,
|
||||
}
|
||||
: searchParams.get('reimbursement')
|
||||
? {
|
||||
title: 'Reimbursement',
|
||||
amount: String(
|
||||
(Number(searchParams.get('amount')) || 0) / 100,
|
||||
) as unknown as number, // hack
|
||||
paidBy: searchParams.get('from') ?? undefined,
|
||||
paidFor: [searchParams.get('to') ?? undefined],
|
||||
isReimbursement: true,
|
||||
}
|
||||
: { title: '', amount: 0, paidFor: [], isReimbursement: false },
|
||||
})
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<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
|
||||
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>
|
||||
<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="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…</>}
|
||||
>
|
||||
{isCreate ? <>Create</> : <>Save</>}
|
||||
</SubmitButton>
|
||||
{!isCreate && onDelete && (
|
||||
<AsyncButton
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loadingContent="Deleting…"
|
||||
action={onDelete}
|
||||
>
|
||||
Delete
|
||||
</AsyncButton>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
'use client'
|
||||
import { SubmitButton } from '@/components/submit-button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@/components/ui/hover-card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { getGroup } from '@/lib/api'
|
||||
import { GroupFormValues, groupFormSchema } from '@/lib/schemas'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Save, Trash2 } from 'lucide-react'
|
||||
import { useFieldArray, useForm } from 'react-hook-form'
|
||||
|
||||
export type Props = {
|
||||
group?: NonNullable<Awaited<ReturnType<typeof getGroup>>>
|
||||
onSubmit: (groupFormValues: GroupFormValues) => Promise<void>
|
||||
protectedParticipantIds?: string[]
|
||||
}
|
||||
|
||||
export function GroupForm({
|
||||
group,
|
||||
onSubmit,
|
||||
protectedParticipantIds = [],
|
||||
}: Props) {
|
||||
const form = useForm<GroupFormValues>({
|
||||
resolver: zodResolver(groupFormSchema),
|
||||
defaultValues: group
|
||||
? {
|
||||
name: group.name,
|
||||
currency: group.currency,
|
||||
participants: group.participants,
|
||||
}
|
||||
: {
|
||||
name: '',
|
||||
currency: '',
|
||||
participants: [{ name: 'John' }, { name: 'Jane' }, { name: 'Jack' }],
|
||||
},
|
||||
})
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: 'participants',
|
||||
keyName: 'key',
|
||||
})
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(async (values) => {
|
||||
await onSubmit(values)
|
||||
})}
|
||||
>
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Group information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Group name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-base"
|
||||
placeholder="Summer vacations"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter a name for your group.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="currency"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Currency symbol</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-base"
|
||||
placeholder="$, €, £…"
|
||||
max={5}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
We’ll use it to display amounts.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Participants</CardTitle>
|
||||
<CardDescription>
|
||||
Enter the name for each participant
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{fields.map((item, index) => (
|
||||
<li key={item.key}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`participants.${index}.name`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="sr-only">
|
||||
Participant #{index + 1}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<Input className="text-base" {...field} />
|
||||
{item.id &&
|
||||
protectedParticipantIds.includes(item.id) ? (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive-"
|
||||
type="button"
|
||||
size="icon"
|
||||
disabled
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive opacity-50" />
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
align="end"
|
||||
className="text-sm"
|
||||
>
|
||||
This participant is part of expenses, and can
|
||||
not be removed.
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
onClick={() => remove(index)}
|
||||
type="button"
|
||||
size="icon"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
append({ name: 'New' })
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Add participant
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<SubmitButton
|
||||
size="lg"
|
||||
loadingContent={group ? 'Saving…' : 'Creating…'}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" /> {group ? <>Save</> : <> Create</>}
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
'use client'
|
||||
import { Next13ProgressBar } from 'next13-progressbar'
|
||||
|
||||
export function ProgressBar() {
|
||||
return (
|
||||
<Next13ProgressBar
|
||||
height="2px"
|
||||
color="#64748b"
|
||||
options={{ showSpinner: false }}
|
||||
showOnShallow
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Share } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
text: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export function ShareUrlButton({ url, text }: Props) {
|
||||
const canShare = useCanShare(url, text)
|
||||
if (!canShare) return null
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (navigator.share) {
|
||||
navigator.share({ text, url })
|
||||
} else {
|
||||
console.log('Sharing is not available', { text, url })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Share className="w-4 h-4" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function useCanShare(url: string, text: string) {
|
||||
const [canShare, setCanShare] = useState<boolean | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setCanShare(
|
||||
navigator.share !== undefined && navigator.canShare({ url, text }),
|
||||
)
|
||||
}, [text, url])
|
||||
|
||||
return canShare
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Button, ButtonProps } from '@/components/ui/button'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { ReactNode } from 'react'
|
||||
import { useFormState } from 'react-hook-form'
|
||||
|
||||
type Props = {
|
||||
loadingContent: ReactNode
|
||||
} & ButtonProps
|
||||
|
||||
export function SubmitButton({ children, loadingContent, ...props }: Props) {
|
||||
const { isSubmitting } = useFormState()
|
||||
return (
|
||||
<Button type="submit" disabled={isSubmitting} {...props}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> {loadingContent}
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||
import { type ThemeProviderProps } from 'next-themes/dist/types'
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { MoonIcon, SunIcon } from '@radix-ui/react-icons'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="text-primary">
|
||||
<SunIcon className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<MoonIcon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme('light')}>
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')}>
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('system')}>
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -1,36 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -1,56 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -1,79 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
@@ -1,200 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
FormItem.displayName = "FormItem"
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = "FormLabel"
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = "FormControl"
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = "FormDescription"
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message) : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-sm font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
@@ -1,25 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
@@ -1,26 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
@@ -1,31 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
@@ -1,160 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -1,117 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
5
src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
appRouter,
|
||||
type AppRouter,
|
||||
type AppRouterOutput,
|
||||
} from './trpc/routers/_app'
|
||||
220
src/lib/api.ts
@@ -1,18 +1,18 @@
|
||||
import { getPrisma } from '@/lib/prisma'
|
||||
import { ExpenseFormValues, GroupFormValues } from '@/lib/schemas'
|
||||
import { Expense } from '@prisma/client'
|
||||
import { ActivityType, Expense } from '@prisma/client'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { prisma } from './prisma'
|
||||
import { ExpenseFormValues, GroupFormValues } from './schemas'
|
||||
|
||||
export function randomId() {
|
||||
return nanoid()
|
||||
}
|
||||
|
||||
export async function createGroup(groupFormValues: GroupFormValues) {
|
||||
const prisma = await getPrisma()
|
||||
return prisma.group.create({
|
||||
data: {
|
||||
id: randomId(),
|
||||
name: groupFormValues.name,
|
||||
information: groupFormValues.information,
|
||||
currency: groupFormValues.currency,
|
||||
participants: {
|
||||
createMany: {
|
||||
@@ -30,40 +30,72 @@ export async function createGroup(groupFormValues: GroupFormValues) {
|
||||
export async function createExpense(
|
||||
expenseFormValues: ExpenseFormValues,
|
||||
groupId: string,
|
||||
participantId?: string,
|
||||
): Promise<Expense> {
|
||||
const group = await getGroup(groupId)
|
||||
if (!group) throw new Error(`Invalid group ID: ${groupId}`)
|
||||
|
||||
for (const participant of [
|
||||
expenseFormValues.paidBy,
|
||||
...expenseFormValues.paidFor,
|
||||
...expenseFormValues.paidFor.map((p) => p.participant),
|
||||
]) {
|
||||
if (!group.participants.some((p) => p.id === participant))
|
||||
throw new Error(`Invalid participant ID: ${participant}`)
|
||||
}
|
||||
|
||||
const prisma = await getPrisma()
|
||||
const expenseId = randomId()
|
||||
await logActivity(groupId, ActivityType.CREATE_EXPENSE, {
|
||||
participantId,
|
||||
expenseId,
|
||||
data: expenseFormValues.title,
|
||||
})
|
||||
|
||||
return prisma.expense.create({
|
||||
data: {
|
||||
id: randomId(),
|
||||
id: expenseId,
|
||||
groupId,
|
||||
expenseDate: expenseFormValues.expenseDate,
|
||||
categoryId: expenseFormValues.category,
|
||||
amount: expenseFormValues.amount,
|
||||
title: expenseFormValues.title,
|
||||
paidById: expenseFormValues.paidBy,
|
||||
splitMode: expenseFormValues.splitMode,
|
||||
paidFor: {
|
||||
createMany: {
|
||||
data: expenseFormValues.paidFor.map((paidFor) => ({
|
||||
participantId: paidFor,
|
||||
participantId: paidFor.participant,
|
||||
shares: paidFor.shares,
|
||||
})),
|
||||
},
|
||||
},
|
||||
isReimbursement: expenseFormValues.isReimbursement,
|
||||
documents: {
|
||||
createMany: {
|
||||
data: expenseFormValues.documents.map((doc) => ({
|
||||
id: randomId(),
|
||||
url: doc.url,
|
||||
width: doc.width,
|
||||
height: doc.height,
|
||||
})),
|
||||
},
|
||||
},
|
||||
notes: expenseFormValues.notes,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteExpense(expenseId: string) {
|
||||
const prisma = await getPrisma()
|
||||
export async function deleteExpense(
|
||||
groupId: string,
|
||||
expenseId: string,
|
||||
participantId?: string,
|
||||
) {
|
||||
const existingExpense = await getExpense(groupId, expenseId)
|
||||
await logActivity(groupId, ActivityType.DELETE_EXPENSE, {
|
||||
participantId,
|
||||
expenseId,
|
||||
data: existingExpense?.title,
|
||||
})
|
||||
|
||||
await prisma.expense.delete({
|
||||
where: { id: expenseId },
|
||||
include: { paidFor: true, paidBy: true },
|
||||
@@ -75,21 +107,22 @@ export async function getGroupExpensesParticipants(groupId: string) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
expenses.flatMap((e) => [
|
||||
e.paidById,
|
||||
...e.paidFor.map((pf) => pf.participantId),
|
||||
e.paidBy.id,
|
||||
...e.paidFor.map((pf) => pf.participant.id),
|
||||
]),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export async function getGroups(groupIds: string[]) {
|
||||
const prisma = await getPrisma()
|
||||
return (await prisma.group.findMany({
|
||||
where: { id: { in: groupIds } },
|
||||
include: { _count: { select: { participants: true } } },
|
||||
})).map(group => ({
|
||||
return (
|
||||
await prisma.group.findMany({
|
||||
where: { id: { in: groupIds } },
|
||||
include: { _count: { select: { participants: true } } },
|
||||
})
|
||||
).map((group) => ({
|
||||
...group,
|
||||
createdAt: group.createdAt.toISOString()
|
||||
createdAt: group.createdAt.toISOString(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -97,6 +130,7 @@ export async function updateExpense(
|
||||
groupId: string,
|
||||
expenseId: string,
|
||||
expenseFormValues: ExpenseFormValues,
|
||||
participantId?: string,
|
||||
) {
|
||||
const group = await getGroup(groupId)
|
||||
if (!group) throw new Error(`Invalid group ID: ${groupId}`)
|
||||
@@ -106,34 +140,75 @@ export async function updateExpense(
|
||||
|
||||
for (const participant of [
|
||||
expenseFormValues.paidBy,
|
||||
...expenseFormValues.paidFor,
|
||||
...expenseFormValues.paidFor.map((p) => p.participant),
|
||||
]) {
|
||||
if (!group.participants.some((p) => p.id === participant))
|
||||
throw new Error(`Invalid participant ID: ${participant}`)
|
||||
}
|
||||
|
||||
const prisma = await getPrisma()
|
||||
await logActivity(groupId, ActivityType.UPDATE_EXPENSE, {
|
||||
participantId,
|
||||
expenseId,
|
||||
data: expenseFormValues.title,
|
||||
})
|
||||
|
||||
return prisma.expense.update({
|
||||
where: { id: expenseId },
|
||||
data: {
|
||||
expenseDate: expenseFormValues.expenseDate,
|
||||
amount: expenseFormValues.amount,
|
||||
title: expenseFormValues.title,
|
||||
categoryId: expenseFormValues.category,
|
||||
paidById: expenseFormValues.paidBy,
|
||||
splitMode: expenseFormValues.splitMode,
|
||||
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: {
|
||||
expenseId_participantId: { expenseId, participantId: paidFor },
|
||||
expenseId_participantId: {
|
||||
expenseId,
|
||||
participantId: paidFor.participant,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
shares: paidFor.shares,
|
||||
},
|
||||
create: { participantId: paidFor },
|
||||
})),
|
||||
deleteMany: existingExpense.paidFor.filter(
|
||||
(paidFor) =>
|
||||
!expenseFormValues.paidFor.some(
|
||||
(pf) => pf === paidFor.participantId,
|
||||
(pf) => pf.participant === paidFor.participantId,
|
||||
),
|
||||
),
|
||||
},
|
||||
isReimbursement: expenseFormValues.isReimbursement,
|
||||
documents: {
|
||||
connectOrCreate: expenseFormValues.documents.map((doc) => ({
|
||||
create: doc,
|
||||
where: { id: doc.id },
|
||||
})),
|
||||
deleteMany: existingExpense.documents
|
||||
.filter(
|
||||
(existingDoc) =>
|
||||
!expenseFormValues.documents.some(
|
||||
(doc) => doc.id === existingDoc.id,
|
||||
),
|
||||
)
|
||||
.map((doc) => ({
|
||||
id: doc.id,
|
||||
})),
|
||||
},
|
||||
notes: expenseFormValues.notes,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -141,15 +216,18 @@ export async function updateExpense(
|
||||
export async function updateGroup(
|
||||
groupId: string,
|
||||
groupFormValues: GroupFormValues,
|
||||
participantId?: string,
|
||||
) {
|
||||
const existingGroup = await getGroup(groupId)
|
||||
if (!existingGroup) throw new Error('Invalid group ID')
|
||||
|
||||
const prisma = await getPrisma()
|
||||
await logActivity(groupId, ActivityType.UPDATE_GROUP, { participantId })
|
||||
|
||||
return prisma.group.update({
|
||||
where: { id: groupId },
|
||||
data: {
|
||||
name: groupFormValues.name,
|
||||
information: groupFormValues.information,
|
||||
currency: groupFormValues.currency,
|
||||
participants: {
|
||||
deleteMany: existingGroup.participants.filter(
|
||||
@@ -177,26 +255,102 @@ export async function updateGroup(
|
||||
}
|
||||
|
||||
export async function getGroup(groupId: string) {
|
||||
const prisma = await getPrisma()
|
||||
return prisma.group.findUnique({
|
||||
where: { id: groupId },
|
||||
include: { participants: true },
|
||||
})
|
||||
}
|
||||
|
||||
export async function getGroupExpenses(groupId: string) {
|
||||
const prisma = await getPrisma()
|
||||
export async function getCategories() {
|
||||
return prisma.category.findMany()
|
||||
}
|
||||
|
||||
export async function getGroupExpenses(
|
||||
groupId: string,
|
||||
options?: { offset?: number; length?: number; filter?: string },
|
||||
) {
|
||||
return prisma.expense.findMany({
|
||||
where: { groupId },
|
||||
include: { paidFor: { include: { participant: true } }, paidBy: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
amount: true,
|
||||
category: true,
|
||||
createdAt: true,
|
||||
expenseDate: true,
|
||||
id: true,
|
||||
isReimbursement: true,
|
||||
paidBy: { select: { id: true, name: true } },
|
||||
paidFor: {
|
||||
select: {
|
||||
participant: { select: { id: true, name: true } },
|
||||
shares: true,
|
||||
},
|
||||
},
|
||||
splitMode: true,
|
||||
title: true,
|
||||
},
|
||||
where: {
|
||||
groupId,
|
||||
title: options?.filter
|
||||
? { contains: options.filter, mode: 'insensitive' }
|
||||
: undefined,
|
||||
},
|
||||
orderBy: [{ expenseDate: 'desc' }, { createdAt: 'desc' }],
|
||||
skip: options && options.offset,
|
||||
take: options && options.length,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getGroupExpenseCount(groupId: string) {
|
||||
return prisma.expense.count({ where: { groupId } })
|
||||
}
|
||||
|
||||
export async function getExpense(groupId: string, expenseId: string) {
|
||||
const prisma = await getPrisma()
|
||||
return prisma.expense.findUnique({
|
||||
where: { id: expenseId },
|
||||
include: { paidBy: true, paidFor: true },
|
||||
include: { paidBy: true, paidFor: true, category: true, documents: true },
|
||||
})
|
||||
}
|
||||
|
||||
export async function getActivities(
|
||||
groupId: string,
|
||||
options?: { offset?: number; length?: number },
|
||||
) {
|
||||
const activities = await prisma.activity.findMany({
|
||||
where: { groupId },
|
||||
orderBy: [{ time: 'desc' }],
|
||||
skip: options?.offset,
|
||||
take: options?.length,
|
||||
})
|
||||
|
||||
const expenseIds = activities
|
||||
.map((activity) => activity.expenseId)
|
||||
.filter(Boolean)
|
||||
const expenses = await prisma.expense.findMany({
|
||||
where: {
|
||||
groupId,
|
||||
id: { in: expenseIds },
|
||||
},
|
||||
})
|
||||
|
||||
return activities.map((activity) => ({
|
||||
...activity,
|
||||
expense:
|
||||
activity.expenseId !== null
|
||||
? expenses.find((expense) => expense.id === activity.expenseId)
|
||||
: undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function logActivity(
|
||||
groupId: string,
|
||||
activityType: ActivityType,
|
||||
extra?: { participantId?: string; expenseId?: string; data?: string },
|
||||
) {
|
||||
return prisma.activity.create({
|
||||
data: {
|
||||
id: randomId(),
|
||||
groupId,
|
||||
activityType,
|
||||
...extra,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getGroupExpenses } from '@/lib/api'
|
||||
import { Participant } from '@prisma/client'
|
||||
import { match } from 'ts-pattern'
|
||||
import { getGroupExpenses } from './api'
|
||||
|
||||
export type Balances = Record<
|
||||
Participant['id'],
|
||||
@@ -18,33 +19,86 @@ export function getBalances(
|
||||
const balances: Balances = {}
|
||||
|
||||
for (const expense of expenses) {
|
||||
const paidBy = expense.paidById
|
||||
const paidFors = expense.paidFor.map((p) => p.participantId)
|
||||
const paidBy = expense.paidBy.id
|
||||
const paidFors = expense.paidFor
|
||||
|
||||
if (!balances[paidBy]) balances[paidBy] = { paid: 0, paidFor: 0, total: 0 }
|
||||
balances[paidBy].paid += 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(
|
||||
expense.amount,
|
||||
paidFors.length,
|
||||
index === paidFors.length - 1,
|
||||
)
|
||||
balances[paidFor].paidFor += dividedAmount
|
||||
balances[paidFor].total -= dividedAmount
|
||||
const totalPaidForShares = paidFors.reduce(
|
||||
(sum, paidFor) => sum + paidFor.shares,
|
||||
0,
|
||||
)
|
||||
let remaining = expense.amount
|
||||
paidFors.forEach((paidFor, index) => {
|
||||
if (!balances[paidFor.participant.id])
|
||||
balances[paidFor.participant.id] = { 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
|
||||
: (expense.amount * shares) / totalShares
|
||||
remaining -= dividedAmount
|
||||
balances[paidFor.participant.id].paidFor += dividedAmount
|
||||
})
|
||||
}
|
||||
|
||||
// rounding and add total
|
||||
for (const participantId in balances) {
|
||||
// add +0 to avoid negative zeros
|
||||
balances[participantId].paidFor =
|
||||
Math.round(balances[participantId].paidFor) + 0
|
||||
balances[participantId].paid = Math.round(balances[participantId].paid) + 0
|
||||
|
||||
balances[participantId].total =
|
||||
balances[participantId].paid - balances[participantId].paidFor
|
||||
}
|
||||
return balances
|
||||
}
|
||||
|
||||
function divide(total: number, count: number, isLast: boolean): number {
|
||||
if (!isLast) return Math.floor(total / count)
|
||||
export function getPublicBalances(reimbursements: Reimbursement[]): Balances {
|
||||
const balances: Balances = {}
|
||||
reimbursements.forEach((reimbursement) => {
|
||||
if (!balances[reimbursement.from])
|
||||
balances[reimbursement.from] = { paid: 0, paidFor: 0, total: 0 }
|
||||
|
||||
return total - divide(total, count, false) * (count - 1)
|
||||
if (!balances[reimbursement.to])
|
||||
balances[reimbursement.to] = { paid: 0, paidFor: 0, total: 0 }
|
||||
|
||||
balances[reimbursement.from].paidFor += reimbursement.amount
|
||||
balances[reimbursement.from].total -= reimbursement.amount
|
||||
|
||||
balances[reimbursement.to].paid += reimbursement.amount
|
||||
balances[reimbursement.to].total += reimbursement.amount
|
||||
})
|
||||
return balances
|
||||
}
|
||||
|
||||
/**
|
||||
* A comparator that is stable across reimbursements.
|
||||
* This ensures that a participant executing a suggested reimbursement
|
||||
* does not result in completely new repayment suggestions.
|
||||
*/
|
||||
function compareBalancesForReimbursements(
|
||||
b1: { total: number; participantId: string },
|
||||
b2: { total: number; participantId: string },
|
||||
): number {
|
||||
// positive balances come before negative balances
|
||||
if (b1.total > 0 && 0 > b2.total) {
|
||||
return -1
|
||||
} else if (b2.total > 0 && 0 > b1.total) {
|
||||
return 1
|
||||
}
|
||||
// if signs match, sort based on userid
|
||||
return b1.participantId.localeCompare(b2.participantId)
|
||||
}
|
||||
|
||||
export function getSuggestedReimbursements(
|
||||
@@ -53,7 +107,7 @@ export function getSuggestedReimbursements(
|
||||
const balancesArray = Object.entries(balances)
|
||||
.map(([participantId, { total }]) => ({ participantId, total }))
|
||||
.filter((b) => b.total !== 0)
|
||||
balancesArray.sort((b1, b2) => b2.total - b1.total)
|
||||
balancesArray.sort(compareBalancesForReimbursements)
|
||||
const reimbursements: Reimbursement[] = []
|
||||
while (balancesArray.length > 1) {
|
||||
const first = balancesArray[0]
|
||||
@@ -77,5 +131,5 @@ export function getSuggestedReimbursements(
|
||||
balancesArray.shift()
|
||||
}
|
||||
}
|
||||
return reimbursements.filter(({ amount }) => amount !== 0)
|
||||
return reimbursements.filter(({ amount }) => Math.round(amount) + 0 !== 0)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const envSchema = z.object({
|
||||
NEXT_PUBLIC_BASE_URL: z.string().url(),
|
||||
POSTGRES_URL_NON_POOLING: z.string().url(),
|
||||
POSTGRES_PRISMA_URL: z.string().url(),
|
||||
PLAUSIBLE_DOMAIN: z.string().optional(),
|
||||
})
|
||||
|
||||
export const env = envSchema.parse(process.env)
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
let prisma: PrismaClient
|
||||
declare const global: Global & { prisma?: PrismaClient }
|
||||
|
||||
export async function getPrisma() {
|
||||
export let p: PrismaClient = undefined as unknown as PrismaClient
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
// await delay(1000)
|
||||
if (!prisma) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
prisma = new PrismaClient()
|
||||
} else {
|
||||
if (!(global as any).prisma) {
|
||||
;(global as any).prisma = new PrismaClient()
|
||||
}
|
||||
prisma = (global as any).prisma
|
||||
if (process.env['NODE_ENV'] === 'production') {
|
||||
p = new PrismaClient()
|
||||
} else {
|
||||
if (!global.prisma) {
|
||||
global.prisma = new PrismaClient({
|
||||
// log: [{ emit: 'stdout', level: 'query' }],
|
||||
})
|
||||
}
|
||||
p = global.prisma
|
||||
}
|
||||
return prisma
|
||||
}
|
||||
|
||||
export const prisma = p
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
import { SplitMode } from '@prisma/client'
|
||||
import * as z from 'zod'
|
||||
|
||||
export const groupFormSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, 'Enter at least two characters.')
|
||||
.max(50, 'Enter at most 50 characters.'),
|
||||
currency: z
|
||||
.string()
|
||||
.min(1, 'Enter at least one character.')
|
||||
.max(5, 'Enter at most five characters.'),
|
||||
name: z.string().min(2, 'min2').max(50, 'max50'),
|
||||
information: z.string().optional(),
|
||||
currency: z.string().min(1, 'min1').max(5, 'max5'),
|
||||
participants: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().optional(),
|
||||
name: z
|
||||
.string()
|
||||
.min(2, 'Enter at least two characters.')
|
||||
.max(50, 'Enter at most 50 characters.'),
|
||||
name: z.string().min(2, 'min2').max(50, 'max50'),
|
||||
}),
|
||||
)
|
||||
.min(1),
|
||||
@@ -28,7 +21,7 @@ export const groupFormSchema = z
|
||||
if (otherParticipant.name === participant.name) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Another participant already has this name.',
|
||||
message: 'duplicateParticipantName',
|
||||
path: ['participants', i, 'name'],
|
||||
})
|
||||
}
|
||||
@@ -38,36 +31,127 @@ export const groupFormSchema = z
|
||||
|
||||
export type GroupFormValues = z.infer<typeof groupFormSchema>
|
||||
|
||||
export const expenseFormSchema = z.object({
|
||||
title: z
|
||||
.string({ required_error: 'Please enter a title.' })
|
||||
.min(2, 'Enter at least two characters.'),
|
||||
amount: z
|
||||
.union(
|
||||
[
|
||||
z.number(),
|
||||
z.string().transform((value, ctx) => {
|
||||
const valueAsNumber = Number(value)
|
||||
if (Number.isNaN(valueAsNumber))
|
||||
export const expenseFormSchema = z
|
||||
.object({
|
||||
expenseDate: z.coerce.date(),
|
||||
title: z.string({ required_error: 'titleRequired' }).min(2, 'min2'),
|
||||
category: z.coerce.number().default(0),
|
||||
amount: z
|
||||
.union(
|
||||
[
|
||||
z.number(),
|
||||
z.string().transform((value, ctx) => {
|
||||
const valueAsNumber = Number(value)
|
||||
if (Number.isNaN(valueAsNumber))
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'invalidNumber',
|
||||
})
|
||||
return Math.round(valueAsNumber * 100)
|
||||
}),
|
||||
],
|
||||
{ required_error: 'amountRequired' },
|
||||
)
|
||||
.refine((amount) => amount != 1, 'amountNotZero')
|
||||
.refine((amount) => amount <= 10_000_000_00, 'amountTenMillion'),
|
||||
paidBy: z.string({ required_error: 'paidByRequired' }),
|
||||
paidFor: z
|
||||
.array(
|
||||
z.object({
|
||||
participant: z.string(),
|
||||
shares: z.union([
|
||||
z.number(),
|
||||
z.string().transform((value, ctx) => {
|
||||
const normalizedValue = value.replace(/,/g, '.')
|
||||
const valueAsNumber = Number(normalizedValue)
|
||||
if (Number.isNaN(valueAsNumber))
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'invalidNumber',
|
||||
})
|
||||
return Math.round(valueAsNumber * 100)
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
.min(1, 'paidForMin1')
|
||||
.superRefine((paidFor, ctx) => {
|
||||
let sum = 0
|
||||
for (const { shares } of paidFor) {
|
||||
sum += shares
|
||||
if (shares < 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Invalid number.',
|
||||
message: 'noZeroShares',
|
||||
})
|
||||
return Math.round(valueAsNumber * 100)
|
||||
}
|
||||
}
|
||||
}),
|
||||
splitMode: z
|
||||
.enum<SplitMode, [SplitMode, ...SplitMode[]]>(
|
||||
Object.values(SplitMode) as any,
|
||||
)
|
||||
.default('EVENLY'),
|
||||
saveDefaultSplittingOptions: z.boolean(),
|
||||
isReimbursement: z.boolean(),
|
||||
documents: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
url: z.string().url(),
|
||||
width: z.number().int().min(1),
|
||||
height: z.number().int().min(1),
|
||||
}),
|
||||
],
|
||||
{ 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.string())
|
||||
.min(1, 'The expense must be paid for at least one participant.'),
|
||||
isReimbursement: z.boolean(),
|
||||
})
|
||||
)
|
||||
.default([]),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
.superRefine((expense, ctx) => {
|
||||
let sum = 0
|
||||
for (const { shares } of expense.paidFor) {
|
||||
sum +=
|
||||
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: 'amountSum',
|
||||
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: 'percentageSum',
|
||||
path: ['paidFor'],
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export type ExpenseFormValues = z.infer<typeof expenseFormSchema>
|
||||
|
||||
export type SplittingOptions = {
|
||||
// Used for saving default splitting options in localStorage
|
||||
splitMode: SplitMode
|
||||
paidFor: ExpenseFormValues['paidFor'] | null
|
||||
}
|
||||
|
||||
71
src/lib/totals.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { getGroupExpenses } from './api'
|
||||
|
||||
export function getTotalGroupSpending(
|
||||
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>,
|
||||
): number {
|
||||
return expenses.reduce(
|
||||
(total, expense) =>
|
||||
expense.isReimbursement ? total : total + expense.amount,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
export function getTotalActiveUserPaidFor(
|
||||
activeUserId: string | null,
|
||||
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>,
|
||||
): number {
|
||||
return expenses.reduce(
|
||||
(total, expense) =>
|
||||
expense.paidBy.id === activeUserId && !expense.isReimbursement
|
||||
? total + expense.amount
|
||||
: total,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
export function getTotalActiveUserShare(
|
||||
activeUserId: string | null,
|
||||
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>,
|
||||
): number {
|
||||
let total = 0
|
||||
|
||||
expenses.forEach((expense) => {
|
||||
if (expense.isReimbursement) return
|
||||
|
||||
const paidFors = expense.paidFor
|
||||
const userPaidFor = paidFors.find(
|
||||
(paidFor) => paidFor.participant.id === activeUserId,
|
||||
)
|
||||
|
||||
if (!userPaidFor) {
|
||||
// If the active user is not involved in the expense, skip it
|
||||
return
|
||||
}
|
||||
|
||||
switch (expense.splitMode) {
|
||||
case 'EVENLY':
|
||||
// Divide the total expense evenly among all participants
|
||||
total += expense.amount / paidFors.length
|
||||
break
|
||||
case 'BY_AMOUNT':
|
||||
// Directly add the user's share if the split mode is BY_AMOUNT
|
||||
total += userPaidFor.shares
|
||||
break
|
||||
case 'BY_PERCENTAGE':
|
||||
// Calculate the user's share based on their percentage of the total expense
|
||||
total += (expense.amount * userPaidFor.shares) / 10000 // Assuming shares are out of 10000 for percentage
|
||||
break
|
||||
case 'BY_SHARES': // Calculate the user's share based on their shares relative to the total shares
|
||||
{
|
||||
const totalShares = paidFors.reduce(
|
||||
(sum, paidFor) => sum + paidFor.shares,
|
||||
0,
|
||||
)
|
||||
total += (expense.amount * userPaidFor.shares) / totalShares
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return parseFloat(total.toFixed(2))
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import { randomId } from '@/lib/api'
|
||||
import { getPrisma } from '@/lib/prisma'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { Client } from 'pg'
|
||||
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
|
||||
|
||||
async function main() {
|
||||
withClient(async (client) => {
|
||||
const prisma = await getPrisma()
|
||||
|
||||
// console.log('Deleting all groups…')
|
||||
// await prisma.group.deleteMany({})
|
||||
|
||||
const { rows: groupRows } = await client.query<{
|
||||
id: string
|
||||
name: string
|
||||
currency: string
|
||||
created_at: Date
|
||||
}>('select id, name, currency, created_at from groups')
|
||||
|
||||
const existingGroups = (
|
||||
await prisma.group.findMany({ select: { id: true } })
|
||||
).map((group) => group.id)
|
||||
|
||||
for (const groupRow of groupRows) {
|
||||
const participants: Prisma.ParticipantCreateManyInput[] = []
|
||||
const expenses: Prisma.ExpenseCreateManyInput[] = []
|
||||
const expenseParticipants: Prisma.ExpensePaidForCreateManyInput[] = []
|
||||
const participantIdsMapping: Record<number, string> = {}
|
||||
const expenseIdsMapping: Record<number, string> = {}
|
||||
|
||||
if (existingGroups.includes(groupRow.id)) {
|
||||
console.log(`Group ${groupRow.id} already exists, skipping.`)
|
||||
continue
|
||||
}
|
||||
|
||||
const group: Prisma.GroupCreateInput = {
|
||||
id: groupRow.id,
|
||||
name: groupRow.name,
|
||||
currency: groupRow.currency,
|
||||
createdAt: groupRow.created_at,
|
||||
}
|
||||
|
||||
const { rows: participantRows } = await client.query<{
|
||||
id: number
|
||||
created_at: Date
|
||||
name: string
|
||||
}>(
|
||||
'select id, created_at, name from participants where group_id = $1::text',
|
||||
[groupRow.id],
|
||||
)
|
||||
for (const participantRow of participantRows) {
|
||||
const id = randomId()
|
||||
participantIdsMapping[participantRow.id] = id
|
||||
participants.push({
|
||||
id,
|
||||
groupId: groupRow.id,
|
||||
name: participantRow.name,
|
||||
})
|
||||
}
|
||||
|
||||
const { rows: expenseRows } = await client.query<{
|
||||
id: number
|
||||
created_at: Date
|
||||
description: string
|
||||
amount: number
|
||||
paid_by_participant_id: number
|
||||
is_reimbursement: boolean
|
||||
}>(
|
||||
'select id, created_at, description, amount, paid_by_participant_id, is_reimbursement from expenses where group_id = $1::text and deleted_at is null',
|
||||
[groupRow.id],
|
||||
)
|
||||
for (const expenseRow of expenseRows) {
|
||||
const id = randomId()
|
||||
expenseIdsMapping[expenseRow.id] = id
|
||||
expenses.push({
|
||||
id,
|
||||
amount: Math.round(expenseRow.amount * 100),
|
||||
groupId: groupRow.id,
|
||||
title: expenseRow.description,
|
||||
createdAt: expenseRow.created_at,
|
||||
isReimbursement: expenseRow.is_reimbursement === true,
|
||||
paidById: participantIdsMapping[expenseRow.paid_by_participant_id],
|
||||
})
|
||||
}
|
||||
|
||||
if (expenseRows.length > 0) {
|
||||
const { rows: expenseParticipantRows } = await client.query<{
|
||||
expense_id: number
|
||||
participant_id: number
|
||||
}>(
|
||||
'select expense_id, participant_id from expense_participants where expense_id = any($1::int[]);',
|
||||
[expenseRows.map((row) => row.id)],
|
||||
)
|
||||
for (const expenseParticipantRow of expenseParticipantRows) {
|
||||
expenseParticipants.push({
|
||||
expenseId: expenseIdsMapping[expenseParticipantRow.expense_id],
|
||||
participantId:
|
||||
participantIdsMapping[expenseParticipantRow.participant_id],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Creating group:', group)
|
||||
await prisma.group.create({ data: group })
|
||||
console.log('Creating participants:', participants)
|
||||
await prisma.participant.createMany({ data: participants })
|
||||
console.log('Creating expenses:', expenses)
|
||||
await prisma.expense.createMany({ data: expenses })
|
||||
console.log('Creating expenseParticipants:', expenseParticipants)
|
||||
await prisma.expensePaidFor.createMany({ data: expenseParticipants })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function withClient(fn: (client: Client) => void | Promise<void>) {
|
||||
const client = new Client({
|
||||
connectionString: process.env.OLD_POSTGRES_URL,
|
||||
ssl: true,
|
||||
})
|
||||
await client.connect()
|
||||
console.log('Connected.')
|
||||
|
||||
try {
|
||||
await fn(client)
|
||||
} finally {
|
||||
await client.end()
|
||||
console.log('Disconnected.')
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
17
src/trpc/init.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { initTRPC } from '@trpc/server'
|
||||
import superjson from 'superjson'
|
||||
|
||||
// Avoid exporting the entire t-object
|
||||
// since it's not very descriptive.
|
||||
// For instance, the use of a t variable
|
||||
// is common in i18n libraries.
|
||||
const t = initTRPC.create({
|
||||
/**
|
||||
* @see https://trpc.io/docs/server/data-transformers
|
||||
*/
|
||||
transformer: superjson,
|
||||
})
|
||||
|
||||
// Base router and procedure helpers
|
||||
export const createTRPCRouter = t.router
|
||||
export const baseProcedure = t.procedure
|
||||
12
src/trpc/routers/_app.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { inferRouterOutputs } from '@trpc/server'
|
||||
import { createTRPCRouter } from '../init'
|
||||
import { categoriesRouter } from './categories'
|
||||
import { groupsRouter } from './groups'
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
groups: groupsRouter,
|
||||
categories: categoriesRouter,
|
||||
})
|
||||
|
||||
export type AppRouter = typeof appRouter
|
||||
export type AppRouterOutput = inferRouterOutputs<AppRouter>
|
||||