14 Commits
1.5.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
40 changed files with 1040 additions and 304 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
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
RUN npm ci --omit=dev --omit=optional --ignore-scripts && \
@@ -38,7 +38,7 @@ FROM node:21-alpine as runner
EXPOSE 3000/tcp
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 ./public ./public
COPY ./scripts ./scripts

View File

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

149
package-lock.json generated
View File

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

View File

@@ -39,23 +39,24 @@
"embla-carousel-react": "^8.0.0-rc21",
"lucide-react": "^0.290.0",
"nanoid": "^5.0.4",
"next": "^14.1.0",
"next": "^14.2.3",
"next-s3-upload": "^0.3.4",
"next-themes": "^0.2.1",
"next13-progressbar": "^1.1.1",
"openai": "^4.25.0",
"pg": "^8.11.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"prisma": "^5.7.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.47.0",
"react-intersection-observer": "^9.8.0",
"sharp": "^0.33.2",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
"ts-pattern": "^5.0.6",
"uuid": "^9.0.1",
"vaul": "^0.8.0",
"zod": "^3.22.4",
"prisma": "^5.7.0"
"zod": "^3.22.4"
},
"devDependencies": {
"@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("$")
participants Participant[]
expenses Expense[]
activities Activity[]
createdAt DateTime @default(now())
}
@@ -52,6 +53,7 @@ model Expense {
splitMode SplitMode @default(EVENLY)
createdAt DateTime @default(now())
documents ExpenseDocument[]
notes String?
}
model ExpenseDocument {
@@ -79,3 +81,21 @@ model ExpensePaidFor {
@@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"
docker rm postgres --force
mkdir -p postgres-data
docker run --name postgres -d -p 5432:5432 -e POSTGRES_PASSWORD=1234 -v ./postgres-data:/var/lib/postgresql/data postgres
docker run --name 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
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)
if (!group) notFound()
async function updateGroupAction(values: unknown) {
async function updateGroupAction(values: unknown, participantId?: string) {
'use server'
const groupFormValues = groupFormSchema.parse(values)
const group = await updateGroup(groupId, groupFormValues)
const group = await updateGroup(groupId, groupFormValues, participantId)
redirect(`/groups/${group.id}`)
}

View File

@@ -27,16 +27,16 @@ export default async function EditExpensePage({
const expense = await getExpense(groupId, expenseId)
if (!expense) notFound()
async function updateExpenseAction(values: unknown) {
async function updateExpenseAction(values: unknown, participantId?: string) {
'use server'
const expenseFormValues = expenseFormSchema.parse(values)
await updateExpense(groupId, expenseId, expenseFormValues)
await updateExpense(groupId, expenseId, expenseFormValues, participantId)
redirect(`/groups/${groupId}`)
}
async function deleteExpenseAction() {
async function deleteExpenseAction(participantId?: string) {
'use server'
await deleteExpense(expenseId)
await deleteExpense(groupId, expenseId, participantId)
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 { useToast } from '@/components/ui/use-toast'
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 { ChevronRight, FileQuestion, Loader2, Receipt } from 'lucide-react'
import { getImageData, usePresignedUpload } from 'next-s3-upload'
@@ -212,9 +212,9 @@ export function CreateFromReceiptButton({
<div>
{receiptInfo ? (
receiptInfo.date ? (
formatExpenseDate(
new Date(`${receiptInfo?.date}T12:00:00.000Z`),
)
formatDate(new Date(`${receiptInfo?.date}T12:00:00.000Z`), {
dateStyle: 'medium',
})
) : (
<Unknown />
)

View File

@@ -20,10 +20,10 @@ export default async function ExpensePage({
const group = await cached.getGroup(groupId)
if (!group) notFound()
async function createExpenseAction(values: unknown) {
async function createExpenseAction(values: unknown, participantId?: string) {
'use server'
const expenseFormValues = expenseFormSchema.parse(values)
await createExpense(expenseFormValues, groupId)
await createExpense(expenseFormValues, groupId, participantId)
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,18 +1,22 @@
'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 { SearchBar } from '@/components/ui/search-bar'
import { getGroupExpenses } from '@/lib/api'
import { cn, formatCurrency, formatExpenseDate } from '@/lib/utils'
import { Expense, Participant } from '@prisma/client'
import { Skeleton } from '@/components/ui/skeleton'
import { Participant } from '@prisma/client'
import dayjs, { type Dayjs } from 'dayjs'
import { ChevronRight } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { Fragment, useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useInView } from 'react-intersection-observer'
type ExpensesType = NonNullable<
Awaited<ReturnType<typeof getGroupExpensesAction>>
>
type Props = {
expenses: Awaited<ReturnType<typeof getGroupExpenses>>
expensesFirstPage: ExpensesType
expenseCount: number
participants: Participant[]
currency: string
groupId: string
@@ -46,28 +50,32 @@ function getExpenseGroup(date: Dayjs, today: Dayjs) {
}
}
function getGroupedExpensesByDate(
expenses: Awaited<ReturnType<typeof getGroupExpenses>>,
) {
function getGroupedExpensesByDate(expenses: ExpensesType) {
const today = dayjs()
return expenses.reduce(
(result: { [key: string]: Expense[] }, expense: Expense) => {
const expenseGroup = getExpenseGroup(dayjs(expense.expenseDate), today)
result[expenseGroup] = result[expenseGroup] ?? []
result[expenseGroup].push(expense)
return result
},
{},
)
return expenses.reduce((result: { [key: string]: ExpensesType }, expense) => {
const expenseGroup = getExpenseGroup(dayjs(expense.expenseDate), today)
result[expenseGroup] = result[expenseGroup] ?? []
result[expenseGroup].push(expense)
return result
}, {})
}
export function ExpenseList({
expenses,
expensesFirstPage,
expenseCount,
currency,
participants,
groupId,
}: Props) {
const firstLen = expensesFirstPage.length
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(() => {
const activeUser = localStorage.getItem('newGroup-activeUser')
const newUser = localStorage.getItem(`${groupId}-newUser`)
@@ -87,13 +95,46 @@ export function ExpenseList({
}
}, [groupId, participants])
const getParticipant = (id: string) => participants.find((p) => p.id === id)
const router = useRouter()
useEffect(() => {
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 ? (
<>
<SearchBar onChange={(e) => setSearchText(e.target.value)} />
<SearchBar onValueChange={(value) => setSearchText(value)} />
{Object.values(EXPENSE_GROUPS).map((expenseGroup: string) => {
let groupExpenses = groupedExpensesByDate[expenseGroup]
if (!groupExpenses) return null
@@ -113,73 +154,33 @@ export function ExpenseList({
>
{expenseGroup}
</div>
{groupExpenses.map((expense: any) => (
<div
{groupExpenses.map((expense) => (
<ExpenseCard
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">
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>
expense={expense}
currency={currency}
groupId={groupId}
/>
))}
</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">

View File

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

View File

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

View File

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

View File

@@ -35,7 +35,7 @@ export default async function GroupLayout({
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">
<Link href={`/groups/${groupId}`}>{group.name}</Link>
</h1>

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,6 +35,7 @@ import {
} from '@/components/ui/select'
import { getCategories, getExpense, getGroup, randomId } from '@/lib/api'
import { RuntimeFeatureFlags } from '@/lib/featureFlags'
import { useActiveUser } from '@/lib/hooks'
import {
ExpenseFormValues,
SplittingOptions,
@@ -50,26 +51,28 @@ import { useForm } from 'react-hook-form'
import { match } from 'ts-pattern'
import { DeletePopup } from './delete-popup'
import { extractCategoryFromTitle } from './expense-form-actions'
import { Textarea } from './ui/textarea'
export type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
expense?: NonNullable<Awaited<ReturnType<typeof getExpense>>>
categories: NonNullable<Awaited<ReturnType<typeof getCategories>>>
onSubmit: (values: ExpenseFormValues) => Promise<void>
onDelete?: () => Promise<void>
onSubmit: (values: ExpenseFormValues, participantId?: string) => Promise<void>
onDelete?: (participantId?: string) => Promise<void>
runtimeFeatureFlags: RuntimeFeatureFlags
}
const enforceCurrencyPattern = (value: string) =>
value
// replace first comma with #
.replace(/[.,]/, '#')
// remove all other commas
.replace(/[.,]/g, '')
// change back # to dot
.replace(/#/, '.')
// remove all non-numeric and non-dot characters
.replace(/[^\d.]/g, '')
.replace(/^\s*-/, '_') // replace leading minus with _
.replace(/[.,]/, '#') // replace first comma with #
.replace(/[-.,]/g, '') // remove other minus and commas characters
.replace(/_/, '-') // change back _ to minus
.replace(/#/, '.') // change back # to dot
.replace(/[^-\d.]/g, '') // remove all non-numeric characters
const capitalize = (value: string) =>
value.charAt(0).toUpperCase() + value.slice(1)
const getDefaultSplittingOptions = (group: Props['group']) => {
const defaultValue = {
@@ -156,7 +159,7 @@ export function ExpenseForm({
const getSelectedPayer = (field?: { value: string }) => {
if (isCreate && typeof window !== 'undefined') {
const activeUser = localStorage.getItem(`${group.id}-activeUser`)
if (activeUser && activeUser !== 'None') {
if (activeUser && activeUser !== 'None' && field?.value === undefined) {
return activeUser
}
}
@@ -180,6 +183,7 @@ export function ExpenseForm({
saveDefaultSplittingOptions: false,
isReimbursement: expense.isReimbursement,
documents: expense.documents,
notes: expense.notes ?? '',
}
: searchParams.get('reimbursement')
? {
@@ -202,6 +206,7 @@ export function ExpenseForm({
splitMode: defaultSplittingOptions.splitMode,
saveDefaultSplittingOptions: false,
documents: [],
notes: '',
}
: {
title: searchParams.get('title') ?? '',
@@ -228,23 +233,27 @@ export function ExpenseForm({
},
]
: [],
notes: '',
},
})
const [isCategoryLoading, setCategoryLoading] = useState(false)
const activeUserId = useActiveUser(group.id)
const submit = async (values: ExpenseFormValues) => {
await persistDefaultSplittingOptions(group.id, values)
return onSubmit(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 (
<Form {...form}>
<form onSubmit={form.handleSubmit(submit)}>
<Card>
<CardHeader>
<CardTitle>
{isCreate ? <>Create expense</> : <>Edit expense</>}
</CardTitle>
<CardTitle>{(isCreate ? 'Create ' : 'Edit ') + sExpense}</CardTitle>
</CardHeader>
<CardContent className="grid sm:grid-cols-2 gap-6">
<FormField
@@ -252,7 +261,7 @@ export function ExpenseForm({
name="title"
render={({ field }) => (
<FormItem className="">
<FormLabel>Expense title</FormLabel>
<FormLabel>{capitalize(sExpense)} title</FormLabel>
<FormControl>
<Input
placeholder="Monday evening restaurant"
@@ -272,7 +281,7 @@ export function ExpenseForm({
/>
</FormControl>
<FormDescription>
Enter a description for the expense.
Enter a description for the {sExpense}.
</FormDescription>
<FormMessage />
</FormItem>
@@ -284,7 +293,7 @@ export function ExpenseForm({
name="expenseDate"
render={({ field }) => (
<FormItem className="sm:order-1">
<FormLabel>Expense date</FormLabel>
<FormLabel>{capitalize(sExpense)} date</FormLabel>
<FormControl>
<Input
className="date-base"
@@ -296,7 +305,7 @@ export function ExpenseForm({
/>
</FormControl>
<FormDescription>
Enter the date the expense was made.
Enter the date the {sExpense} was {sPaid}.
</FormDescription>
<FormMessage />
</FormItem>
@@ -313,39 +322,47 @@ export function ExpenseForm({
<span>{group.currency}</span>
<FormControl>
<Input
{...field}
className="text-base max-w-[120px]"
type="text"
inputMode="decimal"
step={0.01}
placeholder="0.00"
onChange={(event) =>
onChange(enforceCurrencyPattern(event.target.value))
}
onClick={(e) => e.currentTarget.select()}
onChange={(event) => {
const v = enforceCurrencyPattern(event.target.value)
const income = Number(v) < 0
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}
/>
</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>
)}
/>
{!isIncome && (
<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>
)}
/>
@@ -365,7 +382,7 @@ export function ExpenseForm({
isLoading={isCategoryLoading}
/>
<FormDescription>
Select the expense category.
Select the {sExpense} category.
</FormDescription>
<FormMessage />
</FormItem>
@@ -377,7 +394,7 @@ export function ExpenseForm({
name="paidBy"
render={({ field }) => (
<FormItem className="sm:order-5">
<FormLabel>Paid by</FormLabel>
<FormLabel>{capitalize(sPaid)} by</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={getSelectedPayer(field)}
@@ -394,19 +411,31 @@ export function ExpenseForm({
</SelectContent>
</Select>
<FormDescription>
Select the participant who paid the expense.
Select the participant who {sPaid} the {sExpense}.
</FormDescription>
<FormMessage />
</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>
</Card>
<Card className="mt-4">
<CardHeader>
<CardTitle className="flex justify-between">
<span>Paid for</span>
<span>{capitalize(sPaid)} for</span>
<Button
variant="link"
type="button"
@@ -439,7 +468,7 @@ export function ExpenseForm({
</Button>
</CardTitle>
<CardDescription>
Select who the expense was paid for.
Select who the {sExpense} was {sPaid} for.
</CardDescription>
</CardHeader>
<CardContent>
@@ -639,7 +668,7 @@ export function ExpenseForm({
</Select>
</FormControl>
<FormDescription>
Select how to split the expense.
Select how to split the {sExpense}.
</FormDescription>
</FormItem>
)}
@@ -676,7 +705,7 @@ export function ExpenseForm({
<span>Attach documents</span>
</CardTitle>
<CardDescription>
See and attach receipts to the expense.
See and attach receipts to the {sExpense}.
</CardDescription>
</CardHeader>
<CardContent>
@@ -702,7 +731,9 @@ export function ExpenseForm({
{isCreate ? <>Create</> : <>Save</>}
</SubmitButton>
{!isCreate && onDelete && (
<DeletePopup onDelete={onDelete}></DeletePopup>
<DeletePopup
onDelete={() => onDelete(activeUserId ?? undefined)}
></DeletePopup>
)}
<Button variant="ghost" asChild>
<Link href={`/groups/${group.id}`}>Cancel</Link>

View File

@@ -41,7 +41,10 @@ import { useFieldArray, useForm } from 'react-hook-form'
export type Props = {
group?: NonNullable<Awaited<ReturnType<typeof getGroup>>>
onSubmit: (groupFormValues: GroupFormValues) => Promise<void>
onSubmit: (
groupFormValues: GroupFormValues,
participantId?: string,
) => Promise<void>
protectedParticipantIds?: string[]
}
@@ -99,7 +102,11 @@ export function GroupForm({
<Form {...form}>
<form
onSubmit={form.handleSubmit(async (values) => {
await onSubmit(values)
await onSubmit(
values,
group?.participants.find((p) => p.name === activeUser)?.id ??
undefined,
)
})}
>
<Card className="mb-4">
@@ -173,7 +180,11 @@ export function GroupForm({
</FormLabel>
<FormControl>
<div className="flex gap-2">
<Input className="text-base" {...field} />
<Input
className="text-base"
{...field}
placeholder="New"
/>
{item.id &&
protectedParticipantIds.includes(item.id) ? (
<HoverCard>
@@ -221,7 +232,7 @@ export function GroupForm({
<Button
variant="secondary"
onClick={() => {
append({ name: 'New' })
append({ name: '' })
}}
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 {cn} from "@/lib/utils";
import {
Search
} from 'lucide-react'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { Search, XCircle } from 'lucide-react'
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
extends React.InputHTMLAttributes<HTMLInputElement> {
onValueChange?: (value: string) => void
}
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 (
<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" />
<Input
type={type}
className={cn(
"pl-10 text-sm focus:text-base bg-muted border-none text-muted-foreground",
className
'pl-10 text-sm focus:text-base bg-muted border-none text-muted-foreground',
className,
)}
ref={ref}
placeholder="Search for an expense…"
value={value}
onChange={(e) => setValue(e.target.value)}
{...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>
)
}
},
)
SearchBar.displayName = "SearchBar"
SearchBar.displayName = '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 { Expense } from '@prisma/client'
import { ActivityType, Expense } from '@prisma/client'
import { nanoid } from 'nanoid'
export function randomId() {
@@ -8,7 +8,6 @@ export function randomId() {
}
export async function createGroup(groupFormValues: GroupFormValues) {
const prisma = await getPrisma()
return prisma.group.create({
data: {
id: randomId(),
@@ -30,6 +29,7 @@ export async function createGroup(groupFormValues: GroupFormValues) {
export async function createExpense(
expenseFormValues: ExpenseFormValues,
groupId: string,
participantId?: string,
): Promise<Expense> {
const group = await getGroup(groupId)
if (!group) throw new Error(`Invalid group ID: ${groupId}`)
@@ -42,10 +42,16 @@ export async function createExpense(
throw new Error(`Invalid participant ID: ${participant}`)
}
const prisma = await getPrisma()
const expenseId = randomId()
await logActivity(groupId, ActivityType.CREATE_EXPENSE, {
participantId,
expenseId,
data: expenseFormValues.title,
})
return prisma.expense.create({
data: {
id: randomId(),
id: expenseId,
groupId,
expenseDate: expenseFormValues.expenseDate,
categoryId: expenseFormValues.category,
@@ -72,12 +78,23 @@ export async function createExpense(
})),
},
},
notes: expenseFormValues.notes,
},
})
}
export async function deleteExpense(expenseId: string) {
const prisma = await getPrisma()
export async function deleteExpense(
groupId: string,
expenseId: string,
participantId?: string,
) {
const existingExpense = await getExpense(groupId, expenseId)
await logActivity(groupId, ActivityType.DELETE_EXPENSE, {
participantId,
expenseId,
data: existingExpense?.title,
})
await prisma.expense.delete({
where: { id: expenseId },
include: { paidFor: true, paidBy: true },
@@ -89,15 +106,14 @@ export async function getGroupExpensesParticipants(groupId: string) {
return Array.from(
new Set(
expenses.flatMap((e) => [
e.paidById,
...e.paidFor.map((pf) => pf.participantId),
e.paidBy.id,
...e.paidFor.map((pf) => pf.participant.id),
]),
),
)
}
export async function getGroups(groupIds: string[]) {
const prisma = await getPrisma()
return (
await prisma.group.findMany({
where: { id: { in: groupIds } },
@@ -113,6 +129,7 @@ export async function updateExpense(
groupId: string,
expenseId: string,
expenseFormValues: ExpenseFormValues,
participantId?: string,
) {
const group = await getGroup(groupId)
if (!group) throw new Error(`Invalid group ID: ${groupId}`)
@@ -128,7 +145,12 @@ export async function updateExpense(
throw new Error(`Invalid participant ID: ${participant}`)
}
const prisma = await getPrisma()
await logActivity(groupId, ActivityType.UPDATE_EXPENSE, {
participantId,
expenseId,
data: expenseFormValues.title,
})
return prisma.expense.update({
where: { id: expenseId },
data: {
@@ -185,6 +207,7 @@ export async function updateExpense(
id: doc.id,
})),
},
notes: expenseFormValues.notes,
},
})
}
@@ -192,11 +215,13 @@ export async function updateExpense(
export async function updateGroup(
groupId: string,
groupFormValues: GroupFormValues,
participantId?: string,
) {
const existingGroup = await getGroup(groupId)
if (!existingGroup) throw new Error('Invalid group ID')
const prisma = await getPrisma()
await logActivity(groupId, ActivityType.UPDATE_GROUP, { participantId })
return prisma.group.update({
where: { id: groupId },
data: {
@@ -228,7 +253,6 @@ export async function updateGroup(
}
export async function getGroup(groupId: string) {
const prisma = await getPrisma()
return prisma.group.findUnique({
where: { id: groupId },
include: { participants: true },
@@ -236,27 +260,67 @@ export async function getGroup(groupId: string) {
}
export async function getCategories() {
const prisma = await getPrisma()
return prisma.category.findMany()
}
export async function getGroupExpenses(groupId: string) {
const prisma = await getPrisma()
export async function getGroupExpenses(
groupId: string,
options?: { offset: number; length: number },
) {
return prisma.expense.findMany({
where: { groupId },
include: {
paidFor: { include: { participant: true } },
paidBy: true,
select: {
amount: true,
category: true,
createdAt: true,
expenseDate: true,
id: true,
isReimbursement: true,
paidBy: { select: { id: true, name: true } },
paidFor: {
select: {
participant: { select: { id: true, name: true } },
shares: true,
},
},
splitMode: true,
title: true,
},
where: { groupId },
orderBy: [{ expenseDate: 'desc' }, { createdAt: 'desc' }],
skip: options && options.offset,
take: options && options.length,
})
}
export async function getGroupExpenseCount(groupId: string) {
return prisma.expense.count({ where: { groupId } })
}
export async function getExpense(groupId: string, expenseId: string) {
const prisma = await getPrisma()
return prisma.expense.findUnique({
where: { id: expenseId },
include: { paidBy: true, paidFor: true, 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 = {}
for (const expense of expenses) {
const paidBy = expense.paidById
const paidBy = expense.paidBy.id
const paidFors = expense.paidFor
if (!balances[paidBy]) balances[paidBy] = { paid: 0, paidFor: 0, total: 0 }
@@ -31,8 +31,8 @@ export function getBalances(
)
let remaining = expense.amount
paidFors.forEach((paidFor, index) => {
if (!balances[paidFor.participantId])
balances[paidFor.participantId] = { paid: 0, paidFor: 0, total: 0 }
if (!balances[paidFor.participant.id])
balances[paidFor.participant.id] = { paid: 0, paidFor: 0, total: 0 }
const isLast = index === paidFors.length - 1
@@ -47,7 +47,7 @@ export function getBalances(
? remaining
: (expense.amount * shares) / totalShares
remaining -= dividedAmount
balances[paidFor.participantId].paidFor += dividedAmount
balances[paidFor.participant.id].paidFor += dividedAmount
})
}

View File

@@ -1,20 +1,21 @@
import { PrismaClient } from '@prisma/client'
let prisma: PrismaClient
declare const global: Global & { prisma?: PrismaClient }
export async function getPrisma() {
export let p: PrismaClient = undefined as any as PrismaClient
if (typeof window === 'undefined') {
// await delay(1000)
if (!prisma) {
if (process.env.NODE_ENV === 'production') {
prisma = new PrismaClient()
} else {
if (!(global as any).prisma) {
;(global as any).prisma = new PrismaClient({
// log: [{ emit: 'stdout', level: 'query' }],
})
}
prisma = (global as any).prisma
if (process.env['NODE_ENV'] === 'production') {
p = new PrismaClient()
} else {
if (!global.prisma) {
global.prisma = new PrismaClient({
// log: [{ emit: 'stdout', level: 'query' }],
})
}
p = global.prisma
}
return prisma
}
export const prisma = p

View File

@@ -62,7 +62,7 @@ export const expenseFormSchema = z
],
{ 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(
(amount) => amount <= 10_000_000_00,
'The amount must be lower than 10,000,000.',
@@ -117,6 +117,7 @@ export const expenseFormSchema = z
}),
)
.default([]),
notes: z.string().optional(),
})
.superRefine((expense, ctx) => {
let sum = 0

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
// @ts-nocheck
import { randomId } from '@/lib/api'
import { getPrisma } from '@/lib/prisma'
import { prisma } from '@/lib/prisma'
import { Prisma } from '@prisma/client'
import { Client } from 'pg'
@@ -8,8 +8,6 @@ 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({})