mirror of
https://github.com/spliit-app/spliit.git
synced 2026-02-16 04:26:13 +01:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e990e00a75 | ||
|
|
0c05499107 | ||
|
|
3887efd9ee | ||
|
|
e619c1a5b4 | ||
|
|
10e13d1f6b | ||
|
|
f9d915378b | ||
|
|
74465c0565 | ||
|
|
d3fd8027a5 | ||
|
|
833237b613 | ||
|
|
1cd2b273f9 | ||
|
|
1ad470309b | ||
|
|
2fd38aadd9 | ||
|
|
b61d1836ea | ||
|
|
c3903849ec |
42
.devcontainer/devcontainer.json
Normal file
42
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||||
|
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node-postgres
|
||||||
|
{
|
||||||
|
"name": "spliit",
|
||||||
|
"dockerComposeFile": "docker-compose.yml",
|
||||||
|
"service": "app",
|
||||||
|
|
||||||
|
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||||
|
// "features": {
|
||||||
|
// "ghcr.io/frntn/devcontainers-features/prism:1": {}
|
||||||
|
// },
|
||||||
|
|
||||||
|
// Use 'postCreateCommand' to run commands after the container is created.
|
||||||
|
"postCreateCommand": "cp container.env.example .env && npm install",
|
||||||
|
"postAttachCommand": {
|
||||||
|
"npm": "npm run dev"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||||
|
// This can be used to network with other containers or with the host.
|
||||||
|
"forwardPorts": [3000, 5432],
|
||||||
|
"portsAttributes": {
|
||||||
|
"3000": {
|
||||||
|
"label": "App"
|
||||||
|
},
|
||||||
|
"5432": {
|
||||||
|
"label": "PostgreSQL"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Configure tool-specific properties.
|
||||||
|
"customizations": {
|
||||||
|
"codespaces": {
|
||||||
|
"openFiles": [
|
||||||
|
"README.md"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||||
|
// "remoteUser": "root"
|
||||||
|
}
|
||||||
33
.devcontainer/docker-compose.yml
Normal file
33
.devcontainer/docker-compose.yml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: mcr.microsoft.com/devcontainers/typescript-node:latest
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- ../..:/workspaces:cached
|
||||||
|
|
||||||
|
# Overrides default command so things don't shut down after the process ends.
|
||||||
|
command: sleep infinity
|
||||||
|
|
||||||
|
# Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function.
|
||||||
|
network_mode: service:db
|
||||||
|
|
||||||
|
# Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
|
||||||
|
# (Adding the "ports" property to this file will not forward from a Codespace.)
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: 1234
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_DB: postgres
|
||||||
|
|
||||||
|
# Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally.
|
||||||
|
# (Adding the "ports" property to this file will not forward from a Codespace.)
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
149
package-lock.json
generated
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Expense" ADD COLUMN "notes" TEXT;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ActivityType" AS ENUM ('UPDATE_GROUP', 'CREATE_EXPENSE', 'UPDATE_EXPENSE', 'DELETE_EXPENSE');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Activity" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"groupId" TEXT NOT NULL,
|
||||||
|
"time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"activityType" "ActivityType" NOT NULL,
|
||||||
|
"participantId" TEXT,
|
||||||
|
"expenseId" TEXT,
|
||||||
|
"data" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "Activity_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
102
src/app/groups/[groupId]/activity/activity-item.tsx
Normal file
102
src/app/groups/[groupId]/activity/activity-item.tsx
Normal 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>“{expense}”</em> created by{' '}
|
||||||
|
<strong>{participant}</strong>.
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
} else if (activity.activityType == ActivityType.UPDATE_EXPENSE) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
Expense <em>“{expense}”</em> updated by{' '}
|
||||||
|
<strong>{participant}</strong>.
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
} else if (activity.activityType == ActivityType.DELETE_EXPENSE) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
Expense <em>“{expense}”</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
112
src/app/groups/[groupId]/activity/activity-list.tsx
Normal file
112
src/app/groups/[groupId]/activity/activity-list.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
51
src/app/groups/[groupId]/activity/page.tsx
Normal file
51
src/app/groups/[groupId]/activity/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
43
src/app/groups/[groupId]/expenses/active-user-balance.tsx
Normal file
43
src/app/groups/[groupId]/expenses/active-user-balance.tsx
Normal 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>
|
||||||
|
}
|
||||||
@@ -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 />
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
79
src/app/groups/[groupId]/expenses/expense-card.tsx
Normal file
79
src/app/groups/[groupId]/expenses/expense-card.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
'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
|
||||||
@@ -46,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`)
|
||||||
@@ -87,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
|
||||||
@@ -113,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">
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ 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 { useActiveUser } from '@/lib/hooks'
|
||||||
import {
|
import {
|
||||||
ExpenseFormValues,
|
ExpenseFormValues,
|
||||||
SplittingOptions,
|
SplittingOptions,
|
||||||
@@ -50,26 +51,28 @@ import { useForm } from 'react-hook-form'
|
|||||||
import { match } from 'ts-pattern'
|
import { match } from 'ts-pattern'
|
||||||
import { DeletePopup } from './delete-popup'
|
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 getDefaultSplittingOptions = (group: Props['group']) => {
|
||||||
const defaultValue = {
|
const defaultValue = {
|
||||||
@@ -156,7 +159,7 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,6 +183,7 @@ export function ExpenseForm({
|
|||||||
saveDefaultSplittingOptions: false,
|
saveDefaultSplittingOptions: false,
|
||||||
isReimbursement: expense.isReimbursement,
|
isReimbursement: expense.isReimbursement,
|
||||||
documents: expense.documents,
|
documents: expense.documents,
|
||||||
|
notes: expense.notes ?? '',
|
||||||
}
|
}
|
||||||
: searchParams.get('reimbursement')
|
: searchParams.get('reimbursement')
|
||||||
? {
|
? {
|
||||||
@@ -202,6 +206,7 @@ export function ExpenseForm({
|
|||||||
splitMode: defaultSplittingOptions.splitMode,
|
splitMode: defaultSplittingOptions.splitMode,
|
||||||
saveDefaultSplittingOptions: false,
|
saveDefaultSplittingOptions: false,
|
||||||
documents: [],
|
documents: [],
|
||||||
|
notes: '',
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
title: searchParams.get('title') ?? '',
|
title: searchParams.get('title') ?? '',
|
||||||
@@ -228,23 +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) => {
|
const submit = async (values: ExpenseFormValues) => {
|
||||||
await persistDefaultSplittingOptions(group.id, values)
|
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 (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(submit)}>
|
<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
|
||||||
@@ -252,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"
|
||||||
@@ -272,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>
|
||||||
@@ -284,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"
|
||||||
@@ -296,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>
|
||||||
@@ -313,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>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -365,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>
|
||||||
@@ -377,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)}
|
||||||
@@ -394,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"
|
||||||
@@ -439,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>
|
||||||
@@ -639,7 +668,7 @@ 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>
|
||||||
)}
|
)}
|
||||||
@@ -676,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>
|
||||||
@@ -702,7 +731,9 @@ export function ExpenseForm({
|
|||||||
{isCreate ? <>Create</> : <>Save</>}
|
{isCreate ? <>Create</> : <>Save</>}
|
||||||
</SubmitButton>
|
</SubmitButton>
|
||||||
{!isCreate && onDelete && (
|
{!isCreate && onDelete && (
|
||||||
<DeletePopup onDelete={onDelete}></DeletePopup>
|
<DeletePopup
|
||||||
|
onDelete={() => onDelete(activeUserId ?? undefined)}
|
||||||
|
></DeletePopup>
|
||||||
)}
|
)}
|
||||||
<Button variant="ghost" asChild>
|
<Button variant="ghost" asChild>
|
||||||
<Link href={`/groups/${group.id}`}>Cancel</Link>
|
<Link href={`/groups/${group.id}`}>Cancel</Link>
|
||||||
|
|||||||
@@ -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
31
src/components/money.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
|||||||
106
src/lib/api.ts
106
src/lib/api.ts
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.',
|
||||||
@@ -117,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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user