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_PRISMA_URL=postgresql://postgres:1234@localhost
|
||||||
POSTGRES_URL_NON_POOLING=postgresql://postgres:1234@localhost
|
POSTGRES_URL_NON_POOLING=postgresql://postgres:1234@localhost
|
||||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
|
||||||
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
|
# local env files
|
||||||
.env*.local
|
.env*.local
|
||||||
.env
|
*.env
|
||||||
|
!scripts/build.env
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
@@ -38,3 +39,5 @@ next-env.d.ts
|
|||||||
|
|
||||||
# db
|
# db
|
||||||
postgres-data
|
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
|
## 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] Create reimbursement expenses
|
||||||
- [x] Progressive Web App
|
- [x] Progressive Web App
|
||||||
- [x] Select all/no participant for expenses
|
- [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
|
### 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/spliit-app/spliit/issues/5)
|
||||||
- [ ] Ability to create recurring expenses [(#5)](https://github.com/scastiel/spliit2/issues/5)
|
- [ ] Import expenses from Splitwise [(#22)](https://github.com/spliit-app/spliit/issues/22)
|
||||||
- [ ] Ability to split expenses unevenly [(#6)](https://github.com/scastiel/spliit2/issues/6)
|
|
||||||
|
|
||||||
## Stack
|
## 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!
|
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
|
## Run locally
|
||||||
|
|
||||||
1. Clone the repository (or fork it if you intend to contribute)
|
1. Clone the repository (or fork it if you intend to contribute)
|
||||||
2. `npm install`
|
2. Start a PostgreSQL server. You can run `./scripts/start-local-db.sh` if you don’t have a server already.
|
||||||
3. Start a PostgreSQL server. You can run `./start-local-db.sh` if you don’t have a server already.
|
3. Copy the file `.env.example` as `.env`
|
||||||
4. 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. `npm run dev`
|
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
|
## 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",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"lint": "eslint",
|
||||||
"build": "next build",
|
"check-types": "tsc --noEmit",
|
||||||
"start": "next start",
|
"check-formatting": "prettier -c src",
|
||||||
"lint": "next lint",
|
"prettier": "prettier -w src",
|
||||||
"postinstall": "prisma migrate deploy && prisma generate"
|
"postinstall": "prisma generate"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@formatjs/intl-localematcher": "^0.5.4",
|
||||||
"@hookform/resolvers": "^3.3.2",
|
"@hookform/resolvers": "^3.3.2",
|
||||||
"@prisma/client": "5.6.0",
|
"@prisma/client": "^5.6.0",
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
"@trpc/client": "^11.0.0-rc.586",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
"@trpc/react-query": "^11.0.0-rc.586",
|
||||||
"@radix-ui/react-hover-card": "^1.0.7",
|
"@trpc/server": "^11.0.0-rc.586",
|
||||||
"@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",
|
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.0.0",
|
"content-disposition": "^0.5.4",
|
||||||
"lucide-react": "^0.290.0",
|
"dayjs": "^1.11.10",
|
||||||
"nanoid": "^5.0.4",
|
"nanoid": "^5.0.4",
|
||||||
"next": "^14.0.4",
|
"negotiator": "^0.6.3",
|
||||||
"next-plausible": "^3.12.0",
|
"openai": "^4.25.0",
|
||||||
"next-themes": "^0.2.1",
|
|
||||||
"next13-progressbar": "^1.1.1",
|
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"react": "^18.2.0",
|
"prisma": "^5.7.0",
|
||||||
"react-dom": "^18.2.0",
|
"sharp": "^0.33.2",
|
||||||
"react-hook-form": "^7.47.0",
|
"superjson": "^2.2.1",
|
||||||
"tailwind-merge": "^1.14.0",
|
"ts-pattern": "^5.0.6",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"zod": "^3.22.4"
|
"vaul": "^0.8.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.13.0",
|
||||||
"@total-typescript/ts-reset": "^0.5.1",
|
"@total-typescript/ts-reset": "^0.5.1",
|
||||||
|
"@types/content-disposition": "^0.5.8",
|
||||||
|
"@types/negotiator": "^0.6.3",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pg": "^8.10.9",
|
"@types/pg": "^8.10.9",
|
||||||
"@types/react": "^18",
|
|
||||||
"@types/react-dom": "^18",
|
|
||||||
"@types/uuid": "^9.0.6",
|
"@types/uuid": "^9.0.6",
|
||||||
"autoprefixer": "^10",
|
"autoprefixer": "^10",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"eslint": "^8",
|
"eslint": "^8.57.1",
|
||||||
"eslint-config-next": "^14.0.4",
|
"globals": "^15.11.0",
|
||||||
"postcss": "^8",
|
|
||||||
"prettier": "^3.0.3",
|
"prettier": "^3.0.3",
|
||||||
"prettier-plugin-organize-imports": "^3.2.3",
|
"prettier-plugin-organize-imports": "^3.2.3",
|
||||||
"prisma": "^5.7.0",
|
|
||||||
"tailwindcss": "^3",
|
|
||||||
"tsconfig-paths": "^4.2.0",
|
"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 {
|
model Group {
|
||||||
id String @id
|
id String @id
|
||||||
name String
|
name String
|
||||||
|
information String? @db.Text
|
||||||
currency String @default("$")
|
currency String @default("$")
|
||||||
participants Participant[]
|
participants Participant[]
|
||||||
expenses Expense[]
|
expenses Expense[]
|
||||||
|
activities Activity[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,17 +31,46 @@ model Participant {
|
|||||||
expensesPaidFor ExpensePaidFor[]
|
expensesPaidFor ExpensePaidFor[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Category {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
grouping String
|
||||||
|
name String
|
||||||
|
Expense Expense[]
|
||||||
|
}
|
||||||
|
|
||||||
model Expense {
|
model Expense {
|
||||||
id String @id
|
id String @id
|
||||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||||
|
expenseDate DateTime @default(dbgenerated("CURRENT_DATE")) @db.Date
|
||||||
title String
|
title String
|
||||||
|
category Category? @relation(fields: [categoryId], references: [id])
|
||||||
|
categoryId Int @default(0)
|
||||||
amount Int
|
amount Int
|
||||||
paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade)
|
paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade)
|
||||||
paidById String
|
paidById String
|
||||||
paidFor ExpensePaidFor[]
|
paidFor ExpensePaidFor[]
|
||||||
groupId String
|
groupId String
|
||||||
isReimbursement Boolean @default(false)
|
isReimbursement Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
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 {
|
model ExpensePaidFor {
|
||||||
@@ -47,6 +78,25 @@ model ExpensePaidFor {
|
|||||||
participant Participant @relation(fields: [participantId], references: [id], onDelete: Cascade)
|
participant Participant @relation(fields: [participantId], references: [id], onDelete: Cascade)
|
||||||
expenseId String
|
expenseId String
|
||||||
participantId String
|
participantId String
|
||||||
|
shares Int @default(1)
|
||||||
|
|
||||||
@@id([expenseId, participantId])
|
@@id([expenseId, participantId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 ];
|
if [ $? -eq 0 ];
|
||||||
then
|
then
|
||||||
echo "postgres is already running, doing nothing"
|
echo "postgres is already running, doing nothing"
|
||||||
@@ -6,6 +6,6 @@ else
|
|||||||
echo "postgres is not running, starting it"
|
echo "postgres is not running, starting it"
|
||||||
docker rm postgres --force
|
docker rm postgres --force
|
||||||
mkdir -p postgres-data
|
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
|
sleep 5 # Wait for postgres to start
|
||||||
fi
|
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 { ActivityType, Expense } from '@prisma/client'
|
||||||
import { ExpenseFormValues, GroupFormValues } from '@/lib/schemas'
|
|
||||||
import { Expense } from '@prisma/client'
|
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
|
import { prisma } from './prisma'
|
||||||
|
import { ExpenseFormValues, GroupFormValues } from './schemas'
|
||||||
|
|
||||||
export function randomId() {
|
export function randomId() {
|
||||||
return nanoid()
|
return nanoid()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createGroup(groupFormValues: GroupFormValues) {
|
export async function createGroup(groupFormValues: GroupFormValues) {
|
||||||
const prisma = await getPrisma()
|
|
||||||
return prisma.group.create({
|
return prisma.group.create({
|
||||||
data: {
|
data: {
|
||||||
id: randomId(),
|
id: randomId(),
|
||||||
name: groupFormValues.name,
|
name: groupFormValues.name,
|
||||||
|
information: groupFormValues.information,
|
||||||
currency: groupFormValues.currency,
|
currency: groupFormValues.currency,
|
||||||
participants: {
|
participants: {
|
||||||
createMany: {
|
createMany: {
|
||||||
@@ -30,40 +30,72 @@ export async function createGroup(groupFormValues: GroupFormValues) {
|
|||||||
export async function createExpense(
|
export async function createExpense(
|
||||||
expenseFormValues: ExpenseFormValues,
|
expenseFormValues: ExpenseFormValues,
|
||||||
groupId: string,
|
groupId: string,
|
||||||
|
participantId?: string,
|
||||||
): Promise<Expense> {
|
): Promise<Expense> {
|
||||||
const group = await getGroup(groupId)
|
const group = await getGroup(groupId)
|
||||||
if (!group) throw new Error(`Invalid group ID: ${groupId}`)
|
if (!group) throw new Error(`Invalid group ID: ${groupId}`)
|
||||||
|
|
||||||
for (const participant of [
|
for (const participant of [
|
||||||
expenseFormValues.paidBy,
|
expenseFormValues.paidBy,
|
||||||
...expenseFormValues.paidFor,
|
...expenseFormValues.paidFor.map((p) => p.participant),
|
||||||
]) {
|
]) {
|
||||||
if (!group.participants.some((p) => p.id === participant))
|
if (!group.participants.some((p) => p.id === participant))
|
||||||
throw new Error(`Invalid participant ID: ${participant}`)
|
throw new Error(`Invalid participant ID: ${participant}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const prisma = await getPrisma()
|
const expenseId = randomId()
|
||||||
|
await logActivity(groupId, ActivityType.CREATE_EXPENSE, {
|
||||||
|
participantId,
|
||||||
|
expenseId,
|
||||||
|
data: expenseFormValues.title,
|
||||||
|
})
|
||||||
|
|
||||||
return prisma.expense.create({
|
return prisma.expense.create({
|
||||||
data: {
|
data: {
|
||||||
id: randomId(),
|
id: expenseId,
|
||||||
groupId,
|
groupId,
|
||||||
|
expenseDate: expenseFormValues.expenseDate,
|
||||||
|
categoryId: expenseFormValues.category,
|
||||||
amount: expenseFormValues.amount,
|
amount: expenseFormValues.amount,
|
||||||
title: expenseFormValues.title,
|
title: expenseFormValues.title,
|
||||||
paidById: expenseFormValues.paidBy,
|
paidById: expenseFormValues.paidBy,
|
||||||
|
splitMode: expenseFormValues.splitMode,
|
||||||
paidFor: {
|
paidFor: {
|
||||||
createMany: {
|
createMany: {
|
||||||
data: expenseFormValues.paidFor.map((paidFor) => ({
|
data: expenseFormValues.paidFor.map((paidFor) => ({
|
||||||
participantId: paidFor,
|
participantId: paidFor.participant,
|
||||||
|
shares: paidFor.shares,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
isReimbursement: expenseFormValues.isReimbursement,
|
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) {
|
export async function deleteExpense(
|
||||||
const prisma = await getPrisma()
|
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({
|
await prisma.expense.delete({
|
||||||
where: { id: expenseId },
|
where: { id: expenseId },
|
||||||
include: { paidFor: true, paidBy: true },
|
include: { paidFor: true, paidBy: true },
|
||||||
@@ -75,21 +107,22 @@ export async function getGroupExpensesParticipants(groupId: string) {
|
|||||||
return Array.from(
|
return Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
expenses.flatMap((e) => [
|
expenses.flatMap((e) => [
|
||||||
e.paidById,
|
e.paidBy.id,
|
||||||
...e.paidFor.map((pf) => pf.participantId),
|
...e.paidFor.map((pf) => pf.participant.id),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getGroups(groupIds: string[]) {
|
export async function getGroups(groupIds: string[]) {
|
||||||
const prisma = await getPrisma()
|
return (
|
||||||
return (await prisma.group.findMany({
|
await prisma.group.findMany({
|
||||||
where: { id: { in: groupIds } },
|
where: { id: { in: groupIds } },
|
||||||
include: { _count: { select: { participants: true } } },
|
include: { _count: { select: { participants: true } } },
|
||||||
})).map(group => ({
|
})
|
||||||
|
).map((group) => ({
|
||||||
...group,
|
...group,
|
||||||
createdAt: group.createdAt.toISOString()
|
createdAt: group.createdAt.toISOString(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +130,7 @@ export async function updateExpense(
|
|||||||
groupId: string,
|
groupId: string,
|
||||||
expenseId: string,
|
expenseId: string,
|
||||||
expenseFormValues: ExpenseFormValues,
|
expenseFormValues: ExpenseFormValues,
|
||||||
|
participantId?: string,
|
||||||
) {
|
) {
|
||||||
const group = await getGroup(groupId)
|
const group = await getGroup(groupId)
|
||||||
if (!group) throw new Error(`Invalid group ID: ${groupId}`)
|
if (!group) throw new Error(`Invalid group ID: ${groupId}`)
|
||||||
@@ -106,34 +140,75 @@ export async function updateExpense(
|
|||||||
|
|
||||||
for (const participant of [
|
for (const participant of [
|
||||||
expenseFormValues.paidBy,
|
expenseFormValues.paidBy,
|
||||||
...expenseFormValues.paidFor,
|
...expenseFormValues.paidFor.map((p) => p.participant),
|
||||||
]) {
|
]) {
|
||||||
if (!group.participants.some((p) => p.id === participant))
|
if (!group.participants.some((p) => p.id === participant))
|
||||||
throw new Error(`Invalid participant ID: ${participant}`)
|
throw new Error(`Invalid participant ID: ${participant}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const prisma = await getPrisma()
|
await logActivity(groupId, ActivityType.UPDATE_EXPENSE, {
|
||||||
|
participantId,
|
||||||
|
expenseId,
|
||||||
|
data: expenseFormValues.title,
|
||||||
|
})
|
||||||
|
|
||||||
return prisma.expense.update({
|
return prisma.expense.update({
|
||||||
where: { id: expenseId },
|
where: { id: expenseId },
|
||||||
data: {
|
data: {
|
||||||
|
expenseDate: expenseFormValues.expenseDate,
|
||||||
amount: expenseFormValues.amount,
|
amount: expenseFormValues.amount,
|
||||||
title: expenseFormValues.title,
|
title: expenseFormValues.title,
|
||||||
|
categoryId: expenseFormValues.category,
|
||||||
paidById: expenseFormValues.paidBy,
|
paidById: expenseFormValues.paidBy,
|
||||||
|
splitMode: expenseFormValues.splitMode,
|
||||||
paidFor: {
|
paidFor: {
|
||||||
connectOrCreate: expenseFormValues.paidFor.map((paidFor) => ({
|
create: expenseFormValues.paidFor
|
||||||
|
.filter(
|
||||||
|
(p) =>
|
||||||
|
!existingExpense.paidFor.some(
|
||||||
|
(pp) => pp.participantId === p.participant,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((paidFor) => ({
|
||||||
|
participantId: paidFor.participant,
|
||||||
|
shares: paidFor.shares,
|
||||||
|
})),
|
||||||
|
update: expenseFormValues.paidFor.map((paidFor) => ({
|
||||||
where: {
|
where: {
|
||||||
expenseId_participantId: { expenseId, participantId: paidFor },
|
expenseId_participantId: {
|
||||||
|
expenseId,
|
||||||
|
participantId: paidFor.participant,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
shares: paidFor.shares,
|
||||||
},
|
},
|
||||||
create: { participantId: paidFor },
|
|
||||||
})),
|
})),
|
||||||
deleteMany: existingExpense.paidFor.filter(
|
deleteMany: existingExpense.paidFor.filter(
|
||||||
(paidFor) =>
|
(paidFor) =>
|
||||||
!expenseFormValues.paidFor.some(
|
!expenseFormValues.paidFor.some(
|
||||||
(pf) => pf === paidFor.participantId,
|
(pf) => pf.participant === paidFor.participantId,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
isReimbursement: expenseFormValues.isReimbursement,
|
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(
|
export async function updateGroup(
|
||||||
groupId: string,
|
groupId: string,
|
||||||
groupFormValues: GroupFormValues,
|
groupFormValues: GroupFormValues,
|
||||||
|
participantId?: string,
|
||||||
) {
|
) {
|
||||||
const existingGroup = await getGroup(groupId)
|
const existingGroup = await getGroup(groupId)
|
||||||
if (!existingGroup) throw new Error('Invalid group ID')
|
if (!existingGroup) throw new Error('Invalid group ID')
|
||||||
|
|
||||||
const prisma = await getPrisma()
|
await logActivity(groupId, ActivityType.UPDATE_GROUP, { participantId })
|
||||||
|
|
||||||
return prisma.group.update({
|
return prisma.group.update({
|
||||||
where: { id: groupId },
|
where: { id: groupId },
|
||||||
data: {
|
data: {
|
||||||
name: groupFormValues.name,
|
name: groupFormValues.name,
|
||||||
|
information: groupFormValues.information,
|
||||||
currency: groupFormValues.currency,
|
currency: groupFormValues.currency,
|
||||||
participants: {
|
participants: {
|
||||||
deleteMany: existingGroup.participants.filter(
|
deleteMany: existingGroup.participants.filter(
|
||||||
@@ -177,26 +255,102 @@ export async function updateGroup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getGroup(groupId: string) {
|
export async function getGroup(groupId: string) {
|
||||||
const prisma = await getPrisma()
|
|
||||||
return prisma.group.findUnique({
|
return prisma.group.findUnique({
|
||||||
where: { id: groupId },
|
where: { id: groupId },
|
||||||
include: { participants: true },
|
include: { participants: true },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getGroupExpenses(groupId: string) {
|
export async function getCategories() {
|
||||||
const prisma = await getPrisma()
|
return prisma.category.findMany()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGroupExpenses(
|
||||||
|
groupId: string,
|
||||||
|
options?: { offset?: number; length?: number; filter?: string },
|
||||||
|
) {
|
||||||
return prisma.expense.findMany({
|
return prisma.expense.findMany({
|
||||||
where: { groupId },
|
select: {
|
||||||
include: { paidFor: { include: { participant: true } }, paidBy: true },
|
amount: true,
|
||||||
orderBy: { createdAt: 'desc' },
|
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) {
|
export async function getExpense(groupId: string, expenseId: string) {
|
||||||
const prisma = await getPrisma()
|
|
||||||
return prisma.expense.findUnique({
|
return prisma.expense.findUnique({
|
||||||
where: { id: expenseId },
|
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 { Participant } from '@prisma/client'
|
||||||
|
import { match } from 'ts-pattern'
|
||||||
|
import { getGroupExpenses } from './api'
|
||||||
|
|
||||||
export type Balances = Record<
|
export type Balances = Record<
|
||||||
Participant['id'],
|
Participant['id'],
|
||||||
@@ -18,33 +19,86 @@ export function getBalances(
|
|||||||
const balances: Balances = {}
|
const balances: Balances = {}
|
||||||
|
|
||||||
for (const expense of expenses) {
|
for (const expense of expenses) {
|
||||||
const paidBy = expense.paidById
|
const paidBy = expense.paidBy.id
|
||||||
const paidFors = expense.paidFor.map((p) => p.participantId)
|
const paidFors = expense.paidFor
|
||||||
|
|
||||||
if (!balances[paidBy]) balances[paidBy] = { paid: 0, paidFor: 0, total: 0 }
|
if (!balances[paidBy]) balances[paidBy] = { paid: 0, paidFor: 0, total: 0 }
|
||||||
balances[paidBy].paid += expense.amount
|
balances[paidBy].paid += expense.amount
|
||||||
balances[paidBy].total += expense.amount
|
|
||||||
paidFors.forEach((paidFor, index) => {
|
|
||||||
if (!balances[paidFor])
|
|
||||||
balances[paidFor] = { paid: 0, paidFor: 0, total: 0 }
|
|
||||||
|
|
||||||
const dividedAmount = divide(
|
const totalPaidForShares = paidFors.reduce(
|
||||||
expense.amount,
|
(sum, paidFor) => sum + paidFor.shares,
|
||||||
paidFors.length,
|
0,
|
||||||
index === paidFors.length - 1,
|
)
|
||||||
)
|
let remaining = expense.amount
|
||||||
balances[paidFor].paidFor += dividedAmount
|
paidFors.forEach((paidFor, index) => {
|
||||||
balances[paidFor].total -= dividedAmount
|
if (!balances[paidFor.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
|
return balances
|
||||||
}
|
}
|
||||||
|
|
||||||
function divide(total: number, count: number, isLast: boolean): number {
|
export function getPublicBalances(reimbursements: Reimbursement[]): Balances {
|
||||||
if (!isLast) return Math.floor(total / count)
|
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(
|
export function getSuggestedReimbursements(
|
||||||
@@ -53,7 +107,7 @@ export function getSuggestedReimbursements(
|
|||||||
const balancesArray = Object.entries(balances)
|
const balancesArray = Object.entries(balances)
|
||||||
.map(([participantId, { total }]) => ({ participantId, total }))
|
.map(([participantId, { total }]) => ({ participantId, total }))
|
||||||
.filter((b) => b.total !== 0)
|
.filter((b) => b.total !== 0)
|
||||||
balancesArray.sort((b1, b2) => b2.total - b1.total)
|
balancesArray.sort(compareBalancesForReimbursements)
|
||||||
const reimbursements: Reimbursement[] = []
|
const reimbursements: Reimbursement[] = []
|
||||||
while (balancesArray.length > 1) {
|
while (balancesArray.length > 1) {
|
||||||
const first = balancesArray[0]
|
const first = balancesArray[0]
|
||||||
@@ -77,5 +131,5 @@ export function getSuggestedReimbursements(
|
|||||||
balancesArray.shift()
|
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'
|
import { z } from 'zod'
|
||||||
|
|
||||||
const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
NEXT_PUBLIC_BASE_URL: z.string().url(),
|
|
||||||
POSTGRES_URL_NON_POOLING: z.string().url(),
|
POSTGRES_URL_NON_POOLING: z.string().url(),
|
||||||
POSTGRES_PRISMA_URL: z.string().url(),
|
POSTGRES_PRISMA_URL: z.string().url(),
|
||||||
PLAUSIBLE_DOMAIN: z.string().optional(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const env = envSchema.parse(process.env)
|
export const env = envSchema.parse(process.env)
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { PrismaClient } from '@prisma/client'
|
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)
|
// await delay(1000)
|
||||||
if (!prisma) {
|
if (process.env['NODE_ENV'] === 'production') {
|
||||||
if (process.env.NODE_ENV === 'production') {
|
p = new PrismaClient()
|
||||||
prisma = new PrismaClient()
|
} else {
|
||||||
} else {
|
if (!global.prisma) {
|
||||||
if (!(global as any).prisma) {
|
global.prisma = new PrismaClient({
|
||||||
;(global as any).prisma = new PrismaClient()
|
// log: [{ emit: 'stdout', level: 'query' }],
|
||||||
}
|
})
|
||||||
prisma = (global as any).prisma
|
|
||||||
}
|
}
|
||||||
|
p = global.prisma
|
||||||
}
|
}
|
||||||
return prisma
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const prisma = p
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
|
import { SplitMode } from '@prisma/client'
|
||||||
import * as z from 'zod'
|
import * as z from 'zod'
|
||||||
|
|
||||||
export const groupFormSchema = z
|
export const groupFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z
|
name: z.string().min(2, 'min2').max(50, 'max50'),
|
||||||
.string()
|
information: z.string().optional(),
|
||||||
.min(2, 'Enter at least two characters.')
|
currency: z.string().min(1, 'min1').max(5, 'max5'),
|
||||||
.max(50, 'Enter at most 50 characters.'),
|
|
||||||
currency: z
|
|
||||||
.string()
|
|
||||||
.min(1, 'Enter at least one character.')
|
|
||||||
.max(5, 'Enter at most five characters.'),
|
|
||||||
participants: z
|
participants: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
name: z
|
name: z.string().min(2, 'min2').max(50, 'max50'),
|
||||||
.string()
|
|
||||||
.min(2, 'Enter at least two characters.')
|
|
||||||
.max(50, 'Enter at most 50 characters.'),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.min(1),
|
.min(1),
|
||||||
@@ -28,7 +21,7 @@ export const groupFormSchema = z
|
|||||||
if (otherParticipant.name === participant.name) {
|
if (otherParticipant.name === participant.name) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: 'custom',
|
code: 'custom',
|
||||||
message: 'Another participant already has this name.',
|
message: 'duplicateParticipantName',
|
||||||
path: ['participants', i, 'name'],
|
path: ['participants', i, 'name'],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -38,36 +31,127 @@ export const groupFormSchema = z
|
|||||||
|
|
||||||
export type GroupFormValues = z.infer<typeof groupFormSchema>
|
export type GroupFormValues = z.infer<typeof groupFormSchema>
|
||||||
|
|
||||||
export const expenseFormSchema = z.object({
|
export const expenseFormSchema = z
|
||||||
title: z
|
.object({
|
||||||
.string({ required_error: 'Please enter a title.' })
|
expenseDate: z.coerce.date(),
|
||||||
.min(2, 'Enter at least two characters.'),
|
title: z.string({ required_error: 'titleRequired' }).min(2, 'min2'),
|
||||||
amount: z
|
category: z.coerce.number().default(0),
|
||||||
.union(
|
amount: z
|
||||||
[
|
.union(
|
||||||
z.number(),
|
[
|
||||||
z.string().transform((value, ctx) => {
|
z.number(),
|
||||||
const valueAsNumber = Number(value)
|
z.string().transform((value, ctx) => {
|
||||||
if (Number.isNaN(valueAsNumber))
|
const valueAsNumber = Number(value)
|
||||||
|
if (Number.isNaN(valueAsNumber))
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: '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({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
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.' },
|
.default([]),
|
||||||
)
|
notes: z.string().optional(),
|
||||||
.refine((amount) => amount >= 1, 'The amount must be higher than 0.01.')
|
})
|
||||||
.refine(
|
.superRefine((expense, ctx) => {
|
||||||
(amount) => amount <= 10_000_000_00,
|
let sum = 0
|
||||||
'The amount must be lower than 10,000,000.',
|
for (const { shares } of expense.paidFor) {
|
||||||
),
|
sum +=
|
||||||
paidBy: z.string({ required_error: 'You must select a participant.' }),
|
typeof shares === 'number' ? shares : Math.round(Number(shares) * 100)
|
||||||
paidFor: z
|
}
|
||||||
.array(z.string())
|
switch (expense.splitMode) {
|
||||||
.min(1, 'The expense must be paid for at least one participant.'),
|
case 'EVENLY':
|
||||||
isReimbursement: z.boolean(),
|
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 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>
|
||||||