Compare commits
109 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
d242378bb9 | ||
![]() |
2bddc56195 | ||
![]() |
13b9025fbf | ||
![]() |
3ce44a3302 | ||
![]() |
8e94a0bea5 | ||
![]() |
0cd553600a | ||
![]() |
fe58b0190b | ||
![]() |
5bb0d71836 | ||
![]() |
fc1658602a | ||
![]() |
76535bed45 | ||
![]() |
2a6fbfd70c | ||
![]() |
13fc8c1e94 | ||
![]() |
4abe52d4e8 | ||
![]() |
0bb1db9acc | ||
![]() |
576c2b0c0c | ||
![]() |
e38157e604 | ||
![]() |
4720ff553d | ||
![]() |
fc361f721f | ||
![]() |
f56f466b40 | ||
![]() |
d526ccf5ff | ||
![]() |
ed90d66898 | ||
![]() |
0f2f055a57 | ||
![]() |
25793bb7c9 | ||
![]() |
237131aa11 | ||
![]() |
7389b600ec | ||
![]() |
33e3b34305 | ||
![]() |
3c3ad5ee38 | ||
![]() |
583bc1aa5d | ||
![]() |
5d8554068c | ||
![]() |
ed49ad4ce7 | ||
![]() |
4a25a93186 | ||
![]() |
803bfc5807 | ||
![]() |
fc0a9abc7b | ||
![]() |
91de5a730c | ||
![]() |
59007f5973 | ||
![]() |
5be1e78ddd | ||
![]() |
f24d4e4a38 | ||
![]() |
4834750659 | ||
![]() |
155ab2f2e3 | ||
![]() |
021bfcc65d | ||
![]() |
1aa3ed85c5 | ||
![]() |
0e952e4933 | ||
![]() |
f378e2a045 | ||
![]() |
98f29a8366 | ||
![]() |
53247d382d | ||
![]() |
57f3381829 | ||
![]() |
ba71cbef0e | ||
![]() |
12c689d1d6 | ||
![]() |
c4146a36a4 | ||
![]() |
6ba9a8872b | ||
![]() |
642d64ad5e | ||
![]() |
39cd91a53a | ||
![]() |
c16ac9c2fa | ||
![]() |
5a42108063 | ||
![]() |
b09c705dd7 | ||
![]() |
4dd33369d1 | ||
![]() |
0807c1cbc7 | ||
![]() |
f36a44aacd | ||
![]() |
25e85f68ed | ||
![]() |
51902d779e | ||
![]() |
00b4e51aee | ||
![]() |
7731d0143e | ||
![]() |
2c01251a32 | ||
![]() |
a7863d4b31 | ||
![]() |
609e0056da | ||
![]() |
c9b9dec342 | ||
![]() |
2de7d36138 | ||
![]() |
e809912ae3 | ||
![]() |
0e62b7c2fd | ||
![]() |
410d96a8b8 | ||
![]() |
c64c5fed35 | ||
![]() |
bdbb80121a | ||
![]() |
0e0e152a6e | ||
![]() |
9796962a24 | ||
![]() |
d638196b31 | ||
![]() |
1b0c5bf6e1 | ||
![]() |
3c19d8b639 | ||
![]() |
715ce17e1f | ||
![]() |
781de28b6a | ||
![]() |
d5c4f02871 | ||
![]() |
50b8ba399d | ||
![]() |
47c094ba72 | ||
![]() |
228aa983e0 | ||
![]() |
5556901115 | ||
![]() |
74bb618706 | ||
![]() |
b34f5e7f3f | ||
![]() |
86b47db26f | ||
![]() |
62d6290cf0 | ||
![]() |
34a76cf93b | ||
![]() |
f0ee68beb2 | ||
![]() |
ebf174e9a2 | ||
![]() |
206ad0c528 | ||
![]() |
a6c9074490 | ||
![]() |
b064448119 | ||
![]() |
e60561ffc0 | ||
![]() |
557d898981 | ||
![]() |
36817e581b | ||
![]() |
a90eb64301 | ||
![]() |
7ebc05c257 | ||
![]() |
36563db77e | ||
![]() |
698d416314 | ||
![]() |
c40834f92b | ||
![]() |
75c1b82eff | ||
![]() |
3454c8a03a | ||
![]() |
586182e69b | ||
![]() |
fd31d58186 | ||
![]() |
81c41567bc | ||
![]() |
87ffc64995 | ||
![]() |
de13aa5625 |
77 changed files with 2713 additions and 7493 deletions
14
.env.example
Normal file
14
.env.example
Normal 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=''
|
34
.github/workflows/docker-image-build-and-push-manual.yaml
vendored
Normal file
34
.github/workflows/docker-image-build-and-push-manual.yaml
vendored
Normal 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
|
|
@ -1,4 +1,4 @@
|
||||||
name: Docker Image Build and Push
|
name: Production Deployment
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -38,3 +38,6 @@ next-env.d.ts
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
/.idea
|
/.idea
|
||||||
|
|
||||||
|
# serwist
|
||||||
|
public/sw.js
|
||||||
|
|
24
Dockerfile
24
Dockerfile
|
@ -1,4 +1,4 @@
|
||||||
FROM node:21-alpine AS base
|
FROM oven/bun:1-alpine AS base
|
||||||
|
|
||||||
# Install dependencies only when needed
|
# Install dependencies only when needed
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
|
@ -6,8 +6,8 @@ FROM base AS deps
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json bun.lockb* ./
|
||||||
RUN npm ci
|
RUN bun install
|
||||||
|
|
||||||
|
|
||||||
# Rebuild the source code only when needed
|
# Rebuild the source code only when needed
|
||||||
|
@ -18,23 +18,23 @@ COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# dependencies have to be changed depending on target architecture
|
# 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 bun install @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/bcrypt-linux-x64-musl # arm64 = @node-rs/bcrypt-linux-arm64-musl
|
||||||
|
|
||||||
COPY prisma/ ./prisma/
|
COPY prisma/ ./prisma/
|
||||||
|
|
||||||
RUN npx prisma generate
|
RUN bunx prisma generate
|
||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
RUN npm run build
|
RUN bun run build
|
||||||
|
|
||||||
|
|
||||||
# Production image, copy all the files and run next
|
# Production image, copy all the files and run next
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
@ -52,7 +52,7 @@ USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
ENV PORT 3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME "0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|
72
README.md
72
README.md
|
@ -1,3 +1,75 @@
|
||||||
# Next-Finances
|
# Next-Finances
|
||||||
|
|
||||||
This is my simple finances tracker that I use to keep track of my spending.
|
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.
|
||||||
|
|
11
docker/finances-prod/.env.example
Normal file
11
docker/finances-prod/.env.example
Normal 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"
|
|
@ -8,7 +8,7 @@ services:
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
command: npx prisma migrate deploy
|
command: bunx prisma migrate deploy
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=true"
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
networks:
|
networks:
|
||||||
|
@ -25,7 +25,7 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "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.entrypoints=web, websecure"
|
||||||
- "traefik.http.routers.xyz-next-finances.tls=true"
|
- "traefik.http.routers.xyz-next-finances.tls=true"
|
||||||
- "traefik.http.routers.xyz-next-finances.tls.certresolver=lets-encrypt"
|
- "traefik.http.routers.xyz-next-finances.tls.certresolver=lets-encrypt"
|
||||||
|
@ -42,11 +42,11 @@ services:
|
||||||
depends_on:
|
depends_on:
|
||||||
app-migrations:
|
app-migrations:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
command: npx prisma studio
|
command: bunx prisma studio
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "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.routers.xyz-next-finances-studio.entrypoints=web, websecure"
|
||||||
- "traefik.http.services.xyz-next-finances-studio.loadbalancer.server.port=5555"
|
- "traefik.http.services.xyz-next-finances-studio.loadbalancer.server.port=5555"
|
||||||
- "traefik.http.routers.xyz-next-finances-studio.tls=true"
|
- "traefik.http.routers.xyz-next-finances-studio.tls=true"
|
||||||
|
|
13
docker/finances-prod/traefik.toml
Normal file
13
docker/finances-prod/traefik.toml
Normal 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"
|
13
docker/finances-prod/traefik_setup.sh
Normal file
13
docker/finances-prod/traefik_setup.sh
Normal 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
|
13
docker/finances-prod/watchtower_setup.sh
Normal file
13
docker/finances-prod/watchtower_setup.sh
Normal 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
|
|
@ -1,5 +1,11 @@
|
||||||
/** @type {import('next').NextConfig} */
|
import withSerwistInit from '@serwist/next';
|
||||||
const nextConfig = {
|
|
||||||
|
const withSerwist = withSerwistInit({
|
||||||
|
swSrc: 'src/app/service-worker.ts',
|
||||||
|
swDest: 'public/sw.js',
|
||||||
|
});
|
||||||
|
|
||||||
|
export default withSerwist({
|
||||||
webpack: (config) => {
|
webpack: (config) => {
|
||||||
config.externals.push(
|
config.externals.push(
|
||||||
'@node-rs/argon2',
|
'@node-rs/argon2',
|
||||||
|
@ -11,6 +17,4 @@ const nextConfig = {
|
||||||
env: {
|
env: {
|
||||||
appVersion: process.env.npm_package_version,
|
appVersion: process.env.npm_package_version,
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
|
|
6417
package-lock.json
generated
6417
package-lock.json
generated
File diff suppressed because it is too large
Load diff
84
package.json
84
package.json
|
@ -2,8 +2,7 @@
|
||||||
"name": "next-finances",
|
"name": "next-finances",
|
||||||
"description": "A finances application to keep track of my personal spendings",
|
"description": "A finances application to keep track of my personal spendings",
|
||||||
"homepage": "https://github.com/MarkusThielker/next-finances",
|
"homepage": "https://github.com/MarkusThielker/next-finances",
|
||||||
"version": "1.0.1",
|
"version": "1.3.1",
|
||||||
"license": "MIT",
|
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Markus Thielker"
|
"name": "Markus Thielker"
|
||||||
},
|
},
|
||||||
|
@ -19,50 +18,53 @@
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.3.4",
|
"@auth0/nextjs-auth0": "^4.1.0",
|
||||||
"@lucia-auth/adapter-prisma": "^4.0.0",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@prisma/client": "^5.10.2",
|
"@prisma/client": "^6.1.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-dialog": "^1.0.5",
|
"@radix-ui/react-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||||
"@radix-ui/react-label": "^2.0.2",
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
"@radix-ui/react-navigation-menu": "^1.1.4",
|
"@radix-ui/react-navigation-menu": "^1.2.3",
|
||||||
"@radix-ui/react-popover": "^1.0.7",
|
"@radix-ui/react-popover": "^1.1.6",
|
||||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||||
"@radix-ui/react-select": "^2.0.0",
|
"@radix-ui/react-select": "^2.1.4",
|
||||||
"@radix-ui/react-separator": "^1.0.3",
|
"@radix-ui/react-separator": "^1.1.1",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
"@tanstack/react-table": "^8.13.2",
|
"@serwist/next": "^9.0.11",
|
||||||
"class-variance-authority": "^0.7.0",
|
"@serwist/precaching": "^9.0.11",
|
||||||
"clsx": "^2.1.0",
|
"@serwist/sw": "^9.0.11",
|
||||||
"cmdk": "^1.0.0",
|
"@tanstack/react-table": "^8.20.6",
|
||||||
"date-fns": "^3.3.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"lucia": "^3.0.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.350.0",
|
"cmdk": "^1.0.4",
|
||||||
"next": "14.1.3",
|
"date-fns": "^4.1.0",
|
||||||
"next-themes": "^0.2.1",
|
"lucia": "^3.2.2",
|
||||||
"oslo": "^1.1.3",
|
"lucide-react": "^0.469.0",
|
||||||
"react": "^18",
|
"next": "15.1.2",
|
||||||
"react-day-picker": "^8.10.0",
|
"next-themes": "^0.4.4",
|
||||||
"react-dom": "^18",
|
"react": "^19.0.0",
|
||||||
"react-hook-form": "^7.51.0",
|
"react-day-picker": "8.10.1",
|
||||||
"sonner": "^1.4.3",
|
"react-dom": "^19.0.0",
|
||||||
"swr": "^2.2.5",
|
"react-hook-form": "^7.54.2",
|
||||||
"tailwind-merge": "^2.2.1",
|
"sonner": "^1.7.1",
|
||||||
|
"swr": "^2.3.0",
|
||||||
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zod": "^3.22.4"
|
"vaul": "^1.1.2",
|
||||||
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.11.25",
|
"@types/node": "^22.10.2",
|
||||||
"@types/react": "^18",
|
"@types/react": "^19.0.2",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^19.0.2",
|
||||||
"autoprefixer": "^10.0.1",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "14.1.3",
|
"eslint-config-next": "15.1.2",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"prisma": "^5.10.2",
|
"prisma": "^6.1.0",
|
||||||
"tailwindcss": "^3.3.0",
|
"tailwindcss": "^3.4.17",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.4.2"
|
"typescript": "^5.7.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
@ -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";
|
|
@ -7,40 +7,15 @@ datasource db {
|
||||||
url = env("DATABASE_URL")
|
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 {
|
model Entity {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
user User @relation(fields: [userId], references: [id])
|
name String
|
||||||
name String
|
type EntityType
|
||||||
type EntityType
|
defaultCategory Category? @relation(fields: [defaultCategoryId], references: [id])
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
defaultCategoryId Int? @map("default_category_id")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
paymentsAsPayor Payment[] @relation("PayorEntity")
|
paymentsAsPayor Payment[] @relation("PayorEntity")
|
||||||
paymentsAsPayee Payment[] @relation("PayeeEntity")
|
paymentsAsPayee Payment[] @relation("PayeeEntity")
|
||||||
|
@ -57,7 +32,6 @@ enum EntityType {
|
||||||
model Payment {
|
model Payment {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
user User @relation(fields: [userId], references: [id])
|
|
||||||
amount Int
|
amount Int
|
||||||
currency String @default("EUR")
|
currency String @default("EUR")
|
||||||
date DateTime @default(now())
|
date DateTime @default(now())
|
||||||
|
@ -77,13 +51,13 @@ model Payment {
|
||||||
model Category {
|
model Category {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
user User @relation(fields: [userId], references: [id])
|
|
||||||
name String
|
name String
|
||||||
color String
|
color String
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
payments Payment[]
|
payments Payment[]
|
||||||
|
Entity Entity[]
|
||||||
|
|
||||||
@@unique(fields: [userId, name])
|
@@unique(fields: [userId, name])
|
||||||
@@map("categories")
|
@@map("categories")
|
||||||
|
|
15
public/logo_t_hq_o.svg
Normal file
15
public/logo_t_hq_o.svg
Normal 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
15
public/logo_t_hq_w.svg
Normal 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
16
public/manifest.json
Normal 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"
|
||||||
|
}
|
|
@ -1,50 +1,50 @@
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import React from 'react';
|
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 { Label } from '@/components/ui/label';
|
||||||
import { Input } from '@/components/ui/input';
|
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 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() {
|
export default async function AccountPage() {
|
||||||
|
|
||||||
const user = await getUser();
|
const session = await auth0.getSession();
|
||||||
|
if (!session) {
|
||||||
if (!user) {
|
return redirect('/auth/login');
|
||||||
redirect(URL_SIGN_IN);
|
|
||||||
}
|
}
|
||||||
|
const user = session.user;
|
||||||
|
|
||||||
let paymentCount = 0;
|
let paymentCount = 0;
|
||||||
paymentCount = await prismaClient.payment.count({
|
paymentCount = await prisma.payment.count({
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.sub,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let entityCount = 0;
|
let entityCount = 0;
|
||||||
entityCount = await prismaClient.entity.count({
|
entityCount = await prisma.entity.count({
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.sub,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let categoryCount = 0;
|
let categoryCount = 0;
|
||||||
categoryCount = await prismaClient.category.count({
|
categoryCount = await prisma.category.count({
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.sub,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center">
|
<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>
|
<CardHeader>
|
||||||
<CardTitle>Hey, {user?.username}!</CardTitle>
|
<CardTitle>Hey, {user.name}!</CardTitle>
|
||||||
<CardDescription>This is your account overview.</CardDescription>
|
<CardDescription>This is your account overview.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
|
@ -52,13 +52,13 @@ export default async function AccountPage() {
|
||||||
<Label>ID</Label>
|
<Label>ID</Label>
|
||||||
<Input
|
<Input
|
||||||
disabled
|
disabled
|
||||||
value={user?.id}/>
|
value={user.sub}/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Username</Label>
|
<Label>Username</Label>
|
||||||
<Input
|
<Input
|
||||||
disabled
|
disabled
|
||||||
value={user?.username}/>
|
value={user.name}/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center space-x-4">
|
<div className="flex flex-row items-center space-x-4">
|
||||||
<div>
|
<div>
|
||||||
|
@ -81,13 +81,33 @@ export default async function AccountPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</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' && (
|
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>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
<div className="flex w-full items-center justify-between max-w-md mt-2 text-neutral-600">
|
<div className="flex w-full items-center justify-between max-w-md mt-2 text-neutral-600">
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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't have an account? Sign up
|
|
||||||
</Link>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -25,6 +25,7 @@ export const columns = (
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
size: 65,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'createdAt',
|
accessorKey: 'createdAt',
|
||||||
|
|
|
@ -1,15 +1,20 @@
|
||||||
import { getUser } from '@/auth';
|
import prisma from '@/prisma';
|
||||||
import { prismaClient } from '@/prisma';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import CategoryPageClientContent from '@/components/categoryPageClientComponents';
|
import CategoryPageClientContent from '@/components/categoryPageClientComponents';
|
||||||
import categoryCreateUpdate from '@/lib/actions/categoryCreateUpdate';
|
import categoryCreateUpdate from '@/lib/actions/categoryCreateUpdate';
|
||||||
import categoryDelete from '@/lib/actions/categoryDelete';
|
import categoryDelete from '@/lib/actions/categoryDelete';
|
||||||
|
import { auth0 } from '@/lib/auth';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
export default async function CategoriesPage() {
|
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: {
|
where: {
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
},
|
},
|
||||||
|
@ -25,6 +30,6 @@ export default async function CategoriesPage() {
|
||||||
categories={categories}
|
categories={categories}
|
||||||
onSubmit={categoryCreateUpdate}
|
onSubmit={categoryCreateUpdate}
|
||||||
onDelete={categoryDelete}
|
onDelete={categoryDelete}
|
||||||
className="flex flex-col justify-center space-y-4 p-10"/>
|
className="flex flex-col justify-center space-y-4"/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ColumnDef } from '@tanstack/react-table';
|
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 { CellContext, ColumnDefTemplate } from '@tanstack/table-core';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
export const columns = (
|
export const columns = (
|
||||||
actionCell: ColumnDefTemplate<CellContext<Entity, unknown>>,
|
actionCell: ColumnDefTemplate<CellContext<Entity, unknown>>,
|
||||||
|
categories: Category[],
|
||||||
) => {
|
) => {
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
@ -17,6 +18,31 @@ export const columns = (
|
||||||
{
|
{
|
||||||
accessorKey: 'type',
|
accessorKey: 'type',
|
||||||
header: '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',
|
accessorKey: 'createdAt',
|
||||||
|
|
|
@ -1,15 +1,20 @@
|
||||||
import { prismaClient } from '@/prisma';
|
import prisma from '@/prisma';
|
||||||
import { getUser } from '@/auth';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import EntityPageClientContent from '@/components/entityPageClientComponents';
|
import EntityPageClientContent from '@/components/entityPageClientComponents';
|
||||||
import entityCreateUpdate from '@/lib/actions/entityCreateUpdate';
|
import entityCreateUpdate from '@/lib/actions/entityCreateUpdate';
|
||||||
import entityDelete from '@/lib/actions/entityDelete';
|
import entityDelete from '@/lib/actions/entityDelete';
|
||||||
|
import { auth0 } from '@/lib/auth';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
export default async function EntitiesPage() {
|
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: {
|
where: {
|
||||||
userId: user?.id,
|
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 (
|
return (
|
||||||
<EntityPageClientContent
|
<EntityPageClientContent
|
||||||
entities={entities}
|
entities={entities}
|
||||||
|
categories={categories}
|
||||||
onSubmit={entityCreateUpdate}
|
onSubmit={entityCreateUpdate}
|
||||||
onDelete={entityDelete}
|
onDelete={entityDelete}
|
||||||
className="flex flex-col justify-center space-y-4 p-10"/>
|
className="flex flex-col justify-center space-y-4"/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Metadata } from 'next';
|
import type { Viewport } from 'next';
|
||||||
import { Inter } from 'next/font/google';
|
import { Inter } from 'next/font/google';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
@ -8,9 +8,31 @@ import Navigation from '@/components/navigation';
|
||||||
|
|
||||||
const inter = Inter({subsets: ['latin']});
|
const inter = Inter({subsets: ['latin']});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
const APP_NAME = 'Finances';
|
||||||
title: 'Finances',
|
const APP_DEFAULT_TITLE = 'Finances';
|
||||||
description: 'Track your finances with ease',
|
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({
|
export default function RootLayout({
|
||||||
|
@ -20,12 +42,19 @@ export default function RootLayout({
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<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)}>
|
<body className={cn('dark', inter.className)}>
|
||||||
<Navigation/>
|
<Navigation/>
|
||||||
<main>
|
<main className="p-4 sm:p-8">
|
||||||
{children}
|
{children}
|
||||||
<Toaster/>
|
|
||||||
</main>
|
</main>
|
||||||
|
<Toaster/>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Category, Entity, EntityType } from '@prisma/client';
|
import { Category, Entity, EntityType } from '@prisma/client';
|
||||||
import { Scope, ScopeType } from '@/lib/types/scope';
|
import { Scope, ScopeType } from '@/lib/types/scope';
|
||||||
import { prismaClient } from '@/prisma';
|
import prisma from '@/prisma';
|
||||||
import { getUser } from '@/auth';
|
|
||||||
import DashboardPageClient from '@/components/dashboardPageClientComponents';
|
import DashboardPageClient from '@/components/dashboardPageClientComponents';
|
||||||
|
import { auth0 } from '@/lib/auth';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
export type CategoryNumber = {
|
export type CategoryNumber = {
|
||||||
category: Category,
|
category: Category,
|
||||||
|
@ -15,19 +16,20 @@ export type EntityNumber = {
|
||||||
value: number,
|
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();
|
const session = await auth0.getSession();
|
||||||
if (!user) {
|
if (!session) {
|
||||||
return;
|
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
|
// get all payments in the current scope
|
||||||
const payments = await prismaClient.payment.findMany({
|
const payments = await prisma.payment.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: user?.id,
|
userId: user.sub,
|
||||||
date: {
|
date: {
|
||||||
gte: scope.start,
|
gte: scope.start,
|
||||||
lte: scope.end,
|
lte: scope.end,
|
||||||
|
@ -108,6 +110,7 @@ export default async function DashboardPage(props: { searchParams?: { scope: Sco
|
||||||
userId: '',
|
userId: '',
|
||||||
name: 'Other',
|
name: 'Other',
|
||||||
type: EntityType.Entity,
|
type: EntityType.Entity,
|
||||||
|
defaultCategoryId: null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
},
|
},
|
||||||
|
@ -200,7 +203,7 @@ export default async function DashboardPage(props: { searchParams?: { scope: Sco
|
||||||
categoryPercentages={categoryPercentages}
|
categoryPercentages={categoryPercentages}
|
||||||
entityExpenses={entityExpensesFormat}
|
entityExpenses={entityExpensesFormat}
|
||||||
entityPercentages={entityPercentages}
|
entityPercentages={entityPercentages}
|
||||||
className="flex flex-col justify-center space-y-4 p-10"
|
className="flex flex-col justify-center space-y-4"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,7 @@ export const columns = (
|
||||||
cell: ({row}) => {
|
cell: ({row}) => {
|
||||||
return format(row.original.date, 'PPP');
|
return format(row.original.date, 'PPP');
|
||||||
},
|
},
|
||||||
|
size: 175,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'amount',
|
accessorKey: 'amount',
|
||||||
|
@ -28,6 +29,7 @@ export const columns = (
|
||||||
currency: 'EUR',
|
currency: 'EUR',
|
||||||
}).format(row.getValue('amount') as number / 100);
|
}).format(row.getValue('amount') as number / 100);
|
||||||
},
|
},
|
||||||
|
size: 70,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'payorId',
|
accessorKey: 'payorId',
|
||||||
|
@ -36,6 +38,7 @@ export const columns = (
|
||||||
const entity = entities.find((entity) => entity.id === row.original.payorId);
|
const entity = entities.find((entity) => entity.id === row.original.payorId);
|
||||||
return entity?.name ?? '-';
|
return entity?.name ?? '-';
|
||||||
},
|
},
|
||||||
|
size: 200,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'payeeId',
|
accessorKey: 'payeeId',
|
||||||
|
@ -44,6 +47,7 @@ export const columns = (
|
||||||
const entity = entities.find((entity) => entity.id === row.original.payeeId);
|
const entity = entities.find((entity) => entity.id === row.original.payeeId);
|
||||||
return entity?.name ?? '-';
|
return entity?.name ?? '-';
|
||||||
},
|
},
|
||||||
|
size: 200,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'categoryId',
|
accessorKey: 'categoryId',
|
||||||
|
@ -51,19 +55,27 @@ export const columns = (
|
||||||
cell: ({row}) => {
|
cell: ({row}) => {
|
||||||
const category = categories.find((category) => category.id === row.original.categoryId);
|
const category = categories.find((category) => category.id === row.original.categoryId);
|
||||||
return (
|
return (
|
||||||
<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">
|
category && (
|
||||||
<circle cx="10" cy="10" r="10"/>
|
<div className="flex items-center space-x-4">
|
||||||
</svg>
|
<svg className="h-5" fill={category?.color} viewBox="0 0 20 20"
|
||||||
<p>{category?.name ?? '-'}</p>
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
</div>
|
<circle cx="10" cy="10" r="10"/>
|
||||||
|
</svg>
|
||||||
|
<p>{category?.name ?? '-'}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
size: 200,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'note',
|
accessorKey: 'note',
|
||||||
header: 'Note',
|
header: 'Note',
|
||||||
|
size: 200,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
|
|
|
@ -1,17 +1,22 @@
|
||||||
import { getUser } from '@/auth';
|
import prisma from '@/prisma';
|
||||||
import { prismaClient } from '@/prisma';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PaymentPageClientContent from '@/components/paymentPageClientComponents';
|
import PaymentPageClientContent from '@/components/paymentPageClientComponents';
|
||||||
import paymentCreateUpdate from '@/lib/actions/paymentCreateUpdate';
|
import paymentCreateUpdate from '@/lib/actions/paymentCreateUpdate';
|
||||||
import paymentDelete from '@/lib/actions/paymentDelete';
|
import paymentDelete from '@/lib/actions/paymentDelete';
|
||||||
|
import { auth0 } from '@/lib/auth';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
export default async function PaymentsPage() {
|
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: {
|
where: {
|
||||||
userId: user?.id,
|
userId: user.sub,
|
||||||
},
|
},
|
||||||
orderBy: [
|
orderBy: [
|
||||||
{
|
{
|
||||||
|
@ -23,9 +28,9 @@ export default async function PaymentsPage() {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const entities = await prismaClient.entity.findMany({
|
const entities = await prisma.entity.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: user?.id,
|
userId: user.sub,
|
||||||
},
|
},
|
||||||
orderBy: [
|
orderBy: [
|
||||||
{
|
{
|
||||||
|
@ -37,9 +42,9 @@ export default async function PaymentsPage() {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const categories = await prismaClient.category.findMany({
|
const categories = await prisma.category.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: user?.id,
|
userId: user.sub,
|
||||||
},
|
},
|
||||||
orderBy: [
|
orderBy: [
|
||||||
{
|
{
|
||||||
|
@ -58,6 +63,6 @@ export default async function PaymentsPage() {
|
||||||
categories={categories}
|
categories={categories}
|
||||||
onSubmit={paymentCreateUpdate}
|
onSubmit={paymentCreateUpdate}
|
||||||
onDelete={paymentDelete}
|
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
46
src/app/service-worker.ts
Normal 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();
|
67
src/auth.ts
67
src/auth.ts
|
@ -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;
|
|
||||||
}
|
|
|
@ -23,6 +23,8 @@ import {
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { categoryFormSchema } from '@/lib/form-schemas/categoryFormSchema';
|
import { categoryFormSchema } from '@/lib/form-schemas/categoryFormSchema';
|
||||||
import CategoryForm from '@/components/form/categoryForm';
|
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}: {
|
export default function CategoryPageClientContent({categories, onSubmit, onDelete, className}: {
|
||||||
categories: Category[],
|
categories: Category[],
|
||||||
|
@ -31,6 +33,7 @@ export default function CategoryPageClientContent({categories, onSubmit, onDelet
|
||||||
className: string,
|
className: string,
|
||||||
}) {
|
}) {
|
||||||
|
|
||||||
|
const isDesktop = useMediaQuery('(min-width: 768px)');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
|
@ -97,26 +100,51 @@ export default function CategoryPageClientContent({categories, onSubmit, onDelet
|
||||||
<p className="text-3xl font-semibold">Categories</p>
|
<p className="text-3xl font-semibold">Categories</p>
|
||||||
|
|
||||||
{/* Edit dialog */}
|
{/* Edit dialog */}
|
||||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
{
|
||||||
<DialogTrigger asChild>
|
isDesktop ? (
|
||||||
<Button
|
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||||
onClick={() => {
|
<DialogTrigger asChild>
|
||||||
setSelectedCategory(undefined);
|
<Button
|
||||||
setIsEditDialogOpen(true);
|
onClick={() => {
|
||||||
}}>
|
setSelectedCategory(undefined);
|
||||||
Create Category
|
setIsEditDialogOpen(true);
|
||||||
</Button>
|
}}>
|
||||||
</DialogTrigger>
|
Create Category
|
||||||
<DialogContent>
|
</Button>
|
||||||
<DialogHeader>
|
</DialogTrigger>
|
||||||
<DialogTitle>{selectedCategory?.id ? 'Update Category' : 'Create Category'}</DialogTitle>
|
<DialogContent>
|
||||||
</DialogHeader>
|
<DialogHeader>
|
||||||
<CategoryForm
|
<DialogTitle>{selectedCategory?.id ? 'Update Category' : 'Create Category'}</DialogTitle>
|
||||||
value={selectedCategory}
|
</DialogHeader>
|
||||||
onSubmit={handleSubmit}
|
<CategoryForm
|
||||||
className="flex flex-row space-x-4 py-4"/>
|
value={selectedCategory}
|
||||||
</DialogContent>
|
onSubmit={handleSubmit}
|
||||||
</Dialog>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Data Table */}
|
{/* Data Table */}
|
||||||
|
|
|
@ -49,7 +49,7 @@ export default function DashboardPageClientContent(
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<div className="flex flex-col space-y-4">
|
<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>
|
<p className="text-3xl font-semibold">Dashboard</p>
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Entity } from '@prisma/client';
|
import { Category, Entity } from '@prisma/client';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { CellContext } from '@tanstack/table-core';
|
import { CellContext } from '@tanstack/table-core';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
@ -24,14 +24,18 @@ import {
|
||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
} from '@/components/ui/alert-dialog';
|
} 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[],
|
entities: Entity[],
|
||||||
|
categories: Category[],
|
||||||
onSubmit: (data: z.infer<typeof entityFormSchema>) => Promise<ActionResponse>,
|
onSubmit: (data: z.infer<typeof entityFormSchema>) => Promise<ActionResponse>,
|
||||||
onDelete: (id: number) => Promise<ActionResponse>,
|
onDelete: (id: number) => Promise<ActionResponse>,
|
||||||
className: string,
|
className: string,
|
||||||
}) {
|
}) {
|
||||||
|
|
||||||
|
const isDesktop = useMediaQuery('(min-width: 768px)');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
|
@ -125,26 +129,53 @@ export default function EntityPageClientContent({entities, onSubmit, onDelete, c
|
||||||
<p className="text-3xl font-semibold">Entities</p>
|
<p className="text-3xl font-semibold">Entities</p>
|
||||||
|
|
||||||
{/* Edit dialog */}
|
{/* Edit dialog */}
|
||||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
{
|
||||||
<DialogTrigger asChild>
|
isDesktop ? (
|
||||||
<Button
|
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||||
onClick={() => {
|
<DialogTrigger asChild>
|
||||||
setSelectedEntity(undefined);
|
<Button
|
||||||
setIsEditDialogOpen(true);
|
onClick={() => {
|
||||||
}}>
|
setSelectedEntity(undefined);
|
||||||
Create Entity
|
setIsEditDialogOpen(true);
|
||||||
</Button>
|
}}>
|
||||||
</DialogTrigger>
|
Create Entity
|
||||||
<DialogContent>
|
</Button>
|
||||||
<DialogHeader>
|
</DialogTrigger>
|
||||||
<DialogTitle>{selectedEntity?.id ? 'Update Entity' : 'Create Entity'}</DialogTitle>
|
<DialogContent>
|
||||||
</DialogHeader>
|
<DialogHeader>
|
||||||
<EntityForm
|
<DialogTitle>{selectedEntity?.id ? 'Update Entity' : 'Create Entity'}</DialogTitle>
|
||||||
value={selectedEntity}
|
</DialogHeader>
|
||||||
onSubmit={handleSubmit}
|
<EntityForm
|
||||||
className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4"/>
|
value={selectedEntity}
|
||||||
</DialogContent>
|
categories={categories}
|
||||||
</Dialog>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Filter input */}
|
{/* Filter input */}
|
||||||
|
@ -156,7 +187,7 @@ export default function EntityPageClientContent({entities, onSubmit, onDelete, c
|
||||||
{/* Data Table */}
|
{/* Data Table */}
|
||||||
<DataTable
|
<DataTable
|
||||||
className="w-full"
|
className="w-full"
|
||||||
columns={columns(actionCell)}
|
columns={columns(actionCell, categories)}
|
||||||
data={filterEntities(entities, filter)}
|
data={filterEntities(entities, filter)}
|
||||||
pagination/>
|
pagination/>
|
||||||
|
|
||||||
|
|
|
@ -12,11 +12,13 @@ import { useRouter } from 'next/navigation';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { sonnerContent } from '@/components/ui/sonner';
|
import { sonnerContent } from '@/components/ui/sonner';
|
||||||
import { entityFormSchema } from '@/lib/form-schemas/entityFormSchema';
|
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 { 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,
|
value: Entity | undefined,
|
||||||
|
categories: Category[],
|
||||||
onSubmit: (data: z.infer<typeof entityFormSchema>) => Promise<ActionResponse>
|
onSubmit: (data: z.infer<typeof entityFormSchema>) => Promise<ActionResponse>
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
|
@ -29,6 +31,7 @@ export default function EntityForm({value, onSubmit, className}: {
|
||||||
id: value?.id ?? undefined,
|
id: value?.id ?? undefined,
|
||||||
name: value?.name ?? '',
|
name: value?.name ?? '',
|
||||||
type: value?.type ?? EntityType.Entity,
|
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 (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form autoComplete="off" onSubmit={form.handleSubmit(handleSubmit)}>
|
<form autoComplete="off" onSubmit={form.handleSubmit(handleSubmit)}>
|
||||||
|
@ -94,6 +104,22 @@ export default function EntityForm({value, onSubmit, className}: {
|
||||||
</FormItem>
|
</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>
|
</div>
|
||||||
<Button type="submit" className="w-full">{value?.id ? 'Update Entity' : 'Create Entity'}</Button>
|
<Button type="submit" className="w-full">{value?.id ? 'Update Entity' : 'Create Entity'}</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -5,7 +5,7 @@ import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import React, { useState } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ActionResponse } from '@/lib/types/actionResponse';
|
import { ActionResponse } from '@/lib/types/actionResponse';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
@ -16,11 +16,11 @@ import { paymentFormSchema } from '@/lib/form-schemas/paymentFormSchema';
|
||||||
import CurrencyInput from '@/components/ui/currency-input';
|
import CurrencyInput from '@/components/ui/currency-input';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { CalendarIcon, Check, ChevronsUpDown } from 'lucide-react';
|
import { CalendarIcon } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { Calendar } from '@/components/ui/calendar';
|
import { Calendar } from '@/components/ui/calendar';
|
||||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { AutoCompleteInput } from '@/components/ui/auto-complete-input';
|
||||||
|
|
||||||
export default function PaymentForm({value, entities, categories, onSubmit, className}: {
|
export default function PaymentForm({value, entities, categories, onSubmit, className}: {
|
||||||
value: Payment | undefined,
|
value: Payment | undefined,
|
||||||
|
@ -32,12 +32,6 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
|
||||||
|
|
||||||
const router = useRouter();
|
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>>({
|
const form = useForm<z.infer<typeof paymentFormSchema>>({
|
||||||
resolver: zodResolver(paymentFormSchema),
|
resolver: zodResolver(paymentFormSchema),
|
||||||
defaultValues: {
|
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 (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form autoComplete="off" onSubmit={form.handleSubmit(handleSubmit)}>
|
<form autoComplete="off" onSubmit={form.handleSubmit(handleSubmit)}>
|
||||||
|
@ -127,10 +125,7 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
|
||||||
<Calendar
|
<Calendar
|
||||||
mode="single"
|
mode="single"
|
||||||
selected={field.value}
|
selected={field.value}
|
||||||
onSelect={(e) => {
|
onSelect={field.onChange}
|
||||||
field.onChange(e);
|
|
||||||
}}
|
|
||||||
initialFocus
|
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
@ -145,61 +140,18 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
|
||||||
render={({field}) => (
|
render={({field}) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Payor</FormLabel>
|
<FormLabel>Payor</FormLabel>
|
||||||
<Popover open={payorOpen} onOpenChange={(open) => {
|
<FormControl>
|
||||||
setPayorOpen(open);
|
<AutoCompleteInput
|
||||||
setFilter('');
|
placeholder="Select payor"
|
||||||
}}>
|
items={entitiesMapped}
|
||||||
<PopoverTrigger asChild>
|
{...field}
|
||||||
<FormControl>
|
onChange={(e) => {
|
||||||
<Button
|
field.onChange(e);
|
||||||
variant="outline"
|
if (e && e.target.value) {
|
||||||
role="combobox"
|
payeeRef && payeeRef.current.focus();
|
||||||
className={cn(
|
}
|
||||||
'w-full justify-between',
|
}}/>
|
||||||
!field.value && 'text-muted-foreground',
|
</FormControl>
|
||||||
)}
|
|
||||||
>
|
|
||||||
{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>
|
|
||||||
</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/>
|
<FormMessage/>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
@ -211,62 +163,26 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
|
||||||
render={({field}) => (
|
render={({field}) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Payee</FormLabel>
|
<FormLabel>Payee</FormLabel>
|
||||||
<Popover open={payeeOpen} onOpenChange={(open) => {
|
<FormControl ref={payeeRef}>
|
||||||
setPayeeOpen(open);
|
<AutoCompleteInput
|
||||||
setFilter('');
|
placeholder="Select payee"
|
||||||
}}>
|
items={entitiesMapped}
|
||||||
<PopoverTrigger asChild>
|
{...field}
|
||||||
<FormControl>
|
onChange={(e) => {
|
||||||
<Button
|
field.onChange(e);
|
||||||
variant="outline"
|
if (e && e.target.value) {
|
||||||
role="combobox"
|
const entity = entities.find((entity) => entity.id === Number(e.target.value));
|
||||||
className={cn(
|
|
||||||
'w-full justify-between',
|
// only focus category input if payee has no default category
|
||||||
!field.value && 'text-muted-foreground',
|
if (entity?.defaultCategoryId !== null) {
|
||||||
)}
|
form.setValue('categoryId', entity?.defaultCategoryId);
|
||||||
>
|
submitRef && submitRef.current.focus();
|
||||||
{field.value
|
} else {
|
||||||
? entitiesMapped.find(
|
categoryRef && categoryRef.current.focus();
|
||||||
(item) => item.value === field.value,
|
}
|
||||||
)?.label
|
}
|
||||||
: 'Select entity'}
|
}}/>
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50"/>
|
</FormControl>
|
||||||
</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">
|
|
||||||
{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/>
|
<FormMessage/>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
@ -278,62 +194,19 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
|
||||||
render={({field}) => (
|
render={({field}) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Category</FormLabel>
|
<FormLabel>Category</FormLabel>
|
||||||
<Popover open={categoryOpen} onOpenChange={(open) => {
|
<FormControl ref={categoryRef}>
|
||||||
setCategoryOpen(open);
|
<AutoCompleteInput
|
||||||
setFilter('');
|
placeholder="Select category"
|
||||||
}}>
|
items={categoriesMapped}
|
||||||
<PopoverTrigger asChild>
|
{...field}
|
||||||
<FormControl>
|
onChange={(e) => {
|
||||||
<Button
|
field.onChange(e);
|
||||||
variant="outline"
|
if (e && e.target.value) {
|
||||||
role="combobox"
|
submitRef && submitRef.current.focus();
|
||||||
className={cn(
|
}
|
||||||
'w-full justify-between',
|
}}
|
||||||
!field.value && 'text-muted-foreground',
|
/>
|
||||||
)}
|
</FormControl>
|
||||||
>
|
|
||||||
{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);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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/>
|
<FormMessage/>
|
||||||
</FormItem>
|
</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>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|
100
src/components/form/serverActionTrigger.tsx
Normal file
100
src/components/form/serverActionTrigger.tsx
Normal 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 };
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -2,65 +2,125 @@
|
||||||
|
|
||||||
import {
|
import {
|
||||||
NavigationMenu,
|
NavigationMenu,
|
||||||
|
navigationMenuIconTriggerStyle,
|
||||||
NavigationMenuItem,
|
NavigationMenuItem,
|
||||||
NavigationMenuLink,
|
NavigationMenuLink,
|
||||||
NavigationMenuList,
|
NavigationMenuList,
|
||||||
navigationMenuTriggerStyle,
|
navigationMenuTriggerStyle,
|
||||||
} from '@/components/ui/navigation-menu';
|
} from '@/components/ui/navigation-menu';
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import Link from 'next/link';
|
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() {
|
export default function Navigation() {
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex sticky items-center border-b border-border bg-background">
|
<div className="flex sticky items-center border-b border-border bg-background">
|
||||||
<NavigationMenu>
|
<div className="md:hidden">
|
||||||
<NavigationMenuList className="flex w-screen items-center justify-between px-4 py-2">
|
<Drawer open={open} onOpenChange={open => setOpen(open)}>
|
||||||
<div className="inline-flex space-x-2">
|
<DrawerTrigger asChild>
|
||||||
|
<Button size="icon" variant="ghost" className="m-2">
|
||||||
<img src={'/logo_white.png'} alt="Finances" className="h-10 w-10 mx-3"/>
|
<Menu/>
|
||||||
|
</Button>
|
||||||
<NavigationMenuItem>
|
</DrawerTrigger>
|
||||||
<Link href="/" legacyBehavior passHref>
|
<DrawerContent>
|
||||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
<div className="flex flex-col space-y-2 w-full rounded-none p-4">
|
||||||
Dashboard
|
<Link
|
||||||
</NavigationMenuLink>
|
href="/"
|
||||||
|
className={navigationMenuIconTriggerStyle()}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
passHref>
|
||||||
|
<Home/>
|
||||||
|
<span>Dashboard</span>
|
||||||
</Link>
|
</Link>
|
||||||
</NavigationMenuItem>
|
<Link
|
||||||
<NavigationMenuItem>
|
href="/payments"
|
||||||
<Link href="/payments" legacyBehavior passHref>
|
className={navigationMenuIconTriggerStyle()}
|
||||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
onClick={() => setOpen(false)}
|
||||||
Payments
|
passHref>
|
||||||
</NavigationMenuLink>
|
<Banknote/>
|
||||||
|
<span>Payments</span>
|
||||||
</Link>
|
</Link>
|
||||||
</NavigationMenuItem>
|
<Link
|
||||||
<NavigationMenuItem>
|
href="/entities"
|
||||||
<Link href="/entities" legacyBehavior passHref>
|
className={navigationMenuIconTriggerStyle()}
|
||||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
onClick={() => setOpen(false)}
|
||||||
Entities
|
passHref>
|
||||||
</NavigationMenuLink>
|
<UserSearch/>
|
||||||
|
<span>Entities</span>
|
||||||
</Link>
|
</Link>
|
||||||
</NavigationMenuItem>
|
<Link
|
||||||
<NavigationMenuItem>
|
href="/categories"
|
||||||
<Link href="/categories" legacyBehavior passHref>
|
className={navigationMenuIconTriggerStyle()}
|
||||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
onClick={() => setOpen(false)}
|
||||||
Categories
|
passHref>
|
||||||
</NavigationMenuLink>
|
<Tag/>
|
||||||
|
<span>Categories</span>
|
||||||
</Link>
|
</Link>
|
||||||
</NavigationMenuItem>
|
<Link
|
||||||
</div>
|
href="/account"
|
||||||
|
className={navigationMenuIconTriggerStyle()}
|
||||||
<NavigationMenuItem>
|
onClick={() => setOpen(false)}
|
||||||
<Link href="/account" legacyBehavior passHref>
|
passHref>
|
||||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
|
||||||
<span className="sr-only">Account</span>
|
|
||||||
<User/>
|
<User/>
|
||||||
</NavigationMenuLink>
|
<span>Account</span>
|
||||||
</Link>
|
</Link>
|
||||||
</NavigationMenuItem>
|
</div>
|
||||||
</NavigationMenuList>
|
</DrawerContent>
|
||||||
</NavigationMenu>
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:flex">
|
||||||
|
<NavigationMenu>
|
||||||
|
<NavigationMenuList className="flex w-screen items-center justify-between sm:px-4 py-2">
|
||||||
|
<div className="inline-flex space-x-2">
|
||||||
|
|
||||||
|
<img src={'/logo_t_hq_w.svg'} alt="Finances" className="h-10 w-10 mx-3"/>
|
||||||
|
|
||||||
|
<NavigationMenuItem>
|
||||||
|
<Link href="/" legacyBehavior passHref>
|
||||||
|
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||||
|
Dashboard
|
||||||
|
</NavigationMenuLink>
|
||||||
|
</Link>
|
||||||
|
</NavigationMenuItem>
|
||||||
|
<NavigationMenuItem>
|
||||||
|
<Link href="/payments" legacyBehavior passHref>
|
||||||
|
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||||
|
Payments
|
||||||
|
</NavigationMenuLink>
|
||||||
|
</Link>
|
||||||
|
</NavigationMenuItem>
|
||||||
|
<NavigationMenuItem>
|
||||||
|
<Link href="/entities" legacyBehavior passHref>
|
||||||
|
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||||
|
Entities
|
||||||
|
</NavigationMenuLink>
|
||||||
|
</Link>
|
||||||
|
</NavigationMenuItem>
|
||||||
|
<NavigationMenuItem>
|
||||||
|
<Link href="/categories" legacyBehavior passHref>
|
||||||
|
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||||
|
Categories
|
||||||
|
</NavigationMenuLink>
|
||||||
|
</Link>
|
||||||
|
</NavigationMenuItem>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NavigationMenuItem>
|
||||||
|
<Link href="/account" legacyBehavior passHref>
|
||||||
|
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||||
|
<span className="sr-only">Account</span>
|
||||||
|
<User/>
|
||||||
|
</NavigationMenuLink>
|
||||||
|
</Link>
|
||||||
|
</NavigationMenuItem>
|
||||||
|
</NavigationMenuList>
|
||||||
|
</NavigationMenu>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,6 +23,8 @@ import { paymentFormSchema } from '@/lib/form-schemas/paymentFormSchema';
|
||||||
import { Category, Entity, Payment } from '@prisma/client';
|
import { Category, Entity, Payment } from '@prisma/client';
|
||||||
import PaymentForm from '@/components/form/paymentForm';
|
import PaymentForm from '@/components/form/paymentForm';
|
||||||
import { columns } from '@/app/payments/columns';
|
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({
|
export default function PaymentPageClientContent({
|
||||||
payments,
|
payments,
|
||||||
|
@ -40,6 +42,7 @@ export default function PaymentPageClientContent({
|
||||||
className: string,
|
className: string,
|
||||||
}) {
|
}) {
|
||||||
|
|
||||||
|
const isDesktop = useMediaQuery('(min-width: 768px)');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
|
@ -100,48 +103,61 @@ 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 (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
<p className="text-3xl font-semibold">Payments</p>
|
<p className="text-3xl font-semibold">Payments</p>
|
||||||
|
|
||||||
{/* Edit dialog */}
|
{/* Edit dialog */}
|
||||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
{
|
||||||
<DialogTrigger asChild>
|
isDesktop ? (
|
||||||
<Button
|
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||||
onClick={() => {
|
<DialogTrigger asChild>
|
||||||
setSelectedPayment(undefined);
|
<Button
|
||||||
setIsEditDialogOpen(true);
|
onClick={() => {
|
||||||
}}>
|
setSelectedPayment(undefined);
|
||||||
Create Payment
|
setIsEditDialogOpen(true);
|
||||||
</Button>
|
}}>
|
||||||
</DialogTrigger>
|
Create Payment
|
||||||
<DialogContent>
|
</Button>
|
||||||
<DialogHeader>
|
</DialogTrigger>
|
||||||
<DialogTitle>{selectedPayment?.id ? 'Update Payment' : 'Create Payment'}</DialogTitle>
|
<DialogContent>
|
||||||
</DialogHeader>
|
<DialogHeader>
|
||||||
<PaymentForm
|
<DialogTitle>{selectedPayment?.id ? 'Update Payment' : 'Create Payment'}</DialogTitle>
|
||||||
value={selectedPayment}
|
</DialogHeader>
|
||||||
entities={entities}
|
<PaymentForm
|
||||||
categories={categories}
|
value={selectedPayment}
|
||||||
onSubmit={handleSubmit}
|
entities={entities}
|
||||||
className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4"/>
|
categories={categories}
|
||||||
</DialogContent>
|
onSubmit={handleSubmit}
|
||||||
</Dialog>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Data Table */}
|
{/* Data Table */}
|
||||||
|
|
129
src/components/ui/auto-complete-input.tsx
Normal file
129
src/components/ui/auto-complete-input.tsx
Normal 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 };
|
|
@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
@ -40,11 +40,11 @@ export interface ButtonProps
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({className, variant, size, asChild = false, ...props}, ref) => {
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
const Comp = asChild ? Slot : 'button';
|
const Comp = asChild ? Slot : 'button';
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
className={cn(buttonVariants({variant, size, className}))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -10,11 +10,11 @@ import { buttonVariants } from '@/components/ui/button';
|
||||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||||
|
|
||||||
function Calendar({
|
function Calendar({
|
||||||
className,
|
className,
|
||||||
classNames,
|
classNames,
|
||||||
showOutsideDays = true,
|
showOutsideDays = true,
|
||||||
...props
|
...props
|
||||||
}: CalendarProps) {
|
}: CalendarProps) {
|
||||||
return (
|
return (
|
||||||
<DayPicker
|
<DayPicker
|
||||||
showOutsideDays={showOutsideDays}
|
showOutsideDays={showOutsideDays}
|
||||||
|
@ -26,7 +26,7 @@ function Calendar({
|
||||||
caption_label: 'text-sm font-medium',
|
caption_label: 'text-sm font-medium',
|
||||||
nav: 'space-x-1 flex items-center',
|
nav: 'space-x-1 flex items-center',
|
||||||
nav_button: cn(
|
nav_button: cn(
|
||||||
buttonVariants({variant: 'outline'}),
|
buttonVariants({ variant: 'outline' }),
|
||||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||||
),
|
),
|
||||||
nav_button_previous: 'absolute left-1',
|
nav_button_previous: 'absolute left-1',
|
||||||
|
@ -38,7 +38,7 @@ function Calendar({
|
||||||
row: 'flex w-full mt-2',
|
row: 'flex w-full mt-2',
|
||||||
cell: 'h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
|
cell: 'h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
|
||||||
day: cn(
|
day: cn(
|
||||||
buttonVariants({variant: 'ghost'}),
|
buttonVariants({ variant: 'ghost' }),
|
||||||
'h-9 w-9 p-0 font-normal aria-selected:opacity-100',
|
'h-9 w-9 p-0 font-normal aria-selected:opacity-100',
|
||||||
),
|
),
|
||||||
day_range_end: 'day-range-end',
|
day_range_end: 'day-range-end',
|
||||||
|
@ -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',
|
'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_today: 'bg-accent text-accent-foreground',
|
||||||
day_outside:
|
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_disabled: 'text-muted-foreground opacity-50',
|
||||||
day_range_middle:
|
day_range_middle:
|
||||||
'aria-selected:bg-accent aria-selected:text-accent-foreground',
|
'aria-selected:bg-accent aria-selected:text-accent-foreground',
|
||||||
|
@ -54,8 +54,12 @@ function Calendar({
|
||||||
...classNames,
|
...classNames,
|
||||||
}}
|
}}
|
||||||
components={{
|
components={{
|
||||||
IconLeft: ({...props}) => <ChevronLeft className="h-4 w-4"/>,
|
IconLeft: ({ className, ...props }) => (
|
||||||
IconRight: ({...props}) => <ChevronRight className="h-4 w-4"/>,
|
<ChevronLeft className={cn('h-4 w-4', className)} {...props} />
|
||||||
|
),
|
||||||
|
IconRight: ({ className, ...props }) => (
|
||||||
|
<ChevronRight className={cn('h-4 w-4', className)} {...props} />
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -53,6 +53,7 @@ export default function CurrencyInput(props: TextInputProps) {
|
||||||
<Input
|
<Input
|
||||||
placeholder={props.placeholder}
|
placeholder={props.placeholder}
|
||||||
type="text"
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(ev) => {
|
onChange={(ev) => {
|
||||||
setValue(ev.target.value);
|
setValue(ev.target.value);
|
||||||
|
|
|
@ -45,7 +45,8 @@ export function DataTable<TData, TValue>({
|
||||||
<TableRow key={headerGroup.id}>
|
<TableRow key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => {
|
{headerGroup.headers.map((header) => {
|
||||||
return (
|
return (
|
||||||
<TableHead key={header.id}>
|
<TableHead key={header.id}
|
||||||
|
style={{minWidth: `${header.column.getSize()}px`}}>
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
|
|
118
src/components/ui/drawer.tsx
Normal file
118
src/components/ui/drawer.tsx
Normal 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,
|
||||||
|
};
|
|
@ -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',
|
'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<
|
const NavigationMenuTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
|
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
|
||||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
|
||||||
|
@ -117,6 +121,7 @@ NavigationMenuIndicator.displayName =
|
||||||
|
|
||||||
export {
|
export {
|
||||||
navigationMenuTriggerStyle,
|
navigationMenuTriggerStyle,
|
||||||
|
navigationMenuIconTriggerStyle,
|
||||||
NavigationMenu,
|
NavigationMenu,
|
||||||
NavigationMenuList,
|
NavigationMenuList,
|
||||||
NavigationMenuItem,
|
NavigationMenuItem,
|
||||||
|
|
|
@ -12,7 +12,7 @@ const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||||
const PopoverContent = React.forwardRef<
|
const PopoverContent = React.forwardRef<
|
||||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
>(({className, align = 'center', sideOffset = 4, ...props}, ref) => (
|
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||||
<PopoverPrimitive.Portal>
|
<PopoverPrimitive.Portal>
|
||||||
<PopoverPrimitive.Content
|
<PopoverPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { ActionResponse } from '@/lib/types/actionResponse';
|
import { ActionResponse } from '@/lib/types/actionResponse';
|
||||||
import { prismaClient } from '@/prisma';
|
import prisma from '@/prisma';
|
||||||
import { getUser } from '@/auth';
|
|
||||||
import { URL_SIGN_IN } from '@/lib/constants';
|
import { URL_SIGN_IN } from '@/lib/constants';
|
||||||
import { categoryFormSchema } from '@/lib/form-schemas/categoryFormSchema';
|
import { categoryFormSchema } from '@/lib/form-schemas/categoryFormSchema';
|
||||||
|
import { auth0 } from '@/lib/auth';
|
||||||
|
|
||||||
export default async function categoryCreateUpdate({
|
export default async function categoryCreateUpdate({
|
||||||
id,
|
id,
|
||||||
|
@ -12,20 +12,20 @@ export default async function categoryCreateUpdate({
|
||||||
}: z.infer<typeof categoryFormSchema>): Promise<ActionResponse> {
|
}: z.infer<typeof categoryFormSchema>): Promise<ActionResponse> {
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
// check that user is logged in
|
const session = await auth0.getSession();
|
||||||
const user = await getUser();
|
if (!session) {
|
||||||
if (!user) {
|
|
||||||
return {
|
return {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: 'You must be logged in to create/update an category.',
|
message: 'You aren\'t signed in.',
|
||||||
redirect: URL_SIGN_IN,
|
redirect: URL_SIGN_IN,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const user = session.user;
|
||||||
|
|
||||||
// create/update category
|
// create/update category
|
||||||
try {
|
try {
|
||||||
if (id) {
|
if (id) {
|
||||||
await prismaClient.category.update({
|
await prisma.category.update({
|
||||||
where: {
|
where: {
|
||||||
id: id,
|
id: id,
|
||||||
},
|
},
|
||||||
|
@ -42,9 +42,9 @@ export default async function categoryCreateUpdate({
|
||||||
message: `'${name}' updated`,
|
message: `'${name}' updated`,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
await prismaClient.category.create({
|
await prisma.category.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.sub,
|
||||||
name: name,
|
name: name,
|
||||||
color: color,
|
color: color,
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { ActionResponse } from '@/lib/types/actionResponse';
|
import { ActionResponse } from '@/lib/types/actionResponse';
|
||||||
import { prismaClient } from '@/prisma';
|
import prisma from '@/prisma';
|
||||||
import { getUser } from '@/auth';
|
|
||||||
import { URL_SIGN_IN } from '@/lib/constants';
|
import { URL_SIGN_IN } from '@/lib/constants';
|
||||||
|
import { auth0 } from '@/lib/auth';
|
||||||
|
|
||||||
export default async function categoryDelete(id: number): Promise<ActionResponse> {
|
export default async function categoryDelete(id: number): Promise<ActionResponse> {
|
||||||
'use server';
|
'use server';
|
||||||
|
@ -14,21 +14,21 @@ export default async function categoryDelete(id: number): Promise<ActionResponse
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// check that user is logged in
|
const session = await auth0.getSession();
|
||||||
const user = await getUser();
|
if (!session) {
|
||||||
if (!user) {
|
|
||||||
return {
|
return {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: 'You must be logged in to delete an category.',
|
message: 'You aren\'t signed in.',
|
||||||
redirect: URL_SIGN_IN,
|
redirect: URL_SIGN_IN,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const user = session.user;
|
||||||
|
|
||||||
// check that category is associated with user
|
// check that category is associated with user
|
||||||
const category = await prismaClient.category.findFirst({
|
const category = await prisma.category.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: id,
|
id: id,
|
||||||
userId: user.id,
|
userId: user.sub,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!category) {
|
if (!category) {
|
||||||
|
@ -38,15 +38,25 @@ export default async function categoryDelete(id: number): Promise<ActionResponse
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// delete category
|
|
||||||
try {
|
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: {
|
where: {
|
||||||
id: category.id,
|
id: category.id,
|
||||||
userId: user.id,
|
userId: user.sub,
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
);
|
});
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {
|
return {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
|
|
40
src/lib/actions/clearAccountData.ts
Normal file
40
src/lib/actions/clearAccountData.ts
Normal 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.',
|
||||||
|
};
|
||||||
|
}
|
|
@ -1,37 +1,39 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { ActionResponse } from '@/lib/types/actionResponse';
|
import { ActionResponse } from '@/lib/types/actionResponse';
|
||||||
import { entityFormSchema } from '@/lib/form-schemas/entityFormSchema';
|
import { entityFormSchema } from '@/lib/form-schemas/entityFormSchema';
|
||||||
import { prismaClient } from '@/prisma';
|
import prisma from '@/prisma';
|
||||||
import { getUser } from '@/auth';
|
|
||||||
import { URL_SIGN_IN } from '@/lib/constants';
|
import { URL_SIGN_IN } from '@/lib/constants';
|
||||||
|
import { auth0 } from '@/lib/auth';
|
||||||
|
|
||||||
export default async function entityCreateUpdate({
|
export default async function entityCreateUpdate({
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
type,
|
type,
|
||||||
|
defaultCategoryId,
|
||||||
}: z.infer<typeof entityFormSchema>): Promise<ActionResponse> {
|
}: z.infer<typeof entityFormSchema>): Promise<ActionResponse> {
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
// check that user is logged in
|
const session = await auth0.getSession();
|
||||||
const user = await getUser();
|
if (!session) {
|
||||||
if (!user) {
|
|
||||||
return {
|
return {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: 'You must be logged in to create/update an entity.',
|
message: 'You aren\'t signed in.',
|
||||||
redirect: URL_SIGN_IN,
|
redirect: URL_SIGN_IN,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const user = session.user;
|
||||||
|
|
||||||
// create/update entity
|
// create/update entity
|
||||||
try {
|
try {
|
||||||
if (id) {
|
if (id) {
|
||||||
await prismaClient.entity.update({
|
await prisma.entity.update({
|
||||||
where: {
|
where: {
|
||||||
id: id,
|
id: id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
name: name,
|
name: name,
|
||||||
type: type,
|
type: type,
|
||||||
|
defaultCategoryId: defaultCategoryId ?? null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -42,11 +44,12 @@ export default async function entityCreateUpdate({
|
||||||
message: `${type} '${name}' updated`,
|
message: `${type} '${name}' updated`,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
await prismaClient.entity.create({
|
await prisma.entity.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.sub,
|
||||||
name: name,
|
name: name,
|
||||||
type: type,
|
type: type,
|
||||||
|
defaultCategoryId: defaultCategoryId ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { ActionResponse } from '@/lib/types/actionResponse';
|
import { ActionResponse } from '@/lib/types/actionResponse';
|
||||||
import { prismaClient } from '@/prisma';
|
import prisma from '@/prisma';
|
||||||
import { getUser } from '@/auth';
|
|
||||||
import { URL_SIGN_IN } from '@/lib/constants';
|
import { URL_SIGN_IN } from '@/lib/constants';
|
||||||
|
import { auth0 } from '@/lib/auth';
|
||||||
|
|
||||||
export default async function entityDelete(id: number): Promise<ActionResponse> {
|
export default async function entityDelete(id: number): Promise<ActionResponse> {
|
||||||
'use server';
|
'use server';
|
||||||
|
@ -14,21 +14,21 @@ export default async function entityDelete(id: number): Promise<ActionResponse>
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// check that user is logged in
|
const session = await auth0.getSession();
|
||||||
const user = await getUser();
|
if (!session) {
|
||||||
if (!user) {
|
|
||||||
return {
|
return {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: 'You must be logged in to delete an entity.',
|
message: 'You aren\'t signed in.',
|
||||||
redirect: URL_SIGN_IN,
|
redirect: URL_SIGN_IN,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const user = session.user;
|
||||||
|
|
||||||
// check that entity is associated with user
|
// check that entity is associated with user
|
||||||
const entity = await prismaClient.entity.findFirst({
|
const entity = await prisma.entity.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: id,
|
id: id,
|
||||||
userId: user.id,
|
userId: user.sub,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!entity) {
|
if (!entity) {
|
||||||
|
@ -40,10 +40,10 @@ export default async function entityDelete(id: number): Promise<ActionResponse>
|
||||||
|
|
||||||
// delete entity
|
// delete entity
|
||||||
try {
|
try {
|
||||||
await prismaClient.entity.delete({
|
await prisma.entity.delete({
|
||||||
where: {
|
where: {
|
||||||
id: entity.id,
|
id: entity.id,
|
||||||
userId: user.id,
|
userId: user.sub,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,64 +1,64 @@
|
||||||
import { prismaClient } from '@/prisma';
|
import prisma from '@/prisma';
|
||||||
import type { Category, Entity } from '@prisma/client';
|
import type { Category, Entity } from '@prisma/client';
|
||||||
import { EntityType } from '@prisma/client';
|
import { EntityType } from '@prisma/client';
|
||||||
import { getUser } from '@/auth';
|
|
||||||
import { URL_SIGN_IN } from '@/lib/constants';
|
import { URL_SIGN_IN } from '@/lib/constants';
|
||||||
import { ActionResponse } from '@/lib/types/actionResponse';
|
import { ActionResponse } from '@/lib/types/actionResponse';
|
||||||
|
import { auth0 } from '@/lib/auth';
|
||||||
|
|
||||||
export default async function generateSampleData(): Promise<ActionResponse> {
|
export default async function generateSampleData(): Promise<ActionResponse> {
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
const user = await getUser();
|
const session = await auth0.getSession();
|
||||||
|
if (!session) {
|
||||||
if (!user) {
|
|
||||||
return {
|
return {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: 'You must be logged in to create/update an category.',
|
message: 'You aren\'t signed in.',
|
||||||
redirect: URL_SIGN_IN,
|
redirect: URL_SIGN_IN,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const user = session.user;
|
||||||
|
|
||||||
// Categories: create sample data
|
// Categories: create sample data
|
||||||
const categories: Category[] = await prismaClient.category.findMany({where: {userId: user.id}});
|
const categories: Category[] = await prisma.category.findMany({where: {userId: user.sub}});
|
||||||
if (await prismaClient.category.count({where: {userId: user.id}}) == 0) {
|
if (await prisma.category.count({where: {userId: user.sub}}) == 0) {
|
||||||
|
|
||||||
console.log('Creating sample categories...');
|
console.log('Creating sample categories...');
|
||||||
|
|
||||||
categories.push(await prismaClient.category.create({
|
categories.push(await prisma.category.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.sub,
|
||||||
name: 'Groceries',
|
name: 'Groceries',
|
||||||
color: '#FFBEAC',
|
color: '#FFBEAC',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
categories.push(await prismaClient.category.create({
|
categories.push(await prisma.category.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.sub,
|
||||||
name: 'Drugstore items',
|
name: 'Drugstore items',
|
||||||
color: '#9CBCFF',
|
color: '#9CBCFF',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
categories.push(await prismaClient.category.create({
|
categories.push(await prisma.category.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.sub,
|
||||||
name: 'Going out',
|
name: 'Going out',
|
||||||
color: '#F1ADFF',
|
color: '#F1ADFF',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
categories.push(await prismaClient.category.create({
|
categories.push(await prisma.category.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.sub,
|
||||||
name: 'Random stuff',
|
name: 'Random stuff',
|
||||||
color: '#C1FFA9',
|
color: '#C1FFA9',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
categories.push(await prismaClient.category.create({
|
categories.push(await prisma.category.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.sub,
|
||||||
name: 'Salary',
|
name: 'Salary',
|
||||||
color: '#FFF787',
|
color: '#FFF787',
|
||||||
},
|
},
|
||||||
|
@ -69,54 +69,54 @@ export default async function generateSampleData(): Promise<ActionResponse> {
|
||||||
console.log(categories);
|
console.log(categories);
|
||||||
|
|
||||||
// Entities: create sample data
|
// Entities: create sample data
|
||||||
const entities: Entity[] = await prismaClient.entity.findMany({where: {userId: user.id}});
|
const entities: Entity[] = await prisma.entity.findMany({where: {userId: user.sub}});
|
||||||
if (await prismaClient.entity.count({where: {userId: user.id}}) == 0) {
|
if (await prisma.entity.count({where: {userId: user.sub}}) == 0) {
|
||||||
|
|
||||||
console.log('Creating sample entities...');
|
console.log('Creating sample entities...');
|
||||||
|
|
||||||
entities.push(await prismaClient.entity.create({
|
entities.push(await prisma.entity.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.sub,
|
||||||
name: 'Main Account',
|
name: 'Main Account',
|
||||||
type: EntityType.Account,
|
type: EntityType.Account,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
entities.push(await prismaClient.entity.create({
|
entities.push(await prisma.entity.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.sub,
|
||||||
name: 'Company',
|
name: 'Company',
|
||||||
type: EntityType.Entity,
|
type: EntityType.Entity,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
entities.push(await prismaClient.entity.create({
|
entities.push(await prisma.entity.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.sub,
|
||||||
name: 'Supermarket 1',
|
name: 'Supermarket 1',
|
||||||
type: EntityType.Entity,
|
type: EntityType.Entity,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
entities.push(await prismaClient.entity.create({
|
entities.push(await prisma.entity.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.sub,
|
||||||
name: 'Supermarket 2',
|
name: 'Supermarket 2',
|
||||||
type: EntityType.Entity,
|
type: EntityType.Entity,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
entities.push(await prismaClient.entity.create({
|
entities.push(await prisma.entity.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.sub,
|
||||||
name: 'Supermarket 3',
|
name: 'Supermarket 3',
|
||||||
type: EntityType.Entity,
|
type: EntityType.Entity,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
entities.push(await prismaClient.entity.create({
|
entities.push(await prisma.entity.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.sub,
|
||||||
name: 'Supermarket 4',
|
name: 'Supermarket 4',
|
||||||
type: EntityType.Entity,
|
type: EntityType.Entity,
|
||||||
},
|
},
|
||||||
|
@ -129,21 +129,24 @@ export default async function generateSampleData(): Promise<ActionResponse> {
|
||||||
// Payments: create sample data
|
// Payments: create sample data
|
||||||
console.log('Creating sample payments...');
|
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++) {
|
for (let i = 0; i < 4; i++) {
|
||||||
|
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
date.setDate(1);
|
date.setDate(1);
|
||||||
date.setMonth(date.getMonth() - i);
|
date.setMonth(date.getMonth() - i);
|
||||||
|
|
||||||
await prismaClient.payment.create({
|
const categoryId =
|
||||||
|
categories.find((it) => it.name === 'Salary')?.id!;
|
||||||
|
|
||||||
|
await prisma.payment.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.sub,
|
||||||
amount: 200000,
|
amount: 200000,
|
||||||
date: date,
|
date: date,
|
||||||
payorId: entities[1].id,
|
payorId: entities[1].id,
|
||||||
payeeId: entities[0].id,
|
payeeId: entities[0].id,
|
||||||
categoryId: 5,
|
categoryId: categoryId,
|
||||||
createdAt: date,
|
createdAt: date,
|
||||||
updatedAt: date,
|
updatedAt: date,
|
||||||
},
|
},
|
||||||
|
@ -164,9 +167,9 @@ export default async function generateSampleData(): Promise<ActionResponse> {
|
||||||
const date = new Date(
|
const date = new Date(
|
||||||
new Date().getTime() - Math.floor(Math.random() * 10000000000));
|
new Date().getTime() - Math.floor(Math.random() * 10000000000));
|
||||||
|
|
||||||
await prismaClient.payment.create({
|
await prisma.payment.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.sub,
|
||||||
amount: Math.floor(
|
amount: Math.floor(
|
||||||
Math.random() * (maxAmount - minAmount) + minAmount),
|
Math.random() * (maxAmount - minAmount) + minAmount),
|
||||||
date: date,
|
date: date,
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { ActionResponse } from '@/lib/types/actionResponse';
|
import { ActionResponse } from '@/lib/types/actionResponse';
|
||||||
import { prismaClient } from '@/prisma';
|
import prisma from '@/prisma';
|
||||||
import { getUser } from '@/auth';
|
|
||||||
import { URL_SIGN_IN } from '@/lib/constants';
|
import { URL_SIGN_IN } from '@/lib/constants';
|
||||||
import { paymentFormSchema } from '@/lib/form-schemas/paymentFormSchema';
|
import { paymentFormSchema } from '@/lib/form-schemas/paymentFormSchema';
|
||||||
|
import { auth0 } from '@/lib/auth';
|
||||||
|
|
||||||
export default async function paymentCreateUpdate({
|
export default async function paymentCreateUpdate({
|
||||||
id,
|
id,
|
||||||
|
@ -16,20 +16,20 @@ export default async function paymentCreateUpdate({
|
||||||
}: z.infer<typeof paymentFormSchema>): Promise<ActionResponse> {
|
}: z.infer<typeof paymentFormSchema>): Promise<ActionResponse> {
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
// check that user is logged in
|
const session = await auth0.getSession();
|
||||||
const user = await getUser();
|
if (!session) {
|
||||||
if (!user) {
|
|
||||||
return {
|
return {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: 'You must be logged in to create/update a payment.',
|
message: 'You aren\'t signed in.',
|
||||||
redirect: URL_SIGN_IN,
|
redirect: URL_SIGN_IN,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const user = session.user;
|
||||||
|
|
||||||
// create/update payment
|
// create/update payment
|
||||||
try {
|
try {
|
||||||
if (id) {
|
if (id) {
|
||||||
await prismaClient.payment.update({
|
await prisma.payment.update({
|
||||||
where: {
|
where: {
|
||||||
id: id,
|
id: id,
|
||||||
},
|
},
|
||||||
|
@ -38,7 +38,7 @@ export default async function paymentCreateUpdate({
|
||||||
date: date,
|
date: date,
|
||||||
payorId: payorId,
|
payorId: payorId,
|
||||||
payeeId: payeeId,
|
payeeId: payeeId,
|
||||||
categoryId: categoryId,
|
categoryId: categoryId ?? null,
|
||||||
note: note,
|
note: note,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -50,14 +50,14 @@ export default async function paymentCreateUpdate({
|
||||||
message: `Payment updated`,
|
message: `Payment updated`,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
await prismaClient.payment.create({
|
await prisma.payment.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.sub,
|
||||||
amount: amount,
|
amount: amount,
|
||||||
date: date,
|
date: date,
|
||||||
payorId: payorId,
|
payorId: payorId,
|
||||||
payeeId: payeeId,
|
payeeId: payeeId,
|
||||||
categoryId: categoryId,
|
categoryId: categoryId ?? null,
|
||||||
note: note,
|
note: note,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { ActionResponse } from '@/lib/types/actionResponse';
|
import { ActionResponse } from '@/lib/types/actionResponse';
|
||||||
import { prismaClient } from '@/prisma';
|
import prisma from '@/prisma';
|
||||||
import { getUser } from '@/auth';
|
|
||||||
import { URL_SIGN_IN } from '@/lib/constants';
|
import { URL_SIGN_IN } from '@/lib/constants';
|
||||||
|
import { auth0 } from '@/lib/auth';
|
||||||
|
|
||||||
export default async function paymentDelete(id: number): Promise<ActionResponse> {
|
export default async function paymentDelete(id: number): Promise<ActionResponse> {
|
||||||
'use server';
|
'use server';
|
||||||
|
@ -14,21 +14,21 @@ export default async function paymentDelete(id: number): Promise<ActionResponse>
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// check that user is logged in
|
const session = await auth0.getSession();
|
||||||
const user = await getUser();
|
if (!session) {
|
||||||
if (!user) {
|
|
||||||
return {
|
return {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: 'You must be logged in to delete a payment.',
|
message: 'You aren\'t signed in.',
|
||||||
redirect: URL_SIGN_IN,
|
redirect: URL_SIGN_IN,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const user = session.user;
|
||||||
|
|
||||||
// check that payment is associated with user
|
// check that payment is associated with user
|
||||||
const payment = await prismaClient.payment.findFirst({
|
const payment = await prisma.payment.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: id,
|
id: id,
|
||||||
userId: user.id,
|
userId: user.sub,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!payment) {
|
if (!payment) {
|
||||||
|
@ -40,10 +40,10 @@ export default async function paymentDelete(id: number): Promise<ActionResponse>
|
||||||
|
|
||||||
// delete payment
|
// delete payment
|
||||||
try {
|
try {
|
||||||
await prismaClient.payment.delete({
|
await prisma.payment.delete({
|
||||||
where: {
|
where: {
|
||||||
id: payment.id,
|
id: payment.id,
|
||||||
userId: user.id,
|
userId: user.sub,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -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
9
src/lib/auth.ts
Normal 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,
|
||||||
|
})
|
|
@ -1,7 +1,6 @@
|
||||||
// auth urls
|
export const URL_SIGN_IN = `/auth/login`;
|
||||||
export const URL_AUTH = '/auth';
|
export const URL_SIGN_OUT = `/auth/logout`;
|
||||||
export const URL_SIGN_IN = `${URL_AUTH}/signin`;
|
|
||||||
export const URL_SIGN_UP = `${URL_AUTH}/signup`;
|
|
||||||
|
|
||||||
// main urls
|
// main urls
|
||||||
export const URL_HOME = '/';
|
export const URL_HOME = '/';
|
||||||
|
|
|
@ -5,4 +5,5 @@ export const entityFormSchema = z.object({
|
||||||
id: z.number().positive().optional(),
|
id: z.number().positive().optional(),
|
||||||
name: z.string().min(1).max(32),
|
name: z.string().min(1).max(32),
|
||||||
type: z.nativeEnum(EntityType),
|
type: z.nativeEnum(EntityType),
|
||||||
|
defaultCategoryId: z.number().positive().optional(),
|
||||||
});
|
});
|
||||||
|
|
|
@ -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),
|
|
||||||
});
|
|
|
@ -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'],
|
|
||||||
});
|
|
21
src/lib/hooks/useMediaQuery.ts
Normal file
21
src/lib/hooks/useMediaQuery.ts
Normal 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;
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
export interface ActionResponse {
|
export interface ActionResponse<T = any> {
|
||||||
type: 'success' | 'info' | 'warning' | 'error';
|
type: 'success' | 'info' | 'warning' | 'error';
|
||||||
message: string;
|
message: string;
|
||||||
redirect?: string;
|
redirect?: string;
|
||||||
|
data?: T;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,27 +1,22 @@
|
||||||
import type { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { NextResponse } from 'next/server';
|
import { auth0 } from '@/lib/auth';
|
||||||
import { URL_AUTH, URL_HOME, URL_SIGN_IN } from './lib/constants';
|
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
export async function middleware(request: NextRequest) {
|
||||||
|
try {
|
||||||
// get session id from cookies
|
return await auth0.middleware(request);
|
||||||
const sessionId = request.cookies.get('auth_session')?.value ?? null;
|
} catch (error) {
|
||||||
|
console.error("Auth0 middleware error:", error);
|
||||||
// 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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 = {
|
export const config = {
|
||||||
matcher: [
|
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).*)",
|
||||||
],
|
],
|
||||||
};
|
}
|
||||||
|
|
|
@ -1,3 +1,18 @@
|
||||||
import { PrismaClient } from '@prisma/client';
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -3,7 +3,8 @@
|
||||||
"lib": [
|
"lib": [
|
||||||
"dom",
|
"dom",
|
||||||
"dom.iterable",
|
"dom.iterable",
|
||||||
"esnext"
|
"esnext",
|
||||||
|
"webworker"
|
||||||
],
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
@ -25,7 +26,11 @@
|
||||||
"@/*": [
|
"@/*": [
|
||||||
"./src/*"
|
"./src/*"
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"types": [
|
||||||
|
"@serwist/next/typings"
|
||||||
|
],
|
||||||
|
"target": "ES2017"
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
|
|
Loading…
Add table
Reference in a new issue