17 Commits
1.4.0 ... 1.8.1

Author SHA1 Message Date
Sebastien Castiel
e990e00a75 Upgrade Next.js & React to latest versions (#159) 2024-05-29 22:25:52 -04:00
Stefan Hynst
0c05499107 Add support for group income (= negative expenses) (#158)
* Allow negative amount for expenses to be entered

- an expense becomes an income
- this does not affect calculations, i.e. an income can be split just like an expense

* Incomes should not be reimbursements

when entering a negative number
- deselect 'isReimbursement'
- hide reimbursement checkbox

* Change captions when entering a negative number

- "expense" becomes "income"
- "paid" becomes "received"

* Format incomes on expense list

- replace "paid by" with "received by"

* Format incomes on "Stats" tab

- a group's or participants balance might be negative
- in this case "spendings" will be "earnings" (display accordingly)
- always display positive numbers
- for active user: highlight spendings/earnings in red/green

* Fix typo

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-05-29 22:20:04 -04:00
Oliver Wong
3887efd9ee Use placeholder for new participant input (#153)
* use placeholder for new participant

* Fix formatting

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-05-29 22:11:24 -04:00
dcbr
e619c1a5b4 Add basic activity log (#141)
* Add basic activity log

* Add database migration

* Fix layout

* Fix types

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-05-29 22:06:45 -04:00
Lauri Vuorela
10e13d1f6b copy the next.config.js in order to get custom domains working again (#147) 2024-05-29 21:46:16 -04:00
Lauri Vuorela
f9d915378b change onClick to onFocus, with a slight delay for safari (#144)
* change onClick to onFocus, with a slight delay for safari

* typo

* fix variable name

* Fix style

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-05-29 21:45:46 -04:00
Antonin RAFFIN
74465c0565 Fix relative path docker db script (#154)
Without this, docker complained:
```
docker: Error response from daemon: create ./postgres-data: "./postgres-data" includes invalid characters for a local volume name, only "[a-zA-Z0-9][a-zA-Z0-9_.-]" are allowed. If you intended to pass a host directory, use absolute path.
```

Followed recommendation from https://stackoverflow.com/questions/46526165/docker-invalid-characters-for-volume-when-using-relative-paths
2024-05-29 21:38:37 -04:00
Stefan Hynst
d3fd8027a5 Implement "infinite scroll" for expenses (#95)
* Extract ExpenseCard vom ExpenseList

* Implement simple pagination of expenses (see #30)

- display only this year's entries by default
- a "Show more" button reveals all expenses

* Turn getPrisma() into constant "prisma"

- getPrisma() is not async and doesn't need to be awaited
- turn getPrisma() into exported constant "prisma"

* Select fields to be returned by getGroupExpenses()

- make JSON more concise and less redundant
- some properties were removed (i.e.instead of "expense.paidById" use "expense.paidBy.id")

* Remove "participants" from ExpenseCard

- no need to search for participant by id to get it's name
- name property is already present in expense

* Add option to fetch a slice of group expenses

- specify offset and length to get expenses for [offset, offset+length[
- add function to get total number of group expenses

* Add api route for client to fetch group expenses

* Remove "Show more" button from expense list

* Implement infinite scroll

- in server component Page
  - only load first 200 expenses max
  - pass preloaded expenses and total count

- in client component ExpenseList, if there are more expenses to show
  - test if there are more expenses
  - append preloading "skeletons" to end of list
  - fetch more expenses when last item in list comes into view
  - after each fetch increase fetch-length by factor 1.5
    - rationale: db fetch usually is not the issue here, the longer the list gets, the longer react needs to redraw

* Use server action instead of api endpoint

* Fixes

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-05-29 21:36:07 -04:00
Stefan Hynst
833237b613 Add "x" button to cancel search in search bar (#107) 2024-05-29 21:26:04 -04:00
dcbr
1cd2b273f9 Show the impact of an expense on the active user's balance (#139)
* Add devcontainer configuration for codespace support

* Show the impact of an expense on the active user's balance

* Run prettier

* Put the balance on a different line

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-04-13 13:07:18 -04:00
dcbr
1ad470309b Add devcontainer configuration for codespace support (#138) 2024-04-13 12:57:47 -04:00
Deep Golani
2fd38aadd9 Add notes in expense (#126)
* Feature: Added notes in expense

* Add missing notes in form values

* Prettier

---------

Co-authored-by: deep.golani <deep.golani@bfhl.in>
Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-04-05 08:38:38 -04:00
magomzr
b61d1836ea Add titles for a better user experience (#137)
Co-authored-by: Mario Gómez <60667991+mgomezarr@users.noreply.github.com>
2024-04-05 08:29:08 -04:00
Sahil Mehra
c3903849ec Bug: Fixed wrong paid by Name in Reimbursement (#134) 2024-04-02 08:20:56 -04:00
Jan T
b67a0be0dd Add "save as default splitting options" feature (#120)
* Add "save as default splitting options" feature

* Fix type issue

* Run autoformatter
2024-03-09 11:45:53 -05:00
Guhan
e07d237218 Ask for confirmation to delete an expense (#124)
* feat: added a popup asking for confirmation to delete an expense

* fix: changed cancel option as a button and formatting issues

* fix: removed unnecessary tags and replaced generic tags with proper components

* Small fix to avoid warning in console

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-03-09 11:38:30 -05:00
annalouisep
cc37083389 Expense list: add section for planned purchases (#122)
* add planned purchases

* Updating verbiage to reflect possible future entry types
2024-03-09 11:30:24 -05:00
41 changed files with 1213 additions and 324 deletions

View 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"
}

View 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:

View File

@@ -27,7 +27,7 @@ RUN rm -r .next/cache
FROM node:21-alpine as runtime-deps FROM node:21-alpine as runtime-deps
WORKDIR /usr/app WORKDIR /usr/app
COPY --from=base /usr/app/package.json /usr/app/package-lock.json ./ COPY --from=base /usr/app/package.json /usr/app/package-lock.json /usr/app/next.config.js ./
COPY --from=base /usr/app/prisma ./prisma COPY --from=base /usr/app/prisma ./prisma
RUN npm ci --omit=dev --omit=optional --ignore-scripts && \ RUN npm ci --omit=dev --omit=optional --ignore-scripts && \
@@ -38,7 +38,7 @@ FROM node:21-alpine as runner
EXPOSE 3000/tcp EXPOSE 3000/tcp
WORKDIR /usr/app WORKDIR /usr/app
COPY --from=base /usr/app/package.json /usr/app/package-lock.json ./ COPY --from=base /usr/app/package.json /usr/app/package-lock.json /usr/app/next.config.js ./
COPY --from=runtime-deps /usr/app/node_modules ./node_modules COPY --from=runtime-deps /usr/app/node_modules ./node_modules
COPY ./public ./public COPY ./public ./public
COPY ./scripts ./scripts COPY ./scripts ./scripts

View File

@@ -23,6 +23,12 @@ const nextConfig = {
images: { images: {
remotePatterns remotePatterns
}, },
// Required to run in a codespace (see https://github.com/vercel/next.js/issues/58019)
experimental: {
serverActions: {
allowedOrigins: ['localhost:3000'],
},
},
} }
module.exports = nextConfig module.exports = nextConfig

149
package-lock.json generated
View File

@@ -33,16 +33,17 @@
"embla-carousel-react": "^8.0.0-rc21", "embla-carousel-react": "^8.0.0-rc21",
"lucide-react": "^0.290.0", "lucide-react": "^0.290.0",
"nanoid": "^5.0.4", "nanoid": "^5.0.4",
"next": "^14.1.0", "next": "^14.2.3",
"next-s3-upload": "^0.3.4", "next-s3-upload": "^0.3.4",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"next13-progressbar": "^1.1.1", "next13-progressbar": "^1.1.1",
"openai": "^4.25.0", "openai": "^4.25.0",
"pg": "^8.11.3", "pg": "^8.11.3",
"prisma": "^5.7.0", "prisma": "^5.7.0",
"react": "^18.2.0", "react": "^18.3.1",
"react-dom": "^18.2.0", "react-dom": "^18.3.1",
"react-hook-form": "^7.47.0", "react-hook-form": "^7.47.0",
"react-intersection-observer": "^9.8.0",
"sharp": "^0.33.2", "sharp": "^0.33.2",
"tailwind-merge": "^1.14.0", "tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@@ -1189,9 +1190,9 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "14.1.0", "version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.1.0.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.3.tgz",
"integrity": "sha512-Py8zIo+02ht82brwwhTg36iogzFqGLPXlRGKQw5s+qP/kMNc4MAyDeEwBKDijk6zTIbegEgu8Qy7C1LboslQAw==" "integrity": "sha512-W7fd7IbkfmeeY2gXrzJYDx8D2lWKbVoTIj1o1ScPHNzvp30s1AuoEFSdr39bC5sjxJaxTtq3OTCZboNp0lNWHA=="
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
"version": "14.1.0", "version": "14.1.0",
@@ -1249,9 +1250,9 @@
} }
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "14.1.0", "version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.1.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.3.tgz",
"integrity": "sha512-nUDn7TOGcIeyQni6lZHfzNoo9S0euXnu0jhsbMOmMJUBfgsnESdjN97kM7cBqQxZa8L/bM9om/S5/1dzCrW6wQ==", "integrity": "sha512-3pEYo/RaGqPP0YzwnlmPN2puaF2WMLM3apt5jLW2fFdXD9+pqcoTzRk+iZsf8ta7+quAe4Q6Ms0nR0SFGFdS1A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1264,9 +1265,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "14.1.0", "version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.1.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.3.tgz",
"integrity": "sha512-1jgudN5haWxiAl3O1ljUS2GfupPmcftu2RYJqZiMJmmbBT5M1XDffjUtRUzP4W3cBHsrvkfOFdQ71hAreNQP6g==", "integrity": "sha512-6adp7waE6P1TYFSXpY366xwsOnEXM+y1kgRpjSRVI2CBDOcbRjsJ67Z6EgKIqWIue52d2q/Mx8g9MszARj8IEA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1279,9 +1280,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "14.1.0", "version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.1.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.3.tgz",
"integrity": "sha512-RHo7Tcj+jllXUbK7xk2NyIDod3YcCPDZxj1WLIYxd709BQ7WuRYl3OWUNG+WUfqeQBds6kvZYlc42NJJTNi4tQ==", "integrity": "sha512-cuzCE/1G0ZSnTAHJPUT1rPgQx1w5tzSX7POXSLaS7w2nIUJUD+e25QoXD/hMfxbsT9rslEXugWypJMILBj/QsA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1294,9 +1295,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "14.1.0", "version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.1.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.3.tgz",
"integrity": "sha512-v6kP8sHYxjO8RwHmWMJSq7VZP2nYCkRVQ0qolh2l6xroe9QjbgV8siTbduED4u0hlk0+tjS6/Tuy4n5XCp+l6g==", "integrity": "sha512-0D4/oMM2Y9Ta3nGuCcQN8jjJjmDPYpHX9OJzqk42NZGJocU2MqhBq5tWkJrUQOQY9N+In9xOdymzapM09GeiZw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1309,9 +1310,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "14.1.0", "version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.1.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.3.tgz",
"integrity": "sha512-zJ2pnoFYB1F4vmEVlb/eSe+VH679zT1VdXlZKX+pE66grOgjmKJHKacf82g/sWE4MQ4Rk2FMBCRnX+l6/TVYzQ==", "integrity": "sha512-ENPiNnBNDInBLyUU5ii8PMQh+4XLr4pG51tOp6aJ9xqFQ2iRI6IH0Ds2yJkAzNV1CfyagcyzPfROMViS2wOZ9w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1324,9 +1325,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "14.1.0", "version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.1.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.3.tgz",
"integrity": "sha512-rbaIYFt2X9YZBSbH/CwGAjbBG2/MrACCVu2X0+kSykHzHnYH5FjHxwXLkcoJ10cX0aWCEynpu+rP76x0914atg==", "integrity": "sha512-BTAbq0LnCbF5MtoM7I/9UeUu/8ZBY0i8SFjUMCbPDOLv+un67e2JgyN4pmgfXBwy/I+RHu8q+k+MCkDN6P9ViQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1339,9 +1340,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "14.1.0", "version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.1.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.3.tgz",
"integrity": "sha512-o1N5TsYc8f/HpGt39OUQpQ9AKIGApd3QLueu7hXk//2xq5Z9OxmV6sQfNp8C7qYmiOlHYODOGqNNa0e9jvchGQ==", "integrity": "sha512-AEHIw/dhAMLNFJFJIJIyOFDzrzI5bAjI9J26gbO5xhAKHYTZ9Or04BesFPXiAYXDNdrwTP2dQceYA4dL1geu8A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1354,9 +1355,9 @@
} }
}, },
"node_modules/@next/swc-win32-ia32-msvc": { "node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.1.0", "version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.1.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.3.tgz",
"integrity": "sha512-XXIuB1DBRCFwNO6EEzCTMHT5pauwaSj4SWs7CYnME57eaReAKBXCnkUE80p/pAZcewm7hs+vGvNqDPacEXHVkw==", "integrity": "sha512-vga40n1q6aYb0CLrM+eEmisfKCR45ixQYXuBXxOOmmoV8sYST9k7E3US32FsY+CkkF7NtzdcebiFT4CHuMSyZw==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -1369,9 +1370,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "14.1.0", "version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.1.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.3.tgz",
"integrity": "sha512-9WEbVRRAqJ3YFVqEZIxUqkiO8l1nool1LmNxygr5HWF8AcSYsEpneUDhmjUVJEzO2A04+oPtZdombzzPPkTtgg==", "integrity": "sha512-Q1/zm43RWynxrO7lW4ehciQVj+5ePBhOK+/K2P7pLFX3JaJ/IZVC69SHidrmZSOkqz7ECIOhhy7XhAFG4JYyHA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -3057,12 +3058,17 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="
},
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
"version": "0.5.2", "version": "0.5.5",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.2.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz",
"integrity": "sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==", "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==",
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"@swc/counter": "^0.1.3",
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
@@ -6572,12 +6578,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/next": { "node_modules/next": {
"version": "14.1.0", "version": "14.2.3",
"resolved": "https://registry.npmjs.org/next/-/next-14.1.0.tgz", "resolved": "https://registry.npmjs.org/next/-/next-14.2.3.tgz",
"integrity": "sha512-wlzrsbfeSU48YQBjZhDzOwhWhGsy+uQycR8bHAOt1LY1bn3zZEcDyHQOEoN3aWzQ8LHCAJ1nqrWCc9XF2+O45Q==", "integrity": "sha512-dowFkFTR8v79NPJO4QsBUtxv0g9BrS/phluVpMAt2ku7H+cbcBJlopXjkWlwxrk/xGqMemr7JkGPGemPrLLX7A==",
"dependencies": { "dependencies": {
"@next/env": "14.1.0", "@next/env": "14.2.3",
"@swc/helpers": "0.5.2", "@swc/helpers": "0.5.5",
"busboy": "1.6.0", "busboy": "1.6.0",
"caniuse-lite": "^1.0.30001579", "caniuse-lite": "^1.0.30001579",
"graceful-fs": "^4.2.11", "graceful-fs": "^4.2.11",
@@ -6591,18 +6597,19 @@
"node": ">=18.17.0" "node": ">=18.17.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "14.1.0", "@next/swc-darwin-arm64": "14.2.3",
"@next/swc-darwin-x64": "14.1.0", "@next/swc-darwin-x64": "14.2.3",
"@next/swc-linux-arm64-gnu": "14.1.0", "@next/swc-linux-arm64-gnu": "14.2.3",
"@next/swc-linux-arm64-musl": "14.1.0", "@next/swc-linux-arm64-musl": "14.2.3",
"@next/swc-linux-x64-gnu": "14.1.0", "@next/swc-linux-x64-gnu": "14.2.3",
"@next/swc-linux-x64-musl": "14.1.0", "@next/swc-linux-x64-musl": "14.2.3",
"@next/swc-win32-arm64-msvc": "14.1.0", "@next/swc-win32-arm64-msvc": "14.2.3",
"@next/swc-win32-ia32-msvc": "14.1.0", "@next/swc-win32-ia32-msvc": "14.2.3",
"@next/swc-win32-x64-msvc": "14.1.0" "@next/swc-win32-x64-msvc": "14.2.3"
}, },
"peerDependencies": { "peerDependencies": {
"@opentelemetry/api": "^1.1.0", "@opentelemetry/api": "^1.1.0",
"@playwright/test": "^1.41.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"sass": "^1.3.0" "sass": "^1.3.0"
@@ -6611,6 +6618,9 @@
"@opentelemetry/api": { "@opentelemetry/api": {
"optional": true "optional": true
}, },
"@playwright/test": {
"optional": true
},
"sass": { "sass": {
"optional": true "optional": true
} }
@@ -7472,9 +7482,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/react": { "node_modules/react": {
"version": "18.2.0", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -7483,15 +7493,15 @@
} }
}, },
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "18.2.0", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.0" "scheduler": "^0.23.2"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^18.2.0" "react": "^18.3.1"
} }
}, },
"node_modules/react-hook-form": { "node_modules/react-hook-form": {
@@ -7510,6 +7520,20 @@
"react": "^16.8.0 || ^17 || ^18" "react": "^16.8.0 || ^17 || ^18"
} }
}, },
"node_modules/react-intersection-observer": {
"version": "9.8.0",
"resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.8.0.tgz",
"integrity": "sha512-wXHvMQUsTagh3X0Z6jDtGkIXc3VVCd2tjDRYR9kII3GKrZr0XF0xtpfdamo2n8BSF+zzfeeBVOTjxZWpBp9X0g==",
"peerDependencies": {
"react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -7823,10 +7847,9 @@
} }
}, },
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.23.0", "version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
} }

View File

@@ -39,23 +39,24 @@
"embla-carousel-react": "^8.0.0-rc21", "embla-carousel-react": "^8.0.0-rc21",
"lucide-react": "^0.290.0", "lucide-react": "^0.290.0",
"nanoid": "^5.0.4", "nanoid": "^5.0.4",
"next": "^14.1.0", "next": "^14.2.3",
"next-s3-upload": "^0.3.4", "next-s3-upload": "^0.3.4",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"next13-progressbar": "^1.1.1", "next13-progressbar": "^1.1.1",
"openai": "^4.25.0", "openai": "^4.25.0",
"pg": "^8.11.3", "pg": "^8.11.3",
"react": "^18.2.0", "prisma": "^5.7.0",
"react-dom": "^18.2.0", "react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.47.0", "react-hook-form": "^7.47.0",
"react-intersection-observer": "^9.8.0",
"sharp": "^0.33.2", "sharp": "^0.33.2",
"tailwind-merge": "^1.14.0", "tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"ts-pattern": "^5.0.6", "ts-pattern": "^5.0.6",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"vaul": "^0.8.0", "vaul": "^0.8.0",
"zod": "^3.22.4", "zod": "^3.22.4"
"prisma": "^5.7.0"
}, },
"devDependencies": { "devDependencies": {
"@total-typescript/ts-reset": "^0.5.1", "@total-typescript/ts-reset": "^0.5.1",

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Expense" ADD COLUMN "notes" TEXT;

View File

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

View File

@@ -17,6 +17,7 @@ model Group {
currency String @default("$") currency String @default("$")
participants Participant[] participants Participant[]
expenses Expense[] expenses Expense[]
activities Activity[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
} }
@@ -52,6 +53,7 @@ model Expense {
splitMode SplitMode @default(EVENLY) splitMode SplitMode @default(EVENLY)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
documents ExpenseDocument[] documents ExpenseDocument[]
notes String?
} }
model ExpenseDocument { model ExpenseDocument {
@@ -79,3 +81,21 @@ model ExpensePaidFor {
@@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
}

View File

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

View File

@@ -0,0 +1,102 @@
'use client'
import { Button } from '@/components/ui/button'
import { getGroupExpenses } from '@/lib/api'
import { DateTimeStyle, cn, formatDate } from '@/lib/utils'
import { Activity, ActivityType, Participant } from '@prisma/client'
import { ChevronRight } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
type Props = {
groupId: string
activity: Activity
participant?: Participant
expense?: Awaited<ReturnType<typeof getGroupExpenses>>[number]
dateStyle: DateTimeStyle
}
function getSummary(activity: Activity, participantName?: string) {
const participant = participantName ?? 'Someone'
const expense = activity.data ?? ''
if (activity.activityType == ActivityType.UPDATE_GROUP) {
return (
<>
Group settings were modified by <strong>{participant}</strong>
</>
)
} else if (activity.activityType == ActivityType.CREATE_EXPENSE) {
return (
<>
Expense <em>&ldquo;{expense}&rdquo;</em> created by{' '}
<strong>{participant}</strong>.
</>
)
} else if (activity.activityType == ActivityType.UPDATE_EXPENSE) {
return (
<>
Expense <em>&ldquo;{expense}&rdquo;</em> updated by{' '}
<strong>{participant}</strong>.
</>
)
} else if (activity.activityType == ActivityType.DELETE_EXPENSE) {
return (
<>
Expense <em>&ldquo;{expense}&rdquo;</em> deleted by{' '}
<strong>{participant}</strong>.
</>
)
}
}
export function ActivityItem({
groupId,
activity,
participant,
expense,
dateStyle,
}: Props) {
const router = useRouter()
const expenseExists = expense !== undefined
const summary = getSummary(activity, participant?.name)
return (
<div
className={cn(
'flex justify-between sm:rounded-lg px-2 sm:pr-1 sm:pl-2 py-2 text-sm hover:bg-accent gap-1 items-stretch',
expenseExists && 'cursor-pointer',
)}
onClick={() => {
if (expenseExists) {
router.push(`/groups/${groupId}/expenses/${activity.expenseId}/edit`)
}
}}
>
<div className="flex flex-col justify-between items-start">
{dateStyle !== undefined && (
<div className="mt-1 text-xs/5 text-muted-foreground">
{formatDate(activity.time, { dateStyle })}
</div>
)}
<div className="my-1 text-xs/5 text-muted-foreground">
{formatDate(activity.time, { timeStyle: 'short' })}
</div>
</div>
<div className="flex-1">
<div className="m-1">{summary}</div>
</div>
{expenseExists && (
<Button
size="icon"
variant="link"
className="self-center hidden sm:flex w-5 h-5"
asChild
>
<Link href={`/groups/${groupId}/expenses/${activity.expenseId}/edit`}>
<ChevronRight className="w-4 h-4" />
</Link>
</Button>
)}
</div>
)
}

View File

@@ -0,0 +1,112 @@
import { ActivityItem } from '@/app/groups/[groupId]/activity/activity-item'
import { getGroupExpenses } from '@/lib/api'
import { Activity, Participant } from '@prisma/client'
import dayjs, { type Dayjs } from 'dayjs'
type Props = {
groupId: string
participants: Participant[]
expenses: Awaited<ReturnType<typeof getGroupExpenses>>
activities: Activity[]
}
const DATE_GROUPS = {
TODAY: 'Today',
YESTERDAY: 'Yesterday',
EARLIER_THIS_WEEK: 'Earlier this week',
LAST_WEEK: 'Last week',
EARLIER_THIS_MONTH: 'Earlier this month',
LAST_MONTH: 'Last month',
EARLIER_THIS_YEAR: 'Earlier this year',
LAST_YEAR: 'Last year',
OLDER: 'Older',
}
function getDateGroup(date: Dayjs, today: Dayjs) {
if (today.isSame(date, 'day')) {
return DATE_GROUPS.TODAY
} else if (today.subtract(1, 'day').isSame(date, 'day')) {
return DATE_GROUPS.YESTERDAY
} else if (today.isSame(date, 'week')) {
return DATE_GROUPS.EARLIER_THIS_WEEK
} else if (today.subtract(1, 'week').isSame(date, 'week')) {
return DATE_GROUPS.LAST_WEEK
} else if (today.isSame(date, 'month')) {
return DATE_GROUPS.EARLIER_THIS_MONTH
} else if (today.subtract(1, 'month').isSame(date, 'month')) {
return DATE_GROUPS.LAST_MONTH
} else if (today.isSame(date, 'year')) {
return DATE_GROUPS.EARLIER_THIS_YEAR
} else if (today.subtract(1, 'year').isSame(date, 'year')) {
return DATE_GROUPS.LAST_YEAR
} else {
return DATE_GROUPS.OLDER
}
}
function getGroupedActivitiesByDate(activities: Activity[]) {
const today = dayjs()
return activities.reduce(
(result: { [key: string]: Activity[] }, activity: Activity) => {
const activityGroup = getDateGroup(dayjs(activity.time), today)
result[activityGroup] = result[activityGroup] ?? []
result[activityGroup].push(activity)
return result
},
{},
)
}
export function ActivityList({
groupId,
participants,
expenses,
activities,
}: Props) {
const groupedActivitiesByDate = getGroupedActivitiesByDate(activities)
return activities.length > 0 ? (
<>
{Object.values(DATE_GROUPS).map((dateGroup: string) => {
let groupActivities = groupedActivitiesByDate[dateGroup]
if (!groupActivities || groupActivities.length === 0) return null
const dateStyle =
dateGroup == DATE_GROUPS.TODAY || dateGroup == DATE_GROUPS.YESTERDAY
? undefined
: 'medium'
return (
<div key={dateGroup}>
<div
className={
'text-muted-foreground text-xs py-1 font-semibold sticky top-16 bg-white dark:bg-[#1b1917]'
}
>
{dateGroup}
</div>
{groupActivities.map((activity: Activity) => {
const participant =
activity.participantId !== null
? participants.find((p) => p.id === activity.participantId)
: undefined
const expense =
activity.expenseId !== null
? expenses.find((e) => e.id === activity.expenseId)
: undefined
return (
<ActivityItem
key={activity.id}
{...{ groupId, activity, participant, expense, dateStyle }}
/>
)
})}
</div>
)
})}
</>
) : (
<p className="px-6 text-sm py-6">
There is not yet any activity in your group.
</p>
)
}

View File

@@ -0,0 +1,51 @@
import { cached } from '@/app/cached-functions'
import { ActivityList } from '@/app/groups/[groupId]/activity/activity-list'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { getActivities, getGroupExpenses } from '@/lib/api'
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
export const metadata: Metadata = {
title: 'Activity',
}
export default async function ActivityPage({
params: { groupId },
}: {
params: { groupId: string }
}) {
const group = await cached.getGroup(groupId)
if (!group) notFound()
const expenses = await getGroupExpenses(groupId)
const activities = await getActivities(groupId)
return (
<>
<Card className="mb-4">
<CardHeader>
<CardTitle>Activity</CardTitle>
<CardDescription>
Overview of all activity in this group.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col space-y-4">
<ActivityList
{...{
groupId,
participants: group.participants,
expenses,
activities,
}}
/>
</CardContent>
</Card>
</>
)
}

View File

@@ -17,10 +17,10 @@ export default async function EditGroupPage({
const group = await cached.getGroup(groupId) const group = await cached.getGroup(groupId)
if (!group) notFound() if (!group) notFound()
async function updateGroupAction(values: unknown) { async function updateGroupAction(values: unknown, participantId?: string) {
'use server' 'use server'
const groupFormValues = groupFormSchema.parse(values) const groupFormValues = groupFormSchema.parse(values)
const group = await updateGroup(groupId, groupFormValues) const group = await updateGroup(groupId, groupFormValues, participantId)
redirect(`/groups/${group.id}`) redirect(`/groups/${group.id}`)
} }

View File

@@ -27,16 +27,16 @@ export default async function EditExpensePage({
const expense = await getExpense(groupId, expenseId) const expense = await getExpense(groupId, expenseId)
if (!expense) notFound() if (!expense) notFound()
async function updateExpenseAction(values: unknown) { async function updateExpenseAction(values: unknown, participantId?: string) {
'use server' 'use server'
const expenseFormValues = expenseFormSchema.parse(values) const expenseFormValues = expenseFormSchema.parse(values)
await updateExpense(groupId, expenseId, expenseFormValues) await updateExpense(groupId, expenseId, expenseFormValues, participantId)
redirect(`/groups/${groupId}`) redirect(`/groups/${groupId}`)
} }
async function deleteExpenseAction() { async function deleteExpenseAction(participantId?: string) {
'use server' 'use server'
await deleteExpense(expenseId) await deleteExpense(groupId, expenseId, participantId)
redirect(`/groups/${groupId}`) redirect(`/groups/${groupId}`)
} }

View File

@@ -0,0 +1,43 @@
'use client'
import { Money } from '@/components/money'
import { getBalances } from '@/lib/balances'
import { useActiveUser } from '@/lib/hooks'
type Props = {
groupId: string
currency: string
expense: Parameters<typeof getBalances>[0][number]
}
export function ActiveUserBalance({ groupId, currency, expense }: Props) {
const activeUserId = useActiveUser(groupId)
if (activeUserId === null || activeUserId === '' || activeUserId === 'None') {
return null
}
const balances = getBalances([expense])
let fmtBalance = <>You are not involved</>
if (Object.hasOwn(balances, activeUserId)) {
const balance = balances[activeUserId]
let balanceDetail = <></>
if (balance.paid > 0 && balance.paidFor > 0) {
balanceDetail = (
<>
{' ('}
<Money {...{ currency, amount: balance.paid }} />
{' - '}
<Money {...{ currency, amount: balance.paidFor }} />
{')'}
</>
)
}
fmtBalance = (
<>
Your balance:{' '}
<Money {...{ currency, amount: balance.total }} bold colored />
{balanceDetail}
</>
)
}
return <div className="text-xs text-muted-foreground">{fmtBalance}</div>
}

View File

@@ -26,7 +26,7 @@ import {
import { ToastAction } from '@/components/ui/toast' import { ToastAction } from '@/components/ui/toast'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { useMediaQuery } from '@/lib/hooks' import { useMediaQuery } from '@/lib/hooks'
import { formatCurrency, formatExpenseDate, formatFileSize } from '@/lib/utils' import { formatCurrency, formatDate, formatFileSize } from '@/lib/utils'
import { Category } from '@prisma/client' import { Category } from '@prisma/client'
import { ChevronRight, FileQuestion, Loader2, Receipt } from 'lucide-react' import { ChevronRight, FileQuestion, Loader2, Receipt } from 'lucide-react'
import { getImageData, usePresignedUpload } from 'next-s3-upload' import { getImageData, usePresignedUpload } from 'next-s3-upload'
@@ -212,9 +212,9 @@ export function CreateFromReceiptButton({
<div> <div>
{receiptInfo ? ( {receiptInfo ? (
receiptInfo.date ? ( receiptInfo.date ? (
formatExpenseDate( formatDate(new Date(`${receiptInfo?.date}T12:00:00.000Z`), {
new Date(`${receiptInfo?.date}T12:00:00.000Z`), dateStyle: 'medium',
) })
) : ( ) : (
<Unknown /> <Unknown />
) )

View File

@@ -20,10 +20,10 @@ export default async function ExpensePage({
const group = await cached.getGroup(groupId) const group = await cached.getGroup(groupId)
if (!group) notFound() if (!group) notFound()
async function createExpenseAction(values: unknown) { async function createExpenseAction(values: unknown, participantId?: string) {
'use server' 'use server'
const expenseFormValues = expenseFormSchema.parse(values) const expenseFormValues = expenseFormSchema.parse(values)
await createExpense(expenseFormValues, groupId) await createExpense(expenseFormValues, groupId, participantId)
redirect(`/groups/${groupId}`) redirect(`/groups/${groupId}`)
} }

View File

@@ -0,0 +1,79 @@
'use client'
import { ActiveUserBalance } from '@/app/groups/[groupId]/expenses/active-user-balance'
import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon'
import { Button } from '@/components/ui/button'
import { getGroupExpenses } from '@/lib/api'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { ChevronRight } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { Fragment } from 'react'
type Props = {
expense: Awaited<ReturnType<typeof getGroupExpenses>>[number]
currency: string
groupId: string
}
export function ExpenseCard({ expense, currency, groupId }: Props) {
const router = useRouter()
return (
<div
key={expense.id}
className={cn(
'flex justify-between sm:mx-6 px-4 sm:rounded-lg sm:pr-2 sm:pl-4 py-4 text-sm cursor-pointer hover:bg-accent gap-1 items-stretch',
expense.isReimbursement && 'italic',
)}
onClick={() => {
router.push(`/groups/${groupId}/expenses/${expense.id}/edit`)
}}
>
<CategoryIcon
category={expense.category}
className="w-4 h-4 mr-2 mt-0.5 text-muted-foreground"
/>
<div className="flex-1">
<div className={cn('mb-1', expense.isReimbursement && 'italic')}>
{expense.title}
</div>
<div className="text-xs text-muted-foreground">
{expense.amount > 0 ? 'Paid by ' : 'Received by '}
<strong>{expense.paidBy.name}</strong> for{' '}
{expense.paidFor.map((paidFor, index) => (
<Fragment key={index}>
{index !== 0 && <>, </>}
<strong>{paidFor.participant.name}</strong>
</Fragment>
))}
</div>
<div className="text-xs text-muted-foreground">
<ActiveUserBalance {...{ groupId, currency, expense }} />
</div>
</div>
<div className="flex flex-col justify-between items-end">
<div
className={cn(
'tabular-nums whitespace-nowrap',
expense.isReimbursement ? 'italic' : 'font-bold',
)}
>
{formatCurrency(currency, expense.amount)}
</div>
<div className="text-xs text-muted-foreground">
{formatDate(expense.expenseDate, { dateStyle: 'medium' })}
</div>
</div>
<Button
size="icon"
variant="link"
className="self-center hidden sm:flex"
asChild
>
<Link href={`/groups/${groupId}/expenses/${expense.id}/edit`}>
<ChevronRight className="w-4 h-4" />
</Link>
</Button>
</div>
)
}

View File

@@ -0,0 +1,16 @@
'use server'
import { getGroupExpenses } from '@/lib/api'
export async function getGroupExpensesAction(
groupId: string,
options?: { offset: number; length: number },
) {
'use server'
try {
return getGroupExpenses(groupId, options)
} catch {
return null
}
}

View File

@@ -1,24 +1,29 @@
'use client' 'use client'
import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon' import { ExpenseCard } from '@/app/groups/[groupId]/expenses/expense-card'
import { getGroupExpensesAction } from '@/app/groups/[groupId]/expenses/expense-list-fetch-action'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { SearchBar } from '@/components/ui/search-bar' import { SearchBar } from '@/components/ui/search-bar'
import { getGroupExpenses } from '@/lib/api' import { Skeleton } from '@/components/ui/skeleton'
import { cn, formatCurrency, formatExpenseDate } from '@/lib/utils' import { Participant } from '@prisma/client'
import { Expense, Participant } from '@prisma/client'
import dayjs, { type Dayjs } from 'dayjs' import dayjs, { type Dayjs } from 'dayjs'
import { ChevronRight } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/navigation' import { useEffect, useMemo, useState } from 'react'
import { Fragment, useEffect, useState } from 'react' import { useInView } from 'react-intersection-observer'
type ExpensesType = NonNullable<
Awaited<ReturnType<typeof getGroupExpensesAction>>
>
type Props = { type Props = {
expenses: Awaited<ReturnType<typeof getGroupExpenses>> expensesFirstPage: ExpensesType
expenseCount: number
participants: Participant[] participants: Participant[]
currency: string currency: string
groupId: string groupId: string
} }
const EXPENSE_GROUPS = { const EXPENSE_GROUPS = {
UPCOMING: 'Upcoming',
THIS_WEEK: 'This week', THIS_WEEK: 'This week',
EARLIER_THIS_MONTH: 'Earlier this month', EARLIER_THIS_MONTH: 'Earlier this month',
LAST_MONTH: 'Last month', LAST_MONTH: 'Last month',
@@ -28,7 +33,9 @@ const EXPENSE_GROUPS = {
} }
function getExpenseGroup(date: Dayjs, today: Dayjs) { function getExpenseGroup(date: Dayjs, today: Dayjs) {
if (today.isSame(date, 'week')) { if (today.isBefore(date)) {
return EXPENSE_GROUPS.UPCOMING
} else if (today.isSame(date, 'week')) {
return EXPENSE_GROUPS.THIS_WEEK return EXPENSE_GROUPS.THIS_WEEK
} else if (today.isSame(date, 'month')) { } else if (today.isSame(date, 'month')) {
return EXPENSE_GROUPS.EARLIER_THIS_MONTH return EXPENSE_GROUPS.EARLIER_THIS_MONTH
@@ -43,28 +50,32 @@ function getExpenseGroup(date: Dayjs, today: Dayjs) {
} }
} }
function getGroupedExpensesByDate( function getGroupedExpensesByDate(expenses: ExpensesType) {
expenses: Awaited<ReturnType<typeof getGroupExpenses>>,
) {
const today = dayjs() const today = dayjs()
return expenses.reduce( return expenses.reduce((result: { [key: string]: ExpensesType }, expense) => {
(result: { [key: string]: Expense[] }, expense: Expense) => { const expenseGroup = getExpenseGroup(dayjs(expense.expenseDate), today)
const expenseGroup = getExpenseGroup(dayjs(expense.expenseDate), today) result[expenseGroup] = result[expenseGroup] ?? []
result[expenseGroup] = result[expenseGroup] ?? [] result[expenseGroup].push(expense)
result[expenseGroup].push(expense) return result
return result }, {})
},
{},
)
} }
export function ExpenseList({ export function ExpenseList({
expenses, expensesFirstPage,
expenseCount,
currency, currency,
participants, participants,
groupId, groupId,
}: Props) { }: Props) {
const firstLen = expensesFirstPage.length
const [searchText, setSearchText] = useState('') const [searchText, setSearchText] = useState('')
const [dataIndex, setDataIndex] = useState(firstLen)
const [dataLen, setDataLen] = useState(firstLen)
const [hasMoreData, setHasMoreData] = useState(expenseCount > firstLen)
const [isFetching, setIsFetching] = useState(false)
const [expenses, setExpenses] = useState(expensesFirstPage)
const { ref, inView } = useInView()
useEffect(() => { useEffect(() => {
const activeUser = localStorage.getItem('newGroup-activeUser') const activeUser = localStorage.getItem('newGroup-activeUser')
const newUser = localStorage.getItem(`${groupId}-newUser`) const newUser = localStorage.getItem(`${groupId}-newUser`)
@@ -84,13 +95,46 @@ export function ExpenseList({
} }
}, [groupId, participants]) }, [groupId, participants])
const getParticipant = (id: string) => participants.find((p) => p.id === id) useEffect(() => {
const router = useRouter() const fetchNextPage = async () => {
setIsFetching(true)
const newExpenses = await getGroupExpensesAction(groupId, {
offset: dataIndex,
length: dataLen,
})
if (newExpenses !== null) {
const exp = expenses.concat(newExpenses)
setExpenses(exp)
setHasMoreData(exp.length < expenseCount)
setDataIndex(dataIndex + dataLen)
setDataLen(Math.ceil(1.5 * dataLen))
}
setTimeout(() => setIsFetching(false), 500)
}
if (inView && hasMoreData && !isFetching) fetchNextPage()
}, [
dataIndex,
dataLen,
expenseCount,
expenses,
groupId,
hasMoreData,
inView,
isFetching,
])
const groupedExpensesByDate = useMemo(
() => getGroupedExpensesByDate(expenses),
[expenses],
)
const groupedExpensesByDate = getGroupedExpensesByDate(expenses)
return expenses.length > 0 ? ( return expenses.length > 0 ? (
<> <>
<SearchBar onChange={(e) => setSearchText(e.target.value)} /> <SearchBar onValueChange={(value) => setSearchText(value)} />
{Object.values(EXPENSE_GROUPS).map((expenseGroup: string) => { {Object.values(EXPENSE_GROUPS).map((expenseGroup: string) => {
let groupExpenses = groupedExpensesByDate[expenseGroup] let groupExpenses = groupedExpensesByDate[expenseGroup]
if (!groupExpenses) return null if (!groupExpenses) return null
@@ -110,73 +154,33 @@ export function ExpenseList({
> >
{expenseGroup} {expenseGroup}
</div> </div>
{groupExpenses.map((expense: any) => ( {groupExpenses.map((expense) => (
<div <ExpenseCard
key={expense.id} key={expense.id}
className={cn( expense={expense}
'flex justify-between sm:mx-6 px-4 sm:rounded-lg sm:pr-2 sm:pl-4 py-4 text-sm cursor-pointer hover:bg-accent gap-1 items-stretch', currency={currency}
expense.isReimbursement && 'italic', groupId={groupId}
)} />
onClick={() => {
router.push(`/groups/${groupId}/expenses/${expense.id}/edit`)
}}
>
<CategoryIcon
category={expense.category}
className="w-4 h-4 mr-2 mt-0.5 text-muted-foreground"
/>
<div className="flex-1">
<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: any, index: number) => (
<Fragment key={index}>
{index !== 0 && <>, </>}
<strong>
{
participants.find(
(p) => p.id === paidFor.participantId,
)?.name
}
</strong>
</Fragment>
))}
</div>
</div>
<div className="flex flex-col justify-between items-end">
<div
className={cn(
'tabular-nums whitespace-nowrap',
expense.isReimbursement ? 'italic' : 'font-bold',
)}
>
{formatCurrency(currency, expense.amount)}
</div>
<div className="text-xs text-muted-foreground">
{formatExpenseDate(expense.expenseDate)}
</div>
</div>
<Button
size="icon"
variant="link"
className="self-center hidden sm:flex"
asChild
>
<Link href={`/groups/${groupId}/expenses/${expense.id}/edit`}>
<ChevronRight className="w-4 h-4" />
</Link>
</Button>
</div>
))} ))}
</div> </div>
) )
})} })}
{expenses.length < expenseCount &&
[0, 1, 2].map((i) => (
<div
key={i}
className="border-t flex justify-between items-center px-6 py-4 text-sm"
ref={i === 0 ? ref : undefined}
>
<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>
))}
</> </>
) : ( ) : (
<p className="px-6 text-sm py-6"> <p className="px-6 text-sm py-6">

View File

@@ -1,4 +1,4 @@
import { getPrisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import contentDisposition from 'content-disposition' import contentDisposition from 'content-disposition'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
@@ -6,7 +6,6 @@ export async function GET(
req: Request, req: Request,
{ params: { groupId } }: { params: { groupId: string } }, { params: { groupId } }: { params: { groupId: string } },
) { ) {
const prisma = await getPrisma()
const group = await prisma.group.findUnique({ const group = await prisma.group.findUnique({
where: { id: groupId }, where: { id: groupId },
select: { select: {

View File

@@ -11,7 +11,11 @@ import {
CardTitle, CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { getCategories, getGroupExpenses } from '@/lib/api' import {
getCategories,
getGroupExpenseCount,
getGroupExpenses,
} from '@/lib/api'
import { env } from '@/lib/env' import { env } from '@/lib/env'
import { Download, Plus } from 'lucide-react' import { Download, Plus } from 'lucide-react'
import { Metadata } from 'next' import { Metadata } from 'next'
@@ -51,6 +55,7 @@ export default async function GroupExpensesPage({
prefetch={false} prefetch={false}
href={`/groups/${groupId}/expenses/export/json`} href={`/groups/${groupId}/expenses/export/json`}
target="_blank" target="_blank"
title="Export to JSON"
> >
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
</Link> </Link>
@@ -63,7 +68,10 @@ export default async function GroupExpensesPage({
/> />
)} )}
<Button asChild size="icon"> <Button asChild size="icon">
<Link href={`/groups/${groupId}/expenses/create`}> <Link
href={`/groups/${groupId}/expenses/create`}
title="Create expense"
>
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
</Link> </Link>
</Button> </Button>
@@ -87,7 +95,7 @@ export default async function GroupExpensesPage({
</div> </div>
))} ))}
> >
<Expenses groupId={groupId} /> <Expenses group={group} />
</Suspense> </Suspense>
</CardContent> </CardContent>
</Card> </Card>
@@ -97,14 +105,22 @@ export default async function GroupExpensesPage({
) )
} }
async function Expenses({ groupId }: { groupId: string }) { type Props = {
const group = await cached.getGroup(groupId) group: NonNullable<Awaited<ReturnType<typeof cached.getGroup>>>
if (!group) notFound() }
const expenses = await getGroupExpenses(group.id)
async function Expenses({ group }: Props) {
const expenseCount = await getGroupExpenseCount(group.id)
const expenses = await getGroupExpenses(group.id, {
offset: 0,
length: 200,
})
return ( return (
<ExpenseList <ExpenseList
expenses={expenses} expensesFirstPage={expenses}
expenseCount={expenseCount}
groupId={group.id} groupId={group.id}
currency={group.currency} currency={group.currency}
participants={group.participants} participants={group.participants}

View File

@@ -15,7 +15,7 @@ export function GroupTabs({ groupId }: Props) {
return ( return (
<Tabs <Tabs
value={value} value={value}
className="[&>*]:border" className="[&>*]:border overflow-x-auto"
onValueChange={(value) => { onValueChange={(value) => {
router.push(`/groups/${groupId}/${value}`) router.push(`/groups/${groupId}/${value}`)
}} }}
@@ -24,6 +24,7 @@ export function GroupTabs({ groupId }: Props) {
<TabsTrigger value="expenses">Expenses</TabsTrigger> <TabsTrigger value="expenses">Expenses</TabsTrigger>
<TabsTrigger value="balances">Balances</TabsTrigger> <TabsTrigger value="balances">Balances</TabsTrigger>
<TabsTrigger value="stats">Stats</TabsTrigger> <TabsTrigger value="stats">Stats</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
<TabsTrigger value="edit">Settings</TabsTrigger> <TabsTrigger value="edit">Settings</TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>

View File

@@ -35,7 +35,7 @@ export default async function GroupLayout({
return ( return (
<> <>
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-3"> <div className="flex flex-col justify-between gap-3">
<h1 className="font-bold text-2xl"> <h1 className="font-bold text-2xl">
<Link href={`/groups/${groupId}`}>{group.name}</Link> <Link href={`/groups/${groupId}`}>{group.name}</Link>
</h1> </h1>

View File

@@ -23,7 +23,7 @@ export function ShareButton({ group }: Props) {
return ( return (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button size="icon"> <Button title="Share" size="icon" className="flex-shrink-0">
<Share className="w-4 h-4" /> <Share className="w-4 h-4" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>

View File

@@ -6,11 +6,12 @@ type Props = {
} }
export function TotalsGroupSpending({ totalGroupSpendings, currency }: Props) { export function TotalsGroupSpending({ totalGroupSpendings, currency }: Props) {
const balance = totalGroupSpendings < 0 ? 'earnings' : 'spendings'
return ( return (
<div> <div>
<div className="text-muted-foreground">Total group spendings</div> <div className="text-muted-foreground">Total group {balance}</div>
<div className="text-lg"> <div className="text-lg">
{formatCurrency(currency, totalGroupSpendings)} {formatCurrency(currency, Math.abs(totalGroupSpendings))}
</div> </div>
</div> </div>
) )

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { getGroup, getGroupExpenses } from '@/lib/api' import { getGroup, getGroupExpenses } from '@/lib/api'
import { getTotalActiveUserShare } from '@/lib/totals' import { getTotalActiveUserShare } from '@/lib/totals'
import { formatCurrency } from '@/lib/utils' import { cn, formatCurrency } from '@/lib/utils'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
type Props = { type Props = {
@@ -26,8 +26,13 @@ export function TotalsYourShare({ group, expenses }: Props) {
return ( return (
<div> <div>
<div className="text-muted-foreground">Your total share</div> <div className="text-muted-foreground">Your total share</div>
<div className="text-lg"> <div
{formatCurrency(currency, totalActiveUserShare)} className={cn(
'text-lg',
totalActiveUserShare < 0 ? 'text-green-600' : 'text-red-600',
)}
>
{formatCurrency(currency, Math.abs(totalActiveUserShare))}
</div> </div>
</div> </div>
) )

View File

@@ -2,7 +2,7 @@
import { getGroup, getGroupExpenses } from '@/lib/api' import { getGroup, getGroupExpenses } from '@/lib/api'
import { useActiveUser } from '@/lib/hooks' import { useActiveUser } from '@/lib/hooks'
import { getTotalActiveUserPaidFor } from '@/lib/totals' import { getTotalActiveUserPaidFor } from '@/lib/totals'
import { formatCurrency } from '@/lib/utils' import { cn, formatCurrency } from '@/lib/utils'
type Props = { type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>> group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
@@ -17,13 +17,19 @@ export function TotalsYourSpendings({ group, expenses }: Props) {
? 0 ? 0
: getTotalActiveUserPaidFor(activeUser, expenses) : getTotalActiveUserPaidFor(activeUser, expenses)
const currency = group.currency const currency = group.currency
const balance = totalYourSpendings < 0 ? 'earnings' : 'spendings'
return ( return (
<div> <div>
<div className="text-muted-foreground">Total you paid for</div> <div className="text-muted-foreground">Your total {balance}</div>
<div className="text-lg"> <div
{formatCurrency(currency, totalYourSpendings)} className={cn(
'text-lg',
totalYourSpendings < 0 ? 'text-green-600' : 'text-red-600',
)}
>
{formatCurrency(currency, Math.abs(totalYourSpendings))}
</div> </div>
</div> </div>
) )

View File

@@ -0,0 +1,47 @@
'use client'
import { Trash2 } from 'lucide-react'
import { AsyncButton } from './async-button'
import { Button } from './ui/button'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
DialogTrigger,
} from './ui/dialog'
export function DeletePopup({ onDelete }: { onDelete: () => Promise<void> }) {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="w-4 h-4 mr-2" />
Delete
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Delete this expense?</DialogTitle>
<DialogDescription>
Do you really want to delete this expense? This action is
irreversible.
</DialogDescription>
<DialogFooter className="flex flex-col gap-2">
<AsyncButton
type="button"
variant="destructive"
loadingContent="Deleting…"
action={onDelete}
>
Yes
</AsyncButton>
<DialogClose asChild>
<Button variant={'secondary'}>Cancel</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,5 +1,4 @@
'use client' 'use client'
import { AsyncButton } from '@/components/async-button'
import { CategorySelector } from '@/components/category-selector' import { CategorySelector } from '@/components/category-selector'
import { ExpenseDocumentsInput } from '@/components/expense-documents-input' import { ExpenseDocumentsInput } from '@/components/expense-documents-input'
import { SubmitButton } from '@/components/submit-button' import { SubmitButton } from '@/components/submit-button'
@@ -36,36 +35,116 @@ import {
} from '@/components/ui/select' } from '@/components/ui/select'
import { getCategories, getExpense, getGroup, randomId } from '@/lib/api' import { getCategories, getExpense, getGroup, randomId } from '@/lib/api'
import { RuntimeFeatureFlags } from '@/lib/featureFlags' import { RuntimeFeatureFlags } from '@/lib/featureFlags'
import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas' import { useActiveUser } from '@/lib/hooks'
import {
ExpenseFormValues,
SplittingOptions,
expenseFormSchema,
} from '@/lib/schemas'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { Save, Trash2 } from 'lucide-react' import { Save } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import { useSearchParams } from 'next/navigation' import { useSearchParams } from 'next/navigation'
import { useState } from 'react' import { useState } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { match } from 'ts-pattern' import { match } from 'ts-pattern'
import { DeletePopup } from './delete-popup'
import { extractCategoryFromTitle } from './expense-form-actions' import { extractCategoryFromTitle } from './expense-form-actions'
import { Textarea } from './ui/textarea'
export type Props = { export type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>> group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
expense?: NonNullable<Awaited<ReturnType<typeof getExpense>>> expense?: NonNullable<Awaited<ReturnType<typeof getExpense>>>
categories: NonNullable<Awaited<ReturnType<typeof getCategories>>> categories: NonNullable<Awaited<ReturnType<typeof getCategories>>>
onSubmit: (values: ExpenseFormValues) => Promise<void> onSubmit: (values: ExpenseFormValues, participantId?: string) => Promise<void>
onDelete?: () => Promise<void> onDelete?: (participantId?: string) => Promise<void>
runtimeFeatureFlags: RuntimeFeatureFlags runtimeFeatureFlags: RuntimeFeatureFlags
} }
const enforceCurrencyPattern = (value: string) => const enforceCurrencyPattern = (value: string) =>
value value
// replace first comma with # .replace(/^\s*-/, '_') // replace leading minus with _
.replace(/[.,]/, '#') .replace(/[.,]/, '#') // replace first comma with #
// remove all other commas .replace(/[-.,]/g, '') // remove other minus and commas characters
.replace(/[.,]/g, '') .replace(/_/, '-') // change back _ to minus
// change back # to dot .replace(/#/, '.') // change back # to dot
.replace(/#/, '.') .replace(/[^-\d.]/g, '') // remove all non-numeric characters
// remove all non-numeric and non-dot characters
.replace(/[^\d.]/g, '') const capitalize = (value: string) =>
value.charAt(0).toUpperCase() + value.slice(1)
const getDefaultSplittingOptions = (group: Props['group']) => {
const defaultValue = {
splitMode: 'EVENLY' as const,
paidFor: group.participants.map(({ id }) => ({
participant: id,
shares: '1' as unknown as number,
})),
}
if (typeof localStorage === 'undefined') return defaultValue
const defaultSplitMode = localStorage.getItem(
`${group.id}-defaultSplittingOptions`,
)
if (defaultSplitMode === null) return defaultValue
const parsedDefaultSplitMode = JSON.parse(
defaultSplitMode,
) as SplittingOptions
if (parsedDefaultSplitMode.paidFor === null) {
parsedDefaultSplitMode.paidFor = defaultValue.paidFor
}
// if there is a participant in the default options that does not exist anymore,
// remove the stale default splitting options
for (const parsedPaidFor of parsedDefaultSplitMode.paidFor) {
if (
!group.participants.some(({ id }) => id === parsedPaidFor.participant)
) {
localStorage.removeItem(`${group.id}-defaultSplittingOptions`)
return defaultValue
}
}
return {
splitMode: parsedDefaultSplitMode.splitMode,
paidFor: parsedDefaultSplitMode.paidFor.map((paidFor) => ({
participant: paidFor.participant,
shares: String(paidFor.shares / 100) as unknown as number,
})),
}
}
async function persistDefaultSplittingOptions(
groupId: string,
expenseFormValues: ExpenseFormValues,
) {
if (localStorage && expenseFormValues.saveDefaultSplittingOptions) {
const computePaidFor = (): SplittingOptions['paidFor'] => {
if (expenseFormValues.splitMode === 'EVENLY') {
return expenseFormValues.paidFor.map(({ participant }) => ({
participant,
shares: '100' as unknown as number,
}))
} else if (expenseFormValues.splitMode === 'BY_AMOUNT') {
return null
} else {
return expenseFormValues.paidFor
}
}
const splittingOptions = {
splitMode: expenseFormValues.splitMode,
paidFor: computePaidFor(),
} satisfies SplittingOptions
localStorage.setItem(
`${groupId}-defaultSplittingOptions`,
JSON.stringify(splittingOptions),
)
}
}
export function ExpenseForm({ export function ExpenseForm({
group, group,
@@ -80,12 +159,13 @@ export function ExpenseForm({
const getSelectedPayer = (field?: { value: string }) => { const getSelectedPayer = (field?: { value: string }) => {
if (isCreate && typeof window !== 'undefined') { if (isCreate && typeof window !== 'undefined') {
const activeUser = localStorage.getItem(`${group.id}-activeUser`) const activeUser = localStorage.getItem(`${group.id}-activeUser`)
if (activeUser && activeUser !== 'None') { if (activeUser && activeUser !== 'None' && field?.value === undefined) {
return activeUser return activeUser
} }
} }
return field?.value return field?.value
} }
const defaultSplittingOptions = getDefaultSplittingOptions(group)
const form = useForm<ExpenseFormValues>({ const form = useForm<ExpenseFormValues>({
resolver: zodResolver(expenseFormSchema), resolver: zodResolver(expenseFormSchema),
defaultValues: expense defaultValues: expense
@@ -100,8 +180,10 @@ export function ExpenseForm({
shares: String(shares / 100) as unknown as number, shares: String(shares / 100) as unknown as number,
})), })),
splitMode: expense.splitMode, splitMode: expense.splitMode,
saveDefaultSplittingOptions: false,
isReimbursement: expense.isReimbursement, isReimbursement: expense.isReimbursement,
documents: expense.documents, documents: expense.documents,
notes: expense.notes ?? '',
} }
: searchParams.get('reimbursement') : searchParams.get('reimbursement')
? { ? {
@@ -121,8 +203,10 @@ export function ExpenseForm({
: undefined, : undefined,
], ],
isReimbursement: true, isReimbursement: true,
splitMode: 'EVENLY', splitMode: defaultSplittingOptions.splitMode,
saveDefaultSplittingOptions: false,
documents: [], documents: [],
notes: '',
} }
: { : {
title: searchParams.get('title') ?? '', title: searchParams.get('title') ?? '',
@@ -134,13 +218,11 @@ export function ExpenseForm({
? Number(searchParams.get('categoryId')) ? Number(searchParams.get('categoryId'))
: 0, // category with Id 0 is General : 0, // category with Id 0 is General
// paid for all, split evenly // paid for all, split evenly
paidFor: group.participants.map(({ id }) => ({ paidFor: defaultSplittingOptions.paidFor,
participant: id,
shares: '1' as unknown as number,
})),
paidBy: getSelectedPayer(), paidBy: getSelectedPayer(),
isReimbursement: false, isReimbursement: false,
splitMode: 'EVENLY', splitMode: defaultSplittingOptions.splitMode,
saveDefaultSplittingOptions: false,
documents: searchParams.get('imageUrl') documents: searchParams.get('imageUrl')
? [ ? [
{ {
@@ -151,18 +233,27 @@ export function ExpenseForm({
}, },
] ]
: [], : [],
notes: '',
}, },
}) })
const [isCategoryLoading, setCategoryLoading] = useState(false) const [isCategoryLoading, setCategoryLoading] = useState(false)
const activeUserId = useActiveUser(group.id)
const submit = async (values: ExpenseFormValues) => {
await persistDefaultSplittingOptions(group.id, values)
return onSubmit(values, activeUserId ?? undefined)
}
const [isIncome, setIsIncome] = useState(Number(form.getValues().amount) < 0)
const sExpense = isIncome ? 'income' : 'expense'
const sPaid = isIncome ? 'received' : 'paid'
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit((values) => onSubmit(values))}> <form onSubmit={form.handleSubmit(submit)}>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>{(isCreate ? 'Create ' : 'Edit ') + sExpense}</CardTitle>
{isCreate ? <>Create expense</> : <>Edit expense</>}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="grid sm:grid-cols-2 gap-6"> <CardContent className="grid sm:grid-cols-2 gap-6">
<FormField <FormField
@@ -170,7 +261,7 @@ export function ExpenseForm({
name="title" name="title"
render={({ field }) => ( render={({ field }) => (
<FormItem className=""> <FormItem className="">
<FormLabel>Expense title</FormLabel> <FormLabel>{capitalize(sExpense)} title</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="Monday evening restaurant" placeholder="Monday evening restaurant"
@@ -190,7 +281,7 @@ export function ExpenseForm({
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Enter a description for the expense. Enter a description for the {sExpense}.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -202,7 +293,7 @@ export function ExpenseForm({
name="expenseDate" name="expenseDate"
render={({ field }) => ( render={({ field }) => (
<FormItem className="sm:order-1"> <FormItem className="sm:order-1">
<FormLabel>Expense date</FormLabel> <FormLabel>{capitalize(sExpense)} date</FormLabel>
<FormControl> <FormControl>
<Input <Input
className="date-base" className="date-base"
@@ -214,7 +305,7 @@ export function ExpenseForm({
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Enter the date the expense was made. Enter the date the {sExpense} was {sPaid}.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -231,39 +322,47 @@ export function ExpenseForm({
<span>{group.currency}</span> <span>{group.currency}</span>
<FormControl> <FormControl>
<Input <Input
{...field}
className="text-base max-w-[120px]" className="text-base max-w-[120px]"
type="text" type="text"
inputMode="decimal" inputMode="decimal"
step={0.01}
placeholder="0.00" placeholder="0.00"
onChange={(event) => onChange={(event) => {
onChange(enforceCurrencyPattern(event.target.value)) const v = enforceCurrencyPattern(event.target.value)
} const income = Number(v) < 0
onClick={(e) => e.currentTarget.select()} setIsIncome(income)
if (income) form.setValue('isReimbursement', false)
onChange(v)
}}
onFocus={(e) => {
// we're adding a small delay to get around safaris issue with onMouseUp deselecting things again
const target = e.currentTarget
setTimeout(() => target.select(), 1)
}}
{...field} {...field}
/> />
</FormControl> </FormControl>
</div> </div>
<FormMessage /> <FormMessage />
<FormField {!isIncome && (
control={form.control} <FormField
name="isReimbursement" control={form.control}
render={({ field }) => ( name="isReimbursement"
<FormItem className="flex flex-row gap-2 items-center space-y-0 pt-2"> render={({ field }) => (
<FormControl> <FormItem className="flex flex-row gap-2 items-center space-y-0 pt-2">
<Checkbox <FormControl>
checked={field.value} <Checkbox
onCheckedChange={field.onChange} checked={field.value}
/> onCheckedChange={field.onChange}
</FormControl> />
<div> </FormControl>
<FormLabel>This is a reimbursement</FormLabel> <div>
</div> <FormLabel>This is a reimbursement</FormLabel>
</FormItem> </div>
)} </FormItem>
/> )}
/>
)}
</FormItem> </FormItem>
)} )}
/> />
@@ -283,7 +382,7 @@ export function ExpenseForm({
isLoading={isCategoryLoading} isLoading={isCategoryLoading}
/> />
<FormDescription> <FormDescription>
Select the expense category. Select the {sExpense} category.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -295,7 +394,7 @@ export function ExpenseForm({
name="paidBy" name="paidBy"
render={({ field }) => ( render={({ field }) => (
<FormItem className="sm:order-5"> <FormItem className="sm:order-5">
<FormLabel>Paid by</FormLabel> <FormLabel>{capitalize(sPaid)} by</FormLabel>
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={getSelectedPayer(field)} defaultValue={getSelectedPayer(field)}
@@ -312,19 +411,31 @@ export function ExpenseForm({
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription> <FormDescription>
Select the participant who paid the expense. Select the participant who {sPaid} the {sExpense}.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem className="sm:order-6">
<FormLabel>Notes</FormLabel>
<FormControl>
<Textarea className="text-base" {...field} />
</FormControl>
</FormItem>
)}
/>
</CardContent> </CardContent>
</Card> </Card>
<Card className="mt-4"> <Card className="mt-4">
<CardHeader> <CardHeader>
<CardTitle className="flex justify-between"> <CardTitle className="flex justify-between">
<span>Paid for</span> <span>{capitalize(sPaid)} for</span>
<Button <Button
variant="link" variant="link"
type="button" type="button"
@@ -357,7 +468,7 @@ export function ExpenseForm({
</Button> </Button>
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Select who the expense was paid for. Select who the {sExpense} was {sPaid} for.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -511,7 +622,10 @@ export function ExpenseForm({
)} )}
/> />
<Collapsible className="mt-5"> <Collapsible
className="mt-5"
defaultOpen={form.getValues().splitMode !== 'EVENLY'}
>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button variant="link" className="-mx-4"> <Button variant="link" className="-mx-4">
Advanced splitting options Advanced splitting options
@@ -523,7 +637,7 @@ export function ExpenseForm({
control={form.control} control={form.control}
name="splitMode" name="splitMode"
render={({ field }) => ( render={({ field }) => (
<FormItem className="sm:order-2"> <FormItem>
<FormLabel>Split mode</FormLabel> <FormLabel>Split mode</FormLabel>
<FormControl> <FormControl>
<Select <Select
@@ -554,11 +668,30 @@ export function ExpenseForm({
</Select> </Select>
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Select how to split the expense. Select how to split the {sExpense}.
</FormDescription> </FormDescription>
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="saveDefaultSplittingOptions"
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>
Save as default splitting options
</FormLabel>
</div>
</FormItem>
)}
/>
</div> </div>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
@@ -572,7 +705,7 @@ export function ExpenseForm({
<span>Attach documents</span> <span>Attach documents</span>
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
See and attach receipts to the expense. See and attach receipts to the {sExpense}.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -598,15 +731,9 @@ export function ExpenseForm({
{isCreate ? <>Create</> : <>Save</>} {isCreate ? <>Create</> : <>Save</>}
</SubmitButton> </SubmitButton>
{!isCreate && onDelete && ( {!isCreate && onDelete && (
<AsyncButton <DeletePopup
type="button" onDelete={() => onDelete(activeUserId ?? undefined)}
variant="destructive" ></DeletePopup>
loadingContent="Deleting…"
action={onDelete}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</AsyncButton>
)} )}
<Button variant="ghost" asChild> <Button variant="ghost" asChild>
<Link href={`/groups/${group.id}`}>Cancel</Link> <Link href={`/groups/${group.id}`}>Cancel</Link>

View File

@@ -41,7 +41,10 @@ import { useFieldArray, useForm } from 'react-hook-form'
export type Props = { export type Props = {
group?: NonNullable<Awaited<ReturnType<typeof getGroup>>> group?: NonNullable<Awaited<ReturnType<typeof getGroup>>>
onSubmit: (groupFormValues: GroupFormValues) => Promise<void> onSubmit: (
groupFormValues: GroupFormValues,
participantId?: string,
) => Promise<void>
protectedParticipantIds?: string[] protectedParticipantIds?: string[]
} }
@@ -99,7 +102,11 @@ export function GroupForm({
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(async (values) => { onSubmit={form.handleSubmit(async (values) => {
await onSubmit(values) await onSubmit(
values,
group?.participants.find((p) => p.name === activeUser)?.id ??
undefined,
)
})} })}
> >
<Card className="mb-4"> <Card className="mb-4">
@@ -173,7 +180,11 @@ export function GroupForm({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<div className="flex gap-2"> <div className="flex gap-2">
<Input className="text-base" {...field} /> <Input
className="text-base"
{...field}
placeholder="New"
/>
{item.id && {item.id &&
protectedParticipantIds.includes(item.id) ? ( protectedParticipantIds.includes(item.id) ? (
<HoverCard> <HoverCard>
@@ -221,7 +232,7 @@ export function GroupForm({
<Button <Button
variant="secondary" variant="secondary"
onClick={() => { onClick={() => {
append({ name: 'New' }) append({ name: '' })
}} }}
type="button" type="button"
> >

31
src/components/money.tsx Normal file
View File

@@ -0,0 +1,31 @@
'use client'
import { cn, formatCurrency } from '@/lib/utils'
type Props = {
currency: string
amount: number
bold?: boolean
colored?: boolean
}
export function Money({
currency,
amount,
bold = false,
colored = false,
}: Props) {
return (
<span
className={cn(
colored && amount <= 1
? 'text-red-600'
: colored && amount >= 1
? 'text-green-600'
: '',
bold && 'font-bold',
)}
>
{formatCurrency(currency, amount)}
</span>
)
}

View File

@@ -1,33 +1,49 @@
import * as React from "react" import * as React from 'react'
import {Input} from "@/components/ui/input"; import { Input } from '@/components/ui/input'
import {cn} from "@/lib/utils"; import { cn } from '@/lib/utils'
import { import { Search, XCircle } from 'lucide-react'
Search
} from 'lucide-react'
export interface InputProps export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {} extends React.InputHTMLAttributes<HTMLInputElement> {
onValueChange?: (value: string) => void
}
const SearchBar = React.forwardRef<HTMLInputElement, InputProps>( const SearchBar = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => { ({ className, type, onValueChange, ...props }, ref) => {
const [value, _setValue] = React.useState('')
const setValue = (v: string) => {
_setValue(v)
onValueChange && onValueChange(v)
}
return ( return (
<div className="mx-4 sm:mx-6 flex relative"> <div className="mx-4 sm:mx-6 flex relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input <Input
type={type} type={type}
className={cn( className={cn(
"pl-10 text-sm focus:text-base bg-muted border-none text-muted-foreground", 'pl-10 text-sm focus:text-base bg-muted border-none text-muted-foreground',
className className,
)} )}
ref={ref} ref={ref}
placeholder="Search for an expense…" placeholder="Search for an expense…"
value={value}
onChange={(e) => setValue(e.target.value)}
{...props} {...props}
/> />
<XCircle
className={cn(
'absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 cursor-pointer',
!value && 'hidden',
)}
onClick={() => setValue('')}
/>
</div> </div>
) )
} },
) )
SearchBar.displayName = "SearchBar" SearchBar.displayName = 'SearchBar'
export { SearchBar } export { SearchBar }

View File

@@ -1,6 +1,6 @@
import { getPrisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { ExpenseFormValues, GroupFormValues } from '@/lib/schemas' import { ExpenseFormValues, GroupFormValues } from '@/lib/schemas'
import { Expense } from '@prisma/client' import { ActivityType, Expense } from '@prisma/client'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
export function randomId() { export function randomId() {
@@ -8,7 +8,6 @@ export function randomId() {
} }
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(),
@@ -30,6 +29,7 @@ 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}`)
@@ -42,10 +42,16 @@ export async function createExpense(
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, expenseDate: expenseFormValues.expenseDate,
categoryId: expenseFormValues.category, categoryId: expenseFormValues.category,
@@ -72,12 +78,23 @@ export async function createExpense(
})), })),
}, },
}, },
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 },
@@ -89,15 +106,14 @@ 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 } },
@@ -113,6 +129,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}`)
@@ -128,7 +145,12 @@ export async function updateExpense(
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: {
@@ -185,6 +207,7 @@ export async function updateExpense(
id: doc.id, id: doc.id,
})), })),
}, },
notes: expenseFormValues.notes,
}, },
}) })
} }
@@ -192,11 +215,13 @@ 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: {
@@ -228,7 +253,6 @@ 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 },
@@ -236,27 +260,67 @@ export async function getGroup(groupId: string) {
} }
export async function getCategories() { export async function getCategories() {
const prisma = await getPrisma()
return prisma.category.findMany() return prisma.category.findMany()
} }
export async function getGroupExpenses(groupId: string) { export async function getGroupExpenses(
const prisma = await getPrisma() groupId: string,
options?: { offset: number; length: number },
) {
return prisma.expense.findMany({ return prisma.expense.findMany({
where: { groupId }, select: {
include: { amount: true,
paidFor: { include: { participant: true } },
paidBy: true,
category: true, category: true,
createdAt: true,
expenseDate: true,
id: true,
isReimbursement: true,
paidBy: { select: { id: true, name: true } },
paidFor: {
select: {
participant: { select: { id: true, name: true } },
shares: true,
},
},
splitMode: true,
title: true,
}, },
where: { groupId },
orderBy: [{ expenseDate: 'desc' }, { createdAt: 'desc' }], 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, category: true, documents: true }, include: { paidBy: true, paidFor: true, category: true, documents: true },
}) })
} }
export async function getActivities(groupId: string) {
return prisma.activity.findMany({
where: { groupId },
orderBy: [{ time: 'desc' }],
})
}
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,
},
})
}

View File

@@ -19,7 +19,7 @@ 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 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 }
@@ -31,8 +31,8 @@ export function getBalances(
) )
let remaining = expense.amount let remaining = expense.amount
paidFors.forEach((paidFor, index) => { paidFors.forEach((paidFor, index) => {
if (!balances[paidFor.participantId]) if (!balances[paidFor.participant.id])
balances[paidFor.participantId] = { paid: 0, paidFor: 0, total: 0 } balances[paidFor.participant.id] = { paid: 0, paidFor: 0, total: 0 }
const isLast = index === paidFors.length - 1 const isLast = index === paidFors.length - 1
@@ -47,7 +47,7 @@ export function getBalances(
? remaining ? remaining
: (expense.amount * shares) / totalShares : (expense.amount * shares) / totalShares
remaining -= dividedAmount remaining -= dividedAmount
balances[paidFor.participantId].paidFor += dividedAmount balances[paidFor.participant.id].paidFor += dividedAmount
}) })
} }

View File

@@ -1,20 +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 any 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' }],
// log: [{ emit: 'stdout', level: 'query' }], })
})
}
prisma = (global as any).prisma
} }
p = global.prisma
} }
return prisma
} }
export const prisma = p

View File

@@ -62,7 +62,7 @@ export const expenseFormSchema = z
], ],
{ required_error: 'You must enter an amount.' }, { required_error: 'You must enter an amount.' },
) )
.refine((amount) => amount >= 1, 'The amount must be higher than 0.01.') .refine((amount) => amount != 1, 'The amount must not be zero.')
.refine( .refine(
(amount) => amount <= 10_000_000_00, (amount) => amount <= 10_000_000_00,
'The amount must be lower than 10,000,000.', 'The amount must be lower than 10,000,000.',
@@ -105,6 +105,7 @@ export const expenseFormSchema = z
Object.values(SplitMode) as any, Object.values(SplitMode) as any,
) )
.default('EVENLY'), .default('EVENLY'),
saveDefaultSplittingOptions: z.boolean(),
isReimbursement: z.boolean(), isReimbursement: z.boolean(),
documents: z documents: z
.array( .array(
@@ -116,6 +117,7 @@ export const expenseFormSchema = z
}), }),
) )
.default([]), .default([]),
notes: z.string().optional(),
}) })
.superRefine((expense, ctx) => { .superRefine((expense, ctx) => {
let sum = 0 let sum = 0
@@ -160,3 +162,9 @@ export const expenseFormSchema = z
}) })
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
}

View File

@@ -34,7 +34,7 @@ export function getTotalActiveUserShare(
const paidFors = expense.paidFor const paidFors = expense.paidFor
const userPaidFor = paidFors.find( const userPaidFor = paidFors.find(
(paidFor) => paidFor.participantId === activeUserId, (paidFor) => paidFor.participant.id === activeUserId,
) )
if (!userPaidFor) { if (!userPaidFor) {

View File

@@ -10,9 +10,15 @@ export function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms)) return new Promise((resolve) => setTimeout(resolve, ms))
} }
export function formatExpenseDate(date: Date) { export type DateTimeStyle = NonNullable<
return date.toLocaleDateString('en-US', { ConstructorParameters<typeof Intl.DateTimeFormat>[1]
dateStyle: 'medium', >['dateStyle']
export function formatDate(
date: Date,
options: { dateStyle?: DateTimeStyle; timeStyle?: DateTimeStyle } = {},
) {
return date.toLocaleString('en-GB', {
...options,
timeZone: 'UTC', timeZone: 'UTC',
}) })
} }

View File

@@ -1,6 +1,6 @@
// @ts-nocheck // @ts-nocheck
import { randomId } from '@/lib/api' import { randomId } from '@/lib/api'
import { getPrisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { Prisma } from '@prisma/client' import { Prisma } from '@prisma/client'
import { Client } from 'pg' import { Client } from 'pg'
@@ -8,8 +8,6 @@ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
async function main() { async function main() {
withClient(async (client) => { withClient(async (client) => {
const prisma = await getPrisma()
// console.log('Deleting all groups…') // console.log('Deleting all groups…')
// await prisma.group.deleteMany({}) // await prisma.group.deleteMany({})