Compare commits

...

122 commits
v1.0.0 ... main

Author SHA1 Message Date
Markus Thielker
d242378bb9
Release v1.3.1 (#100) 2025-03-18 09:20:23 +01:00
Markus Thielker
2bddc56195 Release-v1.3.1: update package version 2025-03-18 09:19:06 +01:00
Markus Thielker
13b9025fbf
N-FIN-98: fix date picker not opening (#99) 2025-03-18 09:07:08 +01:00
Markus Thielker
3ce44a3302 N-FIN-98: remove initialFocus attribute 2025-03-18 09:06:05 +01:00
Markus Thielker
8e94a0bea5 N-FIN-98: update related shadcn components 2025-03-18 09:06:04 +01:00
Markus Thielker
0cd553600a
Release v1.3.0 (#97) 2025-03-16 00:57:12 +01:00
Markus Thielker
fe58b0190b
Release v1.3.0 (#96) 2025-03-16 00:57:02 +01:00
Markus Thielker
5bb0d71836 RELEASE-v1.3.0: update README.md 2025-03-16 00:54:46 +01:00
Markus Thielker
fc1658602a RELEASE-v1.3.0: fix variable naming for clarity 2025-03-16 00:49:50 +01:00
Markus Thielker
76535bed45 RELEASE-v1.3.0: update package version 2025-03-16 00:41:22 +01:00
Markus Thielker
2a6fbfd70c
N-FIN-76: update app icon (#95) 2025-03-15 15:33:36 +01:00
Markus Thielker
13fc8c1e94
N-FIN-93: migrate the @auth0/nextjs-auth0 SDK to v4 (#94) 2025-03-14 13:50:01 +01:00
Markus Thielker
4abe52d4e8 N-FIN-93: update url constants 2025-03-14 13:47:04 +01:00
Markus Thielker
0bb1db9acc N-FIN-93: update session handling in pages 2025-03-14 13:46:18 +01:00
Markus Thielker
576c2b0c0c N-FIN-93: update session handling in actions 2025-03-14 13:46:11 +01:00
Markus Thielker
e38157e604 N-FIN-93: migrate to middleware route handling 2025-03-14 13:45:53 +01:00
Markus Thielker
4720ff553d N-FIN-93: add new dependency 2025-03-14 13:45:08 +01:00
Markus Thielker
fc361f721f N-FIN-76: update app icon 2025-03-13 08:30:49 +01:00
Markus Thielker
f56f466b40
N-FIN-66: fix restarting stopped container (#92) 2025-03-09 06:00:48 +01:00
Markus Thielker
d526ccf5ff N-FIN-66: fix restarting stopped container 2025-03-09 06:00:10 +01:00
Markus Thielker
ed90d66898
N-FIN-90: fix docker build failing (#91) 2025-03-09 05:38:25 +01:00
Markus Thielker
0f2f055a57 N-FIN-90: replace legacy ENV format 2025-03-09 05:34:22 +01:00
Markus Thielker
25793bb7c9 N-FIN-90: update shadcn calendar component to fix build error 2025-03-09 05:34:22 +01:00
Markus Thielker
237131aa11 N-FIN-90: set base image to alpine variant 2025-03-09 05:01:34 +01:00
Markus Thielker
7389b600ec
N-FIN-88: add new GitHub action (#89) 2025-03-09 04:33:14 +01:00
Markus Thielker
33e3b34305 N-FIN-88: add new GitHub action 2025-03-09 04:32:23 +01:00
Markus Thielker
3c3ad5ee38
Add manual execution to action 2025-03-09 04:18:41 +01:00
Markus Thielker
583bc1aa5d
N-FIN-86: fix payor input loses focus on typing (#87)
Fixes #86
2024-12-25 18:00:13 +01:00
Markus Thielker
5d8554068c
N-FIN-86: focus submit button after last input filled 2024-12-25 17:59:09 +01:00
Markus Thielker
ed49ad4ce7
N-FIN-86: fix payor input always focus next 2024-12-25 17:57:00 +01:00
Markus Thielker
4a25a93186
N-FIN-67: refactor system to focus next form node (#85)
Resolves #67
2024-12-25 17:43:46 +01:00
Markus Thielker
803bfc5807
N-FIN-67: refactor system to focus next form node
Also added some documentation of the custom auto-complete input
2024-12-25 17:42:31 +01:00
Markus Thielker
fc0a9abc7b
N-FIN-83: upgrade dependencies (#84)
Resolves #83 

Next.js 15 introduces a new async request api leading to warnings in the
`getSession()` call of the `@auth0/nextjs-auth0` package. The app still
works as intended.
2024-12-24 12:56:26 +01:00
Markus Thielker
91de5a730c
N-FIN-83: run 'next-async-request-api' codemod 2024-12-24 12:50:44 +01:00
Markus Thielker
59007f5973
N-FIN-83: fix serwist configuration 2024-12-24 12:49:47 +01:00
Markus Thielker
5be1e78ddd
N-FIN-83: upgrade dependency versions 2024-12-24 12:49:33 +01:00
Markus Thielker
f24d4e4a38
N-FIN-75: fix category can not be deleted (#82)
Resolves #75
2024-12-23 19:46:05 +01:00
Markus Thielker
4834750659
N-FIN-75: update related payments before deleting category 2024-12-23 19:44:06 +01:00
Markus Thielker
155ab2f2e3
N-FIN-78: replace npm with bun (#81)
Resolves #78
2024-12-23 02:19:15 +01:00
Markus Thielker
021bfcc65d
N-FIN-78: replace npm with bun 2024-12-23 02:18:14 +01:00
Markus Thielker
1aa3ed85c5
N-FIN-79: replace lucia with auth0 (#80)
Resolves #79
2024-12-23 00:32:37 +01:00
Markus Thielker
0e952e4933
N-FIN-79: add Auth0 variable to .env.example 2024-12-23 00:26:41 +01:00
Markus Thielker
f378e2a045
N-FIN-79: refactor account data reset 2024-12-23 00:18:05 +01:00
Markus Thielker
98f29a8366
N-FIN-79: fix sample data generation 2024-12-23 00:18:00 +01:00
Markus Thielker
53247d382d
N-FIN-79: remove unused auth.ts 2024-12-23 00:17:52 +01:00
Markus Thielker
57f3381829
N-FIN-79: migrate database dropping lucia tables 2024-12-23 00:17:43 +01:00
Markus Thielker
ba71cbef0e
N-FIN-79: use sign out url constant 2024-12-23 00:17:24 +01:00
Markus Thielker
12c689d1d6
N-FIN-79: refactor server actions to use auth0 session 2024-12-23 00:17:17 +01:00
Markus Thielker
c4146a36a4
N-FIN-79: remove lucia authentication components 2024-12-23 00:17:09 +01:00
Markus Thielker
6ba9a8872b
N-FIN-79: refactor pages to use auth0 session 2024-12-23 00:16:52 +01:00
Markus Thielker
642d64ad5e
N-FIN-79: add auth0 sdk 2024-12-23 00:16:42 +01:00
Markus Thielker
39cd91a53a
Release v1.2.0 (#65) 2024-03-24 20:43:43 +01:00
Markus Thielker
c16ac9c2fa
Release v1.2.0 (#64) 2024-03-24 20:43:40 +01:00
Markus Thielker
5a42108063
N-FIN-v1.2.0: update package.json 2024-03-24 20:41:35 +01:00
Markus Thielker
b09c705dd7
N-FIN-45: add documentation on how to use the app (#63)
Resolves #45
2024-03-24 20:39:03 +01:00
Markus Thielker
4dd33369d1
N-FIN-45: apply environment variable to traefik labels 2024-03-24 20:37:55 +01:00
Markus Thielker
0807c1cbc7
N-FIN-45: add example traefik setup 2024-03-24 20:17:42 +01:00
Markus Thielker
f36a44aacd
N-FIN-45: add example watchtower setup 2024-03-24 20:17:28 +01:00
Markus Thielker
25e85f68ed
N-FIN-45: add README content 2024-03-24 20:17:14 +01:00
Markus Thielker
51902d779e
N-FIN-61: fix prisma client instantiation (#62)
Fixes #61
2024-03-24 17:23:40 +01:00
Markus Thielker
00b4e51aee
N-FIN-61: fix prisma client instantiation 2024-03-24 16:16:02 +01:00
Markus Thielker
7731d0143e
N-FIN-57: add pwa support (#60)
Resolves #57
2024-03-17 20:32:21 +01:00
Markus Thielker
2c01251a32
N-FIN-57: fix passing invalid props 2024-03-17 20:31:08 +01:00
Markus Thielker
a7863d4b31
N-FIN-57: add serwist and PWA configuration 2024-03-17 20:28:41 +01:00
Markus Thielker
609e0056da
N-FIN-47: add an option to delete account and data (#59)
Resolves #47
2024-03-17 19:56:23 +01:00
Markus Thielker
c9b9dec342
N-FIN-47: add button to delete account 2024-03-17 19:55:30 +01:00
Markus Thielker
2de7d36138
N-FIN-47: add server action to delete account 2024-03-17 19:55:17 +01:00
Markus Thielker
e809912ae3
N-FIN-47: add optional confirmation dialog to new component 2024-03-17 19:54:56 +01:00
Markus Thielker
0e62b7c2fd
N-FIN-47: replace buttons with new component 2024-03-17 17:20:00 +01:00
Markus Thielker
410d96a8b8
N-FIN-47: introduce reusable server action trigger component 2024-03-17 17:19:05 +01:00
Markus Thielker
c64c5fed35
N-FIN-47: enhance the action response interface
The interface got a new property called data which can be typed using generics.
2024-03-17 17:17:57 +01:00
Markus Thielker
bdbb80121a
N-FIN-54: fix category selection can not be cleared (#56)
Fixes #54
2024-03-17 15:36:57 +01:00
Markus Thielker
0e0e152a6e
N-FIN-54: fix category selection can not be cleared 2024-03-17 15:36:34 +01:00
Markus Thielker
9796962a24
N-FIN-51: add button to clear auto complete input (#55)
Resolves #51
2024-03-17 15:34:43 +01:00
Markus Thielker
d638196b31
N-FIN-51: add button to clear auto complete input 2024-03-17 15:33:58 +01:00
Markus Thielker
1b0c5bf6e1
N-FIN-42: add default category to entity (#53)
Resolves #42
2024-03-17 12:52:44 +01:00
Markus Thielker
3c19d8b639
N-FIN-42: set default category when payee is selected 2024-03-17 12:52:12 +01:00
Markus Thielker
715ce17e1f
N-FIN-42: add default category selection to form 2024-03-17 12:15:28 +01:00
Markus Thielker
781de28b6a
N-FIN-49: show clear column if no category selected (#52)
Fixes #49
2024-03-17 12:07:26 +01:00
Markus Thielker
d5c4f02871
N-FIN-49: show clear column if no category selected 2024-03-17 12:04:38 +01:00
Markus Thielker
50b8ba399d
N-FIN-48: fix sign out button layout (#50)
Fixes #48
2024-03-17 12:01:56 +01:00
Markus Thielker
47c094ba72
N-FIN-48: fix sign out button layout 2024-03-17 12:01:29 +01:00
Markus Thielker
228aa983e0
N-FIN-42: show default category on entity page 2024-03-17 12:00:32 +01:00
Markus Thielker
5556901115
N-FIN-42: add default category column to entity 2024-03-17 11:31:17 +01:00
Markus Thielker
74bb618706
Release v1.1.0 (#41) 2024-03-17 01:32:44 +01:00
Markus Thielker
b34f5e7f3f
Release v1.1.0 (#40) 2024-03-17 01:31:06 +01:00
Markus Thielker
86b47db26f
N-FIN-v1.1.0: update package.json 2024-03-17 01:28:02 +01:00
Markus Thielker
62d6290cf0
N-FIN-33: dialogs become drawer on small screens (#39)
Resolves #33
2024-03-17 01:25:30 +01:00
Markus Thielker
34a76cf93b
N-FIN-33: fix hook accessing matchMedia while undefined 2024-03-17 01:23:21 +01:00
Markus Thielker
f0ee68beb2
N-FIN-33: fix button texts 2024-03-17 01:22:58 +01:00
Markus Thielker
ebf174e9a2
N-FIN-33: move forms to drawers on mobile devices 2024-03-17 00:42:19 +01:00
Markus Thielker
206ad0c528
N-FIN-33: create useMediaQuery hook 2024-03-17 00:42:01 +01:00
Markus Thielker
a6c9074490
N-FIN-31: add min column width (#38)
Resolves #31
2024-03-17 00:08:35 +01:00
Markus Thielker
b064448119
N-FIN-31: fix card and button alignment 2024-03-17 00:08:00 +01:00
Markus Thielker
e60561ffc0
N-FIN-31: set column sizes to definition 2024-03-16 23:56:17 +01:00
Markus Thielker
557d898981
N-FIN-31: apply column size to data table 2024-03-16 23:56:03 +01:00
Markus Thielker
36817e581b
N-FIN-31: removed unused variables 2024-03-16 23:52:07 +01:00
Markus Thielker
a90eb64301
N-FIN-34: navigation becomes drawer on small screens (#37)
Resolves #34
2024-03-16 21:37:26 +01:00
Markus Thielker
7ebc05c257
N-FIN-34: add space between title and dashboard scope selection 2024-03-16 21:36:31 +01:00
Markus Thielker
36563db77e
N-FIN-34: add new navigation variant for mobile devices 2024-03-16 21:35:13 +01:00
Markus Thielker
698d416314
N-FIN-34: add required shadcn/ui components 2024-03-16 21:33:55 +01:00
Markus Thielker
c40834f92b
N-FIN-30: rework auto complete input (#36)
Resolves #30
2024-03-16 20:40:44 +01:00
Markus Thielker
75c1b82eff
N-FIN-30: apply new component to payment form 2024-03-16 20:39:10 +01:00
Markus Thielker
3454c8a03a
N-FIN-30: add new auto-complete component 2024-03-16 20:39:01 +01:00
Markus Thielker
586182e69b
N-FIN-30: set currency input mode to 'numeric' 2024-03-16 20:02:03 +01:00
Markus Thielker
fd31d58186
N-FIN-32: move global padding to root layout (#35) 2024-03-13 22:52:26 +01:00
Markus Thielker
81c41567bc
N-FIN-32: reduce padding on mobile devices 2024-03-13 22:51:53 +01:00
Markus Thielker
87ffc64995
N-FIN-32: move padding to global layout 2024-03-13 22:49:05 +01:00
Markus Thielker
de13aa5625
Release v1.0.1 (#29) 2024-03-11 12:16:14 +01:00
Markus Thielker
6ea3d90fbb
Release v1.0.1 (#28) 2024-03-11 12:15:58 +01:00
Markus Thielker
897cf58d2a
N-FIN-v1.0.1: update package.json 2024-03-11 12:10:58 +01:00
Markus Thielker
d04b83f707
N-FIN-23: show app version on account page (#27)
Resolves #23
2024-03-11 12:08:34 +01:00
Markus Thielker
c64f28a632
N-FIN-23: show app version on account page 2024-03-11 12:07:36 +01:00
Markus Thielker
6a94e4ecd8
N-FIN-21: show color in front of category name (#26)
Fixes #21
2024-03-11 11:28:39 +01:00
Markus Thielker
2f03dd8b01
N-FIN-21: show color in front of category name 2024-03-11 11:27:55 +01:00
Markus Thielker
2540865e62
N-FIN-20: add fixed width on actions column (#25)
Fixes #20
2024-03-11 11:24:35 +01:00
Markus Thielker
6dd2fde2b5
N-FIN-20: add fixed width on actions column 2024-03-11 11:22:18 +01:00
Markus Thielker
2d578df9eb
N-FIN-19: add page size drop down (#24)
Fixes #19
2024-03-11 10:28:22 +01:00
Markus Thielker
7660f1037b
N-FIN-19: add page size drop down
The default page size is set to 50
2024-03-11 10:27:52 +01:00
Markus Thielker
cbae95b733
N-FIN-18: remove environment condition (#22)
Fixes #18
2024-03-11 09:52:48 +01:00
Markus Thielker
986db250cf
N-FIN-18: remove environment condition 2024-03-11 09:52:06 +01:00
Markus Thielker
48384b7687
Release v1.0.0 (#17) 2024-03-11 06:21:14 +01:00
77 changed files with 2814 additions and 7537 deletions

14
.env.example Normal file
View file

@ -0,0 +1,14 @@
#
# This environment file sets variables for the development
# Database specifics are set in the docker-compose file and the ORIGIN is not required for local development
#
# prisma database url
DATABASE_URL='postgresql://prisma:prisma@localhost:5432/finances?schema=public'
AUTH0_SECRET=''
AUTH0_BASE_URL='http://localhost:3000'
AUTH0_URL=''
AUTH0_CLIENT_ID=''
AUTH0_CLIENT_SECRET=''

View file

@ -0,0 +1,34 @@
name: Development Deployment
on:
workflow_dispatch:
inputs:
image_tag:
required: true
type: string
description: Docker image tag
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v5
with:
push: true
platforms: linux/amd64
tags: markusthielker/next-finances:development, markusthielker/next-finances:${{ github.event.inputs.image_tag }}-dev

View file

@ -1,4 +1,4 @@
name: Docker Image Build and Push
name: Production Deployment
on:
push:

3
.gitignore vendored
View file

@ -38,3 +38,6 @@ next-env.d.ts
# IDE
/.idea
# serwist
public/sw.js

View file

@ -1,4 +1,4 @@
FROM node:21-alpine AS base
FROM oven/bun:1-alpine AS base
# Install dependencies only when needed
FROM base AS deps
@ -6,8 +6,8 @@ FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY package.json bun.lockb* ./
RUN bun install
# Rebuild the source code only when needed
@ -18,23 +18,23 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . .
# dependencies have to be changed depending on target architecture
RUN npm i @node-rs/argon2-linux-x64-musl # arm64 = @node-rs/argon2-linux-arm64-musl
RUN npm i @node-rs/bcrypt-linux-x64-musl # arm64 = @node-rs/bcrypt-linux-arm64-musl
RUN bun install @node-rs/argon2-linux-x64-musl # arm64 = @node-rs/argon2-linux-arm64-musl
RUN bun install @node-rs/bcrypt-linux-x64-musl # arm64 = @node-rs/bcrypt-linux-arm64-musl
COPY prisma/ ./prisma/
RUN npx prisma generate
RUN bunx prisma generate
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build
ENV NEXT_TELEMETRY_DISABLED=1
RUN bun run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
@ -52,7 +52,7 @@ USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View file

@ -1,3 +1,75 @@
# Next-Finances
This is my simple finances tracker that I use to keep track of my spending.
## Using the app
### Understanding the Basics
- **Entities**: The core building blocks of your finances.
- Accounts: Where you hold money (e.g., bank accounts, PayPal account, cash)
- Entities: Where you spend money (e.g., Walmart, Spotify, Netflix)
- **Payments**: Record money movement.
- Expenses: Money leaving an Account. (Account -> Entity)
- Income: Money entering an Account. (Entity -> Account)
- **Categories** *(optional)*: Add labels to Payments for better tracking.
### Your First Steps
- Set up: Create Entities and Accounts that reflect your finances.
- Record a Payment:
- Enter the amount and date.
- Select payor and payee
- *(optional)* Assign a category or enter a note.
- Explore: View your payment history and view your statics at the dashboard
### Tips
- Install the website as a PWA for easy access.
- Get in the habit of recording Payments as they happen for accurate tracking.
- Use categories to understand your spending patterns.
## Development
Clone this repository and run the following commands:
```bash
## create .env file
cp .env.example .env
## start the database
docker compose -f docker/finances-dev/docker-compose.yml up -d
## generate prisma client
bunx prisma generate
## apply database migrations
bunx prisma migrate deploy
## start the development server
bun run dev
```
This project relies on [Auth0](https://auth0.com) authentication. To use it you will have to create an Auth0 account,
create an application of type 'Single Page Application' and now add the required details to your .env file.
You will also have to add `http://localhost:3000/auth/callback` as an `Allowed Callback URL` and `http://localhost:3000`
as an `Allowed Logout URL` and `Allowed Web Origins` in your Auth0 console.
Now open [http://localhost:3000](http://localhost:3000) with your browser and create an account.
While in development mode, you can generate sample data from the [Account page](http://localhost:3000/account).
## Deployment
Copy the [docker-compose.yaml](./docker/finances-prod/docker-compose.yaml) file and
the [.env.example](./docker/finances-prod/.env.example) from 'docker/finances-prod' to your server.
Rename the `.env.example` file to `.env` and adjust the required environment variables.
The docker setup expects you to run a Traefik reverse proxy. It will then register itself automatically.
If your setup is different, you will need to adjust the `docker-compose.yaml` file accordingly.
The finances containers will automatically register themselves to a running watchtower container if it is present.
Finally run `docker-compose up -d` on your server to start the application.

1203
bun.lock Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
# database configuration
DB_USER="db_user"
DB_PASSWORD="db_password"
# prisma database url
DATABASE_URL="postgresql://$DB_USER:$DB_PASSWORD@postgres:5432/finances?schema=public"
APPLICATION_DOMAIN="finances.thielker.xyz"
COOKIE_DOMAIN="$APPLICATION_DOMAIN"
ORIGIN="https://$APPLICATION_DOMAIN"

View file

@ -8,7 +8,7 @@ services:
depends_on:
postgres:
condition: service_healthy
command: npx prisma migrate deploy
command: bunx prisma migrate deploy
labels:
- "com.centurylinklabs.watchtower.enable=true"
networks:
@ -25,7 +25,7 @@ services:
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.xyz-next-finances.rule=Host(`finances.thielker.xyz`)"
- "traefik.http.routers.xyz-next-finances.rule=Host(`${APPLICATION_DOMAIN}`)"
- "traefik.http.routers.xyz-next-finances.entrypoints=web, websecure"
- "traefik.http.routers.xyz-next-finances.tls=true"
- "traefik.http.routers.xyz-next-finances.tls.certresolver=lets-encrypt"
@ -42,11 +42,11 @@ services:
depends_on:
app-migrations:
condition: service_completed_successfully
command: npx prisma studio
command: bunx prisma studio
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.xyz-next-finances-studio.rule=Host(`studio.finances.thielker.xyz`)"
- "traefik.http.routers.xyz-next-finances-studio.rule=Host(`studio.${APPLICATION_DOMAIN}`)"
- "traefik.http.routers.xyz-next-finances-studio.entrypoints=web, websecure"
- "traefik.http.services.xyz-next-finances-studio.loadbalancer.server.port=5555"
- "traefik.http.routers.xyz-next-finances-studio.tls=true"

View file

@ -0,0 +1,13 @@
[entryPoints]
[entryPoints.web]
address = ":80"
# [entryPoints.web.http.redirections.entryPoint]
# to = "websecure"
# scheme = "https"
[entryPoints.websecure]
address = ":443"
[providers.docker]
watch = true
network = "web"

View file

@ -0,0 +1,13 @@
#
# run this container on your server to use traefik as a reverse proxy
#
docker run -d \
-v /var/run/docker.sock:/var/run/docker.sock \
-v $PWD/traefik.toml:/traefik.toml \
-p 80:80 \
-p 443:443 \
--restart unless-stopped \
--network web \
--name traefik \
traefik:v2.10

View file

@ -0,0 +1,13 @@
#
# run this container on your server to keep the labeled containers up to date
#
# run 'docker login' to authenticate with your docker hub account
# label your containers with 'com.centurylinklabs.watchtower.enable=true' to enable watchtower
#
docker run -d \
--name watchtower \
--restart unless-stopped \
-v $HOME/.docker/config.json:/config.json \
-v /var/run/docker.sock:/var/run/docker.sock \
containrrr/watchtower -s "*/30 * * * * *" --label-enable --include-stopped

View file

@ -1,5 +1,11 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
import withSerwistInit from '@serwist/next';
const withSerwist = withSerwistInit({
swSrc: 'src/app/service-worker.ts',
swDest: 'public/sw.js',
});
export default withSerwist({
webpack: (config) => {
config.externals.push(
'@node-rs/argon2',
@ -8,6 +14,7 @@ const nextConfig = {
return config;
},
output: 'standalone',
};
export default nextConfig;
env: {
appVersion: process.env.npm_package_version,
},
});

6416
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -2,8 +2,7 @@
"name": "next-finances",
"description": "A finances application to keep track of my personal spendings",
"homepage": "https://github.com/MarkusThielker/next-finances",
"version": "1.0.0",
"license": "MIT",
"version": "1.3.1",
"author": {
"name": "Markus Thielker"
},
@ -19,50 +18,53 @@
"lint": "next lint"
},
"dependencies": {
"@hookform/resolvers": "^3.3.4",
"@lucia-auth/adapter-prisma": "^4.0.0",
"@prisma/client": "^5.10.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-navigation-menu": "^1.1.4",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.0.2",
"@tanstack/react-table": "^8.13.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"cmdk": "^1.0.0",
"date-fns": "^3.3.1",
"lucia": "^3.0.1",
"lucide-react": "^0.350.0",
"next": "14.1.3",
"next-themes": "^0.2.1",
"oslo": "^1.1.3",
"react": "^18",
"react-day-picker": "^8.10.0",
"react-dom": "^18",
"react-hook-form": "^7.51.0",
"sonner": "^1.4.3",
"swr": "^2.2.5",
"tailwind-merge": "^2.2.1",
"@auth0/nextjs-auth0": "^4.1.0",
"@hookform/resolvers": "^3.9.1",
"@prisma/client": "^6.1.0",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-navigation-menu": "^1.2.3",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.2",
"@serwist/next": "^9.0.11",
"@serwist/precaching": "^9.0.11",
"@serwist/sw": "^9.0.11",
"@tanstack/react-table": "^8.20.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
"date-fns": "^4.1.0",
"lucia": "^3.2.2",
"lucide-react": "^0.469.0",
"next": "15.1.2",
"next-themes": "^0.4.4",
"react": "^19.0.0",
"react-day-picker": "8.10.1",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"sonner": "^1.7.1",
"swr": "^2.3.0",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.22.4"
"vaul": "^1.1.2",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/node": "^20.11.25",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"autoprefixer": "^10.4.20",
"eslint": "^8",
"eslint-config-next": "14.1.3",
"eslint-config-next": "15.1.2",
"postcss": "^8",
"prisma": "^5.10.2",
"tailwindcss": "^3.3.0",
"prisma": "^6.1.0",
"tailwindcss": "^3.4.17",
"ts-node": "^10.9.2",
"typescript": "^5.4.2"
"typescript": "^5.7.2"
}
}

View file

@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE "entities"
ADD COLUMN "default_category_id" INTEGER;
-- AddForeignKey
ALTER TABLE "entities"
ADD CONSTRAINT "entities_default_category_id_fkey" FOREIGN KEY ("default_category_id") REFERENCES "categories" ("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -0,0 +1,28 @@
/*
Warnings:
- You are about to drop the `lucia_session` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `lucia_user` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "categories"
DROP CONSTRAINT "categories_user_id_fkey";
-- DropForeignKey
ALTER TABLE "entities"
DROP CONSTRAINT "entities_user_id_fkey";
-- DropForeignKey
ALTER TABLE "lucia_session"
DROP CONSTRAINT "lucia_session_userId_fkey";
-- DropForeignKey
ALTER TABLE "payments"
DROP CONSTRAINT "payments_user_id_fkey";
-- DropTable
DROP TABLE "lucia_session";
-- DropTable
DROP TABLE "lucia_user";

View file

@ -7,38 +7,13 @@ datasource db {
url = env("DATABASE_URL")
}
model User {
// lucia internal fields
id String @id
sessions Session[]
// custom fields
username String @unique
password String
entities Entity[]
payments Payment[]
categories Category[]
@@map("lucia_user")
}
model Session {
// lucia internal fields
id String @id
userId String
expiresAt DateTime
user User @relation(references: [id], fields: [userId], onDelete: Cascade)
@@map("lucia_session")
}
model Entity {
id Int @id @default(autoincrement())
userId String @map("user_id")
user User @relation(fields: [userId], references: [id])
name String
type EntityType
defaultCategory Category? @relation(fields: [defaultCategoryId], references: [id])
defaultCategoryId Int? @map("default_category_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@ -57,7 +32,6 @@ enum EntityType {
model Payment {
id Int @id @default(autoincrement())
userId String @map("user_id")
user User @relation(fields: [userId], references: [id])
amount Int
currency String @default("EUR")
date DateTime @default(now())
@ -77,13 +51,13 @@ model Payment {
model Category {
id Int @id @default(autoincrement())
userId String @map("user_id")
user User @relation(fields: [userId], references: [id])
name String
color String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
payments Payment[]
Entity Entity[]
@@unique(fields: [userId, name])
@@map("categories")

15
public/logo_t_hq_o.svg Normal file
View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns:serif="http://www.serif.com/" width="100%" height="100%" viewBox="0 0 2250 2250"
version="1.1" xmlns="http://www.w3.org/2000/svg" xml:space="preserve"
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><g id="frame"><rect id="tl" x="0" y="0" width="125" height="520.833" style="fill:#f50;"/><rect
id="t" x="0" y="-0" width="2250" height="125" style="fill:#f50;"/><rect id="tr" x="2125" y="0" width="125"
height="520.833" style="fill:#f50;"/><rect
id="bl" x="0" y="1729.17" width="125" height="520.833" style="fill:#f50;"/><rect id="b" x="0" y="2125"
width="2250" height="125"
style="fill:#f50;"/><rect
id="br" x="2125" y="1729.17" width="125" height="520.833" style="fill:#f50;"/></g>
<g id="text"><g id="text1" serif:id="text" transform="matrix(1,0,0,0.947314,7.10543e-15,-3.78368)"><g transform="matrix(1636.19,0,0,1636.19,2149.91,1766.12)"></g><text
x="-57.321px" y="1766.12px" style="font-family:'Akshar-Regular', 'Akshar';font-size:1636.19px;fill:#f50;">t<tspan
x="513.711px 891.672px 1598.51px " y="1766.12px 1766.12px 1766.12px ">lkr</tspan></text></g>
<circle id="dot" cx="2156.78" cy="1578.59" r="88.542" style="fill:#f50;"/></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

15
public/logo_t_hq_w.svg Normal file
View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns:serif="http://www.serif.com/" width="100%" height="100%" viewBox="0 0 2250 2250"
version="1.1" xmlns="http://www.w3.org/2000/svg" xml:space="preserve"
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><g id="frame"><rect id="tl" x="0" y="0" width="125" height="520.833" style="fill:#fff;"/><rect
id="t" x="0" y="-0" width="2250" height="125" style="fill:#fff;"/><rect id="tr" x="2125" y="0" width="125"
height="520.833" style="fill:#fff;"/><rect
id="bl" x="0" y="1729.17" width="125" height="520.833" style="fill:#fff;"/><rect id="b" x="0" y="2125"
width="2250" height="125"
style="fill:#fff;"/><rect
id="br" x="2125" y="1729.17" width="125" height="520.833" style="fill:#fff;"/></g>
<g id="text"><g id="text1" serif:id="text" transform="matrix(1,0,0,0.947314,7.10543e-15,-3.78368)"><g transform="matrix(1636.19,0,0,1636.19,2149.91,1766.12)"></g><text
x="-57.321px" y="1766.12px" style="font-family:'Akshar-Regular', 'Akshar';font-size:1636.19px;fill:#fff;">t<tspan
x="513.711px 891.672px 1598.51px " y="1766.12px 1766.12px 1766.12px ">lkr</tspan></text></g>
<circle id="dot" cx="2156.78" cy="1578.59" r="88.542" style="fill:#fff;"/></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

16
public/manifest.json Normal file
View file

@ -0,0 +1,16 @@
{
"name": "Finances",
"short_name": "Finances",
"icons": [
{
"src": "/logo_t_hq_o.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#0B0908",
"background_color": "#0B0908",
"start_url": "/",
"display": "standalone",
"orientation": "portrait"
}

View file

@ -1,51 +1,50 @@
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import React from 'react';
import { getUser } from '@/auth';
import { redirect } from 'next/navigation';
import signOut from '@/lib/actions/signOut';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import SignOutForm from '@/components/form/signOutForm';
import { URL_SIGN_IN } from '@/lib/constants';
import GenerateSampleDataForm from '@/components/form/generateSampleDataForm';
import generateSampleData from '@/lib/actions/generateSampleData';
import { prismaClient } from '@/prisma';
import prisma from '@/prisma';
import { ServerActionTrigger } from '@/components/form/serverActionTrigger';
import clearAccountData from '@/lib/actions/clearAccountData';
import { Button } from '@/components/ui/button';
import { URL_SIGN_OUT } from '@/lib/constants';
import { auth0 } from '@/lib/auth';
import { redirect } from 'next/navigation';
export default async function AccountPage() {
const user = await getUser();
if (!user) {
redirect(URL_SIGN_IN);
const session = await auth0.getSession();
if (!session) {
return redirect('/auth/login');
}
const user = session.user;
let paymentCount = 0;
let entityCount = 0;
let categoryCount = 0;
paymentCount = await prisma.payment.count({
where: {
userId: user.sub,
},
});
if (process.env.NODE_ENV === 'development') {
paymentCount = await prismaClient.payment.count({
let entityCount = 0;
entityCount = await prisma.entity.count({
where: {
userId: user.id,
userId: user.sub,
},
});
entityCount = await prismaClient.entity.count({
let categoryCount = 0;
categoryCount = await prisma.category.count({
where: {
userId: user.id,
userId: user.sub,
},
});
categoryCount = await prismaClient.category.count({
where: {
userId: user.id,
},
});
}
return (
<div className="flex flex-col items-center">
<Card className="w-full max-w-md mt-12">
<Card className="w-full max-w-md md:mt-12">
<CardHeader>
<CardTitle>Hey, {user?.username}!</CardTitle>
<CardTitle>Hey, {user.name}!</CardTitle>
<CardDescription>This is your account overview.</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
@ -53,13 +52,13 @@ export default async function AccountPage() {
<Label>ID</Label>
<Input
disabled
value={user?.id}/>
value={user.sub}/>
</div>
<div>
<Label>Username</Label>
<Input
disabled
value={user?.username}/>
value={user.name}/>
</div>
<div className="flex flex-row items-center space-x-4">
<div>
@ -82,15 +81,52 @@ export default async function AccountPage() {
</div>
</div>
</CardContent>
<CardFooter className="space-x-4">
<CardFooter className="w-full grid gap-4 grid-cols-1 md:grid-cols-2">
<ServerActionTrigger
action={clearAccountData}
dialog={{
title: 'Clear account data',
description: 'Are you sure you want to delete all payments, entities and categories from you account? This action is irreversible.',
actionText: 'Clear data',
actionVariant: 'destructive',
}}
variant="outline">
Clear data
</ServerActionTrigger>
<a href={URL_SIGN_OUT}>
<Button className="w-full">
Sign Out
</Button>
</a>
{
process.env.NODE_ENV === 'development' && (
<GenerateSampleDataForm onSubmit={generateSampleData}/>
<ServerActionTrigger
variant="outline"
className="col-span-2"
action={generateSampleData}>
Generate sample data
</ServerActionTrigger>
)
}
<SignOutForm onSubmit={signOut}/>
</CardFooter>
</Card>
<div className="flex w-full items-center justify-between max-w-md mt-2 text-neutral-600">
<p>Version {process.env.appVersion}</p>
<div className="flex items-center justify-between space-x-4">
<a
target="_blank"
className="hover:text-neutral-500 duration-100"
href="https://github.com/MarkusThielker/next-finances">
Source Code
</a>
<a
target="_blank"
className="hover:text-neutral-500 duration-100"
href="https://github.com/MarkusThielker/next-finances/releases">
Changelog
</a>
</div>
</div>
</div>
);
}

View file

@ -1,13 +0,0 @@
import React from 'react';
export default function AuthLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<div className="flex justify-center">
{children}
</div>
);
}

View file

@ -1,25 +0,0 @@
import React from 'react';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import SignInForm from '@/components/form/signInForm';
import signIn from '@/lib/actions/signIn';
import Link from 'next/link';
import { URL_SIGN_UP } from '@/lib/constants';
export default async function SignInPage() {
return (
<Card className="w-full max-w-md mt-12">
<CardHeader>
<CardTitle>Sign in</CardTitle>
<CardDescription>Sign into your existing account</CardDescription>
</CardHeader>
<CardContent>
<SignInForm onSubmit={signIn}/>
</CardContent>
<CardFooter>
<Link href={URL_SIGN_UP}>
Don&apos;t have an account? Sign up
</Link>
</CardFooter>
</Card>
);
}

View file

@ -1,25 +0,0 @@
import React from 'react';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import signUp from '@/lib/actions/signUp';
import SignUpForm from '@/components/form/signUpForm';
import Link from 'next/link';
import { URL_SIGN_IN } from '@/lib/constants';
export default async function SignUpPage() {
return (
<Card className="w-full max-w-md mt-12">
<CardHeader>
<CardTitle>Sign up</CardTitle>
<CardDescription>Create a new account.</CardDescription>
</CardHeader>
<CardContent>
<SignUpForm onSubmit={signUp}/>
</CardContent>
<CardFooter>
<Link href={URL_SIGN_IN}>
Already have an account? Sign in
</Link>
</CardFooter>
</Card>
);
}

View file

@ -25,6 +25,7 @@ export const columns = (
</svg>
);
},
size: 65,
},
{
accessorKey: 'createdAt',

View file

@ -1,15 +1,20 @@
import { getUser } from '@/auth';
import { prismaClient } from '@/prisma';
import prisma from '@/prisma';
import React from 'react';
import CategoryPageClientContent from '@/components/categoryPageClientComponents';
import categoryCreateUpdate from '@/lib/actions/categoryCreateUpdate';
import categoryDelete from '@/lib/actions/categoryDelete';
import { auth0 } from '@/lib/auth';
import { redirect } from 'next/navigation';
export default async function CategoriesPage() {
const user = await getUser();
const session = await auth0.getSession();
if (!session) {
return redirect('/auth/login');
}
const user = session.user;
const categories = await prismaClient.category.findMany({
const categories = await prisma.category.findMany({
where: {
userId: user?.id,
},
@ -25,6 +30,6 @@ export default async function CategoriesPage() {
categories={categories}
onSubmit={categoryCreateUpdate}
onDelete={categoryDelete}
className="flex flex-col justify-center space-y-4 p-10"/>
className="flex flex-col justify-center space-y-4"/>
);
}

View file

@ -1,12 +1,13 @@
'use client';
import { ColumnDef } from '@tanstack/react-table';
import { Entity } from '@prisma/client';
import { Category, Entity } from '@prisma/client';
import { CellContext, ColumnDefTemplate } from '@tanstack/table-core';
import { format } from 'date-fns';
export const columns = (
actionCell: ColumnDefTemplate<CellContext<Entity, unknown>>,
categories: Category[],
) => {
return [
@ -17,6 +18,31 @@ export const columns = (
{
accessorKey: 'type',
header: 'Type',
size: 100,
},
{
accessorKey: 'defaultCategoryId',
header: 'Default Category',
cell: ({row}) => {
const category = categories.find((category) => category.id === row.original.defaultCategoryId);
return (
<>
{
category && (
<div className="flex items-center space-x-4">
<svg className="h-5" fill={category?.color} viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="10"/>
</svg>
<p>{category?.name ?? '-'}</p>
</div>
)
}
</>
);
},
size: 200,
},
{
accessorKey: 'createdAt',

View file

@ -1,15 +1,20 @@
import { prismaClient } from '@/prisma';
import { getUser } from '@/auth';
import prisma from '@/prisma';
import React from 'react';
import EntityPageClientContent from '@/components/entityPageClientComponents';
import entityCreateUpdate from '@/lib/actions/entityCreateUpdate';
import entityDelete from '@/lib/actions/entityDelete';
import { auth0 } from '@/lib/auth';
import { redirect } from 'next/navigation';
export default async function EntitiesPage() {
const user = await getUser();
const session = await auth0.getSession();
if (!session) {
return redirect('/auth/login');
}
const user = session.user;
const entities = await prismaClient.entity.findMany({
const entities = await prisma.entity.findMany({
where: {
userId: user?.id,
},
@ -23,11 +28,26 @@ export default async function EntitiesPage() {
],
});
const categories = await prisma.category.findMany({
where: {
userId: user?.id,
},
orderBy: [
{
name: 'asc',
},
{
id: 'asc',
},
],
});
return (
<EntityPageClientContent
entities={entities}
categories={categories}
onSubmit={entityCreateUpdate}
onDelete={entityDelete}
className="flex flex-col justify-center space-y-4 p-10"/>
className="flex flex-col justify-center space-y-4"/>
);
}

View file

@ -1,4 +1,4 @@
import type { Metadata } from 'next';
import type { Viewport } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { cn } from '@/lib/utils';
@ -8,9 +8,31 @@ import Navigation from '@/components/navigation';
const inter = Inter({subsets: ['latin']});
export const metadata: Metadata = {
title: 'Finances',
description: 'Track your finances with ease',
const APP_NAME = 'Finances';
const APP_DEFAULT_TITLE = 'Finances';
const APP_TITLE_TEMPLATE = `%s | ${APP_DEFAULT_TITLE}`;
const APP_DESCRIPTION = 'Track your finances with ease';
export const metadata = {
applicationName: APP_NAME,
title: {
default: APP_DEFAULT_TITLE,
template: APP_TITLE_TEMPLATE,
},
description: APP_DESCRIPTION,
appleWebApp: {
capable: true,
statusBarStyle: 'default',
title: APP_DEFAULT_TITLE,
},
formatDetection: {
telephone: false,
},
};
export const viewport: Viewport = {
themeColor: '#0B0908',
width: 'device-width',
};
export default function RootLayout({
@ -20,12 +42,19 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<head>
<link crossOrigin="use-credentials" rel="manifest" href="/manifest.json"/>
<link
rel="icon"
href="/logo_t_hq_o.svg"
/>
</head>
<body className={cn('dark', inter.className)}>
<Navigation/>
<main>
<main className="p-4 sm:p-8">
{children}
<Toaster/>
</main>
<Toaster/>
</body>
</html>
);

View file

@ -1,9 +1,10 @@
import React from 'react';
import { Category, Entity, EntityType } from '@prisma/client';
import { Scope, ScopeType } from '@/lib/types/scope';
import { prismaClient } from '@/prisma';
import { getUser } from '@/auth';
import prisma from '@/prisma';
import DashboardPageClient from '@/components/dashboardPageClientComponents';
import { auth0 } from '@/lib/auth';
import { redirect } from 'next/navigation';
export type CategoryNumber = {
category: Category,
@ -15,19 +16,20 @@ export type EntityNumber = {
value: number,
}
export default async function DashboardPage(props: { searchParams?: { scope: ScopeType } }) {
export default async function DashboardPage(props: { searchParams?: Promise<{ scope: ScopeType }> }) {
const user = await getUser();
if (!user) {
return;
const session = await auth0.getSession();
if (!session) {
return redirect('/auth/login');
}
const user = session.user;
const scope = Scope.of(props.searchParams?.scope || ScopeType.ThisMonth);
const scope = Scope.of((await props.searchParams)?.scope || ScopeType.ThisMonth);
// get all payments in the current scope
const payments = await prismaClient.payment.findMany({
const payments = await prisma.payment.findMany({
where: {
userId: user?.id,
userId: user.sub,
date: {
gte: scope.start,
lte: scope.end,
@ -108,6 +110,7 @@ export default async function DashboardPage(props: { searchParams?: { scope: Sco
userId: '',
name: 'Other',
type: EntityType.Entity,
defaultCategoryId: null,
createdAt: new Date(),
updatedAt: new Date(),
},
@ -200,7 +203,7 @@ export default async function DashboardPage(props: { searchParams?: { scope: Sco
categoryPercentages={categoryPercentages}
entityExpenses={entityExpensesFormat}
entityPercentages={entityPercentages}
className="flex flex-col justify-center space-y-4 p-10"
className="flex flex-col justify-center space-y-4"
/>
);
}

View file

@ -18,6 +18,7 @@ export const columns = (
cell: ({row}) => {
return format(row.original.date, 'PPP');
},
size: 175,
},
{
accessorKey: 'amount',
@ -28,6 +29,7 @@ export const columns = (
currency: 'EUR',
}).format(row.getValue('amount') as number / 100);
},
size: 70,
},
{
accessorKey: 'payorId',
@ -36,6 +38,7 @@ export const columns = (
const entity = entities.find((entity) => entity.id === row.original.payorId);
return entity?.name ?? '-';
},
size: 200,
},
{
accessorKey: 'payeeId',
@ -44,18 +47,35 @@ export const columns = (
const entity = entities.find((entity) => entity.id === row.original.payeeId);
return entity?.name ?? '-';
},
size: 200,
},
{
accessorKey: 'categoryId',
header: 'Category',
cell: ({row}) => {
const category = categories.find((category) => category.id === row.original.categoryId);
return category?.name ?? '-';
return (
<>
{
category && (
<div className="flex items-center space-x-4">
<svg className="h-5" fill={category?.color} viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="10"/>
</svg>
<p>{category?.name ?? '-'}</p>
</div>
)
}
</>
);
},
size: 200,
},
{
accessorKey: 'note',
header: 'Note',
size: 200,
},
{
id: 'actions',

View file

@ -1,17 +1,22 @@
import { getUser } from '@/auth';
import { prismaClient } from '@/prisma';
import prisma from '@/prisma';
import React from 'react';
import PaymentPageClientContent from '@/components/paymentPageClientComponents';
import paymentCreateUpdate from '@/lib/actions/paymentCreateUpdate';
import paymentDelete from '@/lib/actions/paymentDelete';
import { auth0 } from '@/lib/auth';
import { redirect } from 'next/navigation';
export default async function PaymentsPage() {
const user = await getUser();
const session = await auth0.getSession();
if (!session) {
return redirect('/auth/login');
}
const user = session.user;
const payments = await prismaClient.payment.findMany({
const payments = await prisma.payment.findMany({
where: {
userId: user?.id,
userId: user.sub,
},
orderBy: [
{
@ -23,9 +28,9 @@ export default async function PaymentsPage() {
],
});
const entities = await prismaClient.entity.findMany({
const entities = await prisma.entity.findMany({
where: {
userId: user?.id,
userId: user.sub,
},
orderBy: [
{
@ -37,9 +42,9 @@ export default async function PaymentsPage() {
],
});
const categories = await prismaClient.category.findMany({
const categories = await prisma.category.findMany({
where: {
userId: user?.id,
userId: user.sub,
},
orderBy: [
{
@ -58,6 +63,6 @@ export default async function PaymentsPage() {
categories={categories}
onSubmit={paymentCreateUpdate}
onDelete={paymentDelete}
className="flex flex-col justify-center space-y-4 p-10"/>
className="flex flex-col justify-center space-y-4"/>
);
}

46
src/app/service-worker.ts Normal file
View file

@ -0,0 +1,46 @@
import type { PrecacheEntry } from '@serwist/precaching';
import { defaultCache } from '@serwist/next/worker';
import { Serwist, SerwistGlobalConfig } from 'serwist';
declare const self: ServiceWorkerGlobalScope & {
__SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
};
declare global {
interface WorkerGlobalScope extends SerwistGlobalConfig {
// Change this attribute's name to your \`injectionPoint\`.
// \`injectionPoint\` is an InjectManifest option.
// See https://serwist.pages.dev/docs/build/configuring
__SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
}
}
const serwist = new Serwist({
// A list of URLs that should be cached. Usually, you don't generate
// this list yourself; rather, you'd rely on a Serwist build tool/your framework
// to do it for you. In this example, it is generated by \`@serwist/vite\`.
precacheEntries: self.__SW_MANIFEST,
// Options to customize how Serwist precaches the URLs.
precacheOptions: {
// Whether outdated caches should be removed.
cleanupOutdatedCaches: true,
concurrency: 10,
ignoreURLParametersMatching: [],
},
// Whether the service worker should skip waiting and become the active one.
skipWaiting: true,
// Whether the service worker should claim any currently available clients.
clientsClaim: true,
// Whether navigation preloading should be used.
navigationPreload: true,
// Whether Serwist should log in development mode.
disableDevLogs: true,
// A list of runtime caching entries. When a request is made and its URL match
// any of the entries, the response to it will be cached according to the matching
// entry's \`handler\`. This does not apply to precached URLs.
runtimeCaching: defaultCache,
// Other options...
// See https://serwist.pages.dev/docs/serwist/core/serwist
});
serwist.addEventListeners();

View file

@ -1,67 +0,0 @@
import { Lucia } from 'lucia';
import { PrismaAdapter } from '@lucia-auth/adapter-prisma';
import { cookies } from 'next/headers';
import { prismaClient } from '@/prisma';
const adapter = new PrismaAdapter(prismaClient.session, prismaClient.user);
export const lucia = new Lucia(adapter, {
sessionCookie: {
expires: false,
attributes: {
sameSite: 'strict',
domain: process.env.NODE_ENV === 'production' ? process.env.COOKIE_DOMAIN : undefined,
secure: process.env.NODE_ENV === 'production',
},
},
getUserAttributes: (attributes) => {
return {
username: attributes.username,
};
},
});
declare module 'lucia' {
interface Register {
Lucia: typeof lucia;
DatabaseUserAttributes: DatabaseUserAttributes;
}
}
interface DatabaseUserAttributes {
username: string;
}
export function getSessionId() {
return cookies().get(lucia.sessionCookieName)?.value ?? null;
}
export async function getSession() {
const sessionId = getSessionId();
if (!sessionId) {
return null;
}
const {session} = await lucia.validateSession(sessionId);
return session;
}
export async function getUser() {
const sessionId = getSessionId();
if (!sessionId) {
return null;
}
const {user, session} = await lucia.validateSession(sessionId);
try {
if (session && session.fresh) {
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
}
if (!session) {
const sessionCookie = lucia.createBlankSessionCookie();
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
}
} catch {
// Next.js throws error when attempting to set cookies when rendering page
}
return user;
}

View file

@ -23,6 +23,8 @@ import {
} from '@/components/ui/alert-dialog';
import { categoryFormSchema } from '@/lib/form-schemas/categoryFormSchema';
import CategoryForm from '@/components/form/categoryForm';
import { useMediaQuery } from '@/lib/hooks/useMediaQuery';
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer';
export default function CategoryPageClientContent({categories, onSubmit, onDelete, className}: {
categories: Category[],
@ -31,6 +33,7 @@ export default function CategoryPageClientContent({categories, onSubmit, onDelet
className: string,
}) {
const isDesktop = useMediaQuery('(min-width: 768px)');
const router = useRouter();
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
@ -97,6 +100,8 @@ export default function CategoryPageClientContent({categories, onSubmit, onDelet
<p className="text-3xl font-semibold">Categories</p>
{/* Edit dialog */}
{
isDesktop ? (
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogTrigger asChild>
<Button
@ -117,6 +122,29 @@ export default function CategoryPageClientContent({categories, onSubmit, onDelet
className="flex flex-row space-x-4 py-4"/>
</DialogContent>
</Dialog>
) : (
<Drawer open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DrawerTrigger asChild>
<Button
onClick={() => {
setSelectedCategory(undefined);
setIsEditDialogOpen(true);
}}>
Create Category
</Button>
</DrawerTrigger>
<DrawerContent className="p-4">
<DrawerHeader>
<DrawerTitle>{selectedCategory?.id ? 'Update Category' : 'Create Category'}</DrawerTitle>
</DrawerHeader>
<CategoryForm
value={selectedCategory}
onSubmit={handleSubmit}
className="flex flex-row space-x-4 py-4"/>
</DrawerContent>
</Drawer>
)
}
</div>
{/* Data Table */}

View file

@ -49,7 +49,7 @@ export default function DashboardPageClientContent(
return (
<div className={className}>
<div className="flex flex-col space-y-4">
<div className="flex items-center justify-between w-full">
<div className="flex items-center justify-between w-full space-x-8">
<p className="text-3xl font-semibold">Dashboard</p>

View file

@ -1,6 +1,6 @@
'use client';
import { Entity } from '@prisma/client';
import { Category, Entity } from '@prisma/client';
import React, { useState } from 'react';
import { CellContext } from '@tanstack/table-core';
import { Button } from '@/components/ui/button';
@ -24,14 +24,18 @@ import {
AlertDialogFooter,
AlertDialogHeader,
} from '@/components/ui/alert-dialog';
import { useMediaQuery } from '@/lib/hooks/useMediaQuery';
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer';
export default function EntityPageClientContent({entities, onSubmit, onDelete, className}: {
export default function EntityPageClientContent({entities, categories, onSubmit, onDelete, className}: {
entities: Entity[],
categories: Category[],
onSubmit: (data: z.infer<typeof entityFormSchema>) => Promise<ActionResponse>,
onDelete: (id: number) => Promise<ActionResponse>,
className: string,
}) {
const isDesktop = useMediaQuery('(min-width: 768px)');
const router = useRouter();
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
@ -125,6 +129,8 @@ export default function EntityPageClientContent({entities, onSubmit, onDelete, c
<p className="text-3xl font-semibold">Entities</p>
{/* Edit dialog */}
{
isDesktop ? (
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogTrigger asChild>
<Button
@ -141,10 +147,35 @@ export default function EntityPageClientContent({entities, onSubmit, onDelete, c
</DialogHeader>
<EntityForm
value={selectedEntity}
categories={categories}
onSubmit={handleSubmit}
className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4"/>
</DialogContent>
</Dialog>
) : (
<Drawer open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DrawerTrigger asChild>
<Button
onClick={() => {
setSelectedEntity(undefined);
setIsEditDialogOpen(true);
}}>
Create Entity
</Button>
</DrawerTrigger>
<DrawerContent className="p-4">
<DrawerHeader>
<DrawerTitle>{selectedEntity?.id ? 'Update Entity' : 'Create Entity'}</DrawerTitle>
</DrawerHeader>
<EntityForm
value={selectedEntity}
categories={categories}
onSubmit={handleSubmit}
className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4"/>
</DrawerContent>
</Drawer>
)
}
</div>
{/* Filter input */}
@ -156,7 +187,7 @@ export default function EntityPageClientContent({entities, onSubmit, onDelete, c
{/* Data Table */}
<DataTable
className="w-full"
columns={columns(actionCell)}
columns={columns(actionCell, categories)}
data={filterEntities(entities, filter)}
pagination/>

View file

@ -12,11 +12,13 @@ import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { sonnerContent } from '@/components/ui/sonner';
import { entityFormSchema } from '@/lib/form-schemas/entityFormSchema';
import { Entity, EntityType } from '@prisma/client';
import { Category, Entity, EntityType } from '@prisma/client';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { AutoCompleteInput } from '@/components/ui/auto-complete-input';
export default function EntityForm({value, onSubmit, className}: {
export default function EntityForm({value, categories, onSubmit, className}: {
value: Entity | undefined,
categories: Category[],
onSubmit: (data: z.infer<typeof entityFormSchema>) => Promise<ActionResponse>
className?: string
}) {
@ -29,6 +31,7 @@ export default function EntityForm({value, onSubmit, className}: {
id: value?.id ?? undefined,
name: value?.name ?? '',
type: value?.type ?? EntityType.Entity,
defaultCategoryId: value?.defaultCategoryId ?? undefined,
},
});
@ -40,6 +43,13 @@ export default function EntityForm({value, onSubmit, className}: {
}
};
const categoriesMapped = categories?.map((category) => {
return {
label: category.name,
value: category.id,
};
}) ?? [];
return (
<Form {...form}>
<form autoComplete="off" onSubmit={form.handleSubmit(handleSubmit)}>
@ -94,6 +104,22 @@ export default function EntityForm({value, onSubmit, className}: {
</FormItem>
)}
/>
<FormField
control={form.control}
name="defaultCategoryId"
render={({field}) => (
<FormItem>
<FormLabel>Category</FormLabel>
<FormControl>
<AutoCompleteInput
placeholder="Select category"
items={categoriesMapped}
{...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</div>
<Button type="submit" className="w-full">{value?.id ? 'Update Entity' : 'Create Entity'}</Button>
</form>

View file

@ -1,23 +0,0 @@
'use client';
import { Button } from '@/components/ui/button';
import React from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { sonnerContent } from '@/components/ui/sonner';
import { ActionResponse } from '@/lib/types/actionResponse';
export default function GenerateSampleDataForm({onSubmit}: { onSubmit: () => Promise<ActionResponse> }) {
const router = useRouter();
const handleSubmit = async () => {
const response = await onSubmit();
toast(sonnerContent(response));
router.refresh();
};
return (
<Button className="w-full" variant="outline" onClick={handleSubmit}>Generate sample data</Button>
);
}

View file

@ -5,7 +5,7 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import React, { useState } from 'react';
import React, { useRef } from 'react';
import { Button } from '@/components/ui/button';
import { ActionResponse } from '@/lib/types/actionResponse';
import { useRouter } from 'next/navigation';
@ -16,11 +16,11 @@ import { paymentFormSchema } from '@/lib/form-schemas/paymentFormSchema';
import CurrencyInput from '@/components/ui/currency-input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { CalendarIcon, Check, ChevronsUpDown } from 'lucide-react';
import { CalendarIcon } from 'lucide-react';
import { format } from 'date-fns';
import { Calendar } from '@/components/ui/calendar';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { Textarea } from '@/components/ui/textarea';
import { AutoCompleteInput } from '@/components/ui/auto-complete-input';
export default function PaymentForm({value, entities, categories, onSubmit, className}: {
value: Payment | undefined,
@ -32,12 +32,6 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
const router = useRouter();
const [filter, setFilter] = useState<string>('');
const [payorOpen, setPayorOpen] = useState(false);
const [payeeOpen, setPayeeOpen] = useState(false);
const [categoryOpen, setCategoryOpen] = useState(false);
const form = useForm<z.infer<typeof paymentFormSchema>>({
resolver: zodResolver(paymentFormSchema),
defaultValues: {
@ -73,6 +67,10 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
};
}) ?? [];
const payeeRef = useRef<HTMLInputElement>({} as HTMLInputElement);
const categoryRef = useRef<HTMLInputElement>({} as HTMLInputElement);
const submitRef = useRef<HTMLButtonElement>({} as HTMLButtonElement);
return (
<Form {...form}>
<form autoComplete="off" onSubmit={form.handleSubmit(handleSubmit)}>
@ -127,10 +125,7 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
<Calendar
mode="single"
selected={field.value}
onSelect={(e) => {
field.onChange(e);
}}
initialFocus
onSelect={field.onChange}
/>
</PopoverContent>
</Popover>
@ -145,61 +140,18 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
render={({field}) => (
<FormItem>
<FormLabel>Payor</FormLabel>
<Popover open={payorOpen} onOpenChange={(open) => {
setPayorOpen(open);
setFilter('');
}}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
'w-full justify-between',
!field.value && 'text-muted-foreground',
)}
>
{field.value
? entitiesMapped.find(
(item) => item.value === field.value,
)?.label
: 'Select entity'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50"/>
</Button>
<AutoCompleteInput
placeholder="Select payor"
items={entitiesMapped}
{...field}
onChange={(e) => {
field.onChange(e);
if (e && e.target.value) {
payeeRef && payeeRef.current.focus();
}
}}/>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[225px] p-0">
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="flex h-10 w-full rounded-md border-b border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Search..."/>
<ScrollArea className="h-64">
{entitiesMapped
.filter((entity) => entity.label.toLowerCase().includes(filter.toLowerCase()))
.map((item) => (
<div
className="relative flex cursor-pointer hover:bg-white/10 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
key={item.value}
onClick={() => {
field.onChange(item.value);
setPayorOpen(false);
}}>
<Check
className={cn(
'mr-2 h-4 w-4',
item.value === field.value
? 'opacity-100'
: 'opacity-0',
)}
/>
{item.label}
</div>
))}
<ScrollBar orientation="vertical"/>
</ScrollArea>
</PopoverContent>
</Popover>
<FormMessage/>
</FormItem>
)}
@ -211,62 +163,26 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
render={({field}) => (
<FormItem>
<FormLabel>Payee</FormLabel>
<Popover open={payeeOpen} onOpenChange={(open) => {
setPayeeOpen(open);
setFilter('');
}}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
'w-full justify-between',
!field.value && 'text-muted-foreground',
)}
>
{field.value
? entitiesMapped.find(
(item) => item.value === field.value,
)?.label
: 'Select entity'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50"/>
</Button>
<FormControl ref={payeeRef}>
<AutoCompleteInput
placeholder="Select payee"
items={entitiesMapped}
{...field}
onChange={(e) => {
field.onChange(e);
if (e && e.target.value) {
const entity = entities.find((entity) => entity.id === Number(e.target.value));
// only focus category input if payee has no default category
if (entity?.defaultCategoryId !== null) {
form.setValue('categoryId', entity?.defaultCategoryId);
submitRef && submitRef.current.focus();
} else {
categoryRef && categoryRef.current.focus();
}
}
}}/>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[225px] p-0">
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="flex h-10 w-full rounded-md border-b border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Search..."/>
<ScrollArea className="h-40">
{entitiesMapped
.filter((entity) => entity.label.toLowerCase().includes(filter.toLowerCase()))
.map((item) => (
<div
className="relative flex cursor-pointer hover:bg-white/10 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
key={item.value}
onClick={() => {
field.onChange(item.value);
setPayeeOpen(false);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
item.value === field.value
? 'opacity-100'
: 'opacity-0',
)}
/>
{item.label}
</div>
))}
<ScrollBar orientation="vertical"/>
</ScrollArea>
</PopoverContent>
</Popover>
<FormMessage/>
</FormItem>
)}
@ -278,62 +194,19 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
render={({field}) => (
<FormItem>
<FormLabel>Category</FormLabel>
<Popover open={categoryOpen} onOpenChange={(open) => {
setCategoryOpen(open);
setFilter('');
}}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
'w-full justify-between',
!field.value && 'text-muted-foreground',
)}
>
{field.value
? categoriesMapped.find(
(item) => item.value === field.value,
)?.label
: 'Select entity'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50"/>
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[225px] p-0">
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="flex h-10 w-full rounded-md border-b border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Search..."/>
<ScrollArea className="h-40">
{categoriesMapped
.filter((entity) => entity.label.toLowerCase().includes(filter.toLowerCase()))
.map((item) => (
<div
className="relative flex cursor-pointer hover:bg-white/10 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
key={item.value}
onClick={() => {
field.onChange(item.value);
setCategoryOpen(false);
<FormControl ref={categoryRef}>
<AutoCompleteInput
placeholder="Select category"
items={categoriesMapped}
{...field}
onChange={(e) => {
field.onChange(e);
if (e && e.target.value) {
submitRef && submitRef.current.focus();
}
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
item.value === field.value
? 'opacity-100'
: 'opacity-0',
)}
/>
{item.label}
</div>
))}
<ScrollBar orientation="vertical"/>
</ScrollArea>
</PopoverContent>
</Popover>
</FormControl>
<FormMessage/>
</FormItem>
)}
@ -354,7 +227,8 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
)}
/>
<Button type="submit" className="w-full">{value?.id ? 'Update Payment' : 'Create Payment'}</Button>
<Button type="submit" ref={submitRef}
className="w-full">{value?.id ? 'Update Payment' : 'Create Payment'}</Button>
</form>
</Form>
);

View file

@ -0,0 +1,100 @@
'use client';
import { Button, buttonVariants } from '@/components/ui/button';
import React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cn } from '@/lib/utils';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { sonnerContent } from '@/components/ui/sonner';
import type { VariantProps } from 'class-variance-authority';
import { ActionResponse } from '@/lib/types/actionResponse';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
export interface ConfirmationDialogProps {
title: string;
description?: string;
actionText?: string;
actionVariant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
}
export interface ButtonWithActionProps<T = any>
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
dialog?: ConfirmationDialogProps;
action: () => Promise<ActionResponse<T>>;
callback?: (data: T) => void;
}
const ServerActionTrigger = React.forwardRef<HTMLButtonElement, ButtonWithActionProps>(
({className, variant, size, asChild = false, ...props}, ref) => {
const router = useRouter();
const Comp = asChild ? Slot : 'button';
const handleSubmit = async () => {
const response = await props.action();
toast(sonnerContent(response));
if (props.callback) {
props.callback(response);
}
if (response.redirect) {
router.push(response.redirect);
}
};
return props.dialog ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<Comp
className={cn(buttonVariants({variant, size, className}))}
{...{...props, dialog: undefined, action: undefined, callback: undefined}}
ref={ref}
/>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{props.dialog.title}</AlertDialogTitle>
{props.dialog?.description && (
<AlertDialogDescription>
{props.dialog.description}
</AlertDialogDescription>
)}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
Cancel
</AlertDialogCancel>
<Button variant={props.dialog.actionVariant || 'default'} asChild>
<AlertDialogAction onClick={handleSubmit}>
{props.dialog.actionText || 'Confirm'}
</AlertDialogAction>
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : (
<Comp
className={cn(buttonVariants({variant, size, className}))}
ref={ref}
onClick={handleSubmit}
{...{...props, dialog: undefined, action: undefined, callback: undefined}}
/>
);
},
);
ServerActionTrigger.displayName = 'ServerActionTrigger';
export { ServerActionTrigger };

View file

@ -1,71 +0,0 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import React from 'react';
import { Button } from '@/components/ui/button';
import { signInFormSchema } from '@/lib/form-schemas/signInFormSchema';
import { ActionResponse } from '@/lib/types/actionResponse';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { sonnerContent } from '@/components/ui/sonner';
export default function SignInForm({onSubmit}: {
onSubmit: (data: z.infer<typeof signInFormSchema>) => Promise<ActionResponse>
}) {
const router = useRouter();
const form = useForm<z.infer<typeof signInFormSchema>>({
resolver: zodResolver(signInFormSchema),
defaultValues: {
username: '',
password: '',
},
});
const handleSubmit = async (data: z.infer<typeof signInFormSchema>) => {
const response = await onSubmit(data);
toast(sonnerContent(response));
if (response.redirect) {
router.push(response.redirect);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-2">
<FormField
control={form.control}
name="username"
render={({field}) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="Username" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({field}) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input placeholder="••••••••" type="password" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<Button type="submit" className="w-full">Sign in</Button>
</form>
</Form>
);
}

View file

@ -1,25 +0,0 @@
'use client';
import { ActionResponse } from '@/lib/types/actionResponse';
import { Button } from '@/components/ui/button';
import React from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { sonnerContent } from '@/components/ui/sonner';
export default function SignOutForm({onSubmit}: { onSubmit: () => Promise<ActionResponse> }) {
const router = useRouter();
const handleSignOut = async () => {
const response = await onSubmit();
toast(sonnerContent(response));
if (response.redirect) {
router.push(response.redirect);
}
};
return (
<Button className="w-full" onClick={handleSignOut}>Sign out</Button>
);
}

View file

@ -1,84 +0,0 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import React from 'react';
import { Button } from '@/components/ui/button';
import { signUpFormSchema } from '@/lib/form-schemas/signUpFormSchema';
import { ActionResponse } from '@/lib/types/actionResponse';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { sonnerContent } from '@/components/ui/sonner';
export default function SignUpForm({onSubmit}: {
onSubmit: (data: z.infer<typeof signUpFormSchema>) => Promise<ActionResponse>
}) {
const router = useRouter();
const form = useForm<z.infer<typeof signUpFormSchema>>({
resolver: zodResolver(signUpFormSchema),
defaultValues: {
username: '',
password: '',
},
});
const handleSubmit = async (data: z.infer<typeof signUpFormSchema>) => {
const response = await onSubmit(data);
toast(sonnerContent(response));
if (response.redirect) {
router.push(response.redirect);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-2">
<FormField
control={form.control}
name="username"
render={({field}) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="Username" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({field}) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input placeholder="••••••••" type="password" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirm"
render={({field}) => (
<FormItem>
<FormLabel>Confirm password</FormLabel>
<FormControl>
<Input placeholder="••••••••" type="password" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<Button type="submit" className="w-full">Create Account</Button>
</form>
</Form>
);
}

View file

@ -2,24 +2,83 @@
import {
NavigationMenu,
navigationMenuIconTriggerStyle,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
navigationMenuTriggerStyle,
} from '@/components/ui/navigation-menu';
import React from 'react';
import React, { useState } from 'react';
import Link from 'next/link';
import { User } from 'lucide-react';
import { Banknote, Home, Menu, Tag, User, UserSearch } from 'lucide-react';
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer';
import { Button } from '@/components/ui/button';
export default function Navigation() {
const [open, setOpen] = useState(false);
return (
<div className="flex sticky items-center border-b border-border bg-background">
<div className="md:hidden">
<Drawer open={open} onOpenChange={open => setOpen(open)}>
<DrawerTrigger asChild>
<Button size="icon" variant="ghost" className="m-2">
<Menu/>
</Button>
</DrawerTrigger>
<DrawerContent>
<div className="flex flex-col space-y-2 w-full rounded-none p-4">
<Link
href="/"
className={navigationMenuIconTriggerStyle()}
onClick={() => setOpen(false)}
passHref>
<Home/>
<span>Dashboard</span>
</Link>
<Link
href="/payments"
className={navigationMenuIconTriggerStyle()}
onClick={() => setOpen(false)}
passHref>
<Banknote/>
<span>Payments</span>
</Link>
<Link
href="/entities"
className={navigationMenuIconTriggerStyle()}
onClick={() => setOpen(false)}
passHref>
<UserSearch/>
<span>Entities</span>
</Link>
<Link
href="/categories"
className={navigationMenuIconTriggerStyle()}
onClick={() => setOpen(false)}
passHref>
<Tag/>
<span>Categories</span>
</Link>
<Link
href="/account"
className={navigationMenuIconTriggerStyle()}
onClick={() => setOpen(false)}
passHref>
<User/>
<span>Account</span>
</Link>
</div>
</DrawerContent>
</Drawer>
</div>
<div className="hidden md:flex">
<NavigationMenu>
<NavigationMenuList className="flex w-screen items-center justify-between px-4 py-2">
<NavigationMenuList className="flex w-screen items-center justify-between sm:px-4 py-2">
<div className="inline-flex space-x-2">
<img src={'/logo_white.png'} alt="Finances" className="h-10 w-10 mx-3"/>
<img src={'/logo_t_hq_w.svg'} alt="Finances" className="h-10 w-10 mx-3"/>
<NavigationMenuItem>
<Link href="/" legacyBehavior passHref>
@ -62,5 +121,6 @@ export default function Navigation() {
</NavigationMenuList>
</NavigationMenu>
</div>
</div>
);
}

View file

@ -23,6 +23,8 @@ import { paymentFormSchema } from '@/lib/form-schemas/paymentFormSchema';
import { Category, Entity, Payment } from '@prisma/client';
import PaymentForm from '@/components/form/paymentForm';
import { columns } from '@/app/payments/columns';
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer';
import { useMediaQuery } from '@/lib/hooks/useMediaQuery';
export default function PaymentPageClientContent({
payments,
@ -40,6 +42,7 @@ export default function PaymentPageClientContent({
className: string,
}) {
const isDesktop = useMediaQuery('(min-width: 768px)');
const router = useRouter();
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
@ -100,26 +103,14 @@ export default function PaymentPageClientContent({
);
};
const entitiesMapped = entities?.map((entity) => {
return {
label: entity.name,
value: entity.id,
};
}) ?? [];
const categoriesMapped = categories?.map((category) => {
return {
label: category.name,
value: category.id,
};
}) ?? [];
return (
<div className={className}>
<div className="flex items-center justify-between w-full">
<p className="text-3xl font-semibold">Payments</p>
{/* Edit dialog */}
{
isDesktop ? (
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogTrigger asChild>
<Button
@ -142,6 +133,31 @@ export default function PaymentPageClientContent({
className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4"/>
</DialogContent>
</Dialog>
) : (
<Drawer open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DrawerTrigger asChild>
<Button
onClick={() => {
setSelectedPayment(undefined);
setIsEditDialogOpen(true);
}}>
Create Payment
</Button>
</DrawerTrigger>
<DrawerContent className="p-4">
<DrawerHeader>
<DrawerTitle>{selectedPayment?.id ? 'Update Payment' : 'Create Payment'}</DrawerTitle>
</DrawerHeader>
<PaymentForm
value={selectedPayment}
entities={entities}
categories={categories}
onSubmit={handleSubmit}
className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4"/>
</DrawerContent>
</Drawer>
)
}
</div>
{/* Data Table */}

View file

@ -0,0 +1,129 @@
'use client';
import * as React from 'react';
import { useEffect, useState } from 'react';
import { cn } from '@/lib/utils';
import { X } from 'lucide-react';
import { Button } from '@/components/ui/button';
export interface AutoCompleteInputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
items: { label: string, value: any }[];
}
const AutoCompleteInput = React.forwardRef<HTMLInputElement, AutoCompleteInputProps>(
({className, type, ...props}, ref) => {
const [value, setValue] = useState(getNameOfPropValue());
const [open, setOpen] = useState(false);
const [lastKey, setLastKey] = useState('');
const [filteredItems, setFilteredItems] = useState(props.items);
function getNameOfPropValue() {
if (!props.items) {
return '';
}
const item = props.items?.find(item => item.value === props.value);
return item?.label || '';
}
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setFilteredItems(props?.items?.filter((item) => {
return item.label.toLowerCase().includes(value.toLowerCase());
}));
setValue(value);
setOpen(value.length > 0);
// on typing only the internal state is changed while the form state is
// set to undefined. This way only the predefined items are actual values
// for the form validation
props.onChange?.(undefined as any);
}
// since typing changes the internal values and therefor the selected value, this effect
// handles every filteredItems change to check if only one item is left
useEffect(() => {
// only one item is left and the last character was a letter or digit.
// the last condition has to be checked to make it possible to use the backspace
if (filteredItems.length === 1 && /^[a-zA-Z0-9]$/.test(lastKey)) {
setValue(filteredItems[0].label);
setOpen(false);
props.onChange?.({target: {value: filteredItems[0].value}} as any);
}
}, [filteredItems]);
useEffect(() => {
if (props.value) {
setValue(getNameOfPropValue());
} else {
setValue('');
}
}, [props.value]);
return (
<div className="relative">
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
value={value}
placeholder={props.placeholder || 'Search...'}
onChange={handleChange}
onKeyDown={(e) => {
if (e.metaKey || e.ctrlKey || e.altKey) {
props.onKeyDown?.(e);
return;
}
setLastKey(e.key);
props.onKeyDown?.(e);
}}
/>
{
value.length > 0 && (
<Button
className="absolute end-0 top-0 z-10"
size="icon"
variant="ghost"
onClick={() => {
handleChange({target: {value: ''}} as any);
}}
>
<X className="h-4 w-4"/>
</Button>
)
}
{
open && (
<div
className="z-50 bg-background rounded-md border border-border absolute inset-x-0 top-12 max-h-44 overflow-scroll">
{filteredItems?.map((item) =>
<div
className="px-3 py-3 hover:bg-accent hover:text-accent-foreground cursor-pointer text-sm font-medium"
onClick={() => {
props.onChange?.({target: {value: item.value}} as any);
setValue(item.label);
setOpen(false);
}}
key={item.value}>
{item.label}
</div>,
)}
</div>
)
}
</div>
);
},
);
AutoCompleteInput.displayName = 'Input';
export { AutoCompleteInput };

View file

@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {

View file

@ -46,7 +46,7 @@ function Calendar({
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
day_today: 'bg-accent text-accent-foreground',
day_outside:
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
'day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground',
day_disabled: 'text-muted-foreground opacity-50',
day_range_middle:
'aria-selected:bg-accent aria-selected:text-accent-foreground',
@ -54,8 +54,12 @@ function Calendar({
...classNames,
}}
components={{
IconLeft: ({...props}) => <ChevronLeft className="h-4 w-4"/>,
IconRight: ({...props}) => <ChevronRight className="h-4 w-4"/>,
IconLeft: ({ className, ...props }) => (
<ChevronLeft className={cn('h-4 w-4', className)} {...props} />
),
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn('h-4 w-4', className)} {...props} />
),
}}
{...props}
/>

View file

@ -53,6 +53,7 @@ export default function CurrencyInput(props: TextInputProps) {
<Input
placeholder={props.placeholder}
type="text"
inputMode="numeric"
{...field}
onChange={(ev) => {
setValue(ev.target.value);

View file

@ -4,8 +4,9 @@ import { ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, useReact
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import React from 'react';
import React, { useEffect, useState } from 'react';
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
@ -20,6 +21,7 @@ export function DataTable<TData, TValue>({
pagination,
className,
}: DataTableProps<TData, TValue>) {
const table = useReactTable({
data,
columns,
@ -27,6 +29,13 @@ export function DataTable<TData, TValue>({
getPaginationRowModel: pagination ? getPaginationRowModel() : undefined,
});
const [pageSize, setPageSize] = useState(50);
useEffect(() => {
if (pagination) {
table.setPageSize(pageSize);
}
}, [table, pagination, pageSize]);
return (
<div className={className}>
<div className="rounded-md border">
@ -36,7 +45,8 @@ export function DataTable<TData, TValue>({
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
<TableHead key={header.id}
style={{minWidth: `${header.column.getSize()}px`}}>
{header.isPlaceholder
? null
: flexRender(
@ -57,7 +67,8 @@ export function DataTable<TData, TValue>({
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
<TableCell key={cell.id}
className={cell.id.endsWith('actions') ? 'w-[120px]' : ''}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
@ -75,7 +86,25 @@ export function DataTable<TData, TValue>({
</div>
{
pagination && (
<div className="flex items-center justify-end space-x-2 py-4">
<div className="flex items-center justify-between py-4">
<Select
onValueChange={(value) => {
setPageSize(parseInt(value));
}}
value={pageSize.toString()}
>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Select a scope"/>
</SelectTrigger>
<SelectContent>
<SelectItem value={'25'} key={'25'}>25</SelectItem>
<SelectItem value={'50'} key={'50'}>50</SelectItem>
<SelectItem value={'75'} key={'75'}>75</SelectItem>
<SelectItem value={'100'} key={'100'}>100</SelectItem>
</SelectContent>
</Select>
<div className="flex flex-row items-center space-x-2">
<Button
variant="outline"
size="icon"
@ -113,6 +142,7 @@ export function DataTable<TData, TValue>({
<ChevronsRight/>
</Button>
</div>
</div>
)
}
</div>

View file

@ -0,0 +1,118 @@
'use client';
import * as React from 'react';
import { Drawer as DrawerPrimitive } from 'vaul';
import { cn } from '@/lib/utils';
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
);
Drawer.displayName = 'Drawer';
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({className, ...props}, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn('fixed inset-0 z-50 bg-black/80', className)}
{...props}
/>
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({className, children, ...props}, ref) => (
<DrawerPortal>
<DrawerOverlay/>
<DrawerPrimitive.Content
ref={ref}
className={cn(
'fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background',
className,
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted"/>
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
));
DrawerContent.displayName = 'DrawerContent';
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('grid gap-1.5 p-4 text-center sm:text-left', className)}
{...props}
/>
);
DrawerHeader.displayName = 'DrawerHeader';
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
);
DrawerFooter.displayName = 'DrawerFooter';
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({className, ...props}, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className,
)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({className, ...props}, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View file

@ -44,6 +44,10 @@ const navigationMenuTriggerStyle = cva(
'group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50',
);
const navigationMenuIconTriggerStyle = cva(
'group inline-flex h-10 w-full items-center justify-start rounded-md bg-background px-4 py-2 space-x-4 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50',
);
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
@ -117,6 +121,7 @@ NavigationMenuIndicator.displayName =
export {
navigationMenuTriggerStyle,
navigationMenuIconTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,

View file

@ -1,9 +1,9 @@
import { z } from 'zod';
import { ActionResponse } from '@/lib/types/actionResponse';
import { prismaClient } from '@/prisma';
import { getUser } from '@/auth';
import prisma from '@/prisma';
import { URL_SIGN_IN } from '@/lib/constants';
import { categoryFormSchema } from '@/lib/form-schemas/categoryFormSchema';
import { auth0 } from '@/lib/auth';
export default async function categoryCreateUpdate({
id,
@ -12,20 +12,20 @@ export default async function categoryCreateUpdate({
}: z.infer<typeof categoryFormSchema>): Promise<ActionResponse> {
'use server';
// check that user is logged in
const user = await getUser();
if (!user) {
const session = await auth0.getSession();
if (!session) {
return {
type: 'error',
message: 'You must be logged in to create/update an category.',
message: 'You aren\'t signed in.',
redirect: URL_SIGN_IN,
};
}
const user = session.user;
// create/update category
try {
if (id) {
await prismaClient.category.update({
await prisma.category.update({
where: {
id: id,
},
@ -42,9 +42,9 @@ export default async function categoryCreateUpdate({
message: `'${name}' updated`,
};
} else {
await prismaClient.category.create({
await prisma.category.create({
data: {
userId: user.id,
userId: user.sub,
name: name,
color: color,
},

View file

@ -1,7 +1,7 @@
import { ActionResponse } from '@/lib/types/actionResponse';
import { prismaClient } from '@/prisma';
import { getUser } from '@/auth';
import prisma from '@/prisma';
import { URL_SIGN_IN } from '@/lib/constants';
import { auth0 } from '@/lib/auth';
export default async function categoryDelete(id: number): Promise<ActionResponse> {
'use server';
@ -14,21 +14,21 @@ export default async function categoryDelete(id: number): Promise<ActionResponse
};
}
// check that user is logged in
const user = await getUser();
if (!user) {
const session = await auth0.getSession();
if (!session) {
return {
type: 'error',
message: 'You must be logged in to delete an category.',
message: 'You aren\'t signed in.',
redirect: URL_SIGN_IN,
};
}
const user = session.user;
// check that category is associated with user
const category = await prismaClient.category.findFirst({
const category = await prisma.category.findFirst({
where: {
id: id,
userId: user.id,
userId: user.sub,
},
});
if (!category) {
@ -38,15 +38,25 @@ export default async function categoryDelete(id: number): Promise<ActionResponse
};
}
// delete category
try {
await prismaClient.category.delete({
await prisma.$transaction(async (tx) => {
// update related payments
await tx.payment.updateMany({
where: {categoryId: category.id},
data: {categoryId: null},
});
// delete the category
await tx.category.delete({
where: {
id: category.id,
userId: user.id,
userId: user.sub,
},
},
);
});
});
} catch (e) {
return {
type: 'error',

View file

@ -0,0 +1,40 @@
import { ActionResponse } from '@/lib/types/actionResponse';
import { URL_SIGN_IN } from '@/lib/constants';
import prisma from '@/prisma';
import { auth0 } from '@/lib/auth';
export default async function clearAccountData(): Promise<ActionResponse> {
'use server';
const session = await auth0.getSession();
if (!session) {
return {
type: 'error',
message: 'You aren\'t signed in.',
redirect: URL_SIGN_IN,
};
}
await prisma.payment.deleteMany({
where: {
userId: session.user.sub,
},
});
await prisma.entity.deleteMany({
where: {
userId: session.user.sub,
},
});
await prisma.category.deleteMany({
where: {
userId: session.user.sub,
},
});
return {
type: 'success',
message: 'Your account data was cleared.',
};
}

View file

@ -1,37 +1,39 @@
import { z } from 'zod';
import { ActionResponse } from '@/lib/types/actionResponse';
import { entityFormSchema } from '@/lib/form-schemas/entityFormSchema';
import { prismaClient } from '@/prisma';
import { getUser } from '@/auth';
import prisma from '@/prisma';
import { URL_SIGN_IN } from '@/lib/constants';
import { auth0 } from '@/lib/auth';
export default async function entityCreateUpdate({
id,
name,
type,
defaultCategoryId,
}: z.infer<typeof entityFormSchema>): Promise<ActionResponse> {
'use server';
// check that user is logged in
const user = await getUser();
if (!user) {
const session = await auth0.getSession();
if (!session) {
return {
type: 'error',
message: 'You must be logged in to create/update an entity.',
message: 'You aren\'t signed in.',
redirect: URL_SIGN_IN,
};
}
const user = session.user;
// create/update entity
try {
if (id) {
await prismaClient.entity.update({
await prisma.entity.update({
where: {
id: id,
},
data: {
name: name,
type: type,
defaultCategoryId: defaultCategoryId ?? null,
},
},
);
@ -42,11 +44,12 @@ export default async function entityCreateUpdate({
message: `${type} '${name}' updated`,
};
} else {
await prismaClient.entity.create({
await prisma.entity.create({
data: {
userId: user.id,
userId: user.sub,
name: name,
type: type,
defaultCategoryId: defaultCategoryId ?? null,
},
});

View file

@ -1,7 +1,7 @@
import { ActionResponse } from '@/lib/types/actionResponse';
import { prismaClient } from '@/prisma';
import { getUser } from '@/auth';
import prisma from '@/prisma';
import { URL_SIGN_IN } from '@/lib/constants';
import { auth0 } from '@/lib/auth';
export default async function entityDelete(id: number): Promise<ActionResponse> {
'use server';
@ -14,21 +14,21 @@ export default async function entityDelete(id: number): Promise<ActionResponse>
};
}
// check that user is logged in
const user = await getUser();
if (!user) {
const session = await auth0.getSession();
if (!session) {
return {
type: 'error',
message: 'You must be logged in to delete an entity.',
message: 'You aren\'t signed in.',
redirect: URL_SIGN_IN,
};
}
const user = session.user;
// check that entity is associated with user
const entity = await prismaClient.entity.findFirst({
const entity = await prisma.entity.findFirst({
where: {
id: id,
userId: user.id,
userId: user.sub,
},
});
if (!entity) {
@ -40,10 +40,10 @@ export default async function entityDelete(id: number): Promise<ActionResponse>
// delete entity
try {
await prismaClient.entity.delete({
await prisma.entity.delete({
where: {
id: entity.id,
userId: user.id,
userId: user.sub,
},
},
);

View file

@ -1,64 +1,64 @@
import { prismaClient } from '@/prisma';
import prisma from '@/prisma';
import type { Category, Entity } from '@prisma/client';
import { EntityType } from '@prisma/client';
import { getUser } from '@/auth';
import { URL_SIGN_IN } from '@/lib/constants';
import { ActionResponse } from '@/lib/types/actionResponse';
import { auth0 } from '@/lib/auth';
export default async function generateSampleData(): Promise<ActionResponse> {
'use server';
const user = await getUser();
if (!user) {
const session = await auth0.getSession();
if (!session) {
return {
type: 'error',
message: 'You must be logged in to create/update an category.',
message: 'You aren\'t signed in.',
redirect: URL_SIGN_IN,
};
}
const user = session.user;
// Categories: create sample data
const categories: Category[] = await prismaClient.category.findMany({where: {userId: user.id}});
if (await prismaClient.category.count({where: {userId: user.id}}) == 0) {
const categories: Category[] = await prisma.category.findMany({where: {userId: user.sub}});
if (await prisma.category.count({where: {userId: user.sub}}) == 0) {
console.log('Creating sample categories...');
categories.push(await prismaClient.category.create({
categories.push(await prisma.category.create({
data: {
userId: user.id,
userId: user.sub,
name: 'Groceries',
color: '#FFBEAC',
},
}));
categories.push(await prismaClient.category.create({
categories.push(await prisma.category.create({
data: {
userId: user.id,
userId: user.sub,
name: 'Drugstore items',
color: '#9CBCFF',
},
}));
categories.push(await prismaClient.category.create({
categories.push(await prisma.category.create({
data: {
userId: user.id,
userId: user.sub,
name: 'Going out',
color: '#F1ADFF',
},
}));
categories.push(await prismaClient.category.create({
categories.push(await prisma.category.create({
data: {
userId: user.id,
userId: user.sub,
name: 'Random stuff',
color: '#C1FFA9',
},
}));
categories.push(await prismaClient.category.create({
categories.push(await prisma.category.create({
data: {
userId: user.id,
userId: user.sub,
name: 'Salary',
color: '#FFF787',
},
@ -69,54 +69,54 @@ export default async function generateSampleData(): Promise<ActionResponse> {
console.log(categories);
// Entities: create sample data
const entities: Entity[] = await prismaClient.entity.findMany({where: {userId: user.id}});
if (await prismaClient.entity.count({where: {userId: user.id}}) == 0) {
const entities: Entity[] = await prisma.entity.findMany({where: {userId: user.sub}});
if (await prisma.entity.count({where: {userId: user.sub}}) == 0) {
console.log('Creating sample entities...');
entities.push(await prismaClient.entity.create({
entities.push(await prisma.entity.create({
data: {
userId: user.id,
userId: user.sub,
name: 'Main Account',
type: EntityType.Account,
},
}));
entities.push(await prismaClient.entity.create({
entities.push(await prisma.entity.create({
data: {
userId: user.id,
userId: user.sub,
name: 'Company',
type: EntityType.Entity,
},
}));
entities.push(await prismaClient.entity.create({
entities.push(await prisma.entity.create({
data: {
userId: user.id,
userId: user.sub,
name: 'Supermarket 1',
type: EntityType.Entity,
},
}));
entities.push(await prismaClient.entity.create({
entities.push(await prisma.entity.create({
data: {
userId: user.id,
userId: user.sub,
name: 'Supermarket 2',
type: EntityType.Entity,
},
}));
entities.push(await prismaClient.entity.create({
entities.push(await prisma.entity.create({
data: {
userId: user.id,
userId: user.sub,
name: 'Supermarket 3',
type: EntityType.Entity,
},
}));
entities.push(await prismaClient.entity.create({
entities.push(await prisma.entity.create({
data: {
userId: user.id,
userId: user.sub,
name: 'Supermarket 4',
type: EntityType.Entity,
},
@ -129,21 +129,24 @@ export default async function generateSampleData(): Promise<ActionResponse> {
// Payments: create sample data
console.log('Creating sample payments...');
if (await prismaClient.payment.count({where: {userId: user.id}}) == 0) {
if (await prisma.payment.count({where: {userId: user.sub}}) == 0) {
for (let i = 0; i < 4; i++) {
const date = new Date();
date.setDate(1);
date.setMonth(date.getMonth() - i);
await prismaClient.payment.create({
const categoryId =
categories.find((it) => it.name === 'Salary')?.id!;
await prisma.payment.create({
data: {
userId: user.id,
userId: user.sub,
amount: 200000,
date: date,
payorId: entities[1].id,
payeeId: entities[0].id,
categoryId: 5,
categoryId: categoryId,
createdAt: date,
updatedAt: date,
},
@ -164,9 +167,9 @@ export default async function generateSampleData(): Promise<ActionResponse> {
const date = new Date(
new Date().getTime() - Math.floor(Math.random() * 10000000000));
await prismaClient.payment.create({
await prisma.payment.create({
data: {
userId: user.id,
userId: user.sub,
amount: Math.floor(
Math.random() * (maxAmount - minAmount) + minAmount),
date: date,

View file

@ -1,9 +1,9 @@
import { z } from 'zod';
import { ActionResponse } from '@/lib/types/actionResponse';
import { prismaClient } from '@/prisma';
import { getUser } from '@/auth';
import prisma from '@/prisma';
import { URL_SIGN_IN } from '@/lib/constants';
import { paymentFormSchema } from '@/lib/form-schemas/paymentFormSchema';
import { auth0 } from '@/lib/auth';
export default async function paymentCreateUpdate({
id,
@ -16,20 +16,20 @@ export default async function paymentCreateUpdate({
}: z.infer<typeof paymentFormSchema>): Promise<ActionResponse> {
'use server';
// check that user is logged in
const user = await getUser();
if (!user) {
const session = await auth0.getSession();
if (!session) {
return {
type: 'error',
message: 'You must be logged in to create/update a payment.',
message: 'You aren\'t signed in.',
redirect: URL_SIGN_IN,
};
}
const user = session.user;
// create/update payment
try {
if (id) {
await prismaClient.payment.update({
await prisma.payment.update({
where: {
id: id,
},
@ -38,7 +38,7 @@ export default async function paymentCreateUpdate({
date: date,
payorId: payorId,
payeeId: payeeId,
categoryId: categoryId,
categoryId: categoryId ?? null,
note: note,
},
},
@ -50,14 +50,14 @@ export default async function paymentCreateUpdate({
message: `Payment updated`,
};
} else {
await prismaClient.payment.create({
await prisma.payment.create({
data: {
userId: user.id,
userId: user.sub,
amount: amount,
date: date,
payorId: payorId,
payeeId: payeeId,
categoryId: categoryId,
categoryId: categoryId ?? null,
note: note,
},
});

View file

@ -1,7 +1,7 @@
import { ActionResponse } from '@/lib/types/actionResponse';
import { prismaClient } from '@/prisma';
import { getUser } from '@/auth';
import prisma from '@/prisma';
import { URL_SIGN_IN } from '@/lib/constants';
import { auth0 } from '@/lib/auth';
export default async function paymentDelete(id: number): Promise<ActionResponse> {
'use server';
@ -14,21 +14,21 @@ export default async function paymentDelete(id: number): Promise<ActionResponse>
};
}
// check that user is logged in
const user = await getUser();
if (!user) {
const session = await auth0.getSession();
if (!session) {
return {
type: 'error',
message: 'You must be logged in to delete a payment.',
message: 'You aren\'t signed in.',
redirect: URL_SIGN_IN,
};
}
const user = session.user;
// check that payment is associated with user
const payment = await prismaClient.payment.findFirst({
const payment = await prisma.payment.findFirst({
where: {
id: id,
userId: user.id,
userId: user.sub,
},
});
if (!payment) {
@ -40,10 +40,10 @@ export default async function paymentDelete(id: number): Promise<ActionResponse>
// delete payment
try {
await prismaClient.payment.delete({
await prisma.payment.delete({
where: {
id: payment.id,
userId: user.id,
userId: user.sub,
},
},
);

View file

@ -1,41 +0,0 @@
import { z } from 'zod';
import { Argon2id } from 'oslo/password';
import { lucia } from '@/auth';
import { cookies } from 'next/headers';
import { signInFormSchema } from '@/lib/form-schemas/signInFormSchema';
import { ActionResponse } from '@/lib/types/actionResponse';
import { URL_HOME } from '@/lib/constants';
import { prismaClient } from '@/prisma';
export default async function signIn({username, password}: z.infer<typeof signInFormSchema>): Promise<ActionResponse> {
'use server';
const existingUser = await prismaClient.user.findFirst({
where: {
username: username.toLowerCase(),
},
});
if (!existingUser) {
return {
type: 'error',
message: 'Incorrect username or password',
};
}
const validPassword = await new Argon2id().verify(existingUser.password, password);
if (!validPassword) {
return {
type: 'error',
message: 'Incorrect username or password',
};
}
const session = await lucia.createSession(existingUser.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return {
type: 'success',
message: 'Signed in successfully',
redirect: URL_HOME,
};
}

View file

@ -1,26 +0,0 @@
import { getSession, lucia } from '@/auth';
import { cookies } from 'next/headers';
import { ActionResponse } from '@/lib/types/actionResponse';
import { URL_SIGN_IN } from '@/lib/constants';
export default async function signOut(): Promise<ActionResponse> {
'use server';
const session = await getSession();
if (!session) {
return {
type: 'error',
message: 'You aren\'t signed in',
};
}
await lucia.invalidateSession(session.id);
const sessionCookie = lucia.createBlankSessionCookie();
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return {
type: 'success',
message: 'Signed out successfully',
redirect: URL_SIGN_IN,
};
}

View file

@ -1,46 +0,0 @@
import { z } from 'zod';
import { Argon2id } from 'oslo/password';
import { generateId } from 'lucia';
import { lucia } from '@/auth';
import { cookies } from 'next/headers';
import { signUpFormSchema } from '@/lib/form-schemas/signUpFormSchema';
import { ActionResponse } from '@/lib/types/actionResponse';
import { URL_HOME } from '@/lib/constants';
import { prismaClient } from '@/prisma';
export default async function signUp({username, password}: z.infer<typeof signUpFormSchema>): Promise<ActionResponse> {
'use server';
const hashedPassword = await new Argon2id().hash(password);
const userId = generateId(15);
const existingUser = await prismaClient.user.findFirst({
where: {
username: username.toLowerCase(),
},
});
if (existingUser) {
return {
type: 'error',
message: 'Username already exists',
};
}
await prismaClient.user.create({
data: {
id: userId,
username: username,
password: hashedPassword,
},
});
const session = await lucia.createSession(userId, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return {
type: 'success',
message: 'Signed up successfully',
redirect: URL_HOME,
};
}

9
src/lib/auth.ts Normal file
View file

@ -0,0 +1,9 @@
import { Auth0Client } from "@auth0/nextjs-auth0/server"
export const auth0 = new Auth0Client({
appBaseUrl: process.env.AUTH0_BASE_URL,
domain: process.env.AUTH0_URL,
secret: process.env.AUTH0_SECRET,
clientId: process.env.AUTH0_CLIENT_ID,
clientSecret: process.env.AUTH0_CLIENT_SECRET,
})

View file

@ -1,7 +1,6 @@
// auth urls
export const URL_AUTH = '/auth';
export const URL_SIGN_IN = `${URL_AUTH}/signin`;
export const URL_SIGN_UP = `${URL_AUTH}/signup`;
export const URL_SIGN_IN = `/auth/login`;
export const URL_SIGN_OUT = `/auth/logout`;
// main urls
export const URL_HOME = '/';

View file

@ -5,4 +5,5 @@ export const entityFormSchema = z.object({
id: z.number().positive().optional(),
name: z.string().min(1).max(32),
type: z.nativeEnum(EntityType),
defaultCategoryId: z.number().positive().optional(),
});

View file

@ -1,6 +0,0 @@
import { z } from 'zod';
export const signInFormSchema = z.object({
username: z.string().min(3).max(16),
password: z.string().min(8).max(255),
});

View file

@ -1,10 +0,0 @@
import { z } from 'zod';
export const signUpFormSchema = z.object({
username: z.string().min(3).max(16),
password: z.string().min(8).max(255),
confirm: z.string().min(8).max(255),
}).refine(data => data.password === data.confirm, {
message: 'Passwords do not match',
path: ['confirm'],
});

View file

@ -0,0 +1,21 @@
'use client';
import { useEffect, useState } from 'react';
export function useMediaQuery(mq: string) {
const [matches, setMatch] = useState(
() => typeof window !== 'undefined' ? window.matchMedia(mq).matches : false,
);
useEffect(() => {
if (typeof window !== 'undefined') {
const mql = window.matchMedia(mq);
const listener = (e: any) => setMatch(e.matches);
mql.addEventListener('change', listener);
return () => mql.removeEventListener('change', listener);
}
}, [mq]);
return matches;
}

View file

@ -1,5 +1,6 @@
export interface ActionResponse {
export interface ActionResponse<T = any> {
type: 'success' | 'info' | 'warning' | 'error';
message: string;
redirect?: string;
data?: T;
}

View file

@ -1,27 +1,22 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { URL_AUTH, URL_HOME, URL_SIGN_IN } from './lib/constants';
import { NextRequest } from 'next/server';
import { auth0 } from '@/lib/auth';
export async function middleware(request: NextRequest) {
// get session id from cookies
const sessionId = request.cookies.get('auth_session')?.value ?? null;
// redirect to home if user is already authenticated
if (request.nextUrl.pathname.startsWith(URL_AUTH) && sessionId) {
return NextResponse.redirect(new URL(URL_HOME, request.url));
try {
return await auth0.middleware(request);
} catch (error) {
console.error("Auth0 middleware error:", error);
}
// redirect to sign in if user is not authenticated
if (!request.nextUrl.pathname.startsWith(URL_AUTH) && !sessionId) {
return NextResponse.redirect(new URL(URL_SIGN_IN, request.url));
}
return NextResponse.next();
}
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico).*)',
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico, sitemap.xml, robots.txt (metadata files)
*/
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
],
};
}

View file

@ -1,3 +1,18 @@
import { PrismaClient } from '@prisma/client';
export const prismaClient = new PrismaClient();
const prismaClientSingleton = () => {
return new PrismaClient();
};
declare global {
// noinspection ES6ConvertVarToLetConst
var prismaGlobal: undefined | ReturnType<typeof prismaClientSingleton>;
}
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton();
export default prisma;
if (process.env.NODE_ENV !== 'production') {
globalThis.prismaGlobal = prisma
}

View file

@ -3,7 +3,8 @@
"lib": [
"dom",
"dom.iterable",
"esnext"
"esnext",
"webworker"
],
"allowJs": true,
"skipLibCheck": true,
@ -25,7 +26,11 @@
"@/*": [
"./src/*"
]
}
},
"types": [
"@serwist/next/typings"
],
"target": "ES2017"
},
"include": [
"next-env.d.ts",