mirror of
https://github.com/spliit-app/spliit.git
synced 2026-02-16 20:46:13 +01:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cd2b273f9 | ||
|
|
1ad470309b | ||
|
|
2fd38aadd9 | ||
|
|
b61d1836ea | ||
|
|
c3903849ec | ||
|
|
b67a0be0dd | ||
|
|
e07d237218 | ||
|
|
cc37083389 | ||
|
|
552953151a | ||
|
|
b227401dd6 | ||
|
|
6a5efc5f3f | ||
|
|
4c5f8a6aa5 | ||
|
|
c2b591349b | ||
|
|
56c1865264 | ||
|
|
2f991e680b | ||
|
|
2af0660383 |
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:
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -28,6 +28,7 @@ yarn-error.log*
|
||||
# local env files
|
||||
.env*.local
|
||||
*.env
|
||||
!scripts/build.env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
60
Dockerfile
60
Dockerfile
@@ -1,22 +1,48 @@
|
||||
FROM node:21-slim as base
|
||||
FROM node:21-alpine as base
|
||||
|
||||
WORKDIR /usr/app
|
||||
COPY ./package.json \
|
||||
./package-lock.json \
|
||||
./next.config.js \
|
||||
./tsconfig.json \
|
||||
./reset.d.ts \
|
||||
./tailwind.config.js \
|
||||
./postcss.config.js ./
|
||||
COPY ./scripts ./scripts
|
||||
COPY ./prisma ./prisma
|
||||
|
||||
RUN apk add --no-cache openssl && \
|
||||
npm ci --ignore-scripts && \
|
||||
npx prisma generate
|
||||
|
||||
COPY ./src ./src
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
COPY scripts/build.env .env
|
||||
RUN npm run build
|
||||
|
||||
RUN rm -r .next/cache
|
||||
|
||||
FROM node:21-alpine as runtime-deps
|
||||
|
||||
WORKDIR /usr/app
|
||||
COPY --from=base /usr/app/package.json /usr/app/package-lock.json ./
|
||||
COPY --from=base /usr/app/prisma ./prisma
|
||||
|
||||
RUN npm ci --omit=dev --omit=optional --ignore-scripts && \
|
||||
npx prisma generate
|
||||
|
||||
FROM node:21-alpine as runner
|
||||
|
||||
EXPOSE 3000/tcp
|
||||
WORKDIR /usr/app
|
||||
COPY ./ ./
|
||||
|
||||
RUN apt update && \
|
||||
apt install openssl -y && \
|
||||
apt clean && \
|
||||
apt autoclean && \
|
||||
apt autoremove && \
|
||||
npm ci --ignore-scripts && \
|
||||
npm install -g prisma && \
|
||||
prisma generate
|
||||
COPY --from=base /usr/app/package.json /usr/app/package-lock.json ./
|
||||
COPY --from=runtime-deps /usr/app/node_modules ./node_modules
|
||||
COPY ./public ./public
|
||||
COPY ./scripts ./scripts
|
||||
COPY --from=base /usr/app/prisma ./prisma
|
||||
COPY --from=base /usr/app/.next ./.next
|
||||
|
||||
# env vars needed for build not to fail
|
||||
ARG POSTGRES_PRISMA_URL
|
||||
ARG POSTGRES_URL_NON_POOLING
|
||||
|
||||
RUN npm run build
|
||||
|
||||
ENTRYPOINT ["/usr/app/scripts/container-entrypoint.sh"]
|
||||
ENTRYPOINT ["/bin/sh", "/usr/app/scripts/container-entrypoint.sh"]
|
||||
|
||||
@@ -23,6 +23,12 @@ const nextConfig = {
|
||||
images: {
|
||||
remotePatterns
|
||||
},
|
||||
// Required to run in a codespace (see https://github.com/vercel/next.js/issues/58019)
|
||||
experimental: {
|
||||
serverActions: {
|
||||
allowedOrigins: ['localhost:3000'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
|
||||
83
package-lock.json
generated
83
package-lock.json
generated
@@ -39,6 +39,7 @@
|
||||
"next13-progressbar": "^1.1.1",
|
||||
"openai": "^4.25.0",
|
||||
"pg": "^8.11.3",
|
||||
"prisma": "^5.7.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.47.0",
|
||||
@@ -65,7 +66,6 @@
|
||||
"postcss": "^8",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier-plugin-organize-imports": "^3.2.3",
|
||||
"prisma": "^5.7.0",
|
||||
"tailwindcss": "^3",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.3.3"
|
||||
@@ -1429,13 +1429,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/client": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.6.0.tgz",
|
||||
"integrity": "sha512-mUDefQFa1wWqk4+JhKPYq8BdVoFk9NFMBXUI8jAkBfQTtgx8WPx02U2HB/XbAz3GSUJpeJOKJQtNvaAIDs6sug==",
|
||||
"version": "5.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.9.1.tgz",
|
||||
"integrity": "sha512-caSOnG4kxcSkhqC/2ShV7rEoWwd3XrftokxJqOCMVvia4NYV/TPtJlS9C2os3Igxw/Qyxumj9GBQzcStzECvtQ==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/engines-version": "5.6.0-32.e95e739751f42d8ca026f6b910f5a2dc5adeaeee"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.13"
|
||||
},
|
||||
@@ -1449,59 +1446,48 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/debug": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.7.0.tgz",
|
||||
"integrity": "sha512-tZ+MOjWlVvz1kOEhNYMa4QUGURY+kgOUBqLHYIV8jmCsMuvA1tWcn7qtIMLzYWCbDcQT4ZS8xDgK0R2gl6/0wA==",
|
||||
"devOptional": true
|
||||
"version": "5.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.9.1.tgz",
|
||||
"integrity": "sha512-yAHFSFCg8KVoL0oRUno3m60GAjsUKYUDkQ+9BA2X2JfVR3kRVSJFc/GpQ2fSORi4pSHZR9orfM4UC9OVXIFFTA=="
|
||||
},
|
||||
"node_modules/@prisma/engines": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.7.0.tgz",
|
||||
"integrity": "sha512-TkOMgMm60n5YgEKPn9erIvFX2/QuWnl3GBo6yTRyZKk5O5KQertXiNnrYgSLy0SpsKmhovEPQb+D4l0SzyE7XA==",
|
||||
"devOptional": true,
|
||||
"version": "5.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.9.1.tgz",
|
||||
"integrity": "sha512-gkdXmjxQ5jktxWNdDA5aZZ6R8rH74JkoKq6LD5mACSvxd2vbqWeWIOV0Py5wFC8vofOYShbt6XUeCIUmrOzOnQ==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/debug": "5.7.0",
|
||||
"@prisma/engines-version": "5.7.0-41.79fb5193cf0a8fdbef536e4b4a159cad677ab1b9",
|
||||
"@prisma/fetch-engine": "5.7.0",
|
||||
"@prisma/get-platform": "5.7.0"
|
||||
"@prisma/debug": "5.9.1",
|
||||
"@prisma/engines-version": "5.9.0-32.23fdc5965b1e05fc54e5f26ed3de66776b93de64",
|
||||
"@prisma/fetch-engine": "5.9.1",
|
||||
"@prisma/get-platform": "5.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/engines-version": {
|
||||
"version": "5.6.0-32.e95e739751f42d8ca026f6b910f5a2dc5adeaeee",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.6.0-32.e95e739751f42d8ca026f6b910f5a2dc5adeaeee.tgz",
|
||||
"integrity": "sha512-UoFgbV1awGL/3wXuUK3GDaX2SolqczeeJ5b4FVec9tzeGbSWJboPSbT0psSrmgYAKiKnkOPFSLlH6+b+IyOwAw=="
|
||||
},
|
||||
"node_modules/@prisma/engines/node_modules/@prisma/engines-version": {
|
||||
"version": "5.7.0-41.79fb5193cf0a8fdbef536e4b4a159cad677ab1b9",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.7.0-41.79fb5193cf0a8fdbef536e4b4a159cad677ab1b9.tgz",
|
||||
"integrity": "sha512-V6tgRVi62jRwTm0Hglky3Scwjr/AKFBFtS+MdbsBr7UOuiu1TKLPc6xfPiyEN1+bYqjEtjxwGsHgahcJsd1rNg==",
|
||||
"devOptional": true
|
||||
"version": "5.9.0-32.23fdc5965b1e05fc54e5f26ed3de66776b93de64",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.9.0-32.23fdc5965b1e05fc54e5f26ed3de66776b93de64.tgz",
|
||||
"integrity": "sha512-HFl7275yF0FWbdcNvcSRbbu9JCBSLMcurYwvWc8WGDnpu7APxQo2ONtZrUggU3WxLxUJ2uBX+0GOFIcJeVeOOQ=="
|
||||
},
|
||||
"node_modules/@prisma/fetch-engine": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.7.0.tgz",
|
||||
"integrity": "sha512-zIn/qmO+N/3FYe7/L9o+yZseIU8ivh4NdPKSkQRIHfg2QVTVMnbhGoTcecbxfVubeTp+DjcbjS0H9fCuM4W04w==",
|
||||
"devOptional": true,
|
||||
"version": "5.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.9.1.tgz",
|
||||
"integrity": "sha512-l0goQOMcNVOJs1kAcwqpKq3ylvkD9F04Ioe1oJoCqmz05mw22bNAKKGWuDd3zTUoUZr97va0c/UfLNru+PDmNA==",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "5.7.0",
|
||||
"@prisma/engines-version": "5.7.0-41.79fb5193cf0a8fdbef536e4b4a159cad677ab1b9",
|
||||
"@prisma/get-platform": "5.7.0"
|
||||
"@prisma/debug": "5.9.1",
|
||||
"@prisma/engines-version": "5.9.0-32.23fdc5965b1e05fc54e5f26ed3de66776b93de64",
|
||||
"@prisma/get-platform": "5.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/fetch-engine/node_modules/@prisma/engines-version": {
|
||||
"version": "5.7.0-41.79fb5193cf0a8fdbef536e4b4a159cad677ab1b9",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.7.0-41.79fb5193cf0a8fdbef536e4b4a159cad677ab1b9.tgz",
|
||||
"integrity": "sha512-V6tgRVi62jRwTm0Hglky3Scwjr/AKFBFtS+MdbsBr7UOuiu1TKLPc6xfPiyEN1+bYqjEtjxwGsHgahcJsd1rNg==",
|
||||
"devOptional": true
|
||||
"version": "5.9.0-32.23fdc5965b1e05fc54e5f26ed3de66776b93de64",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.9.0-32.23fdc5965b1e05fc54e5f26ed3de66776b93de64.tgz",
|
||||
"integrity": "sha512-HFl7275yF0FWbdcNvcSRbbu9JCBSLMcurYwvWc8WGDnpu7APxQo2ONtZrUggU3WxLxUJ2uBX+0GOFIcJeVeOOQ=="
|
||||
},
|
||||
"node_modules/@prisma/get-platform": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.7.0.tgz",
|
||||
"integrity": "sha512-ZeV/Op4bZsWXuw5Tg05WwRI8BlKiRFhsixPcAM+5BKYSiUZiMKIi713tfT3drBq8+T0E1arNZgYSA9QYcglWNA==",
|
||||
"devOptional": true,
|
||||
"version": "5.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.9.1.tgz",
|
||||
"integrity": "sha512-6OQsNxTyhvG+T2Ksr8FPFpuPeL4r9u0JF0OZHUBI/Uy9SS43sPyAIutt4ZEAyqWQt104ERh70EZedkHZKsnNbg==",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "5.7.0"
|
||||
"@prisma/debug": "5.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/number": {
|
||||
@@ -7430,13 +7416,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prisma": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.7.0.tgz",
|
||||
"integrity": "sha512-0rcfXO2ErmGAtxnuTNHQT9ztL0zZheQjOI/VNJzdq87C3TlGPQtMqtM+KCwU6XtmkoEr7vbCQqA7HF9IY0ST+Q==",
|
||||
"devOptional": true,
|
||||
"version": "5.9.1",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.9.1.tgz",
|
||||
"integrity": "sha512-Hy/8KJZz0ELtkw4FnG9MS9rNWlXcJhf98Z2QMqi0QiVMoS8PzsBkpla0/Y5hTlob8F3HeECYphBjqmBxrluUrQ==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/engines": "5.7.0"
|
||||
"@prisma/engines": "5.9.1"
|
||||
},
|
||||
"bin": {
|
||||
"prisma": "build/index.js"
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"lint": "next lint",
|
||||
"check-types": "tsc --noEmit",
|
||||
"check-formatting": "prettier -c src",
|
||||
"prettier": "prettier -w src",
|
||||
"postinstall": "prisma migrate deploy && prisma generate",
|
||||
"build-image": "./scripts/build-image.sh",
|
||||
"start-container": "docker compose --env-file container.env up"
|
||||
@@ -53,7 +54,8 @@
|
||||
"ts-pattern": "^5.0.6",
|
||||
"uuid": "^9.0.1",
|
||||
"vaul": "^0.8.0",
|
||||
"zod": "^3.22.4"
|
||||
"zod": "^3.22.4",
|
||||
"prisma": "^5.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@total-typescript/ts-reset": "^0.5.1",
|
||||
@@ -70,7 +72,6 @@
|
||||
"postcss": "^8",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier-plugin-organize-imports": "^3.2.3",
|
||||
"prisma": "^5.7.0",
|
||||
"tailwindcss": "^3",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.3.3"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Expense" ADD COLUMN "notes" TEXT;
|
||||
@@ -52,6 +52,7 @@ model Expense {
|
||||
splitMode SplitMode @default(EVENLY)
|
||||
createdAt DateTime @default(now())
|
||||
documents ExpenseDocument[]
|
||||
notes String?
|
||||
}
|
||||
|
||||
model ExpenseDocument {
|
||||
|
||||
@@ -5,9 +5,6 @@ SPLIIT_VERSION=$(node -p -e "require('./package.json').version")
|
||||
|
||||
# we need to set dummy data for POSTGRES env vars in order for build not to fail
|
||||
docker buildx build \
|
||||
--no-cache \
|
||||
--build-arg POSTGRES_PRISMA_URL=postgresql://build:@db \
|
||||
--build-arg POSTGRES_URL_NON_POOLING=postgresql://build:@db \
|
||||
-t ${SPLIIT_APP_NAME}:${SPLIIT_VERSION} \
|
||||
-t ${SPLIIT_APP_NAME}:latest \
|
||||
.
|
||||
|
||||
22
scripts/build.env
Normal file
22
scripts/build.env
Normal file
@@ -0,0 +1,22 @@
|
||||
# build file that contains all possible env vars with mocked values
|
||||
# as most of them are used at build time in order to have the production build to work properly
|
||||
|
||||
# db
|
||||
POSTGRES_PASSWORD=1234
|
||||
|
||||
# app
|
||||
POSTGRES_PRISMA_URL=postgresql://postgres:${POSTGRES_PASSWORD}@db
|
||||
POSTGRES_URL_NON_POOLING=postgresql://postgres:${POSTGRES_PASSWORD}@db
|
||||
|
||||
# app-minio
|
||||
NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS=false
|
||||
S3_UPLOAD_KEY=AAAAAAAAAAAAAAAAAAAA
|
||||
S3_UPLOAD_SECRET=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||
S3_UPLOAD_BUCKET=spliit
|
||||
S3_UPLOAD_REGION=eu-north-1
|
||||
S3_UPLOAD_ENDPOINT=s3://minio.example.com
|
||||
|
||||
# app-openai
|
||||
NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT=false
|
||||
OPENAI_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT=false
|
||||
@@ -1,3 +1,6 @@
|
||||
#!/bin/bash
|
||||
prisma migrate deploy
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
npx prisma migrate deploy
|
||||
npm run start
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getExpense,
|
||||
updateExpense,
|
||||
} from '@/lib/api'
|
||||
import { getRuntimeFeatureFlags } from '@/lib/featureFlags'
|
||||
import { expenseFormSchema } from '@/lib/schemas'
|
||||
import { Metadata } from 'next'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
@@ -47,6 +48,7 @@ export default async function EditExpensePage({
|
||||
categories={categories}
|
||||
onSubmit={updateExpenseAction}
|
||||
onDelete={deleteExpenseAction}
|
||||
runtimeFeatureFlags={await getRuntimeFeatureFlags()}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
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>
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cached } from '@/app/cached-functions'
|
||||
import { ExpenseForm } from '@/components/expense-form'
|
||||
import { createExpense, getCategories } from '@/lib/api'
|
||||
import { getRuntimeFeatureFlags } from '@/lib/featureFlags'
|
||||
import { expenseFormSchema } from '@/lib/schemas'
|
||||
import { Metadata } from 'next'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
@@ -32,6 +33,7 @@ export default async function ExpensePage({
|
||||
group={group}
|
||||
categories={categories}
|
||||
onSubmit={createExpenseAction}
|
||||
runtimeFeatureFlags={await getRuntimeFeatureFlags()}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
'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 { SearchBar } from '@/components/ui/search-bar'
|
||||
@@ -19,6 +20,7 @@ type Props = {
|
||||
}
|
||||
|
||||
const EXPENSE_GROUPS = {
|
||||
UPCOMING: 'Upcoming',
|
||||
THIS_WEEK: 'This week',
|
||||
EARLIER_THIS_MONTH: 'Earlier this month',
|
||||
LAST_MONTH: 'Last month',
|
||||
@@ -28,7 +30,9 @@ const EXPENSE_GROUPS = {
|
||||
}
|
||||
|
||||
function getExpenseGroup(date: Dayjs, today: Dayjs) {
|
||||
if (today.isSame(date, 'week')) {
|
||||
if (today.isBefore(date)) {
|
||||
return EXPENSE_GROUPS.UPCOMING
|
||||
} else if (today.isSame(date, 'week')) {
|
||||
return EXPENSE_GROUPS.THIS_WEEK
|
||||
} else if (today.isSame(date, 'month')) {
|
||||
return EXPENSE_GROUPS.EARLIER_THIS_MONTH
|
||||
@@ -148,6 +152,9 @@ export function ExpenseList({
|
||||
</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
|
||||
|
||||
@@ -51,6 +51,7 @@ export default async function GroupExpensesPage({
|
||||
prefetch={false}
|
||||
href={`/groups/${groupId}/expenses/export/json`}
|
||||
target="_blank"
|
||||
title="Export to JSON"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Link>
|
||||
@@ -63,7 +64,10 @@ export default async function GroupExpensesPage({
|
||||
/>
|
||||
)}
|
||||
<Button asChild size="icon">
|
||||
<Link href={`/groups/${groupId}/expenses/create`}>
|
||||
<Link
|
||||
href={`/groups/${groupId}/expenses/create`}
|
||||
title="Create expense"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -23,6 +23,7 @@ export function GroupTabs({ groupId }: Props) {
|
||||
<TabsList>
|
||||
<TabsTrigger value="expenses">Expenses</TabsTrigger>
|
||||
<TabsTrigger value="balances">Balances</TabsTrigger>
|
||||
<TabsTrigger value="stats">Stats</TabsTrigger>
|
||||
<TabsTrigger value="edit">Settings</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
@@ -23,7 +23,7 @@ export function ShareButton({ group }: Props) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="icon">
|
||||
<Button title="Share" size="icon">
|
||||
<Share className="w-4 h-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
49
src/app/groups/[groupId]/stats/page.tsx
Normal file
49
src/app/groups/[groupId]/stats/page.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { cached } from '@/app/cached-functions'
|
||||
import { Totals } from '@/app/groups/[groupId]/stats/totals'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { getGroupExpenses } from '@/lib/api'
|
||||
import { getTotalGroupSpending } from '@/lib/totals'
|
||||
import { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Totals',
|
||||
}
|
||||
|
||||
export default async function TotalsPage({
|
||||
params: { groupId },
|
||||
}: {
|
||||
params: { groupId: string }
|
||||
}) {
|
||||
const group = await cached.getGroup(groupId)
|
||||
if (!group) notFound()
|
||||
|
||||
const expenses = await getGroupExpenses(groupId)
|
||||
const totalGroupSpendings = getTotalGroupSpending(expenses)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Totals</CardTitle>
|
||||
<CardDescription>
|
||||
Spending summary of the entire group.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col space-y-4">
|
||||
<Totals
|
||||
group={group}
|
||||
expenses={expenses}
|
||||
totalGroupSpendings={totalGroupSpendings}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
17
src/app/groups/[groupId]/stats/totals-group-spending.tsx
Normal file
17
src/app/groups/[groupId]/stats/totals-group-spending.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
|
||||
type Props = {
|
||||
totalGroupSpendings: number
|
||||
currency: string
|
||||
}
|
||||
|
||||
export function TotalsGroupSpending({ totalGroupSpendings, currency }: Props) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-muted-foreground">Total group spendings</div>
|
||||
<div className="text-lg">
|
||||
{formatCurrency(currency, totalGroupSpendings)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
src/app/groups/[groupId]/stats/totals-your-share.tsx
Normal file
34
src/app/groups/[groupId]/stats/totals-your-share.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
import { getGroup, getGroupExpenses } from '@/lib/api'
|
||||
import { getTotalActiveUserShare } from '@/lib/totals'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
type Props = {
|
||||
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
|
||||
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>
|
||||
}
|
||||
|
||||
export function TotalsYourShare({ group, expenses }: Props) {
|
||||
const [activeUser, setActiveUser] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const activeUser = localStorage.getItem(`${group.id}-activeUser`)
|
||||
if (activeUser) setActiveUser(activeUser)
|
||||
}, [group, expenses])
|
||||
|
||||
const totalActiveUserShare =
|
||||
activeUser === '' || activeUser === 'None'
|
||||
? 0
|
||||
: getTotalActiveUserShare(activeUser, expenses)
|
||||
const currency = group.currency
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-muted-foreground">Your total share</div>
|
||||
<div className="text-lg">
|
||||
{formatCurrency(currency, totalActiveUserShare)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
src/app/groups/[groupId]/stats/totals-your-spending.tsx
Normal file
30
src/app/groups/[groupId]/stats/totals-your-spending.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
import { getGroup, getGroupExpenses } from '@/lib/api'
|
||||
import { useActiveUser } from '@/lib/hooks'
|
||||
import { getTotalActiveUserPaidFor } from '@/lib/totals'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
|
||||
type Props = {
|
||||
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
|
||||
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>
|
||||
}
|
||||
|
||||
export function TotalsYourSpendings({ group, expenses }: Props) {
|
||||
const activeUser = useActiveUser(group.id)
|
||||
|
||||
const totalYourSpendings =
|
||||
activeUser === '' || activeUser === 'None'
|
||||
? 0
|
||||
: getTotalActiveUserPaidFor(activeUser, expenses)
|
||||
const currency = group.currency
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-muted-foreground">Total you paid for</div>
|
||||
|
||||
<div className="text-lg">
|
||||
{formatCurrency(currency, totalYourSpendings)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
src/app/groups/[groupId]/stats/totals.tsx
Normal file
34
src/app/groups/[groupId]/stats/totals.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
import { TotalsGroupSpending } from '@/app/groups/[groupId]/stats/totals-group-spending'
|
||||
import { TotalsYourShare } from '@/app/groups/[groupId]/stats/totals-your-share'
|
||||
import { TotalsYourSpendings } from '@/app/groups/[groupId]/stats/totals-your-spending'
|
||||
import { getGroup, getGroupExpenses } from '@/lib/api'
|
||||
import { useActiveUser } from '@/lib/hooks'
|
||||
|
||||
export function Totals({
|
||||
group,
|
||||
expenses,
|
||||
totalGroupSpendings,
|
||||
}: {
|
||||
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
|
||||
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>
|
||||
totalGroupSpendings: number
|
||||
}) {
|
||||
const activeUser = useActiveUser(group.id)
|
||||
console.log('activeUser', activeUser)
|
||||
|
||||
return (
|
||||
<>
|
||||
<TotalsGroupSpending
|
||||
totalGroupSpendings={totalGroupSpendings}
|
||||
currency={group.currency}
|
||||
/>
|
||||
{activeUser && activeUser !== 'None' && (
|
||||
<>
|
||||
<TotalsYourSpendings group={group} expenses={expenses} />
|
||||
<TotalsYourShare group={group} expenses={expenses} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
47
src/components/delete-popup.tsx
Normal file
47
src/components/delete-popup.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { AsyncButton } from './async-button'
|
||||
import { Button } from './ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from './ui/dialog'
|
||||
|
||||
export function DeletePopup({ onDelete }: { onDelete: () => Promise<void> }) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogTitle>Delete this expense?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Do you really want to delete this expense? This action is
|
||||
irreversible.
|
||||
</DialogDescription>
|
||||
<DialogFooter className="flex flex-col gap-2">
|
||||
<AsyncButton
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loadingContent="Deleting…"
|
||||
action={onDelete}
|
||||
>
|
||||
Yes
|
||||
</AsyncButton>
|
||||
<DialogClose asChild>
|
||||
<Button variant={'secondary'}>Cancel</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
'use client'
|
||||
import { AsyncButton } from '@/components/async-button'
|
||||
import { CategorySelector } from '@/components/category-selector'
|
||||
import { ExpenseDocumentsInput } from '@/components/expense-documents-input'
|
||||
import { SubmitButton } from '@/components/submit-button'
|
||||
@@ -35,16 +34,23 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { getCategories, getExpense, getGroup, randomId } from '@/lib/api'
|
||||
import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas'
|
||||
import { RuntimeFeatureFlags } from '@/lib/featureFlags'
|
||||
import {
|
||||
ExpenseFormValues,
|
||||
SplittingOptions,
|
||||
expenseFormSchema,
|
||||
} from '@/lib/schemas'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Save, Trash2 } from 'lucide-react'
|
||||
import { Save } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { match } from 'ts-pattern'
|
||||
import { DeletePopup } from './delete-popup'
|
||||
import { extractCategoryFromTitle } from './expense-form-actions'
|
||||
import { Textarea } from './ui/textarea'
|
||||
|
||||
export type Props = {
|
||||
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
|
||||
@@ -52,6 +58,90 @@ export type Props = {
|
||||
categories: NonNullable<Awaited<ReturnType<typeof getCategories>>>
|
||||
onSubmit: (values: ExpenseFormValues) => Promise<void>
|
||||
onDelete?: () => Promise<void>
|
||||
runtimeFeatureFlags: RuntimeFeatureFlags
|
||||
}
|
||||
|
||||
const enforceCurrencyPattern = (value: string) =>
|
||||
value
|
||||
// replace first comma with #
|
||||
.replace(/[.,]/, '#')
|
||||
// remove all other commas
|
||||
.replace(/[.,]/g, '')
|
||||
// change back # to dot
|
||||
.replace(/#/, '.')
|
||||
// remove all non-numeric and non-dot characters
|
||||
.replace(/[^\d.]/g, '')
|
||||
|
||||
const getDefaultSplittingOptions = (group: Props['group']) => {
|
||||
const defaultValue = {
|
||||
splitMode: 'EVENLY' as const,
|
||||
paidFor: group.participants.map(({ id }) => ({
|
||||
participant: id,
|
||||
shares: '1' as unknown as number,
|
||||
})),
|
||||
}
|
||||
|
||||
if (typeof localStorage === 'undefined') return defaultValue
|
||||
const defaultSplitMode = localStorage.getItem(
|
||||
`${group.id}-defaultSplittingOptions`,
|
||||
)
|
||||
if (defaultSplitMode === null) return defaultValue
|
||||
const parsedDefaultSplitMode = JSON.parse(
|
||||
defaultSplitMode,
|
||||
) as SplittingOptions
|
||||
|
||||
if (parsedDefaultSplitMode.paidFor === null) {
|
||||
parsedDefaultSplitMode.paidFor = defaultValue.paidFor
|
||||
}
|
||||
|
||||
// if there is a participant in the default options that does not exist anymore,
|
||||
// remove the stale default splitting options
|
||||
for (const parsedPaidFor of parsedDefaultSplitMode.paidFor) {
|
||||
if (
|
||||
!group.participants.some(({ id }) => id === parsedPaidFor.participant)
|
||||
) {
|
||||
localStorage.removeItem(`${group.id}-defaultSplittingOptions`)
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
splitMode: parsedDefaultSplitMode.splitMode,
|
||||
paidFor: parsedDefaultSplitMode.paidFor.map((paidFor) => ({
|
||||
participant: paidFor.participant,
|
||||
shares: String(paidFor.shares / 100) as unknown as number,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async function persistDefaultSplittingOptions(
|
||||
groupId: string,
|
||||
expenseFormValues: ExpenseFormValues,
|
||||
) {
|
||||
if (localStorage && expenseFormValues.saveDefaultSplittingOptions) {
|
||||
const computePaidFor = (): SplittingOptions['paidFor'] => {
|
||||
if (expenseFormValues.splitMode === 'EVENLY') {
|
||||
return expenseFormValues.paidFor.map(({ participant }) => ({
|
||||
participant,
|
||||
shares: '100' as unknown as number,
|
||||
}))
|
||||
} else if (expenseFormValues.splitMode === 'BY_AMOUNT') {
|
||||
return null
|
||||
} else {
|
||||
return expenseFormValues.paidFor
|
||||
}
|
||||
}
|
||||
|
||||
const splittingOptions = {
|
||||
splitMode: expenseFormValues.splitMode,
|
||||
paidFor: computePaidFor(),
|
||||
} satisfies SplittingOptions
|
||||
|
||||
localStorage.setItem(
|
||||
`${groupId}-defaultSplittingOptions`,
|
||||
JSON.stringify(splittingOptions),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function ExpenseForm({
|
||||
@@ -60,18 +150,20 @@ export function ExpenseForm({
|
||||
categories,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
runtimeFeatureFlags,
|
||||
}: Props) {
|
||||
const isCreate = expense === undefined
|
||||
const searchParams = useSearchParams()
|
||||
const getSelectedPayer = (field?: { value: string }) => {
|
||||
if (isCreate && typeof window !== 'undefined') {
|
||||
const activeUser = localStorage.getItem(`${group.id}-activeUser`)
|
||||
if (activeUser && activeUser !== 'None') {
|
||||
if (activeUser && activeUser !== 'None' && field?.value === undefined) {
|
||||
return activeUser
|
||||
}
|
||||
}
|
||||
return field?.value
|
||||
}
|
||||
const defaultSplittingOptions = getDefaultSplittingOptions(group)
|
||||
const form = useForm<ExpenseFormValues>({
|
||||
resolver: zodResolver(expenseFormSchema),
|
||||
defaultValues: expense
|
||||
@@ -86,8 +178,10 @@ export function ExpenseForm({
|
||||
shares: String(shares / 100) as unknown as number,
|
||||
})),
|
||||
splitMode: expense.splitMode,
|
||||
saveDefaultSplittingOptions: false,
|
||||
isReimbursement: expense.isReimbursement,
|
||||
documents: expense.documents,
|
||||
notes: expense.notes ?? '',
|
||||
}
|
||||
: searchParams.get('reimbursement')
|
||||
? {
|
||||
@@ -100,12 +194,17 @@ export function ExpenseForm({
|
||||
paidBy: searchParams.get('from') ?? undefined,
|
||||
paidFor: [
|
||||
searchParams.get('to')
|
||||
? { participant: searchParams.get('to')!, shares: 1 }
|
||||
? {
|
||||
participant: searchParams.get('to')!,
|
||||
shares: '1' as unknown as number,
|
||||
}
|
||||
: undefined,
|
||||
],
|
||||
isReimbursement: true,
|
||||
splitMode: 'EVENLY',
|
||||
splitMode: defaultSplittingOptions.splitMode,
|
||||
saveDefaultSplittingOptions: false,
|
||||
documents: [],
|
||||
notes: '',
|
||||
}
|
||||
: {
|
||||
title: searchParams.get('title') ?? '',
|
||||
@@ -117,13 +216,11 @@ export function ExpenseForm({
|
||||
? Number(searchParams.get('categoryId'))
|
||||
: 0, // category with Id 0 is General
|
||||
// paid for all, split evenly
|
||||
paidFor: group.participants.map(({ id }) => ({
|
||||
participant: id,
|
||||
shares: 1,
|
||||
})),
|
||||
paidFor: defaultSplittingOptions.paidFor,
|
||||
paidBy: getSelectedPayer(),
|
||||
isReimbursement: false,
|
||||
splitMode: 'EVENLY',
|
||||
splitMode: defaultSplittingOptions.splitMode,
|
||||
saveDefaultSplittingOptions: false,
|
||||
documents: searchParams.get('imageUrl')
|
||||
? [
|
||||
{
|
||||
@@ -134,13 +231,19 @@ export function ExpenseForm({
|
||||
},
|
||||
]
|
||||
: [],
|
||||
notes: '',
|
||||
},
|
||||
})
|
||||
const [isCategoryLoading, setCategoryLoading] = useState(false)
|
||||
|
||||
const submit = async (values: ExpenseFormValues) => {
|
||||
await persistDefaultSplittingOptions(group.id, values)
|
||||
return onSubmit(values)
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit((values) => onSubmit(values))}>
|
||||
<form onSubmit={form.handleSubmit(submit)}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
@@ -161,7 +264,7 @@ export function ExpenseForm({
|
||||
{...field}
|
||||
onBlur={async () => {
|
||||
field.onBlur() // avoid skipping other blur event listeners since we overwrite `field`
|
||||
if (process.env.NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT) {
|
||||
if (runtimeFeatureFlags.enableCategoryExtract) {
|
||||
setCategoryLoading(true)
|
||||
const { categoryId } = await extractCategoryFromTitle(
|
||||
field.value,
|
||||
@@ -207,18 +310,23 @@ export function ExpenseForm({
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="amount"
|
||||
render={({ field }) => (
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<FormItem className="sm:order-3">
|
||||
<FormLabel>Amount</FormLabel>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span>{group.currency}</span>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="text-base max-w-[120px]"
|
||||
type="number"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
step={0.01}
|
||||
placeholder="0.00"
|
||||
onChange={(event) =>
|
||||
onChange(enforceCurrencyPattern(event.target.value))
|
||||
}
|
||||
onClick={(e) => e.currentTarget.select()}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -296,6 +404,18 @@ export function ExpenseForm({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notes"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:order-6">
|
||||
<FormLabel>Notes</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea className="text-base" {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -424,7 +544,7 @@ export function ExpenseForm({
|
||||
),
|
||||
)}
|
||||
className="text-base w-[80px] -my-2"
|
||||
type="number"
|
||||
type="text"
|
||||
disabled={
|
||||
!field.value?.some(
|
||||
({ participant }) =>
|
||||
@@ -444,7 +564,9 @@ export function ExpenseForm({
|
||||
? {
|
||||
participant: id,
|
||||
shares:
|
||||
event.target.value,
|
||||
enforceCurrencyPattern(
|
||||
event.target.value,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
@@ -487,7 +609,10 @@ export function ExpenseForm({
|
||||
)}
|
||||
/>
|
||||
|
||||
<Collapsible className="mt-5">
|
||||
<Collapsible
|
||||
className="mt-5"
|
||||
defaultOpen={form.getValues().splitMode !== 'EVENLY'}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="link" className="-mx-4">
|
||||
Advanced splitting options…
|
||||
@@ -499,7 +624,7 @@ export function ExpenseForm({
|
||||
control={form.control}
|
||||
name="splitMode"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:order-2">
|
||||
<FormItem>
|
||||
<FormLabel>Split mode</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
@@ -535,13 +660,32 @@ export function ExpenseForm({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="saveDefaultSplittingOptions"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row gap-2 items-center space-y-0 pt-2">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div>
|
||||
<FormLabel>
|
||||
Save as default splitting options
|
||||
</FormLabel>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{process.env.NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS && (
|
||||
{runtimeFeatureFlags.enableExpenseDocuments && (
|
||||
<Card className="mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex justify-between">
|
||||
@@ -574,15 +718,7 @@ export function ExpenseForm({
|
||||
{isCreate ? <>Create</> : <>Save</>}
|
||||
</SubmitButton>
|
||||
{!isCreate && onDelete && (
|
||||
<AsyncButton
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loadingContent="Deleting…"
|
||||
action={onDelete}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</AsyncButton>
|
||||
<DeletePopup onDelete={onDelete}></DeletePopup>
|
||||
)}
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href={`/groups/${group.id}`}>Cancel</Link>
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -72,6 +72,7 @@ export async function createExpense(
|
||||
})),
|
||||
},
|
||||
},
|
||||
notes: expenseFormValues.notes,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -185,6 +186,7 @@ export async function updateExpense(
|
||||
id: doc.id,
|
||||
})),
|
||||
},
|
||||
notes: expenseFormValues.notes,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { ZodIssueCode, z } from 'zod'
|
||||
|
||||
const interpretEnvVarAsBool = (val: unknown): boolean => {
|
||||
if (typeof val !== 'string') return false
|
||||
return ['true', 'yes', '1', 'on'].includes(val.toLowerCase())
|
||||
}
|
||||
|
||||
const envSchema = z
|
||||
.object({
|
||||
POSTGRES_URL_NON_POOLING: z.string().url(),
|
||||
@@ -12,14 +17,23 @@ const envSchema = z
|
||||
? `https://${process.env.VERCEL_URL}`
|
||||
: 'http://localhost:3000',
|
||||
),
|
||||
NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS: z.coerce.boolean().default(false),
|
||||
NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS: z.preprocess(
|
||||
interpretEnvVarAsBool,
|
||||
z.boolean().default(false),
|
||||
),
|
||||
S3_UPLOAD_KEY: z.string().optional(),
|
||||
S3_UPLOAD_SECRET: z.string().optional(),
|
||||
S3_UPLOAD_BUCKET: z.string().optional(),
|
||||
S3_UPLOAD_REGION: z.string().optional(),
|
||||
S3_UPLOAD_ENDPOINT: z.string().optional(),
|
||||
NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT: z.coerce.boolean().default(false),
|
||||
NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT: z.coerce.boolean().default(false),
|
||||
NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT: z.preprocess(
|
||||
interpretEnvVarAsBool,
|
||||
z.boolean().default(false),
|
||||
),
|
||||
NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT: z.preprocess(
|
||||
interpretEnvVarAsBool,
|
||||
z.boolean().default(false),
|
||||
),
|
||||
OPENAI_API_KEY: z.string().optional(),
|
||||
})
|
||||
.superRefine((env, ctx) => {
|
||||
|
||||
15
src/lib/featureFlags.ts
Normal file
15
src/lib/featureFlags.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
'use server'
|
||||
|
||||
import { env } from './env'
|
||||
|
||||
export async function getRuntimeFeatureFlags() {
|
||||
return {
|
||||
enableExpenseDocuments: env.NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS,
|
||||
enableReceiptExtract: env.NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT,
|
||||
enableCategoryExtract: env.NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT,
|
||||
}
|
||||
}
|
||||
|
||||
export type RuntimeFeatureFlags = Awaited<
|
||||
ReturnType<typeof getRuntimeFeatureFlags>
|
||||
>
|
||||
@@ -48,3 +48,17 @@ export function useBaseUrl() {
|
||||
}, [])
|
||||
return baseUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The active user, or `null` until it is fetched from local storage
|
||||
*/
|
||||
export function useActiveUser(groupId: string) {
|
||||
const [activeUser, setActiveUser] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const activeUser = localStorage.getItem(`${groupId}-activeUser`)
|
||||
if (activeUser) setActiveUser(activeUser)
|
||||
}, [groupId])
|
||||
|
||||
return activeUser
|
||||
}
|
||||
|
||||
@@ -51,8 +51,7 @@ export const expenseFormSchema = z
|
||||
[
|
||||
z.number(),
|
||||
z.string().transform((value, ctx) => {
|
||||
const normalizedValue = value.replace(/,/g, '.')
|
||||
const valueAsNumber = Number(normalizedValue)
|
||||
const valueAsNumber = Number(value)
|
||||
if (Number.isNaN(valueAsNumber))
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
@@ -106,6 +105,7 @@ export const expenseFormSchema = z
|
||||
Object.values(SplitMode) as any,
|
||||
)
|
||||
.default('EVENLY'),
|
||||
saveDefaultSplittingOptions: z.boolean(),
|
||||
isReimbursement: z.boolean(),
|
||||
documents: z
|
||||
.array(
|
||||
@@ -117,6 +117,7 @@ export const expenseFormSchema = z
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
.superRefine((expense, ctx) => {
|
||||
let sum = 0
|
||||
@@ -161,3 +162,9 @@ export const expenseFormSchema = z
|
||||
})
|
||||
|
||||
export type ExpenseFormValues = z.infer<typeof expenseFormSchema>
|
||||
|
||||
export type SplittingOptions = {
|
||||
// Used for saving default splitting options in localStorage
|
||||
splitMode: SplitMode
|
||||
paidFor: ExpenseFormValues['paidFor'] | null
|
||||
}
|
||||
|
||||
70
src/lib/totals.ts
Normal file
70
src/lib/totals.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { getGroupExpenses } from '@/lib/api'
|
||||
|
||||
export function getTotalGroupSpending(
|
||||
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>,
|
||||
): number {
|
||||
return expenses.reduce(
|
||||
(total, expense) =>
|
||||
expense.isReimbursement ? total : total + expense.amount,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
export function getTotalActiveUserPaidFor(
|
||||
activeUserId: string | null,
|
||||
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>,
|
||||
): number {
|
||||
return expenses.reduce(
|
||||
(total, expense) =>
|
||||
expense.paidBy.id === activeUserId && !expense.isReimbursement
|
||||
? total + expense.amount
|
||||
: total,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
export function getTotalActiveUserShare(
|
||||
activeUserId: string | null,
|
||||
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>,
|
||||
): number {
|
||||
let total = 0
|
||||
|
||||
expenses.forEach((expense) => {
|
||||
if (expense.isReimbursement) return
|
||||
|
||||
const paidFors = expense.paidFor
|
||||
const userPaidFor = paidFors.find(
|
||||
(paidFor) => paidFor.participantId === activeUserId,
|
||||
)
|
||||
|
||||
if (!userPaidFor) {
|
||||
// If the active user is not involved in the expense, skip it
|
||||
return
|
||||
}
|
||||
|
||||
switch (expense.splitMode) {
|
||||
case 'EVENLY':
|
||||
// Divide the total expense evenly among all participants
|
||||
total += expense.amount / paidFors.length
|
||||
break
|
||||
case 'BY_AMOUNT':
|
||||
// Directly add the user's share if the split mode is BY_AMOUNT
|
||||
total += userPaidFor.shares
|
||||
break
|
||||
case 'BY_PERCENTAGE':
|
||||
// Calculate the user's share based on their percentage of the total expense
|
||||
total += (expense.amount * userPaidFor.shares) / 10000 // Assuming shares are out of 10000 for percentage
|
||||
break
|
||||
case 'BY_SHARES':
|
||||
// Calculate the user's share based on their shares relative to the total shares
|
||||
const totalShares = paidFors.reduce(
|
||||
(sum, paidFor) => sum + paidFor.shares,
|
||||
0,
|
||||
)
|
||||
total += (expense.amount * userPaidFor.shares) / totalShares
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
return parseFloat(total.toFixed(2))
|
||||
}
|
||||
Reference in New Issue
Block a user