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