Compare commits
122 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 | ||
![]() |
6ea3d90fbb | ||
![]() |
897cf58d2a | ||
![]() |
d04b83f707 | ||
![]() |
c64f28a632 | ||
![]() |
6a94e4ecd8 | ||
![]() |
2f03dd8b01 | ||
![]() |
2540865e62 | ||
![]() |
6dd2fde2b5 | ||
![]() |
2d578df9eb | ||
![]() |
7660f1037b | ||
![]() |
cbae95b733 | ||
![]() |
986db250cf | ||
![]() |
48384b7687 |
77 changed files with 2814 additions and 7537 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:
|
||||
push:
|
||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -38,3 +38,6 @@ next-env.d.ts
|
|||
|
||||
# IDE
|
||||
/.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
|
||||
FROM base AS deps
|
||||
|
@ -6,8 +6,8 @@ FROM base AS deps
|
|||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
COPY package.json bun.lockb* ./
|
||||
RUN bun install
|
||||
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
|
@ -18,23 +18,23 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||
COPY . .
|
||||
|
||||
# dependencies have to be changed depending on target architecture
|
||||
RUN npm i @node-rs/argon2-linux-x64-musl # arm64 = @node-rs/argon2-linux-arm64-musl
|
||||
RUN npm i @node-rs/bcrypt-linux-x64-musl # arm64 = @node-rs/bcrypt-linux-arm64-musl
|
||||
RUN bun install @node-rs/argon2-linux-x64-musl # arm64 = @node-rs/argon2-linux-arm64-musl
|
||||
RUN bun install @node-rs/bcrypt-linux-x64-musl # arm64 = @node-rs/bcrypt-linux-arm64-musl
|
||||
|
||||
COPY prisma/ ./prisma/
|
||||
|
||||
RUN npx prisma generate
|
||||
RUN bunx prisma generate
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
RUN npm run build
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN bun run build
|
||||
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
@ -52,7 +52,7 @@ USER nextjs
|
|||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
|
72
README.md
72
README.md
|
@ -1,3 +1,75 @@
|
|||
# Next-Finances
|
||||
|
||||
This is my simple finances tracker that I use to keep track of my spending.
|
||||
|
||||
## Using the app
|
||||
|
||||
### Understanding the Basics
|
||||
|
||||
- **Entities**: The core building blocks of your finances.
|
||||
- Accounts: Where you hold money (e.g., bank accounts, PayPal account, cash)
|
||||
- Entities: Where you spend money (e.g., Walmart, Spotify, Netflix)
|
||||
- **Payments**: Record money movement.
|
||||
- Expenses: Money leaving an Account. (Account -> Entity)
|
||||
- Income: Money entering an Account. (Entity -> Account)
|
||||
- **Categories** *(optional)*: Add labels to Payments for better tracking.
|
||||
|
||||
### Your First Steps
|
||||
|
||||
- Set up: Create Entities and Accounts that reflect your finances.
|
||||
- Record a Payment:
|
||||
- Enter the amount and date.
|
||||
- Select payor and payee
|
||||
- *(optional)* Assign a category or enter a note.
|
||||
- Explore: View your payment history and view your statics at the dashboard
|
||||
|
||||
### Tips
|
||||
|
||||
- Install the website as a PWA for easy access.
|
||||
- Get in the habit of recording Payments as they happen for accurate tracking.
|
||||
- Use categories to understand your spending patterns.
|
||||
|
||||
## Development
|
||||
|
||||
Clone this repository and run the following commands:
|
||||
|
||||
```bash
|
||||
|
||||
## create .env file
|
||||
cp .env.example .env
|
||||
|
||||
## start the database
|
||||
docker compose -f docker/finances-dev/docker-compose.yml up -d
|
||||
|
||||
## generate prisma client
|
||||
bunx prisma generate
|
||||
|
||||
## apply database migrations
|
||||
bunx prisma migrate deploy
|
||||
|
||||
## start the development server
|
||||
bun run dev
|
||||
|
||||
```
|
||||
|
||||
This project relies on [Auth0](https://auth0.com) authentication. To use it you will have to create an Auth0 account,
|
||||
create an application of type 'Single Page Application' and now add the required details to your .env file.
|
||||
You will also have to add `http://localhost:3000/auth/callback` as an `Allowed Callback URL` and `http://localhost:3000`
|
||||
as an `Allowed Logout URL` and `Allowed Web Origins` in your Auth0 console.
|
||||
|
||||
Now open [http://localhost:3000](http://localhost:3000) with your browser and create an account.
|
||||
While in development mode, you can generate sample data from the [Account page](http://localhost:3000/account).
|
||||
|
||||
## Deployment
|
||||
|
||||
Copy the [docker-compose.yaml](./docker/finances-prod/docker-compose.yaml) file and
|
||||
the [.env.example](./docker/finances-prod/.env.example) from 'docker/finances-prod' to your server.
|
||||
|
||||
Rename the `.env.example` file to `.env` and adjust the required environment variables.
|
||||
|
||||
The docker setup expects you to run a Traefik reverse proxy. It will then register itself automatically.
|
||||
If your setup is different, you will need to adjust the `docker-compose.yaml` file accordingly.
|
||||
|
||||
The finances containers will automatically register themselves to a running watchtower container if it is present.
|
||||
|
||||
Finally run `docker-compose up -d` on your server to start the application.
|
||||
|
|
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:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
command: npx prisma migrate deploy
|
||||
command: bunx prisma migrate deploy
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
networks:
|
||||
|
@ -25,7 +25,7 @@ services:
|
|||
restart: unless-stopped
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.xyz-next-finances.rule=Host(`finances.thielker.xyz`)"
|
||||
- "traefik.http.routers.xyz-next-finances.rule=Host(`${APPLICATION_DOMAIN}`)"
|
||||
- "traefik.http.routers.xyz-next-finances.entrypoints=web, websecure"
|
||||
- "traefik.http.routers.xyz-next-finances.tls=true"
|
||||
- "traefik.http.routers.xyz-next-finances.tls.certresolver=lets-encrypt"
|
||||
|
@ -42,11 +42,11 @@ services:
|
|||
depends_on:
|
||||
app-migrations:
|
||||
condition: service_completed_successfully
|
||||
command: npx prisma studio
|
||||
command: bunx prisma studio
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.xyz-next-finances-studio.rule=Host(`studio.finances.thielker.xyz`)"
|
||||
- "traefik.http.routers.xyz-next-finances-studio.rule=Host(`studio.${APPLICATION_DOMAIN}`)"
|
||||
- "traefik.http.routers.xyz-next-finances-studio.entrypoints=web, websecure"
|
||||
- "traefik.http.services.xyz-next-finances-studio.loadbalancer.server.port=5555"
|
||||
- "traefik.http.routers.xyz-next-finances-studio.tls=true"
|
||||
|
|
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} */
|
||||
const nextConfig = {
|
||||
import withSerwistInit from '@serwist/next';
|
||||
|
||||
const withSerwist = withSerwistInit({
|
||||
swSrc: 'src/app/service-worker.ts',
|
||||
swDest: 'public/sw.js',
|
||||
});
|
||||
|
||||
export default withSerwist({
|
||||
webpack: (config) => {
|
||||
config.externals.push(
|
||||
'@node-rs/argon2',
|
||||
|
@ -8,6 +14,7 @@ const nextConfig = {
|
|||
return config;
|
||||
},
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
env: {
|
||||
appVersion: process.env.npm_package_version,
|
||||
},
|
||||
});
|
||||
|
|
6416
package-lock.json
generated
6416
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",
|
||||
"description": "A finances application to keep track of my personal spendings",
|
||||
"homepage": "https://github.com/MarkusThielker/next-finances",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"version": "1.3.1",
|
||||
"author": {
|
||||
"name": "Markus Thielker"
|
||||
},
|
||||
|
@ -19,50 +18,53 @@
|
|||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@lucia-auth/adapter-prisma": "^4.0.0",
|
||||
"@prisma/client": "^5.10.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-navigation-menu": "^1.1.4",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@tanstack/react-table": "^8.13.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^3.3.1",
|
||||
"lucia": "^3.0.1",
|
||||
"lucide-react": "^0.350.0",
|
||||
"next": "14.1.3",
|
||||
"next-themes": "^0.2.1",
|
||||
"oslo": "^1.1.3",
|
||||
"react": "^18",
|
||||
"react-day-picker": "^8.10.0",
|
||||
"react-dom": "^18",
|
||||
"react-hook-form": "^7.51.0",
|
||||
"sonner": "^1.4.3",
|
||||
"swr": "^2.2.5",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"@auth0/nextjs-auth0": "^4.1.0",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@prisma/client": "^6.1.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.3",
|
||||
"@radix-ui/react-popover": "^1.1.6",
|
||||
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@serwist/next": "^9.0.11",
|
||||
"@serwist/precaching": "^9.0.11",
|
||||
"@serwist/sw": "^9.0.11",
|
||||
"@tanstack/react-table": "^8.20.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.4",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucia": "^3.2.2",
|
||||
"lucide-react": "^0.469.0",
|
||||
"next": "15.1.2",
|
||||
"next-themes": "^0.4.4",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "8.10.1",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"sonner": "^1.7.1",
|
||||
"swr": "^2.3.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.22.4"
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.25",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"autoprefixer": "^10.0.1",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.1.3",
|
||||
"eslint-config-next": "15.1.2",
|
||||
"postcss": "^8",
|
||||
"prisma": "^5.10.2",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"prisma": "^6.1.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.2"
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
||||
model User {
|
||||
// lucia internal fields
|
||||
id String @id
|
||||
sessions Session[]
|
||||
|
||||
// custom fields
|
||||
username String @unique
|
||||
password String
|
||||
|
||||
entities Entity[]
|
||||
payments Payment[]
|
||||
categories Category[]
|
||||
|
||||
@@map("lucia_user")
|
||||
}
|
||||
|
||||
model Session {
|
||||
// lucia internal fields
|
||||
id String @id
|
||||
userId String
|
||||
expiresAt DateTime
|
||||
user User @relation(references: [id], fields: [userId], onDelete: Cascade)
|
||||
|
||||
@@map("lucia_session")
|
||||
}
|
||||
|
||||
model Entity {
|
||||
id Int @id @default(autoincrement())
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
name String
|
||||
type EntityType
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
id Int @id @default(autoincrement())
|
||||
userId String @map("user_id")
|
||||
name String
|
||||
type EntityType
|
||||
defaultCategory Category? @relation(fields: [defaultCategoryId], references: [id])
|
||||
defaultCategoryId Int? @map("default_category_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
paymentsAsPayor Payment[] @relation("PayorEntity")
|
||||
paymentsAsPayee Payment[] @relation("PayeeEntity")
|
||||
|
@ -57,7 +32,6 @@ enum EntityType {
|
|||
model Payment {
|
||||
id Int @id @default(autoincrement())
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
amount Int
|
||||
currency String @default("EUR")
|
||||
date DateTime @default(now())
|
||||
|
@ -77,13 +51,13 @@ model Payment {
|
|||
model Category {
|
||||
id Int @id @default(autoincrement())
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
name String
|
||||
color String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
payments Payment[]
|
||||
Entity Entity[]
|
||||
|
||||
@@unique(fields: [userId, name])
|
||||
@@map("categories")
|
||||
|
|
15
public/logo_t_hq_o.svg
Normal file
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,51 +1,50 @@
|
|||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import React from 'react';
|
||||
import { getUser } from '@/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
import signOut from '@/lib/actions/signOut';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import SignOutForm from '@/components/form/signOutForm';
|
||||
import { URL_SIGN_IN } from '@/lib/constants';
|
||||
import GenerateSampleDataForm from '@/components/form/generateSampleDataForm';
|
||||
import generateSampleData from '@/lib/actions/generateSampleData';
|
||||
import { prismaClient } from '@/prisma';
|
||||
import prisma from '@/prisma';
|
||||
import { ServerActionTrigger } from '@/components/form/serverActionTrigger';
|
||||
import clearAccountData from '@/lib/actions/clearAccountData';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { URL_SIGN_OUT } from '@/lib/constants';
|
||||
import { auth0 } from '@/lib/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default async function AccountPage() {
|
||||
|
||||
const user = await getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect(URL_SIGN_IN);
|
||||
const session = await auth0.getSession();
|
||||
if (!session) {
|
||||
return redirect('/auth/login');
|
||||
}
|
||||
const user = session.user;
|
||||
|
||||
let paymentCount = 0;
|
||||
let entityCount = 0;
|
||||
let categoryCount = 0;
|
||||
paymentCount = await prisma.payment.count({
|
||||
where: {
|
||||
userId: user.sub,
|
||||
},
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
paymentCount = await prismaClient.payment.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
entityCount = await prismaClient.entity.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
categoryCount = await prismaClient.category.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
let entityCount = 0;
|
||||
entityCount = await prisma.entity.count({
|
||||
where: {
|
||||
userId: user.sub,
|
||||
},
|
||||
});
|
||||
|
||||
let categoryCount = 0;
|
||||
categoryCount = await prisma.category.count({
|
||||
where: {
|
||||
userId: user.sub,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<Card className="w-full max-w-md mt-12">
|
||||
<Card className="w-full max-w-md md:mt-12">
|
||||
<CardHeader>
|
||||
<CardTitle>Hey, {user?.username}!</CardTitle>
|
||||
<CardTitle>Hey, {user.name}!</CardTitle>
|
||||
<CardDescription>This is your account overview.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
|
@ -53,13 +52,13 @@ export default async function AccountPage() {
|
|||
<Label>ID</Label>
|
||||
<Input
|
||||
disabled
|
||||
value={user?.id}/>
|
||||
value={user.sub}/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Username</Label>
|
||||
<Input
|
||||
disabled
|
||||
value={user?.username}/>
|
||||
value={user.name}/>
|
||||
</div>
|
||||
<div className="flex flex-row items-center space-x-4">
|
||||
<div>
|
||||
|
@ -82,15 +81,52 @@ export default async function AccountPage() {
|
|||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="space-x-4">
|
||||
<CardFooter className="w-full grid gap-4 grid-cols-1 md:grid-cols-2">
|
||||
<ServerActionTrigger
|
||||
action={clearAccountData}
|
||||
dialog={{
|
||||
title: 'Clear account data',
|
||||
description: 'Are you sure you want to delete all payments, entities and categories from you account? This action is irreversible.',
|
||||
actionText: 'Clear data',
|
||||
actionVariant: 'destructive',
|
||||
}}
|
||||
variant="outline">
|
||||
Clear data
|
||||
</ServerActionTrigger>
|
||||
<a href={URL_SIGN_OUT}>
|
||||
<Button className="w-full">
|
||||
Sign Out
|
||||
</Button>
|
||||
</a>
|
||||
{
|
||||
process.env.NODE_ENV === 'development' && (
|
||||
<GenerateSampleDataForm onSubmit={generateSampleData}/>
|
||||
<ServerActionTrigger
|
||||
variant="outline"
|
||||
className="col-span-2"
|
||||
action={generateSampleData}>
|
||||
Generate sample data
|
||||
</ServerActionTrigger>
|
||||
)
|
||||
}
|
||||
<SignOutForm onSubmit={signOut}/>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<div className="flex w-full items-center justify-between max-w-md mt-2 text-neutral-600">
|
||||
<p>Version {process.env.appVersion}</p>
|
||||
<div className="flex items-center justify-between space-x-4">
|
||||
<a
|
||||
target="_blank"
|
||||
className="hover:text-neutral-500 duration-100"
|
||||
href="https://github.com/MarkusThielker/next-finances">
|
||||
Source Code
|
||||
</a>
|
||||
<a
|
||||
target="_blank"
|
||||
className="hover:text-neutral-500 duration-100"
|
||||
href="https://github.com/MarkusThielker/next-finances/releases">
|
||||
Changelog
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
size: 65,
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
|
|
|
@ -1,15 +1,20 @@
|
|||
import { getUser } from '@/auth';
|
||||
import { prismaClient } from '@/prisma';
|
||||
import prisma from '@/prisma';
|
||||
import React from 'react';
|
||||
import CategoryPageClientContent from '@/components/categoryPageClientComponents';
|
||||
import categoryCreateUpdate from '@/lib/actions/categoryCreateUpdate';
|
||||
import categoryDelete from '@/lib/actions/categoryDelete';
|
||||
import { auth0 } from '@/lib/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default async function CategoriesPage() {
|
||||
|
||||
const user = await getUser();
|
||||
const session = await auth0.getSession();
|
||||
if (!session) {
|
||||
return redirect('/auth/login');
|
||||
}
|
||||
const user = session.user;
|
||||
|
||||
const categories = await prismaClient.category.findMany({
|
||||
const categories = await prisma.category.findMany({
|
||||
where: {
|
||||
userId: user?.id,
|
||||
},
|
||||
|
@ -25,6 +30,6 @@ export default async function CategoriesPage() {
|
|||
categories={categories}
|
||||
onSubmit={categoryCreateUpdate}
|
||||
onDelete={categoryDelete}
|
||||
className="flex flex-col justify-center space-y-4 p-10"/>
|
||||
className="flex flex-col justify-center space-y-4"/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
'use client';
|
||||
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { Entity } from '@prisma/client';
|
||||
import { Category, Entity } from '@prisma/client';
|
||||
import { CellContext, ColumnDefTemplate } from '@tanstack/table-core';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
export const columns = (
|
||||
actionCell: ColumnDefTemplate<CellContext<Entity, unknown>>,
|
||||
categories: Category[],
|
||||
) => {
|
||||
|
||||
return [
|
||||
|
@ -17,6 +18,31 @@ export const columns = (
|
|||
{
|
||||
accessorKey: 'type',
|
||||
header: 'Type',
|
||||
size: 100,
|
||||
},
|
||||
{
|
||||
accessorKey: 'defaultCategoryId',
|
||||
header: 'Default Category',
|
||||
cell: ({row}) => {
|
||||
const category = categories.find((category) => category.id === row.original.defaultCategoryId);
|
||||
return (
|
||||
<>
|
||||
{
|
||||
category && (
|
||||
<div className="flex items-center space-x-4">
|
||||
<svg className="h-5" fill={category?.color} viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="10" cy="10" r="10"/>
|
||||
</svg>
|
||||
<p>{category?.name ?? '-'}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
</>
|
||||
);
|
||||
},
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
|
|
|
@ -1,15 +1,20 @@
|
|||
import { prismaClient } from '@/prisma';
|
||||
import { getUser } from '@/auth';
|
||||
import prisma from '@/prisma';
|
||||
import React from 'react';
|
||||
import EntityPageClientContent from '@/components/entityPageClientComponents';
|
||||
import entityCreateUpdate from '@/lib/actions/entityCreateUpdate';
|
||||
import entityDelete from '@/lib/actions/entityDelete';
|
||||
import { auth0 } from '@/lib/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default async function EntitiesPage() {
|
||||
|
||||
const user = await getUser();
|
||||
const session = await auth0.getSession();
|
||||
if (!session) {
|
||||
return redirect('/auth/login');
|
||||
}
|
||||
const user = session.user;
|
||||
|
||||
const entities = await prismaClient.entity.findMany({
|
||||
const entities = await prisma.entity.findMany({
|
||||
where: {
|
||||
userId: user?.id,
|
||||
},
|
||||
|
@ -23,11 +28,26 @@ export default async function EntitiesPage() {
|
|||
],
|
||||
});
|
||||
|
||||
const categories = await prisma.category.findMany({
|
||||
where: {
|
||||
userId: user?.id,
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
name: 'asc',
|
||||
},
|
||||
{
|
||||
id: 'asc',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return (
|
||||
<EntityPageClientContent
|
||||
entities={entities}
|
||||
categories={categories}
|
||||
onSubmit={entityCreateUpdate}
|
||||
onDelete={entityDelete}
|
||||
className="flex flex-col justify-center space-y-4 p-10"/>
|
||||
className="flex flex-col justify-center space-y-4"/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { Metadata } from 'next';
|
||||
import type { Viewport } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
@ -8,9 +8,31 @@ import Navigation from '@/components/navigation';
|
|||
|
||||
const inter = Inter({subsets: ['latin']});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Finances',
|
||||
description: 'Track your finances with ease',
|
||||
const APP_NAME = 'Finances';
|
||||
const APP_DEFAULT_TITLE = 'Finances';
|
||||
const APP_TITLE_TEMPLATE = `%s | ${APP_DEFAULT_TITLE}`;
|
||||
const APP_DESCRIPTION = 'Track your finances with ease';
|
||||
|
||||
export const metadata = {
|
||||
applicationName: APP_NAME,
|
||||
title: {
|
||||
default: APP_DEFAULT_TITLE,
|
||||
template: APP_TITLE_TEMPLATE,
|
||||
},
|
||||
description: APP_DESCRIPTION,
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'default',
|
||||
title: APP_DEFAULT_TITLE,
|
||||
},
|
||||
formatDetection: {
|
||||
telephone: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#0B0908',
|
||||
width: 'device-width',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
@ -20,12 +42,19 @@ export default function RootLayout({
|
|||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link crossOrigin="use-credentials" rel="manifest" href="/manifest.json"/>
|
||||
<link
|
||||
rel="icon"
|
||||
href="/logo_t_hq_o.svg"
|
||||
/>
|
||||
</head>
|
||||
<body className={cn('dark', inter.className)}>
|
||||
<Navigation/>
|
||||
<main>
|
||||
<main className="p-4 sm:p-8">
|
||||
{children}
|
||||
<Toaster/>
|
||||
</main>
|
||||
<Toaster/>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import React from 'react';
|
||||
import { Category, Entity, EntityType } from '@prisma/client';
|
||||
import { Scope, ScopeType } from '@/lib/types/scope';
|
||||
import { prismaClient } from '@/prisma';
|
||||
import { getUser } from '@/auth';
|
||||
import prisma from '@/prisma';
|
||||
import DashboardPageClient from '@/components/dashboardPageClientComponents';
|
||||
import { auth0 } from '@/lib/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export type CategoryNumber = {
|
||||
category: Category,
|
||||
|
@ -15,19 +16,20 @@ export type EntityNumber = {
|
|||
value: number,
|
||||
}
|
||||
|
||||
export default async function DashboardPage(props: { searchParams?: { scope: ScopeType } }) {
|
||||
export default async function DashboardPage(props: { searchParams?: Promise<{ scope: ScopeType }> }) {
|
||||
|
||||
const user = await getUser();
|
||||
if (!user) {
|
||||
return;
|
||||
const session = await auth0.getSession();
|
||||
if (!session) {
|
||||
return redirect('/auth/login');
|
||||
}
|
||||
const user = session.user;
|
||||
|
||||
const scope = Scope.of(props.searchParams?.scope || ScopeType.ThisMonth);
|
||||
const scope = Scope.of((await props.searchParams)?.scope || ScopeType.ThisMonth);
|
||||
|
||||
// get all payments in the current scope
|
||||
const payments = await prismaClient.payment.findMany({
|
||||
const payments = await prisma.payment.findMany({
|
||||
where: {
|
||||
userId: user?.id,
|
||||
userId: user.sub,
|
||||
date: {
|
||||
gte: scope.start,
|
||||
lte: scope.end,
|
||||
|
@ -108,6 +110,7 @@ export default async function DashboardPage(props: { searchParams?: { scope: Sco
|
|||
userId: '',
|
||||
name: 'Other',
|
||||
type: EntityType.Entity,
|
||||
defaultCategoryId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
|
@ -200,7 +203,7 @@ export default async function DashboardPage(props: { searchParams?: { scope: Sco
|
|||
categoryPercentages={categoryPercentages}
|
||||
entityExpenses={entityExpensesFormat}
|
||||
entityPercentages={entityPercentages}
|
||||
className="flex flex-col justify-center space-y-4 p-10"
|
||||
className="flex flex-col justify-center space-y-4"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ export const columns = (
|
|||
cell: ({row}) => {
|
||||
return format(row.original.date, 'PPP');
|
||||
},
|
||||
size: 175,
|
||||
},
|
||||
{
|
||||
accessorKey: 'amount',
|
||||
|
@ -28,6 +29,7 @@ export const columns = (
|
|||
currency: 'EUR',
|
||||
}).format(row.getValue('amount') as number / 100);
|
||||
},
|
||||
size: 70,
|
||||
},
|
||||
{
|
||||
accessorKey: 'payorId',
|
||||
|
@ -36,6 +38,7 @@ export const columns = (
|
|||
const entity = entities.find((entity) => entity.id === row.original.payorId);
|
||||
return entity?.name ?? '-';
|
||||
},
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
accessorKey: 'payeeId',
|
||||
|
@ -44,18 +47,35 @@ export const columns = (
|
|||
const entity = entities.find((entity) => entity.id === row.original.payeeId);
|
||||
return entity?.name ?? '-';
|
||||
},
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
accessorKey: 'categoryId',
|
||||
header: 'Category',
|
||||
cell: ({row}) => {
|
||||
const category = categories.find((category) => category.id === row.original.categoryId);
|
||||
return category?.name ?? '-';
|
||||
return (
|
||||
<>
|
||||
{
|
||||
category && (
|
||||
<div className="flex items-center space-x-4">
|
||||
<svg className="h-5" fill={category?.color} viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="10" cy="10" r="10"/>
|
||||
</svg>
|
||||
<p>{category?.name ?? '-'}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</>
|
||||
);
|
||||
},
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
accessorKey: 'note',
|
||||
header: 'Note',
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
|
|
|
@ -1,17 +1,22 @@
|
|||
import { getUser } from '@/auth';
|
||||
import { prismaClient } from '@/prisma';
|
||||
import prisma from '@/prisma';
|
||||
import React from 'react';
|
||||
import PaymentPageClientContent from '@/components/paymentPageClientComponents';
|
||||
import paymentCreateUpdate from '@/lib/actions/paymentCreateUpdate';
|
||||
import paymentDelete from '@/lib/actions/paymentDelete';
|
||||
import { auth0 } from '@/lib/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default async function PaymentsPage() {
|
||||
|
||||
const user = await getUser();
|
||||
const session = await auth0.getSession();
|
||||
if (!session) {
|
||||
return redirect('/auth/login');
|
||||
}
|
||||
const user = session.user;
|
||||
|
||||
const payments = await prismaClient.payment.findMany({
|
||||
const payments = await prisma.payment.findMany({
|
||||
where: {
|
||||
userId: user?.id,
|
||||
userId: user.sub,
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
|
@ -23,9 +28,9 @@ export default async function PaymentsPage() {
|
|||
],
|
||||
});
|
||||
|
||||
const entities = await prismaClient.entity.findMany({
|
||||
const entities = await prisma.entity.findMany({
|
||||
where: {
|
||||
userId: user?.id,
|
||||
userId: user.sub,
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
|
@ -37,9 +42,9 @@ export default async function PaymentsPage() {
|
|||
],
|
||||
});
|
||||
|
||||
const categories = await prismaClient.category.findMany({
|
||||
const categories = await prisma.category.findMany({
|
||||
where: {
|
||||
userId: user?.id,
|
||||
userId: user.sub,
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
|
@ -58,6 +63,6 @@ export default async function PaymentsPage() {
|
|||
categories={categories}
|
||||
onSubmit={paymentCreateUpdate}
|
||||
onDelete={paymentDelete}
|
||||
className="flex flex-col justify-center space-y-4 p-10"/>
|
||||
className="flex flex-col justify-center space-y-4"/>
|
||||
);
|
||||
}
|
||||
|
|
46
src/app/service-worker.ts
Normal file
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';
|
||||
import { categoryFormSchema } from '@/lib/form-schemas/categoryFormSchema';
|
||||
import CategoryForm from '@/components/form/categoryForm';
|
||||
import { useMediaQuery } from '@/lib/hooks/useMediaQuery';
|
||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer';
|
||||
|
||||
export default function CategoryPageClientContent({categories, onSubmit, onDelete, className}: {
|
||||
categories: Category[],
|
||||
|
@ -31,6 +33,7 @@ export default function CategoryPageClientContent({categories, onSubmit, onDelet
|
|||
className: string,
|
||||
}) {
|
||||
|
||||
const isDesktop = useMediaQuery('(min-width: 768px)');
|
||||
const router = useRouter();
|
||||
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
|
@ -97,26 +100,51 @@ export default function CategoryPageClientContent({categories, onSubmit, onDelet
|
|||
<p className="text-3xl font-semibold">Categories</p>
|
||||
|
||||
{/* Edit dialog */}
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedCategory(undefined);
|
||||
setIsEditDialogOpen(true);
|
||||
}}>
|
||||
Create Category
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedCategory?.id ? 'Update Category' : 'Create Category'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<CategoryForm
|
||||
value={selectedCategory}
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-row space-x-4 py-4"/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{
|
||||
isDesktop ? (
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedCategory(undefined);
|
||||
setIsEditDialogOpen(true);
|
||||
}}>
|
||||
Create Category
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedCategory?.id ? 'Update Category' : 'Create Category'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<CategoryForm
|
||||
value={selectedCategory}
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-row space-x-4 py-4"/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Drawer open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedCategory(undefined);
|
||||
setIsEditDialogOpen(true);
|
||||
}}>
|
||||
Create Category
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="p-4">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{selectedCategory?.id ? 'Update Category' : 'Create Category'}</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<CategoryForm
|
||||
value={selectedCategory}
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-row space-x-4 py-4"/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Data Table */}
|
||||
|
|
|
@ -49,7 +49,7 @@ export default function DashboardPageClientContent(
|
|||
return (
|
||||
<div className={className}>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center justify-between w-full space-x-8">
|
||||
|
||||
<p className="text-3xl font-semibold">Dashboard</p>
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { Entity } from '@prisma/client';
|
||||
import { Category, Entity } from '@prisma/client';
|
||||
import React, { useState } from 'react';
|
||||
import { CellContext } from '@tanstack/table-core';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
@ -24,14 +24,18 @@ import {
|
|||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { useMediaQuery } from '@/lib/hooks/useMediaQuery';
|
||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer';
|
||||
|
||||
export default function EntityPageClientContent({entities, onSubmit, onDelete, className}: {
|
||||
export default function EntityPageClientContent({entities, categories, onSubmit, onDelete, className}: {
|
||||
entities: Entity[],
|
||||
categories: Category[],
|
||||
onSubmit: (data: z.infer<typeof entityFormSchema>) => Promise<ActionResponse>,
|
||||
onDelete: (id: number) => Promise<ActionResponse>,
|
||||
className: string,
|
||||
}) {
|
||||
|
||||
const isDesktop = useMediaQuery('(min-width: 768px)');
|
||||
const router = useRouter();
|
||||
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
|
@ -125,26 +129,53 @@ export default function EntityPageClientContent({entities, onSubmit, onDelete, c
|
|||
<p className="text-3xl font-semibold">Entities</p>
|
||||
|
||||
{/* Edit dialog */}
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedEntity(undefined);
|
||||
setIsEditDialogOpen(true);
|
||||
}}>
|
||||
Create Entity
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedEntity?.id ? 'Update Entity' : 'Create Entity'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<EntityForm
|
||||
value={selectedEntity}
|
||||
onSubmit={handleSubmit}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4"/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{
|
||||
isDesktop ? (
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedEntity(undefined);
|
||||
setIsEditDialogOpen(true);
|
||||
}}>
|
||||
Create Entity
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedEntity?.id ? 'Update Entity' : 'Create Entity'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<EntityForm
|
||||
value={selectedEntity}
|
||||
categories={categories}
|
||||
onSubmit={handleSubmit}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4"/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Drawer open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedEntity(undefined);
|
||||
setIsEditDialogOpen(true);
|
||||
}}>
|
||||
Create Entity
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="p-4">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{selectedEntity?.id ? 'Update Entity' : 'Create Entity'}</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<EntityForm
|
||||
value={selectedEntity}
|
||||
categories={categories}
|
||||
onSubmit={handleSubmit}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4"/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Filter input */}
|
||||
|
@ -156,7 +187,7 @@ export default function EntityPageClientContent({entities, onSubmit, onDelete, c
|
|||
{/* Data Table */}
|
||||
<DataTable
|
||||
className="w-full"
|
||||
columns={columns(actionCell)}
|
||||
columns={columns(actionCell, categories)}
|
||||
data={filterEntities(entities, filter)}
|
||||
pagination/>
|
||||
|
||||
|
|
|
@ -12,11 +12,13 @@ import { useRouter } from 'next/navigation';
|
|||
import { toast } from 'sonner';
|
||||
import { sonnerContent } from '@/components/ui/sonner';
|
||||
import { entityFormSchema } from '@/lib/form-schemas/entityFormSchema';
|
||||
import { Entity, EntityType } from '@prisma/client';
|
||||
import { Category, Entity, EntityType } from '@prisma/client';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { AutoCompleteInput } from '@/components/ui/auto-complete-input';
|
||||
|
||||
export default function EntityForm({value, onSubmit, className}: {
|
||||
export default function EntityForm({value, categories, onSubmit, className}: {
|
||||
value: Entity | undefined,
|
||||
categories: Category[],
|
||||
onSubmit: (data: z.infer<typeof entityFormSchema>) => Promise<ActionResponse>
|
||||
className?: string
|
||||
}) {
|
||||
|
@ -29,6 +31,7 @@ export default function EntityForm({value, onSubmit, className}: {
|
|||
id: value?.id ?? undefined,
|
||||
name: value?.name ?? '',
|
||||
type: value?.type ?? EntityType.Entity,
|
||||
defaultCategoryId: value?.defaultCategoryId ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -40,6 +43,13 @@ export default function EntityForm({value, onSubmit, className}: {
|
|||
}
|
||||
};
|
||||
|
||||
const categoriesMapped = categories?.map((category) => {
|
||||
return {
|
||||
label: category.name,
|
||||
value: category.id,
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form autoComplete="off" onSubmit={form.handleSubmit(handleSubmit)}>
|
||||
|
@ -94,6 +104,22 @@ export default function EntityForm({value, onSubmit, className}: {
|
|||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="defaultCategoryId"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Category</FormLabel>
|
||||
<FormControl>
|
||||
<AutoCompleteInput
|
||||
placeholder="Select category"
|
||||
items={categoriesMapped}
|
||||
{...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full">{value?.id ? 'Update Entity' : 'Create Entity'}</Button>
|
||||
</form>
|
||||
|
|
|
@ -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 { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ActionResponse } from '@/lib/types/actionResponse';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
@ -16,11 +16,11 @@ import { paymentFormSchema } from '@/lib/form-schemas/paymentFormSchema';
|
|||
import CurrencyInput from '@/components/ui/currency-input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CalendarIcon, Check, ChevronsUpDown } from 'lucide-react';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { AutoCompleteInput } from '@/components/ui/auto-complete-input';
|
||||
|
||||
export default function PaymentForm({value, entities, categories, onSubmit, className}: {
|
||||
value: Payment | undefined,
|
||||
|
@ -32,12 +32,6 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
|
|||
|
||||
const router = useRouter();
|
||||
|
||||
const [filter, setFilter] = useState<string>('');
|
||||
|
||||
const [payorOpen, setPayorOpen] = useState(false);
|
||||
const [payeeOpen, setPayeeOpen] = useState(false);
|
||||
const [categoryOpen, setCategoryOpen] = useState(false);
|
||||
|
||||
const form = useForm<z.infer<typeof paymentFormSchema>>({
|
||||
resolver: zodResolver(paymentFormSchema),
|
||||
defaultValues: {
|
||||
|
@ -73,6 +67,10 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
|
|||
};
|
||||
}) ?? [];
|
||||
|
||||
const payeeRef = useRef<HTMLInputElement>({} as HTMLInputElement);
|
||||
const categoryRef = useRef<HTMLInputElement>({} as HTMLInputElement);
|
||||
const submitRef = useRef<HTMLButtonElement>({} as HTMLButtonElement);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form autoComplete="off" onSubmit={form.handleSubmit(handleSubmit)}>
|
||||
|
@ -127,10 +125,7 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
|
|||
<Calendar
|
||||
mode="single"
|
||||
selected={field.value}
|
||||
onSelect={(e) => {
|
||||
field.onChange(e);
|
||||
}}
|
||||
initialFocus
|
||||
onSelect={field.onChange}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
@ -145,61 +140,18 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
|
|||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Payor</FormLabel>
|
||||
<Popover open={payorOpen} onOpenChange={(open) => {
|
||||
setPayorOpen(open);
|
||||
setFilter('');
|
||||
}}>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
'w-full justify-between',
|
||||
!field.value && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? entitiesMapped.find(
|
||||
(item) => item.value === field.value,
|
||||
)?.label
|
||||
: 'Select entity'}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50"/>
|
||||
</Button>
|
||||
</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>
|
||||
<FormControl>
|
||||
<AutoCompleteInput
|
||||
placeholder="Select payor"
|
||||
items={entitiesMapped}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
if (e && e.target.value) {
|
||||
payeeRef && payeeRef.current.focus();
|
||||
}
|
||||
}}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
|
@ -211,62 +163,26 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
|
|||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Payee</FormLabel>
|
||||
<Popover open={payeeOpen} onOpenChange={(open) => {
|
||||
setPayeeOpen(open);
|
||||
setFilter('');
|
||||
}}>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
'w-full justify-between',
|
||||
!field.value && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? entitiesMapped.find(
|
||||
(item) => item.value === field.value,
|
||||
)?.label
|
||||
: 'Select entity'}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50"/>
|
||||
</Button>
|
||||
</FormControl>
|
||||
</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>
|
||||
<FormControl ref={payeeRef}>
|
||||
<AutoCompleteInput
|
||||
placeholder="Select payee"
|
||||
items={entitiesMapped}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
if (e && e.target.value) {
|
||||
const entity = entities.find((entity) => entity.id === Number(e.target.value));
|
||||
|
||||
// only focus category input if payee has no default category
|
||||
if (entity?.defaultCategoryId !== null) {
|
||||
form.setValue('categoryId', entity?.defaultCategoryId);
|
||||
submitRef && submitRef.current.focus();
|
||||
} else {
|
||||
categoryRef && categoryRef.current.focus();
|
||||
}
|
||||
}
|
||||
}}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
|
@ -278,62 +194,19 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
|
|||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Category</FormLabel>
|
||||
<Popover open={categoryOpen} onOpenChange={(open) => {
|
||||
setCategoryOpen(open);
|
||||
setFilter('');
|
||||
}}>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
'w-full justify-between',
|
||||
!field.value && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? categoriesMapped.find(
|
||||
(item) => item.value === field.value,
|
||||
)?.label
|
||||
: 'Select entity'}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50"/>
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[225px] p-0">
|
||||
<input
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border-b border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Search..."/>
|
||||
<ScrollArea className="h-40">
|
||||
{categoriesMapped
|
||||
.filter((entity) => entity.label.toLowerCase().includes(filter.toLowerCase()))
|
||||
.map((item) => (
|
||||
<div
|
||||
className="relative flex cursor-pointer hover:bg-white/10 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
key={item.value}
|
||||
onClick={() => {
|
||||
field.onChange(item.value);
|
||||
setCategoryOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
item.value === field.value
|
||||
? 'opacity-100'
|
||||
: 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
{item.label}
|
||||
</div>
|
||||
))}
|
||||
<ScrollBar orientation="vertical"/>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormControl ref={categoryRef}>
|
||||
<AutoCompleteInput
|
||||
placeholder="Select category"
|
||||
items={categoriesMapped}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
if (e && e.target.value) {
|
||||
submitRef && submitRef.current.focus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
|
@ -354,7 +227,8 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
|
|||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full">{value?.id ? 'Update Payment' : 'Create Payment'}</Button>
|
||||
<Button type="submit" ref={submitRef}
|
||||
className="w-full">{value?.id ? 'Update Payment' : 'Create Payment'}</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
|
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 {
|
||||
NavigationMenu,
|
||||
navigationMenuIconTriggerStyle,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
navigationMenuTriggerStyle,
|
||||
} from '@/components/ui/navigation-menu';
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { User } from 'lucide-react';
|
||||
import { Banknote, Home, Menu, Tag, User, UserSearch } from 'lucide-react';
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function Navigation() {
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex sticky items-center border-b border-border bg-background">
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList className="flex w-screen items-center justify-between px-4 py-2">
|
||||
<div className="inline-flex space-x-2">
|
||||
|
||||
<img src={'/logo_white.png'} alt="Finances" className="h-10 w-10 mx-3"/>
|
||||
|
||||
<NavigationMenuItem>
|
||||
<Link href="/" legacyBehavior passHref>
|
||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||
Dashboard
|
||||
</NavigationMenuLink>
|
||||
<div className="md:hidden">
|
||||
<Drawer open={open} onOpenChange={open => setOpen(open)}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button size="icon" variant="ghost" className="m-2">
|
||||
<Menu/>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<div className="flex flex-col space-y-2 w-full rounded-none p-4">
|
||||
<Link
|
||||
href="/"
|
||||
className={navigationMenuIconTriggerStyle()}
|
||||
onClick={() => setOpen(false)}
|
||||
passHref>
|
||||
<Home/>
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<Link href="/payments" legacyBehavior passHref>
|
||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||
Payments
|
||||
</NavigationMenuLink>
|
||||
<Link
|
||||
href="/payments"
|
||||
className={navigationMenuIconTriggerStyle()}
|
||||
onClick={() => setOpen(false)}
|
||||
passHref>
|
||||
<Banknote/>
|
||||
<span>Payments</span>
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<Link href="/entities" legacyBehavior passHref>
|
||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||
Entities
|
||||
</NavigationMenuLink>
|
||||
<Link
|
||||
href="/entities"
|
||||
className={navigationMenuIconTriggerStyle()}
|
||||
onClick={() => setOpen(false)}
|
||||
passHref>
|
||||
<UserSearch/>
|
||||
<span>Entities</span>
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<Link href="/categories" legacyBehavior passHref>
|
||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||
Categories
|
||||
</NavigationMenuLink>
|
||||
<Link
|
||||
href="/categories"
|
||||
className={navigationMenuIconTriggerStyle()}
|
||||
onClick={() => setOpen(false)}
|
||||
passHref>
|
||||
<Tag/>
|
||||
<span>Categories</span>
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
</div>
|
||||
|
||||
<NavigationMenuItem>
|
||||
<Link href="/account" legacyBehavior passHref>
|
||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||
<span className="sr-only">Account</span>
|
||||
<Link
|
||||
href="/account"
|
||||
className={navigationMenuIconTriggerStyle()}
|
||||
onClick={() => setOpen(false)}
|
||||
passHref>
|
||||
<User/>
|
||||
</NavigationMenuLink>
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
<span>Account</span>
|
||||
</Link>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -23,6 +23,8 @@ import { paymentFormSchema } from '@/lib/form-schemas/paymentFormSchema';
|
|||
import { Category, Entity, Payment } from '@prisma/client';
|
||||
import PaymentForm from '@/components/form/paymentForm';
|
||||
import { columns } from '@/app/payments/columns';
|
||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer';
|
||||
import { useMediaQuery } from '@/lib/hooks/useMediaQuery';
|
||||
|
||||
export default function PaymentPageClientContent({
|
||||
payments,
|
||||
|
@ -40,6 +42,7 @@ export default function PaymentPageClientContent({
|
|||
className: string,
|
||||
}) {
|
||||
|
||||
const isDesktop = useMediaQuery('(min-width: 768px)');
|
||||
const router = useRouter();
|
||||
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
|
@ -100,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 (
|
||||
<div className={className}>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<p className="text-3xl font-semibold">Payments</p>
|
||||
|
||||
{/* Edit dialog */}
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedPayment(undefined);
|
||||
setIsEditDialogOpen(true);
|
||||
}}>
|
||||
Create Payment
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedPayment?.id ? 'Update Payment' : 'Create Payment'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<PaymentForm
|
||||
value={selectedPayment}
|
||||
entities={entities}
|
||||
categories={categories}
|
||||
onSubmit={handleSubmit}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4"/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{
|
||||
isDesktop ? (
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedPayment(undefined);
|
||||
setIsEditDialogOpen(true);
|
||||
}}>
|
||||
Create Payment
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedPayment?.id ? 'Update Payment' : 'Create Payment'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<PaymentForm
|
||||
value={selectedPayment}
|
||||
entities={entities}
|
||||
categories={categories}
|
||||
onSubmit={handleSubmit}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4"/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Drawer open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedPayment(undefined);
|
||||
setIsEditDialogOpen(true);
|
||||
}}>
|
||||
Create Payment
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="p-4">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{selectedPayment?.id ? 'Update Payment' : 'Create Payment'}</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<PaymentForm
|
||||
value={selectedPayment}
|
||||
entities={entities}
|
||||
categories={categories}
|
||||
onSubmit={handleSubmit}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4"/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Data Table */}
|
||||
|
|
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';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
@ -40,11 +40,11 @@ export interface 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';
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({variant, size, className}))}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
@ -10,11 +10,11 @@ import { buttonVariants } from '@/components/ui/button';
|
|||
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
|
@ -26,7 +26,7 @@ function Calendar({
|
|||
caption_label: 'text-sm font-medium',
|
||||
nav: 'space-x-1 flex items-center',
|
||||
nav_button: cn(
|
||||
buttonVariants({variant: 'outline'}),
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||
),
|
||||
nav_button_previous: 'absolute left-1',
|
||||
|
@ -38,7 +38,7 @@ function Calendar({
|
|||
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',
|
||||
day: cn(
|
||||
buttonVariants({variant: 'ghost'}),
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'h-9 w-9 p-0 font-normal aria-selected:opacity-100',
|
||||
),
|
||||
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',
|
||||
day_today: 'bg-accent text-accent-foreground',
|
||||
day_outside:
|
||||
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
|
||||
'day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground',
|
||||
day_disabled: 'text-muted-foreground opacity-50',
|
||||
day_range_middle:
|
||||
'aria-selected:bg-accent aria-selected:text-accent-foreground',
|
||||
|
@ -54,8 +54,12 @@ function Calendar({
|
|||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: ({...props}) => <ChevronLeft className="h-4 w-4"/>,
|
||||
IconRight: ({...props}) => <ChevronRight className="h-4 w-4"/>,
|
||||
IconLeft: ({ className, ...props }) => (
|
||||
<ChevronLeft className={cn('h-4 w-4', className)} {...props} />
|
||||
),
|
||||
IconRight: ({ className, ...props }) => (
|
||||
<ChevronRight className={cn('h-4 w-4', className)} {...props} />
|
||||
),
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
@ -53,6 +53,7 @@ export default function CurrencyInput(props: TextInputProps) {
|
|||
<Input
|
||||
placeholder={props.placeholder}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
{...field}
|
||||
onChange={(ev) => {
|
||||
setValue(ev.target.value);
|
||||
|
|
|
@ -4,8 +4,9 @@ import { ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, useReact
|
|||
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
|
@ -20,6 +21,7 @@ export function DataTable<TData, TValue>({
|
|||
pagination,
|
||||
className,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
|
@ -27,6 +29,13 @@ export function DataTable<TData, TValue>({
|
|||
getPaginationRowModel: pagination ? getPaginationRowModel() : undefined,
|
||||
});
|
||||
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
useEffect(() => {
|
||||
if (pagination) {
|
||||
table.setPageSize(pageSize);
|
||||
}
|
||||
}, [table, pagination, pageSize]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="rounded-md border">
|
||||
|
@ -36,7 +45,8 @@ export function DataTable<TData, TValue>({
|
|||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
<TableHead key={header.id}
|
||||
style={{minWidth: `${header.column.getSize()}px`}}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
|
@ -57,7 +67,8 @@ export function DataTable<TData, TValue>({
|
|||
data-state={row.getIsSelected() && 'selected'}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
<TableCell key={cell.id}
|
||||
className={cell.id.endsWith('actions') ? 'w-[120px]' : ''}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
|
@ -75,43 +86,62 @@ export function DataTable<TData, TValue>({
|
|||
</div>
|
||||
{
|
||||
pagination && (
|
||||
<div className="flex items-center justify-end space-x-2 py-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => table.firstPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
<div className="flex items-center justify-between py-4">
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
setPageSize(parseInt(value));
|
||||
}}
|
||||
value={pageSize.toString()}
|
||||
>
|
||||
<span className="sr-only">First page</span>
|
||||
<ChevronsLeft/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Previous page</span>
|
||||
<ChevronLeft/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Next page</span>
|
||||
<ChevronRight/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => table.lastPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Last page</span>
|
||||
<ChevronsRight/>
|
||||
</Button>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Select a scope"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={'25'} key={'25'}>25</SelectItem>
|
||||
<SelectItem value={'50'} key={'50'}>50</SelectItem>
|
||||
<SelectItem value={'75'} key={'75'}>75</SelectItem>
|
||||
<SelectItem value={'100'} key={'100'}>100</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => table.firstPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">First page</span>
|
||||
<ChevronsLeft/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Previous page</span>
|
||||
<ChevronLeft/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Next page</span>
|
||||
<ChevronRight/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => table.lastPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Last page</span>
|
||||
<ChevronsRight/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
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',
|
||||
);
|
||||
|
||||
const navigationMenuIconTriggerStyle = cva(
|
||||
'group inline-flex h-10 w-full items-center justify-start rounded-md bg-background px-4 py-2 space-x-4 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50',
|
||||
);
|
||||
|
||||
const NavigationMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
|
||||
|
@ -117,6 +121,7 @@ NavigationMenuIndicator.displayName =
|
|||
|
||||
export {
|
||||
navigationMenuTriggerStyle,
|
||||
navigationMenuIconTriggerStyle,
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
|
|
|
@ -12,7 +12,7 @@ const PopoverTrigger = PopoverPrimitive.Trigger;
|
|||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<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.Content
|
||||
ref={ref}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { z } from 'zod';
|
||||
import { ActionResponse } from '@/lib/types/actionResponse';
|
||||
import { prismaClient } from '@/prisma';
|
||||
import { getUser } from '@/auth';
|
||||
import prisma from '@/prisma';
|
||||
import { URL_SIGN_IN } from '@/lib/constants';
|
||||
import { categoryFormSchema } from '@/lib/form-schemas/categoryFormSchema';
|
||||
import { auth0 } from '@/lib/auth';
|
||||
|
||||
export default async function categoryCreateUpdate({
|
||||
id,
|
||||
|
@ -12,20 +12,20 @@ export default async function categoryCreateUpdate({
|
|||
}: z.infer<typeof categoryFormSchema>): Promise<ActionResponse> {
|
||||
'use server';
|
||||
|
||||
// check that user is logged in
|
||||
const user = await getUser();
|
||||
if (!user) {
|
||||
const session = await auth0.getSession();
|
||||
if (!session) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'You must be logged in to create/update an category.',
|
||||
message: 'You aren\'t signed in.',
|
||||
redirect: URL_SIGN_IN,
|
||||
};
|
||||
}
|
||||
const user = session.user;
|
||||
|
||||
// create/update category
|
||||
try {
|
||||
if (id) {
|
||||
await prismaClient.category.update({
|
||||
await prisma.category.update({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
|
@ -42,9 +42,9 @@ export default async function categoryCreateUpdate({
|
|||
message: `'${name}' updated`,
|
||||
};
|
||||
} else {
|
||||
await prismaClient.category.create({
|
||||
await prisma.category.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
userId: user.sub,
|
||||
name: name,
|
||||
color: color,
|
||||
},
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { ActionResponse } from '@/lib/types/actionResponse';
|
||||
import { prismaClient } from '@/prisma';
|
||||
import { getUser } from '@/auth';
|
||||
import prisma from '@/prisma';
|
||||
import { URL_SIGN_IN } from '@/lib/constants';
|
||||
import { auth0 } from '@/lib/auth';
|
||||
|
||||
export default async function categoryDelete(id: number): Promise<ActionResponse> {
|
||||
'use server';
|
||||
|
@ -14,21 +14,21 @@ export default async function categoryDelete(id: number): Promise<ActionResponse
|
|||
};
|
||||
}
|
||||
|
||||
// check that user is logged in
|
||||
const user = await getUser();
|
||||
if (!user) {
|
||||
const session = await auth0.getSession();
|
||||
if (!session) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'You must be logged in to delete an category.',
|
||||
message: 'You aren\'t signed in.',
|
||||
redirect: URL_SIGN_IN,
|
||||
};
|
||||
}
|
||||
const user = session.user;
|
||||
|
||||
// check that category is associated with user
|
||||
const category = await prismaClient.category.findFirst({
|
||||
const category = await prisma.category.findFirst({
|
||||
where: {
|
||||
id: id,
|
||||
userId: user.id,
|
||||
userId: user.sub,
|
||||
},
|
||||
});
|
||||
if (!category) {
|
||||
|
@ -38,15 +38,25 @@ export default async function categoryDelete(id: number): Promise<ActionResponse
|
|||
};
|
||||
}
|
||||
|
||||
// delete category
|
||||
try {
|
||||
await prismaClient.category.delete({
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
|
||||
// update related payments
|
||||
await tx.payment.updateMany({
|
||||
where: {categoryId: category.id},
|
||||
data: {categoryId: null},
|
||||
});
|
||||
|
||||
// delete the category
|
||||
await tx.category.delete({
|
||||
where: {
|
||||
id: category.id,
|
||||
userId: user.id,
|
||||
userId: user.sub,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
return {
|
||||
type: 'error',
|
||||
|
|
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 { ActionResponse } from '@/lib/types/actionResponse';
|
||||
import { entityFormSchema } from '@/lib/form-schemas/entityFormSchema';
|
||||
import { prismaClient } from '@/prisma';
|
||||
import { getUser } from '@/auth';
|
||||
import prisma from '@/prisma';
|
||||
import { URL_SIGN_IN } from '@/lib/constants';
|
||||
import { auth0 } from '@/lib/auth';
|
||||
|
||||
export default async function entityCreateUpdate({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
defaultCategoryId,
|
||||
}: z.infer<typeof entityFormSchema>): Promise<ActionResponse> {
|
||||
'use server';
|
||||
|
||||
// check that user is logged in
|
||||
const user = await getUser();
|
||||
if (!user) {
|
||||
const session = await auth0.getSession();
|
||||
if (!session) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'You must be logged in to create/update an entity.',
|
||||
message: 'You aren\'t signed in.',
|
||||
redirect: URL_SIGN_IN,
|
||||
};
|
||||
}
|
||||
const user = session.user;
|
||||
|
||||
// create/update entity
|
||||
try {
|
||||
if (id) {
|
||||
await prismaClient.entity.update({
|
||||
await prisma.entity.update({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
data: {
|
||||
name: name,
|
||||
type: type,
|
||||
defaultCategoryId: defaultCategoryId ?? null,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
@ -42,11 +44,12 @@ export default async function entityCreateUpdate({
|
|||
message: `${type} '${name}' updated`,
|
||||
};
|
||||
} else {
|
||||
await prismaClient.entity.create({
|
||||
await prisma.entity.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
userId: user.sub,
|
||||
name: name,
|
||||
type: type,
|
||||
defaultCategoryId: defaultCategoryId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { ActionResponse } from '@/lib/types/actionResponse';
|
||||
import { prismaClient } from '@/prisma';
|
||||
import { getUser } from '@/auth';
|
||||
import prisma from '@/prisma';
|
||||
import { URL_SIGN_IN } from '@/lib/constants';
|
||||
import { auth0 } from '@/lib/auth';
|
||||
|
||||
export default async function entityDelete(id: number): Promise<ActionResponse> {
|
||||
'use server';
|
||||
|
@ -14,21 +14,21 @@ export default async function entityDelete(id: number): Promise<ActionResponse>
|
|||
};
|
||||
}
|
||||
|
||||
// check that user is logged in
|
||||
const user = await getUser();
|
||||
if (!user) {
|
||||
const session = await auth0.getSession();
|
||||
if (!session) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'You must be logged in to delete an entity.',
|
||||
message: 'You aren\'t signed in.',
|
||||
redirect: URL_SIGN_IN,
|
||||
};
|
||||
}
|
||||
const user = session.user;
|
||||
|
||||
// check that entity is associated with user
|
||||
const entity = await prismaClient.entity.findFirst({
|
||||
const entity = await prisma.entity.findFirst({
|
||||
where: {
|
||||
id: id,
|
||||
userId: user.id,
|
||||
userId: user.sub,
|
||||
},
|
||||
});
|
||||
if (!entity) {
|
||||
|
@ -40,10 +40,10 @@ export default async function entityDelete(id: number): Promise<ActionResponse>
|
|||
|
||||
// delete entity
|
||||
try {
|
||||
await prismaClient.entity.delete({
|
||||
await prisma.entity.delete({
|
||||
where: {
|
||||
id: entity.id,
|
||||
userId: user.id,
|
||||
userId: user.sub,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
@ -1,64 +1,64 @@
|
|||
import { prismaClient } from '@/prisma';
|
||||
import prisma from '@/prisma';
|
||||
import type { Category, Entity } from '@prisma/client';
|
||||
import { EntityType } from '@prisma/client';
|
||||
import { getUser } from '@/auth';
|
||||
import { URL_SIGN_IN } from '@/lib/constants';
|
||||
import { ActionResponse } from '@/lib/types/actionResponse';
|
||||
import { auth0 } from '@/lib/auth';
|
||||
|
||||
export default async function generateSampleData(): Promise<ActionResponse> {
|
||||
'use server';
|
||||
|
||||
const user = await getUser();
|
||||
|
||||
if (!user) {
|
||||
const session = await auth0.getSession();
|
||||
if (!session) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'You must be logged in to create/update an category.',
|
||||
message: 'You aren\'t signed in.',
|
||||
redirect: URL_SIGN_IN,
|
||||
};
|
||||
}
|
||||
const user = session.user;
|
||||
|
||||
// Categories: create sample data
|
||||
const categories: Category[] = await prismaClient.category.findMany({where: {userId: user.id}});
|
||||
if (await prismaClient.category.count({where: {userId: user.id}}) == 0) {
|
||||
const categories: Category[] = await prisma.category.findMany({where: {userId: user.sub}});
|
||||
if (await prisma.category.count({where: {userId: user.sub}}) == 0) {
|
||||
|
||||
console.log('Creating sample categories...');
|
||||
|
||||
categories.push(await prismaClient.category.create({
|
||||
categories.push(await prisma.category.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
userId: user.sub,
|
||||
name: 'Groceries',
|
||||
color: '#FFBEAC',
|
||||
},
|
||||
}));
|
||||
|
||||
categories.push(await prismaClient.category.create({
|
||||
categories.push(await prisma.category.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
userId: user.sub,
|
||||
name: 'Drugstore items',
|
||||
color: '#9CBCFF',
|
||||
},
|
||||
}));
|
||||
|
||||
categories.push(await prismaClient.category.create({
|
||||
categories.push(await prisma.category.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
userId: user.sub,
|
||||
name: 'Going out',
|
||||
color: '#F1ADFF',
|
||||
},
|
||||
}));
|
||||
|
||||
categories.push(await prismaClient.category.create({
|
||||
categories.push(await prisma.category.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
userId: user.sub,
|
||||
name: 'Random stuff',
|
||||
color: '#C1FFA9',
|
||||
},
|
||||
}));
|
||||
|
||||
categories.push(await prismaClient.category.create({
|
||||
categories.push(await prisma.category.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
userId: user.sub,
|
||||
name: 'Salary',
|
||||
color: '#FFF787',
|
||||
},
|
||||
|
@ -69,54 +69,54 @@ export default async function generateSampleData(): Promise<ActionResponse> {
|
|||
console.log(categories);
|
||||
|
||||
// Entities: create sample data
|
||||
const entities: Entity[] = await prismaClient.entity.findMany({where: {userId: user.id}});
|
||||
if (await prismaClient.entity.count({where: {userId: user.id}}) == 0) {
|
||||
const entities: Entity[] = await prisma.entity.findMany({where: {userId: user.sub}});
|
||||
if (await prisma.entity.count({where: {userId: user.sub}}) == 0) {
|
||||
|
||||
console.log('Creating sample entities...');
|
||||
|
||||
entities.push(await prismaClient.entity.create({
|
||||
entities.push(await prisma.entity.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
userId: user.sub,
|
||||
name: 'Main Account',
|
||||
type: EntityType.Account,
|
||||
},
|
||||
}));
|
||||
|
||||
entities.push(await prismaClient.entity.create({
|
||||
entities.push(await prisma.entity.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
userId: user.sub,
|
||||
name: 'Company',
|
||||
type: EntityType.Entity,
|
||||
},
|
||||
}));
|
||||
|
||||
entities.push(await prismaClient.entity.create({
|
||||
entities.push(await prisma.entity.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
userId: user.sub,
|
||||
name: 'Supermarket 1',
|
||||
type: EntityType.Entity,
|
||||
},
|
||||
}));
|
||||
|
||||
entities.push(await prismaClient.entity.create({
|
||||
entities.push(await prisma.entity.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
userId: user.sub,
|
||||
name: 'Supermarket 2',
|
||||
type: EntityType.Entity,
|
||||
},
|
||||
}));
|
||||
|
||||
entities.push(await prismaClient.entity.create({
|
||||
entities.push(await prisma.entity.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
userId: user.sub,
|
||||
name: 'Supermarket 3',
|
||||
type: EntityType.Entity,
|
||||
},
|
||||
}));
|
||||
|
||||
entities.push(await prismaClient.entity.create({
|
||||
entities.push(await prisma.entity.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
userId: user.sub,
|
||||
name: 'Supermarket 4',
|
||||
type: EntityType.Entity,
|
||||
},
|
||||
|
@ -129,21 +129,24 @@ export default async function generateSampleData(): Promise<ActionResponse> {
|
|||
// Payments: create sample data
|
||||
console.log('Creating sample payments...');
|
||||
|
||||
if (await prismaClient.payment.count({where: {userId: user.id}}) == 0) {
|
||||
if (await prisma.payment.count({where: {userId: user.sub}}) == 0) {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
|
||||
const date = new Date();
|
||||
date.setDate(1);
|
||||
date.setMonth(date.getMonth() - i);
|
||||
|
||||
await prismaClient.payment.create({
|
||||
const categoryId =
|
||||
categories.find((it) => it.name === 'Salary')?.id!;
|
||||
|
||||
await prisma.payment.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
userId: user.sub,
|
||||
amount: 200000,
|
||||
date: date,
|
||||
payorId: entities[1].id,
|
||||
payeeId: entities[0].id,
|
||||
categoryId: 5,
|
||||
categoryId: categoryId,
|
||||
createdAt: date,
|
||||
updatedAt: date,
|
||||
},
|
||||
|
@ -164,9 +167,9 @@ export default async function generateSampleData(): Promise<ActionResponse> {
|
|||
const date = new Date(
|
||||
new Date().getTime() - Math.floor(Math.random() * 10000000000));
|
||||
|
||||
await prismaClient.payment.create({
|
||||
await prisma.payment.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
userId: user.sub,
|
||||
amount: Math.floor(
|
||||
Math.random() * (maxAmount - minAmount) + minAmount),
|
||||
date: date,
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { z } from 'zod';
|
||||
import { ActionResponse } from '@/lib/types/actionResponse';
|
||||
import { prismaClient } from '@/prisma';
|
||||
import { getUser } from '@/auth';
|
||||
import prisma from '@/prisma';
|
||||
import { URL_SIGN_IN } from '@/lib/constants';
|
||||
import { paymentFormSchema } from '@/lib/form-schemas/paymentFormSchema';
|
||||
import { auth0 } from '@/lib/auth';
|
||||
|
||||
export default async function paymentCreateUpdate({
|
||||
id,
|
||||
|
@ -16,20 +16,20 @@ export default async function paymentCreateUpdate({
|
|||
}: z.infer<typeof paymentFormSchema>): Promise<ActionResponse> {
|
||||
'use server';
|
||||
|
||||
// check that user is logged in
|
||||
const user = await getUser();
|
||||
if (!user) {
|
||||
const session = await auth0.getSession();
|
||||
if (!session) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'You must be logged in to create/update a payment.',
|
||||
message: 'You aren\'t signed in.',
|
||||
redirect: URL_SIGN_IN,
|
||||
};
|
||||
}
|
||||
const user = session.user;
|
||||
|
||||
// create/update payment
|
||||
try {
|
||||
if (id) {
|
||||
await prismaClient.payment.update({
|
||||
await prisma.payment.update({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
|
@ -38,7 +38,7 @@ export default async function paymentCreateUpdate({
|
|||
date: date,
|
||||
payorId: payorId,
|
||||
payeeId: payeeId,
|
||||
categoryId: categoryId,
|
||||
categoryId: categoryId ?? null,
|
||||
note: note,
|
||||
},
|
||||
},
|
||||
|
@ -50,14 +50,14 @@ export default async function paymentCreateUpdate({
|
|||
message: `Payment updated`,
|
||||
};
|
||||
} else {
|
||||
await prismaClient.payment.create({
|
||||
await prisma.payment.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
userId: user.sub,
|
||||
amount: amount,
|
||||
date: date,
|
||||
payorId: payorId,
|
||||
payeeId: payeeId,
|
||||
categoryId: categoryId,
|
||||
categoryId: categoryId ?? null,
|
||||
note: note,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { ActionResponse } from '@/lib/types/actionResponse';
|
||||
import { prismaClient } from '@/prisma';
|
||||
import { getUser } from '@/auth';
|
||||
import prisma from '@/prisma';
|
||||
import { URL_SIGN_IN } from '@/lib/constants';
|
||||
import { auth0 } from '@/lib/auth';
|
||||
|
||||
export default async function paymentDelete(id: number): Promise<ActionResponse> {
|
||||
'use server';
|
||||
|
@ -14,21 +14,21 @@ export default async function paymentDelete(id: number): Promise<ActionResponse>
|
|||
};
|
||||
}
|
||||
|
||||
// check that user is logged in
|
||||
const user = await getUser();
|
||||
if (!user) {
|
||||
const session = await auth0.getSession();
|
||||
if (!session) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'You must be logged in to delete a payment.',
|
||||
message: 'You aren\'t signed in.',
|
||||
redirect: URL_SIGN_IN,
|
||||
};
|
||||
}
|
||||
const user = session.user;
|
||||
|
||||
// check that payment is associated with user
|
||||
const payment = await prismaClient.payment.findFirst({
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: {
|
||||
id: id,
|
||||
userId: user.id,
|
||||
userId: user.sub,
|
||||
},
|
||||
});
|
||||
if (!payment) {
|
||||
|
@ -40,10 +40,10 @@ export default async function paymentDelete(id: number): Promise<ActionResponse>
|
|||
|
||||
// delete payment
|
||||
try {
|
||||
await prismaClient.payment.delete({
|
||||
await prisma.payment.delete({
|
||||
where: {
|
||||
id: payment.id,
|
||||
userId: user.id,
|
||||
userId: user.sub,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
@ -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_AUTH = '/auth';
|
||||
export const URL_SIGN_IN = `${URL_AUTH}/signin`;
|
||||
export const URL_SIGN_UP = `${URL_AUTH}/signup`;
|
||||
export const URL_SIGN_IN = `/auth/login`;
|
||||
export const URL_SIGN_OUT = `/auth/logout`;
|
||||
|
||||
|
||||
// main urls
|
||||
export const URL_HOME = '/';
|
||||
|
|
|
@ -5,4 +5,5 @@ export const entityFormSchema = z.object({
|
|||
id: z.number().positive().optional(),
|
||||
name: z.string().min(1).max(32),
|
||||
type: z.nativeEnum(EntityType),
|
||||
defaultCategoryId: z.number().positive().optional(),
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
message: string;
|
||||
redirect?: string;
|
||||
data?: T;
|
||||
}
|
||||
|
|
|
@ -1,27 +1,22 @@
|
|||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { URL_AUTH, URL_HOME, URL_SIGN_IN } from './lib/constants';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { auth0 } from '@/lib/auth';
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
|
||||
// get session id from cookies
|
||||
const sessionId = request.cookies.get('auth_session')?.value ?? null;
|
||||
|
||||
// redirect to home if user is already authenticated
|
||||
if (request.nextUrl.pathname.startsWith(URL_AUTH) && sessionId) {
|
||||
return NextResponse.redirect(new URL(URL_HOME, request.url));
|
||||
try {
|
||||
return await auth0.middleware(request);
|
||||
} catch (error) {
|
||||
console.error("Auth0 middleware error:", error);
|
||||
}
|
||||
|
||||
// redirect to sign in if user is not authenticated
|
||||
if (!request.nextUrl.pathname.startsWith(URL_AUTH) && !sessionId) {
|
||||
return NextResponse.redirect(new URL(URL_SIGN_IN, request.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!api|_next/static|_next/image|favicon.ico).*)',
|
||||
/*
|
||||
* Match all request paths except for the ones starting with:
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico, sitemap.xml, robots.txt (metadata files)
|
||||
*/
|
||||
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,3 +1,18 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export const prismaClient = new PrismaClient();
|
||||
const prismaClientSingleton = () => {
|
||||
return new PrismaClient();
|
||||
};
|
||||
|
||||
declare global {
|
||||
// noinspection ES6ConvertVarToLetConst
|
||||
var prismaGlobal: undefined | ReturnType<typeof prismaClientSingleton>;
|
||||
}
|
||||
|
||||
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton();
|
||||
|
||||
export default prisma;
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalThis.prismaGlobal = prisma
|
||||
}
|
||||
|
|
|
@ -3,7 +3,8 @@
|
|||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
"esnext",
|
||||
"webworker"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
|
@ -25,7 +26,11 @@
|
|||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"types": [
|
||||
"@serwist/next/typings"
|
||||
],
|
||||
"target": "ES2017"
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
|
|
Loading…
Add table
Reference in a new issue