diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml
new file mode 100644
index 0000000..15d42ad
--- /dev/null
+++ b/.github/workflows/build-push.yml
@@ -0,0 +1,44 @@
+name: Build and push docker image to GHCR
+on:
+ workflow_run:
+ workflows: ["Tests"]
+ types:
+ - completed
+ branches: [main, test]
+
+jobs:
+ build-and-push:
+ runs-on: ubuntu-latest
+ if: ${{ github.event.workflow_run.conclusion == 'success' }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Log in to container registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: olabekkevold
+ password: ${{ secrets.PAT }}
+
+ - name: Determine image tag
+ id: tag
+ run: |
+ if: [ "${{ github.ref }}" == 'refs/heads/main' ]; then
+ echo "env_tag=latest" >> $GITHUB_OUTPUT
+ else
+ echo "env_tag=test" >> $GITHUB_OUTPUT
+ fi
+ ghcr.io/olabekkevold/labmanager:${{ github.sha }}
+
+ - name: Build and push Docker image
+ uses: docker/build-push-action@v6
+ with:
+ context: ./labman
+ push: true
+ tags: |
+ ghcr.io/olabekkevold/labmanager:${{ steps.tag.outputs.env_tag }}
+ ghcr.io/olabekkevold/labmanager:${{ github.sha }}
+
+
diff --git a/.github/workflows/main.yml b/.github/workflows/test.yml
similarity index 93%
rename from .github/workflows/main.yml
rename to .github/workflows/test.yml
index 2dfea5a..cf2b293 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/test.yml
@@ -1,9 +1,9 @@
name: Tests
on:
push:
- branches: [ main, develop ]
+ branches: [ main, develop, test ]
pull_request:
- branches: [ main, develop ]
+ branches: [ main, develop, test ]
defaults:
run:
@@ -59,7 +59,7 @@ jobs:
env:
NODE_ENV: test
HOME: /root
- run: npx playwright test
+ run: npx playwright test --project=firefox
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
diff --git a/.gitignore b/.gitignore
index 45c1abc..2162cd4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,6 +27,7 @@ yarn-error.log*
# local env files
.env*.local
.env
+.env.production
# vercel
.vercel
diff --git a/README.md b/README.md
index 2ac51c7..5eade70 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,65 @@
-# LabManager
\ No newline at end of file
+# VR Lab Manager
+
+The purpose of this project of is to create system for organizing and managing equipment in addition to managing borrowing and returning of equipment.
+Initially made for the IMTEL VR Lab at NTNU but it is also usable for other inventories.
+
+## Features
+
+- Add edit and delete equipment
+- Manage borrowing and returning of equipment
+- Keep track of borrowers and equipment usage
+- Manage inventory administrators
+
+## Build and run your own instance
+
+### Requirements
+
+- A computer with Docker installed
+
+### Instructions
+
+1. Build a Docker image using the Dockerfile in ./labman. Run ``docker build -t labman .``
+2. Use the provided compose.yaml to run the application and make to make a .env file in the same directory containing the variables reqiored by compose.yaml.
+3. In compose.yaml, replace the labman image with name of the image you built in step 1.
+4. Start the application by running `docker compose up -d` in the same directory as compose.yaml
+5. Open `http://localhost:5000` and login with your own preferred credentials to start using the application
+
+## Develop
+
+### Database
+This project uses PostgreSQL as the database, using Prisma as the ORM.
+
+1. Create a PostgreSQL database using your preferred method
+2. Make an .env file with the database connection string: `DATABASE_URL=postgres://{username}:{password}@{route}/{database}?schema=public
+
+### Run the application
+
+1. Clone the repository
+2. Run `cd labman`
+3. Run `npm install`
+4. Run `npm prisma generate` to generate the Prisma client
+5. Run `npm prisma migrate dev` to run the database migrations
+6. Run `npm run dev` to start the development server
+7. Open `http://localhost:3000` and login with your own preferred credentials to make a user to access the application
+
+
+# FAQ
+
+- **Can I use this application for managing other types of inventory?**
+
+Yes, the current name is misleading. The application is perfectly suitable for other things than VR equipment too.
+
+- **I forgot the credentials to my accounts?**
+
+As of now, there is no way to recover lost accounts. If no one has access to create a new account for you, you can directly manipulate the database to delete all users,
+which will force the creation of a new account on the next login attempt.
+
+- **Is it safe to store sensitive information in the database?**
+
+Everything is hosted locally with no external connections, and the login passwords are encrypted. Though if you are hosting the application publicly, it is your own responsibility to host it securely.
+
+
+
+
+
+
diff --git a/compose.yaml b/compose.yaml
new file mode 100644
index 0000000..ccebf7c
--- /dev/null
+++ b/compose.yaml
@@ -0,0 +1,45 @@
+services:
+ labman:
+ image: ghcr.io/olabekkevold/labmanager:latest
+ environment:
+ DATABASE_URL:
+ ports:
+ - "5000:3000"
+ restart: "unless-stopped"
+ depends_on:
+ db:
+ condition: service_healthy
+
+ db:
+ image: postgres:18
+ restart: always
+ # set shared memory limit when using docker compose
+ shm_size: 128mb
+ # or set shared memory limit when deploy via swarm stack
+ #volumes:
+ # - type: tmpfs
+ # target: /dev/shm
+ # tmpfs:
+ # size: 134217728 # 128*2^20 bytes = 128Mb
+ environment:
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ POSTGRES_USER: ${POSTGRES_USER}
+ ports:
+ - "5432:5432"
+ volumes:
+ - postgres_data:/var/lib/postgresql
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+
+ adminer:
+ image: adminer
+ restart: always
+ ports:
+ - 8080:8080
+
+
+volumes:
+ postgres_data:
\ No newline at end of file
diff --git a/labman/.gitignore b/labman/.gitignore
index c3d20b0..cec7877 100644
--- a/labman/.gitignore
+++ b/labman/.gitignore
@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env
+.env.production
# vercel
.vercel
@@ -42,6 +43,8 @@ next-env.d.ts
/src/generated/prisma
+.idea
+
# Playwright
node_modules/
/test-results/
diff --git a/labman/.idea/.gitignore b/labman/.idea/.gitignore
deleted file mode 100644
index 7e5b7d7..0000000
--- a/labman/.idea/.gitignore
+++ /dev/null
@@ -1,8 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
-# Editor-based HTTP Client requests
-/httpRequests/
-# Datasource local storage ignored files
-/dataSources/
-/dataSources.local.xml
diff --git a/labman/.idea/encodings.xml b/labman/.idea/encodings.xml
deleted file mode 100644
index df87cf9..0000000
--- a/labman/.idea/encodings.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/labman/.idea/labman.iml b/labman/.idea/labman.iml
deleted file mode 100644
index 24643cc..0000000
--- a/labman/.idea/labman.iml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/labman/.idea/material_theme_project_new.xml b/labman/.idea/material_theme_project_new.xml
deleted file mode 100644
index 52e0d89..0000000
--- a/labman/.idea/material_theme_project_new.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/labman/.idea/modules.xml b/labman/.idea/modules.xml
deleted file mode 100644
index 4c3f2bc..0000000
--- a/labman/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/labman/.idea/vcs.xml b/labman/.idea/vcs.xml
deleted file mode 100644
index efccd08..0000000
--- a/labman/.idea/vcs.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/labman/Dockerfile b/labman/Dockerfile
new file mode 100644
index 0000000..901cabe
--- /dev/null
+++ b/labman/Dockerfile
@@ -0,0 +1,75 @@
+# syntax=docker.io/docker/dockerfile:1
+
+FROM node:20-alpine AS base
+
+# Install dependencies only when needed
+FROM base AS deps
+# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
+RUN apk add --no-cache libc6-compat
+WORKDIR /app
+
+# Install dependencies based on the preferred package manager
+COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
+RUN \
+ if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
+ elif [ -f package-lock.json ]; then npm ci; \
+ elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
+ else echo "Lockfile not found." && exit 1; \
+ fi
+
+
+# Rebuild the source code only when needed
+FROM base AS builder
+WORKDIR /app
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+
+# Next.js collects completely anonymous telemetry data about general usage.
+# Learn more here: https://nextjs.org/telemetry
+# Uncomment the following line in case you want to disable telemetry during the build.
+# ENV NEXT_TELEMETRY_DISABLED=1
+
+RUN npx prisma generate
+
+RUN npm run build;
+
+# Production image, copy all the files and run next
+FROM base AS runner
+WORKDIR /app
+
+
+# Uncomment the following line in case you want to disable telemetry during runtime.
+# ENV NEXT_TELEMETRY_DISABLED=1
+
+RUN addgroup --system --gid 1001 nodejs
+RUN adduser --system --uid 1001 nextjs
+
+COPY --from=builder /app/public ./public
+
+# Automatically leverage output traces to reduce image size
+# https://nextjs.org/docs/advanced-features/output-file-tracing
+COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
+COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+
+COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
+
+COPY --chown=nextjs:nodejs docker-entrypoint.sh .
+# Make sure the entrypoint is in Unix format
+RUN apk add --no-cache dos2unix && \
+ dos2unix docker-entrypoint.sh && \
+ chmod +x docker-entrypoint.sh
+
+RUN npm install -g prisma@6.19.2
+
+USER nextjs
+
+EXPOSE 3000
+
+ENV PORT=3000
+
+# server.js is created by next build from the standalone output
+# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output
+ENV HOSTNAME="0.0.0.0"
+
+ENTRYPOINT ["./docker-entrypoint.sh"]
+CMD ["node", "server.js"]
diff --git a/labman/docker-entrypoint.sh b/labman/docker-entrypoint.sh
new file mode 100644
index 0000000..aef9127
--- /dev/null
+++ b/labman/docker-entrypoint.sh
@@ -0,0 +1,7 @@
+#!/bin/sh
+
+set -e
+
+prisma migrate deploy
+
+exec "$@"
\ No newline at end of file
diff --git a/labman/eslint.config.mjs b/labman/eslint.config.mjs
index c85fb67..ec4854b 100644
--- a/labman/eslint.config.mjs
+++ b/labman/eslint.config.mjs
@@ -9,8 +9,9 @@ const compat = new FlatCompat({
baseDirectory: __dirname,
});
-const eslintConfig = [
+// Commented out because it is too strict for auto-generated files from other dependencies
+/* const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
-export default eslintConfig;
+export default eslintConfig; */
diff --git a/labman/next.config.ts b/labman/next.config.ts
index e9ffa30..3a5d1e9 100644
--- a/labman/next.config.ts
+++ b/labman/next.config.ts
@@ -2,6 +2,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
+ output: "standalone",
};
export default nextConfig;
diff --git a/labman/package-lock.json b/labman/package-lock.json
index 47cf7f2..9eeaafb 100644
--- a/labman/package-lock.json
+++ b/labman/package-lock.json
@@ -9,13 +9,13 @@
"version": "0.1.0",
"dependencies": {
"@heroicons/react": "^2.2.0",
- "@prisma/client": "^6.14.0",
+ "@prisma/client": "^6.19.2",
"bcrypt": "^6.0.0",
"dotenv": "^17.2.3",
"next": "^15.4.10",
"pg": "^8.17.1",
"postcss": "^8.5.6",
- "prisma": "^6.14.0",
+ "prisma": "^6.19.2",
"react": "19.1.0",
"react-dom": "19.1.0"
},
@@ -1975,9 +1975,9 @@
}
},
"node_modules/@prisma/client": {
- "version": "6.14.0",
- "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.14.0.tgz",
- "integrity": "sha512-8E/Nk3eL5g7RQIg/LUj1ICyDmhD053STjxrPxUtCRybs2s/2sOEcx9NpITuAOPn07HEpWBfhAVe1T/HYWXUPOw==",
+ "version": "6.19.2",
+ "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz",
+ "integrity": "sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
@@ -1997,60 +1997,60 @@
}
},
"node_modules/@prisma/config": {
- "version": "6.14.0",
- "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.14.0.tgz",
- "integrity": "sha512-IwC7o5KNNGhmblLs23swnfBjADkacBb7wvyDXUWLwuvUQciKJZqyecU0jw0d7JRkswrj+XTL8fdr0y2/VerKQQ==",
+ "version": "6.19.2",
+ "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.2.tgz",
+ "integrity": "sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ==",
"license": "Apache-2.0",
"dependencies": {
"c12": "3.1.0",
"deepmerge-ts": "7.1.5",
- "effect": "3.16.12",
+ "effect": "3.18.4",
"empathic": "2.0.0"
}
},
"node_modules/@prisma/debug": {
- "version": "6.14.0",
- "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.14.0.tgz",
- "integrity": "sha512-j4Lf+y+5QIJgQD4sJWSbkOD7geKx9CakaLp/TyTy/UDu9Wo0awvWCBH/BAxTHUaCpIl9USA5VS/KJhDqKJSwug==",
+ "version": "6.19.2",
+ "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.2.tgz",
+ "integrity": "sha512-lFnEZsLdFLmEVCVNdskLDCL8Uup41GDfU0LUfquw+ercJC8ODTuL0WNKgOKmYxCJVvFwf0OuZBzW99DuWmoH2A==",
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
- "version": "6.14.0",
- "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.14.0.tgz",
- "integrity": "sha512-LhJjqsALFEcoAtF07nSaOkVguaxw/ZsgfROIYZ8bAZDobe7y8Wy+PkYQaPOK1iLSsFgV2MhCO/eNrI1gdSOj6w==",
+ "version": "6.19.2",
+ "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.2.tgz",
+ "integrity": "sha512-TTkJ8r+uk/uqczX40wb+ODG0E0icVsMgwCTyTHXehaEfb0uo80M9g1aW1tEJrxmFHeOZFXdI2sTA1j1AgcHi4A==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
- "@prisma/debug": "6.14.0",
- "@prisma/engines-version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49",
- "@prisma/fetch-engine": "6.14.0",
- "@prisma/get-platform": "6.14.0"
+ "@prisma/debug": "6.19.2",
+ "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
+ "@prisma/fetch-engine": "6.19.2",
+ "@prisma/get-platform": "6.19.2"
}
},
"node_modules/@prisma/engines-version": {
- "version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49",
- "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49.tgz",
- "integrity": "sha512-EgN9ODJpiX45yvwcngoStp3uQPJ3l+AEVoQ6dMMO2QvmwIlnxfApzKmJQExzdo7/hqQANrz5txHJdGYHzOnGHA==",
+ "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
+ "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
+ "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==",
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
- "version": "6.14.0",
- "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.14.0.tgz",
- "integrity": "sha512-MPzYPOKMENYOaY3AcAbaKrfvXVlvTc6iHmTXsp9RiwCX+bPyfDMqMFVUSVXPYrXnrvEzhGHfyiFy0PRLHPysNg==",
+ "version": "6.19.2",
+ "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.2.tgz",
+ "integrity": "sha512-h4Ff4Pho+SR1S8XerMCC12X//oY2bG3Iug/fUnudfcXEUnIeRiBdXHFdGlGOgQ3HqKgosTEhkZMvGM9tWtYC+Q==",
"license": "Apache-2.0",
"dependencies": {
- "@prisma/debug": "6.14.0",
- "@prisma/engines-version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49",
- "@prisma/get-platform": "6.14.0"
+ "@prisma/debug": "6.19.2",
+ "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
+ "@prisma/get-platform": "6.19.2"
}
},
"node_modules/@prisma/get-platform": {
- "version": "6.14.0",
- "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.14.0.tgz",
- "integrity": "sha512-7VjuxKNwjnBhKfqPpMeWiHEa2sVjYzmHdl1slW6STuUCe9QnOY0OY1ljGSvz6wpG4U8DfbDqkG1yofd/1GINww==",
+ "version": "6.19.2",
+ "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.2.tgz",
+ "integrity": "sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==",
"license": "Apache-2.0",
"dependencies": {
- "@prisma/debug": "6.14.0"
+ "@prisma/debug": "6.19.2"
}
},
"node_modules/@rolldown/pluginutils": {
@@ -4604,9 +4604,9 @@
}
},
"node_modules/effect": {
- "version": "3.16.12",
- "resolved": "https://registry.npmjs.org/effect/-/effect-3.16.12.tgz",
- "integrity": "sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg==",
+ "version": "3.18.4",
+ "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
+ "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
@@ -5359,9 +5359,9 @@
}
},
"node_modules/exsolve": {
- "version": "1.0.7",
- "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz",
- "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==",
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
+ "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
"license": "MIT"
},
"node_modules/fast-check": {
@@ -7127,24 +7127,28 @@
"license": "MIT"
},
"node_modules/nypm": {
- "version": "0.6.1",
- "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.1.tgz",
- "integrity": "sha512-hlacBiRiv1k9hZFiphPUkfSQ/ZfQzZDzC+8z0wL3lvDAOUu/2NnChkKuMoMjNur/9OpKuz2QsIeiPVN0xM5Q0w==",
+ "version": "0.6.4",
+ "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.4.tgz",
+ "integrity": "sha512-1TvCKjZyyklN+JJj2TS3P4uSQEInrM/HkkuSXsEzm1ApPgBffOn8gFguNnZf07r/1X6vlryfIqMUkJKQMzlZiw==",
"license": "MIT",
"dependencies": {
- "citty": "^0.1.6",
- "consola": "^3.4.2",
+ "citty": "^0.2.0",
"pathe": "^2.0.3",
- "pkg-types": "^2.2.0",
- "tinyexec": "^1.0.1"
+ "tinyexec": "^1.0.2"
},
"bin": {
"nypm": "dist/cli.mjs"
},
"engines": {
- "node": "^14.16.0 || >=16.10.0"
+ "node": ">=18"
}
},
+ "node_modules/nypm/node_modules/citty": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.0.tgz",
+ "integrity": "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA==",
+ "license": "MIT"
+ },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -7707,14 +7711,14 @@
"license": "MIT"
},
"node_modules/prisma": {
- "version": "6.14.0",
- "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.14.0.tgz",
- "integrity": "sha512-QEuCwxu+Uq9BffFw7in8In+WfbSUN0ewnaSUKloLkbJd42w6EyFckux4M0f7VwwHlM3A8ssaz4OyniCXlsn0WA==",
+ "version": "6.19.2",
+ "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz",
+ "integrity": "sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
- "@prisma/config": "6.14.0",
- "@prisma/engines": "6.14.0"
+ "@prisma/config": "6.19.2",
+ "@prisma/engines": "6.19.2"
},
"bin": {
"prisma": "build/index.js"
diff --git a/labman/package.json b/labman/package.json
index 06b5d96..0389254 100644
--- a/labman/package.json
+++ b/labman/package.json
@@ -5,8 +5,8 @@
"private": true,
"scripts": {
"dev": "next dev --turbopack",
- "build": "next build",
- "start": "next start",
+ "build": "dotenv -e .env.production -- next build",
+ "start": "dotenv -e .env.production -- next start",
"lint": "next lint",
"test": "vitest",
"playwright:test": "dotenv -e .env.test -- next dev --turbopack",
@@ -14,13 +14,13 @@
},
"dependencies": {
"@heroicons/react": "^2.2.0",
- "@prisma/client": "^6.14.0",
+ "@prisma/client": "^6.19.2",
"bcrypt": "^6.0.0",
"dotenv": "^17.2.3",
"next": "^15.4.10",
"pg": "^8.17.1",
"postcss": "^8.5.6",
- "prisma": "^6.14.0",
+ "prisma": "^6.19.2",
"react": "19.1.0",
"react-dom": "19.1.0"
},
diff --git a/labman/prisma/migrations/20260122151137_optional_image/migration.sql b/labman/prisma/migrations/20260122151137_optional_image/migration.sql
new file mode 100644
index 0000000..db89cff
--- /dev/null
+++ b/labman/prisma/migrations/20260122151137_optional_image/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Equipment" ALTER COLUMN "image" DROP NOT NULL;
diff --git a/labman/prisma/migrations/20260309105825_add_statuses/migration.sql b/labman/prisma/migrations/20260309105825_add_statuses/migration.sql
new file mode 100644
index 0000000..2bbf039
--- /dev/null
+++ b/labman/prisma/migrations/20260309105825_add_statuses/migration.sql
@@ -0,0 +1,14 @@
+-- AlterTable
+ALTER TABLE "Borrower" ADD COLUMN "status" TEXT;
+
+-- AlterTable
+ALTER TABLE "Equipment" ADD COLUMN "status" TEXT;
+
+-- AlterTable
+ALTER TABLE "Item" ALTER COLUMN "status" DROP NOT NULL;
+
+-- AlterTable
+ALTER TABLE "Loan" ALTER COLUMN "status" DROP NOT NULL;
+
+-- AlterTable
+ALTER TABLE "User" ADD COLUMN "status" TEXT;
diff --git a/labman/prisma/migrations/20260309142105_status_correction/migration.sql b/labman/prisma/migrations/20260309142105_status_correction/migration.sql
new file mode 100644
index 0000000..965fcac
--- /dev/null
+++ b/labman/prisma/migrations/20260309142105_status_correction/migration.sql
@@ -0,0 +1,23 @@
+/*
+ Warnings:
+
+ - Made the column `status` on table `Borrower` required. This step will fail if there are existing NULL values in that column.
+ - Made the column `status` on table `Equipment` required. This step will fail if there are existing NULL values in that column.
+ - Made the column `status` on table `Item` required. This step will fail if there are existing NULL values in that column.
+ - Made the column `status` on table `Loan` required. This step will fail if there are existing NULL values in that column.
+
+*/
+UPDATE "Borrower" SET "status" = 'Active' WHERE "status" IS NULL;
+UPDATE "Equipment" SET "status" = 'Active' WHERE "status" IS NULL;
+
+-- AlterTable
+ALTER TABLE "Borrower" ALTER COLUMN "status" SET NOT NULL;
+
+-- AlterTable
+ALTER TABLE "Equipment" ALTER COLUMN "status" SET NOT NULL;
+
+-- AlterTable
+ALTER TABLE "Item" ALTER COLUMN "status" SET NOT NULL;
+
+-- AlterTable
+ALTER TABLE "Loan" ALTER COLUMN "status" SET NOT NULL;
diff --git a/labman/prisma/schema.prisma b/labman/prisma/schema.prisma
index 2fbd77e..fba02de 100644
--- a/labman/prisma/schema.prisma
+++ b/labman/prisma/schema.prisma
@@ -6,7 +6,7 @@
generator client {
provider = "prisma-client-js"
- binaryTargets = ["native", "debian-openssl-3.0.x"]
+ binaryTargets = ["native", "debian-openssl-3.0.x", "linux-musl-openssl-3.0.x"]
output = "../src/generated/prisma"
}
@@ -25,8 +25,9 @@ model Equipment {
id Int @id @default(autoincrement())
categoryId Int
name String @unique
- image String
+ image String?
createdAt DateTime @default(now())
+ status String
items Item[]
category EquipmentCategory @relation(fields: [categoryId], references: [id])
}
@@ -60,6 +61,7 @@ model User {
username String @unique
createdAt DateTime @default(now())
latestActivity DateTime @default(now())
+ status String?
sessions Session[]
loans Loan[]
}
@@ -70,6 +72,7 @@ model Borrower {
phone String? @unique
email String? @unique
note String?
+ status String
creationDate DateTime @default(now())
loans Loan[]
}
diff --git a/labman/src/app/(auth)/login/page.tsx b/labman/src/app/(auth)/login/page.tsx
index cbf5500..e6b1b15 100644
--- a/labman/src/app/(auth)/login/page.tsx
+++ b/labman/src/app/(auth)/login/page.tsx
@@ -30,18 +30,22 @@ export default function Home() {
}
return (
-
-
VR Lab Management
-
-
Login
-
-
-
-
+ <>
+
+
VR Lab Management
+
+
Login
+
+
+
+ v1.0.0
+ >
+
+
);
diff --git a/labman/src/app/(main)/globals.css b/labman/src/app/(main)/globals.css
index ed354b4..6440177 100644
--- a/labman/src/app/(main)/globals.css
+++ b/labman/src/app/(main)/globals.css
@@ -83,7 +83,7 @@ html {
}
.item-view {
- @apply bg-brand-950 rounded-md p-3 h-180 overflow-y-auto
+ @apply bg-brand-950 rounded-md p-3 h-155 overflow-y-auto
}
.side-form-label {
@@ -101,4 +101,12 @@ input:invalid {
.delete-button {
@apply text-black font-bold rounded-full h-7 w-7 bg-red-600
+}
+
+.card-attributes {
+ @apply text-2xl ml-10 w-40 truncate
+}
+
+.card-attributes-value {
+ @apply text-2xl text-gray-300 font-bold
}
\ No newline at end of file
diff --git a/labman/src/app/(main)/layout.tsx b/labman/src/app/(main)/layout.tsx
index 6633ae7..c3fd74b 100644
--- a/labman/src/app/(main)/layout.tsx
+++ b/labman/src/app/(main)/layout.tsx
@@ -2,11 +2,10 @@ import type { Metadata } from "next";
import "./globals.css";
import { League_Spartan } from "next/font/google";
import NavBar from "@/components/core/NavBar";
-import prisma from "@/lib/prisma";
-import { cookies } from "next/headers";
-import {validateSessionToken} from "@/auth/session";
-import {getSession, getUser} from "@/lib/actions";
+//import {PopupProvider} from "./popupProvider"
+import {getUser} from "@/lib/actions";
import SideBar from "@/components/core/SideBar";
+import {SideViewProvider} from "@/app/sideViewContext";
const spartan = League_Spartan({
subsets: ["latin"],
@@ -29,20 +28,20 @@ export default async function RootLayout({
return (
-
-
-
-
-
-
-
-
- {children}
-
-
+
+
+
+
+
+
+
+ {children}
+
+
+
);
diff --git a/labman/src/app/(main)/loans/page.tsx b/labman/src/app/(main)/loans/page.tsx
index b8f6e3d..4fe4e93 100644
--- a/labman/src/app/(main)/loans/page.tsx
+++ b/labman/src/app/(main)/loans/page.tsx
@@ -15,12 +15,23 @@ export default async function Loans() {
}
const loans = await prisma.loan.findMany({
include: {
+ borrower: true,
item: {
include: {
- equipment: true
+ equipment: {
+ include: {
+ category: true,
+ items: {
+ include: {
+ loans: true,
+ activeLoan: true
+ }
+ }
+
+ }
+ }
}
- },
- borrower: true
+ }
}
});
diff --git a/labman/src/app/(main)/page.tsx b/labman/src/app/(main)/page.tsx
index 97c4cce..3850f24 100644
--- a/labman/src/app/(main)/page.tsx
+++ b/labman/src/app/(main)/page.tsx
@@ -1,4 +1,4 @@
-export const dynamic = 'force-dynamic';
+ export const dynamic = 'force-dynamic';
import prisma from '@/lib/prisma';
import { validateSessionToken} from "@/auth/session";
import { cookies } from "next/headers";
diff --git a/labman/src/app/api/equipment/route.ts b/labman/src/app/api/equipment/route.ts
index f963878..f4e4948 100644
--- a/labman/src/app/api/equipment/route.ts
+++ b/labman/src/app/api/equipment/route.ts
@@ -1,52 +1,73 @@
import {NextResponse} from "next/server";
import prisma from "@/lib/prisma";
-export async function POST(req: Request) {
- const body = await req.json();
- const { name, category, image } = body;
+//TODO: Generally post reguests like this should be done in actions.ts. But there is little point in changing this right now as the backend might be moved later anyways.
- let categoryId = 0;
+export async function POST(req: Request) : Promise {
+ try {
+ const body = await req.json();
+ const {name, category, image} = body;
- let equipmentCategory = await prisma.equipmentCategory.findUnique({
- where: {
- name: category
- }
- })
-
- // Add equipment category if it doesn't exist'
- if (equipmentCategory) {
- console.log("Category exists");
- categoryId = equipmentCategory.id;
- } else {
- console.log("Category does not exist");
- equipmentCategory = await prisma.equipmentCategory.create({
- data: {
+ let categoryId = 0;
+
+ let equipmentCategory = await prisma.equipmentCategory.findUnique({
+ where: {
name: category
}
})
- categoryId = equipmentCategory.id;
- }
- // Add equipment to the database
- const newEquipment = await prisma.equipment.create({
- data: {
- name,
- image,
- categoryId,
- items: {
- create: {
- status: "Available"
+ // Add equipment category if it doesn't exist'
+ if (equipmentCategory) {
+ console.log("Category exists");
+ categoryId = equipmentCategory.id;
+ } else {
+ console.log("Category does not exist");
+ equipmentCategory = await prisma.equipmentCategory.create({
+ data: {
+ name: category
}
- }
- },
- include: {
- category: true,
- items: true
+ })
+ categoryId = equipmentCategory.id;
+ }
+
+ const existingEquipment = await prisma.equipment.findUnique({where: {name: name}});
+
+ if (existingEquipment) {
+ return NextResponse.json(
+ { type: "error", message: "Equipment already exists" },
+ { status: 409 }
+ );
}
- });
+ // Add equipment to the database
+ const newEquipment = await prisma.equipment.create({
+ data: {
+ name,
+ image,
+ categoryId,
+ status: "Active",
+ items: {
+ create: {
+ status: "Available"
+ }
+ }
+ },
+ include: {
+ category: true,
+ items: true
+ }
+ });
- return NextResponse.json(newEquipment);
+ return NextResponse.json(
+ {type: "success", data: newEquipment},
+ {status: 201}
+ )
+ } catch (error) {
+ return NextResponse.json(
+ {type: "error", message: "An error occurred while adding the equipment"},
+ {status: 500}
+ )
+ }
}
\ No newline at end of file
diff --git a/labman/src/app/api/login/route.ts b/labman/src/app/api/login/route.ts
index e60b3bb..6021dbc 100644
--- a/labman/src/app/api/login/route.ts
+++ b/labman/src/app/api/login/route.ts
@@ -7,17 +7,34 @@ import comparePassword from "@/lib/auth/comparePassword";
export async function POST(req: Request) {
const { username, password } = await req.json();
+ const users = await prisma.user.findMany();
+ // TODO: Temporary initial user creation. Will create a proper init of the system later
+ if (users.length === 0) {
+ console.log("No existing users, creating new user")
+ const res = await fetch("http://localhost:3000/api/register", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({
+ username,
+ password
+ })
+ })
+
+ }
+
const user = await prisma.user.findUnique({where: { username }});
+ console.log(user);
// Check if the user exists
- if (!user) return Response.json({ error: "Invalid credentials"}, { status: 401 } )
- if (!( await comparePassword(password, user.hashedPassword))) return Response.json({ error: "Invalid credentials"}, { status: 401 } )
+ if (!user) return Response.json({ error: "Username not found"}, { status: 401 } )
+ if (!( await comparePassword(password, user.hashedPassword))) return Response.json({ error: "Invalid password"}, { status: 401 } )
// Create a session for the user
const session = await createSession(user.id);
// Set the session cookie
(await cookies()).set("session", session.token, {
httpOnly: true,
- secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24,
path: "/"
diff --git a/labman/src/app/sideViewContext.tsx b/labman/src/app/sideViewContext.tsx
new file mode 100644
index 0000000..a304d85
--- /dev/null
+++ b/labman/src/app/sideViewContext.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import React, { createContext, useContext, useState } from "react";
+
+type SideViewCtx = {
+ sideView: string;
+ setSideView: React.Dispatch>;
+};
+
+const SideViewContext = createContext(null);
+
+export function SideViewProvider({ children, initialType = "",}: { children: React.ReactNode; initialType?: string; }) {
+ const [sideView, setSideView] = useState(initialType);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useSideView() {
+ const ctx = useContext(SideViewContext);
+ if (!ctx) throw new Error("useString must be used within ");
+ return ctx;
+}
\ No newline at end of file
diff --git a/labman/src/components/core/Button.tsx b/labman/src/components/core/Button.tsx
index 5504797..1f520ee 100644
--- a/labman/src/components/core/Button.tsx
+++ b/labman/src/components/core/Button.tsx
@@ -1,38 +1,19 @@
"use client"
-import { deleteUser, logout } from "@/lib/actions";
+import { logout } from "@/lib/actions";
import {redirect} from "next/navigation";
-interface ButtonProps {
- type?: string;
- username?: string;
-}
+// TODO: Replcace with a html button
-export default function Button({ username, type }: ButtonProps) {
+export default function Button() {
async function deletion(){
- if (username) {
- if (type == "deleteUser") {
- await deleteUser(username)
- }
-
- } else if (type == "logout") {
- await logout()
- redirect("/login");
- }
- }
-
- if (type == "deleteUser") {
- return (
-
- )
- } else if (type == "logout") {
- return (
-
- )
+ await logout()
+ redirect("/login");
}
+ return (
+
+ )
}
\ No newline at end of file
diff --git a/labman/src/components/core/Card.tsx b/labman/src/components/core/Card.tsx
index bc09e6c..9567717 100644
--- a/labman/src/components/core/Card.tsx
+++ b/labman/src/components/core/Card.tsx
@@ -1,52 +1,30 @@
"use client"
-import {User} from "@/generated/prisma";
-
-
-
-type Loan = {
- id: number;
- startDate: Date;
- endDate: Date;
- status: string;
-
- borrower: {
- id: number;
- name: string;
- phone?: string | null;
- email?: string | null
- note?: string | null
- creationDate: Date;
-
- }
- item: {
- id: number;
- equipment: {
- id: number;
- name: string;
- categoryId: number;
- image: string;
- createdAt: Date;
-
- }
- };
-}
+import {UserClass} from "@/types/User";
+import {LoanClass} from "@/types/Loan";
interface CardProps {
- user?: User
- loan?: Loan;
- returnLoan?: (id: number) => void
- deleteLoan?: (id: number) => void
- deleteUser?: (id: number) => void
+ user?: UserClass
+ loan?: LoanClass;
+ setSideView?: (view: string) => void;
+ setSelectedLoanId?: (id: number | null) => void;
}
// TODO: Could have a button to reactivate a loan, not a priority right now
-export default function Card({ loan, user, returnLoan, deleteLoan, deleteUser}: CardProps) {
+export default function Card({ loan, user, setSelectedLoanId, setSideView }: CardProps) {
- const name = loan?.item.equipment.name || user?.username;
- const start = loan?.startDate.toLocaleDateString("no") || new Date(user.createdAt).toLocaleDateString("no");
- const last = loan?.endDate.toLocaleDateString("no") || new Date(user.latestActivity).toLocaleDateString("no");
+ let {name, start, last} = {name: "", start: "", last: ""};
+
+ if (user) {
+ name = user.username;
+ start = new Date(user.createdAt).toLocaleDateString("no");
+ last = new Date(user.latestActivity).toLocaleDateString("no");
+ } else if (loan) {
+ name = loan.item.equipment.name
+ start = loan.startDate.toLocaleDateString("no");
+ last = loan.endDate.toLocaleDateString("no");
+ }
if (loan) {
if (new Date(loan.endDate) < new Date() && loan.status === "Active") {
@@ -55,11 +33,11 @@ export default function Card({ loan, user, returnLoan, deleteLoan, deleteUser}:
}
return(
-
+
-
{name}
+
{name}
{loan &&
|}
- {loan &&
{loan.item?.equipment.name}
}
+ {loan &&
Unit {loan.item?.id}
}
{loan &&
{loan.status === "Returned" ? "Returned" : loan.status === "Active" ? "Active" : "Due" }
}
@@ -72,7 +50,7 @@ export default function Card({ loan, user, returnLoan, deleteLoan, deleteUser}:
{loan &&
-
Equipment:
+
Equipment:
{loan.item.equipment.name}
@@ -80,22 +58,26 @@ export default function Card({ loan, user, returnLoan, deleteLoan, deleteUser}:
-
Borrower:
- {loan.borrower.name}
- Start:
- {start}
- End:
- {last}
+ Borrower:
+ {loan.borrower.name}
+ Start:
+ {start}
+ End:
+ {last}
}
-
-
- { loan && loan.status != "Returned" && }
+
+
+ { loan && loan.status != "Returned" && }
-
)
}
\ No newline at end of file
diff --git a/labman/src/components/core/CardList.tsx b/labman/src/components/core/CardList.tsx
index fc5c811..6970801 100644
--- a/labman/src/components/core/CardList.tsx
+++ b/labman/src/components/core/CardList.tsx
@@ -1,39 +1,13 @@
"use client"
import Card from "@/components/core/Card";
-import {useState} from "react";
+import {useState, useOptimistic, startTransition} from "react";
import {User} from "@/generated/prisma";
+import {UserClass} from "@/types/User";
import {returnLoan, deleteLoan, deleteUser} from "@/lib/actions";
-
-
-type Loans = {
- id: number;
- startDate: Date;
- endDate: Date;
- status: string;
-
- borrower: {
- id: number;
- name: string;
- phone?: string | null;
- email?: string | null
- note?: string | null
- creationDate: Date;
-
- }
- item: {
- id: number;
- equipment: {
- id: number;
- name: string;
- categoryId: number;
- image: string;
- createdAt: Date;
-
- }
- }
-}[];
-
-type Loan = Loans[0];
+import {LoanClass} from "@/types/Loan";
+import EditLoan from "@/components/loans/EditLoan";
+import {useSideView} from "@/app/sideViewContext";
+import {Loan} from "@/types/Loan";
interface CardListProps {
loansProp?: Loan[];
@@ -45,10 +19,20 @@ export default function CardList({ loansProp = [], usersProp = []}: CardListProp
const [loans, setLoans] = useState
(loansProp);
const [users, setUsers] = useState(usersProp);
-
+ const [optimisticUsers, removeUser] = useOptimistic(
+ users,
+ (currentUsers, idToRemove : number) =>
+ currentUsers.map(user =>
+ user.id === idToRemove ? { ...user, status: "deleting" } : user))
+// TODO: Temporary use of password field until I add another alternative
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
+ const [selectedLoanId, setSelectedLoanId] = useState(null);
+
+ const { sideView, setSideView } = useSideView();
+
+
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const res = await fetch("/api/register", {
@@ -65,7 +49,6 @@ export default function CardList({ loansProp = [], usersProp = []}: CardListProp
const newUser : User = await res.json();
if (newUser) {
- console.log(newUser)
setUsername("");
setPassword("");
setUsers(prev => [...prev, newUser]);
@@ -76,14 +59,28 @@ export default function CardList({ loansProp = [], usersProp = []}: CardListProp
}
}
+ async function deleteAction(id: number) {
+ const res = await deleteUser(id);
+ if (res.type === "error") return alert(res.message);
+ setUsers(prev => prev.filter(user => user.id !== id));
+ }
+
async function handleDeleteUser(userId: number) {
if (window.confirm("Are you sure you want to delete this user?")) {
- setUsers(prev => prev.filter(user => user.id !== userId));
- await deleteUser(userId);
+ // Optimistically remove the user from the UI
+ startTransition(async () => {
+ removeUser(userId)
+ try {
+ await deleteAction(userId);
+ } catch (e) {
+ alert("Failed to delete user: " + e);
+ }
+ })
}
}
+
const hasReturnedLoans = loans.some(
(loan) => loan.status === "Returned"
)
@@ -113,10 +110,14 @@ export default function CardList({ loansProp = [], usersProp = []}: CardListProp
setLoans(prev => prev.filter(loan => loan.id !== loanId));
await deleteLoan(loanId)
}
- console.log(users)
return (
-
+
+ { sideView == "loanEdit" && selectedLoanId &&
loan.id === selectedLoanId)!}
+ setLoans={setLoans}
+
+ />}
{ users.length !== 0 &&
}
- {loans.map(loan => {
- if (loan.status === "Returned") return;
- return ;
+ {loans.filter(loanDto => loanDto.status !== "Returned").map(loanDto => {
+ const loan = new LoanClass(
+ loanDto.id,
+ loanDto.status,
+ loanDto.startDate,
+ loanDto.endDate,
+ loanDto.borrower,
+ loanDto.item,
+ {
+ deleteLoan: async (id : number) => handleDeleteLoan(id),
+ returnLoan: async (id : number) => handleReturnLoan(id)
+ }
+ )
+ return ;
})}
- {users.map(user => {
- return ;
+ {optimisticUsers.map(userDto => {
+ const user = new UserClass(
+ userDto.id,
+ userDto.username,
+ userDto.createdAt,
+ userDto.latestActivity,
+ {
+ deleteUser: async (id: number) => handleDeleteUser(id)
+ },
+ userDto.status
+ )
+ return ;
})}
{hasReturnedLoans && (
Returned loans:
- {loans.map((loan) => {
- if (loan.status === "Returned") {
- return (
-
- )
- }
+ {loans.filter(loanDto => loanDto.status === "Returned").map(loanDto => {
+ const loan = new LoanClass(
+ loanDto.id,
+ loanDto.status,
+ loanDto.startDate,
+ loanDto.endDate,
+ loanDto.borrower,
+ loanDto.item,
+ {
+ deleteLoan: async (id : number) => handleDeleteLoan(id),
+ returnLoan: async (id : number) => handleReturnLoan(id)
+ }
+ )
+ return ;
})}
diff --git a/labman/src/components/core/NavBar.tsx b/labman/src/components/core/NavBar.tsx
index 4d1876b..127d9bf 100644
--- a/labman/src/components/core/NavBar.tsx
+++ b/labman/src/components/core/NavBar.tsx
@@ -1,13 +1,19 @@
-import Button from "@/components/core/Button";
+"use client"
import PathName from "@/components/core/PathName";
+import {logout} from "@/lib/actions";
export default function NavBar({ username }: { username: string | null }) {
+ async function logoutButton(){
+ await logout();
+
+ }
+
return(
< PathName />
- < Button type="logout" />
+
{username || "Not logged in"}
diff --git a/labman/src/components/core/SideView/ItemList.tsx b/labman/src/components/core/SideView/ItemList.tsx
new file mode 100644
index 0000000..acad258
--- /dev/null
+++ b/labman/src/components/core/SideView/ItemList.tsx
@@ -0,0 +1,97 @@
+import {Equipment, Unit} from "@/types/inventory";
+import {useRef} from "react";
+import {useEffect} from "react";
+
+type BaseProps = {
+ equipmentData: Equipment;
+}
+
+type SelectableProps = BaseProps & {
+ variant: "selectable";
+ selectedUnit: Unit | undefined;
+ setSelectedUnit: (unit: Unit) => void;
+}
+
+type SelectableEditProps = BaseProps & {
+ variant: "selectableEdit";
+ selectedUnit: Unit | undefined;
+ setSelectedUnit: (unit: Unit) => void;
+}
+
+type editableProps = BaseProps & {
+ variant: "editable";
+ handleAddUnit: (name: string) => void;
+ handleDeleteUnit: (id: number) => void;
+ }
+
+ type Props = SelectableProps | editableProps | SelectableEditProps;
+
+export default function ItemList(props: Props) {
+
+ // The initially selected unit has to persist between renders but uses to useEffect to update when the loan changes
+ let selectedUnitRef : React.RefObject
;
+
+ if (props.variant === "selectableEdit") {
+ selectedUnitRef = useRef(props.selectedUnit?.id);
+
+ useEffect(() => {
+ selectedUnitRef.current = props.selectedUnit?.id;
+ }, [props.equipmentData])
+ }
+
+
+ return(
+
+
Items
+
+ { props.variant === "editable" &&
}
+
+ {props.equipmentData.items.map((unit, index) => (
+
+
{unit.id}
+ {(() => {
+ switch (props.variant) {
+ case "editable":
+ return (
+ <>
+ { unit.activeLoan && unit.activeLoan.status !== "Returned" &&
Borrowed
}
+ {(unit.activeLoan == null || unit.activeLoan.status === "Returned") &&
+
}
+ >
+
+ )
+ case "selectable":
+ return (
+ <>
+ { unit.activeLoan && (unit.activeLoan.status !== "Returned") &&
Borrowed
}
+ { (unit.activeLoan == null || unit.activeLoan.status === "Returned") &&
}
+ >
+ )
+ case "selectableEdit":
+ return (
+ <>
+ { unit.activeLoan && (unit.activeLoan.status !== "Returned" && unit.id !== selectedUnitRef.current) &&
Borrowed
}
+ { (unit.activeLoan == null || unit.activeLoan.status === "Returned" || unit.id == selectedUnitRef.current) &&
}
+ >
+
+ )
+ }
+ })()}
+
+ ))}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/labman/src/components/core/SideView/SideView.tsx b/labman/src/components/core/SideView/SideView.tsx
new file mode 100644
index 0000000..9129a0b
--- /dev/null
+++ b/labman/src/components/core/SideView/SideView.tsx
@@ -0,0 +1,68 @@
+import {JSX} from "react";
+import {Equipment, Unit} from "@/types/inventory";
+import {useSideView} from "@/app/sideViewContext";
+import ItemList from "@/components/core/SideView/ItemList";
+import {loanCount} from "@/utils/inventoryUtils";
+
+interface SideViewProps {
+ children: JSX.Element;
+ title: string;
+ equipmentData: Equipment;
+ itemList?: JSX.Element;
+
+}
+
+export default function SideView( { children, title, equipmentData, itemList} : SideViewProps) {
+ const {sideView, setSideView} = useSideView();
+ return (
+ <>
+ {/* Dark backdrop */}
+ setSideView("")}
+ />
+
+ {/* Right-side panel */}
+
+
+ {/* Vertical split */}
+
+ {/* Left side of a panel */}
+
+
{title}
+
+
+
+
+ {children}
+
+
---------------------------------------------------------------------------------------
+ {itemList}
+
+
+ {/* Right side of panel */}
+
+
+
+ {(sideView === "loanView" || sideView === "eqInfo") &&
+ }
+
+
+
+
+
+
+
{equipmentData.name}
+ {equipmentData.category.name}
+ {equipmentData.items.length - loanCount(equipmentData)}/{equipmentData.items.length} Available
+
+
----------------------------------------------------------------------------------
+
+
+
+
+ >
+ )
+}
\ No newline at end of file
diff --git a/labman/src/components/core/card.test.tsx b/labman/src/components/core/card.test.tsx
index df2d32f..c869ebb 100644
--- a/labman/src/components/core/card.test.tsx
+++ b/labman/src/components/core/card.test.tsx
@@ -1,6 +1,7 @@
import { expect, test } from 'vitest'
import { render, screen } from '@testing-library/react'
import Card from './Card'
+import {LoanClass} from "@/types/Loan";
type Loan = {
id: number;
@@ -30,34 +31,42 @@ type Loan = {
};
}
-const loan : Loan = {
- id: 1,
- startDate: new Date("2026-01-01"),
- endDate: new Date("2026-01-19"),
- status: "Active",
- borrower: {
+const loan = new LoanClass (
+ 1,
+ "Active",
+ new Date("2026-01-01"),
+ new Date("2026-01-19"),
+ {
id: 1,
name: "ola",
phone: "95387901",
email: "fkdsfd@g.com",
note: "",
creationDate: new Date(),
+
},
- item: {
+ {
id: 1,
equipment: {
id: 1,
name: "test",
- categoryId: 1,
image: "",
createdAt: new Date(),
+ category: {
+ id: 1,
+ name: "test"
+ },
+ items: []
}
- }
+ },
+ {
+ deleteLoan: () => {},
+ returnLoan: () => {}
+ }
+)
-}
-
-// TODO: JS only supports YYYY-MM-DD natively, so we need to parse Norwegian date format
+// JS only supports YYYY-MM-DD natively, so we need to parse Norwegian date format
function parseNorwegianDate(dateStr: string): Date {
const [day, month, year] = dateStr.split('.').map(Number);
return new Date(Date.UTC(year, month - 1, day));
@@ -71,7 +80,7 @@ test('Loan card correctly updates based on date', () => {
const parentDiv = child.parentElement;
const returnDate = parentDiv?.children[5].textContent;
- const loanStatus = screen.getByText(/Active|Due/)
+ const loanStatus = screen.getByText(/Active|Due/);
if (returnDate && parseNorwegianDate(returnDate) < currentDate) {
expect(loanStatus.textContent).toBe("Due")
diff --git a/labman/src/components/inventory/EquipmentClient.tsx b/labman/src/components/inventory/EquipmentClient.tsx
index 080e154..1a7eaad 100644
--- a/labman/src/components/inventory/EquipmentClient.tsx
+++ b/labman/src/components/inventory/EquipmentClient.tsx
@@ -7,6 +7,7 @@ import SortIcon from "@/components/inventory/sortIcon";
import EquipmentInfo from "@/components/inventory/EquipmentInfo";
import LoanView from "@/components/inventory/LoanView";
import {Equipment} from "@/types/inventory";
+import {useSideView} from "@/app/sideViewContext";
@@ -33,7 +34,7 @@ export default function EquipmentClient({equipmentList}: EquipmentClientProps) {
const [image, setImage] = useState("");
const [selectedEquipment, setSelectedEquipment ] = useState
(null);
- const [sideView, setSideView] = useState("");
+ const { sideView, setSideView } = useSideView();
const [sort, setSort] = useState<{ column: SortColumn, direction: SortDirection}>({
column: null,
@@ -64,7 +65,7 @@ export default function EquipmentClient({equipmentList}: EquipmentClientProps) {
// Adding equipment to the database based on form input
async function handleSubmit(e: React.FormEvent) {
- if (!name || !category || !image) return;
+ if (!name || !category) return;
e.preventDefault();
const res = await fetch("/api/equipment", {
@@ -79,14 +80,19 @@ export default function EquipmentClient({equipmentList}: EquipmentClientProps) {
})
})
// Adding the new equipment to the state
- const newEquipment = await res.json();
- console.log(newEquipment)
+ const result = await res.json();
- setAllEquipment(prev => [...prev, newEquipment]);
- setName("")
- setCategory("")
- setImage("")
+ if (result.type === "success") {
+ const newEquipment = result.data
+ setAllEquipment(prev => [...prev, newEquipment]);
+ setName("")
+ setCategory("")
+ setImage("")
+
+ } else {
+ alert(result.message || "Failed to add equipment")
+ }
}
async function handleDeleteEquipment(name: string) {
@@ -119,14 +125,12 @@ export default function EquipmentClient({equipmentList}: EquipmentClientProps) {
{ sideView == "eqInfo" && selectedEquipment && }
{ sideView == "loanView" && selectedEquipment && }
@@ -134,8 +138,8 @@ export default function EquipmentClient({equipmentList}: EquipmentClientProps) {
diff --git a/labman/src/components/inventory/EquipmentInfo.tsx b/labman/src/components/inventory/EquipmentInfo.tsx
index 7d5342d..2e3ee0d 100644
--- a/labman/src/components/inventory/EquipmentInfo.tsx
+++ b/labman/src/components/inventory/EquipmentInfo.tsx
@@ -3,6 +3,8 @@ import {useEffect, useState} from "react";
import {addUnit, deleteUnit, updateEquipment} from "@/lib/actions";
import {Equipment} from "@/types/inventory";
import {loanCount} from "@/utils/inventoryUtils";
+import SideView from "@/components/core/SideView/SideView";
+import ItemList from "@/components/core/SideView/ItemList";
type Unit = {
id: number;
@@ -16,14 +18,13 @@ type Unit = {
interface EquipmentInfoProps {
equipmentData: Equipment;
- setSideView: (view: string) => void;
allEquipment: Equipment[];
setAllEquipment: React.Dispatch
>;
setSelectedEquipment: (equipment: Equipment | null) => void;
deleteEquipment: (name: string) => void;
}
-export default function EquipmentInfo({equipmentData, setSideView, setAllEquipment, setSelectedEquipment, deleteEquipment}: EquipmentInfoProps) {
+export default function EquipmentInfo({equipmentData, setAllEquipment, setSelectedEquipment, deleteEquipment}: EquipmentInfoProps) {
const [initialFormData, setInitialFormData] = useState({name: equipmentData?.name, category: equipmentData?.category.name, image: equipmentData?.image})
@@ -78,8 +79,8 @@ export default function EquipmentInfo({equipmentData, setSideView, setAllEquipme
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
- // Check so no fields are empty
- if (!formData.name?.trim() || !formData.category?.trim() || !formData.image?.trim()) {
+ // Check so required fields are not empty (image is optional)
+ if (!formData.name?.trim() || !formData.category?.trim()) {
alert("Please fill in all fields");
setFormData(initialFormData);
return;
@@ -88,17 +89,23 @@ export default function EquipmentInfo({equipmentData, setSideView, setAllEquipme
if (JSON.stringify(formData) === JSON.stringify(initialFormData)) return;
- const updatedEq = await updateEquipment(equipmentData!.id, formData.name!, formData.category!, formData.image!)
+ const res = await updateEquipment(equipmentData.id, formData.name, formData.category, formData.image!)
+ if (res.type !== "success") {
+ alert(res.message);
+ return;
+ }
+
+ const updatedEq = res.data;
const updatedEquipment = {
- ...equipmentData!,
+ ...equipmentData,
name: updatedEq.name,
category: {
id: updatedEq.category.id,
name: updatedEq.category.name
},
image: updatedEq.image,
- categoryId: updatedEq.categoryId
+ categoryId: updatedEq.category.id
}
@@ -111,85 +118,37 @@ export default function EquipmentInfo({equipmentData, setSideView, setAllEquipme
let hasActiveLoan = false;
return (
- <>
- {/* Dark backdrop */}
- setSideView("")}
- />
-
- {/* Right-side panel */}
-
-
- {/* Vertical split */}
-
- {/* Left side of a panel */}
-
-
Equipment information
-
-
-
-
-
-
-
-
- {/* Right side of panel */}
-
-
-
-
-
-
-
-
{equipmentData?.name}
- {equipmentData?.category.name}
- {equipmentData.items.length - loanCount(equipmentData)}/{equipmentData.items.length} Available
-
-
----------------------------------------------------------------------------------
-
History
-
-
+ className="side-form-input" /> */}
+
-
- >
+ >
+
);
}
\ No newline at end of file
diff --git a/labman/src/components/inventory/LoanView.tsx b/labman/src/components/inventory/LoanView.tsx
index c8263ab..01e12b4 100644
--- a/labman/src/components/inventory/LoanView.tsx
+++ b/labman/src/components/inventory/LoanView.tsx
@@ -2,16 +2,9 @@
import {useEffect, useState} from "react";
import {addLoan} from "@/lib/actions";
import {Equipment} from "@/types/inventory";
-import {loanCount} from "@/utils/inventoryUtils";
-
-type Unit = {
- id: number;
- equipmentId: number;
- status: string;
- createdAt: Date;
- notes: string[];
- errors: string[];
-};
+import {Unit} from "@/types/inventory";
+import SideView from "@/components/core/SideView/SideView"
+import ItemList from "@/components/core/SideView/ItemList";
type Borrower = {
id: number;
@@ -22,14 +15,12 @@ type Borrower = {
interface LoanViewProps {
equipmentData: Equipment;
- setSideView: (view: string) => void;
setAllEquipment: React.Dispatch
>;
setSelectedEquipment: (equipment: Equipment | null) => void;
}
-export default function LoanView({setSideView, equipmentData, setAllEquipment, setSelectedEquipment} : LoanViewProps) {
+export default function LoanView({equipmentData, setAllEquipment, setSelectedEquipment} : LoanViewProps) {
const [borrowers, setBorrowers] = useState([]);
-
useEffect(() => {
fetch("/api/borrower")
.then(res => res.json())
@@ -51,9 +42,13 @@ export default function LoanView({setSideView, equipmentData, setAllEquipment, s
alert("Please fill in all required fields");
return;
}
- const newLoan = await addLoan(formData.borrower, formData.startDate, formData.endDate, selectedUnit.id, formData.borrowerPhone, formData.borrowerEmail);
+ const res = await addLoan(formData.borrower, formData.startDate, formData.endDate, selectedUnit.id, formData.borrowerPhone, formData.borrowerEmail);
- if (!newLoan) return;
+ if (res.type !== "success") {
+ alert(res.message);
+ return;
+ }
+ const newLoan = res.data;
const updatedEquipment = {
...equipmentData,
@@ -80,141 +75,91 @@ export default function LoanView({setSideView, equipmentData, setAllEquipment, s
//TODO: More imrpovements to do on this form and the other forms plus valditation of the form data. Delaying this until the core functionality is done.
return (
- <>
- {/* Dark backdrop */}
- setSideView("")}
- />
-
- {/* Right-side panel */}
-
-
- {/* Vertical split */}
-
- {/* Left side of a panel */}
-
-
New Loan
-
-
-
-
-
-
-
-
-
-
---------------------------------------------------------------------------------------
-
Items
-
-
- {equipmentData.items.map((unit, index) => (
- console.log(unit.activeLoan),
- hasActiveLoan = unit.activeLoan != null,
-
-
Unit {index + 1}
- { hasActiveLoan && (unit.activeLoan.status !== "Returned") &&
Borrowed
}
- { (!hasActiveLoan || (hasActiveLoan && unit.activeLoan.status === "Returned")) &&
}
-
-
- )) }
-
-
-
-
- {/* Right side of panel */}
-
-
-
-
-
-
-
-
{equipmentData.name}
- {equipmentData.category.name}
- {equipmentData.items.length - loanCount(equipmentData)}/{equipmentData.items.length} Available
-
-
----------------------------------------------------------------------------------
-
History
-
-
+
}>
+ <>
+
+
+
+
+
+
+
+
-
- >
+ >
+
);
}
\ No newline at end of file
diff --git a/labman/src/components/loans/EditLoan.tsx b/labman/src/components/loans/EditLoan.tsx
new file mode 100644
index 0000000..8d902fb
--- /dev/null
+++ b/labman/src/components/loans/EditLoan.tsx
@@ -0,0 +1,201 @@
+"use client"
+
+import {useEffect, useState} from "react";
+import {updateLoan} from "@/lib/actions";
+import {Loan} from "@/types/Loan";
+import SideView from "@/components/core/SideView/SideView";
+import {Unit} from "@/types/inventory";
+import ItemList from "@/components/core/SideView/ItemList";
+
+
+type Borrower = {
+ id: number;
+ name: string;
+ phone: string;
+ email: string;
+}
+
+interface EditLoanProps {
+ loan: Loan;
+ setLoans: React.Dispatch
>;
+}
+
+export default function EditLoan({loan, setLoans}: EditLoanProps) {
+
+ const [initialFormData, setInitialFormData] = useState({borrower: loan.borrower.name, startDate: loan.startDate, endDate: loan.endDate, borrowerPhone: loan.borrower.phone, borrowerMail: loan.borrower.email})
+ const [formData, setFormData] = useState(initialFormData);
+
+ const phoneRequired = formData.borrowerMail?.trim() === "";
+ const emailRequired = formData.borrowerPhone?.trim() === "";
+
+ const [selectedUnit, setSelectedUnit] = useState(loan.item);
+
+ const [borrowers, setBorrowers] = useState([]);
+ useEffect(() => {
+ fetch("/api/borrower")
+ .then(res => res.json())
+ .then(data => setBorrowers(data))
+ }, [initialFormData]);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+
+ if (
+ !formData.borrower.trim()
+ || !formData.startDate
+ || !formData.endDate
+ || (!formData.borrowerPhone?.trim() && !formData.borrowerMail?.trim())
+ ) {
+ alert("Please fill in all required fields");
+ return;
+ }
+ let updatedLoan: Loan;
+
+ // Will wait for a confirmation from the user before updating the loan
+ // To prevent empty lines in the message it will render a list of strings instead of a single string, and filter out strings that are empty
+ if (confirm(`These changes will be applied:\n${[
+ formData.borrower !== initialFormData.borrower ? `Borrower name: ${initialFormData.borrower} -> ${formData.borrower}` : "",
+ formData.startDate !== initialFormData.startDate ? `Start date: ${initialFormData.startDate.toLocaleDateString()} -> ${formData.startDate.toLocaleDateString()}` : "",
+ formData.endDate !== initialFormData.endDate ? `End date: ${initialFormData.endDate.toLocaleDateString()} -> ${formData.endDate.toLocaleDateString()}` : "",
+ formData.borrowerPhone !== initialFormData.borrowerPhone ? `Borrower phone number: ${initialFormData.borrowerPhone} -> ${formData.borrowerPhone}` : "",
+ formData.borrowerMail !== initialFormData.borrowerMail ? `Borrower email: ${initialFormData.borrowerMail} -> ${formData.borrowerMail}` : "",
+ selectedUnit.id !== loan.item.id ? `Unit ${loan.item.id} -> Borrowed equipment unit: Unit ${selectedUnit.id}` : ""
+ ].filter(Boolean).join("\n")}`)) {
+
+ const res = await updateLoan(
+ loan.id,
+ formData.startDate,
+ formData.endDate,
+ formData.borrower,
+ loan.borrower.id,
+ selectedUnit.id,
+ formData.borrowerPhone,
+ formData.borrowerMail
+ );
+ console.log(res);
+
+ if (res.type === "error") {
+ alert(res.message);
+ return;
+ } else if (res.type === "success") {
+ updatedLoan = res.data;
+ }
+
+ setLoans(prev =>
+ prev.map(loan => loan.id === updatedLoan.id ? updatedLoan : loan)
+
+ )
+ setInitialFormData(formData);
+
+ } else {
+ return;
+ }
+
+
+ }
+
+ return(
+ <>
+ }>
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
+
+
+ >
+ )
+}
\ No newline at end of file
diff --git a/labman/src/lib/actions.ts b/labman/src/lib/actions.ts
index bcf85d4..82e6a12 100644
--- a/labman/src/lib/actions.ts
+++ b/labman/src/lib/actions.ts
@@ -3,33 +3,44 @@ import prisma from "@/lib/prisma"
import {revalidatePath} from "next/cache";
import {deleteSession, validateSessionToken} from "@/auth/session"
import {cookies} from "next/headers";
+import {Borrower} from "@/generated/prisma";
+import {Equipment, EquipmentWithCategoryAndItems} from "@/types/inventory"
+import {Loan as ExtendedLoan} from "@/types/Loan";
+import {Loan} from "@/generated/prisma";
+import {redirect} from "next/navigation";
-export async function deleteUser(userId : number) {
+type ActionResult = | { type: "success"; data: T} | { type: "confirm"; message: string} | { type: "error"; message: string}
+
+// Used to delay the execution of an action for testing purposes
+function delay(ms : number) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+export async function deleteUser(userId : number) : Promise> {
+
+ if (await getUser() === null) {
+ return {type: "error", message: "Could not find a valid user"}
+ }
+
+ // if (userId === 1) {return {type: "error", message: "Cannot delete admin user"}}
const user = await prisma.user.findUnique({
- where: {
- id: userId
- },
- include: {
- sessions: true
- }
+ where: {id: userId},
+ include: {sessions: true}
})
-
if (user) {
- console.log(user.sessions)
for (const session of user.sessions) {
await deleteSession(session.id);
}
}
await prisma.user.delete({
- where: {
- id: userId
- }
+ where: {id: userId}
});
revalidatePath("/users");
+ return {type: "success", data: undefined};
}
export async function getSession() {
@@ -38,33 +49,26 @@ export async function getSession() {
if (token) {
return validateSessionToken(token);
} else {
- console.log("No active session");
return null;
}
}
export async function logout() {
- console.log("Logging out");
const session = await getSession();
- if (session) {
- await deleteSession(session.id);
- }
+ if (session) {await deleteSession(session.id);}
+ redirect("/login")
}
export async function deleteEquipment(name: string) {
await prisma.equipment.delete({
- where: {
- name: name
- }
+ where: {name: name}
})
revalidatePath("/");
}
export async function deleteUnit(id: number) {
await prisma.item.delete({
- where: {
- id: id
- }
+ where: {id: id}
})
revalidatePath("/");
}
@@ -72,107 +76,212 @@ export async function deleteUnit(id: number) {
export async function addUnit(equipmentName: string) {
const equipment = await prisma.equipment.findUnique({
- where: {
- name: equipmentName
- }
+ where: {name: equipmentName}
})
if (!equipment) {alert("Equipment not found"); return}
const newUnit = await prisma.item.create({
- data: {
- equipmentId: equipment.id,
- status: "Available",
- },
+ data: {equipmentId: equipment.id, status: "Available",},
// TODO: Relational properties always have to be specified or else they will not be included in the response
- include: {
- loans: true,
- activeLoan: true
- }
+ include: {loans: true, activeLoan: true}
})
revalidatePath("/");
- console.log("Added unit");
return newUnit;
}
-export async function updateEquipment (equipmentId: number, name: string, category: string, image: string) {
+export async function updateEquipment (equipmentId: number, name: string, category: string, image: string) : Promise> {
let categoryId = 0;
-
let equipmentCategory = await prisma.equipmentCategory.findUnique({where: {name: category}})
if (equipmentCategory) {
- console.log("Category exists");
categoryId = equipmentCategory.id
} else {
- console.log("Category exists")
equipmentCategory = await prisma.equipmentCategory.create({data: {name: category}})
categoryId = equipmentCategory.id
}
+ const existingEquipment = await prisma.equipment.findUnique({where: {name: name}})
+
+ if (existingEquipment) {
+ return {type: "error", message: "Equipment already exists"}
+ }
+
const equipment = await prisma.equipment.update({
- where: {
- id: equipmentId,
- },
- data : {
- name: name,
- categoryId: categoryId,
- image: image
- },
- include: {category: true}
+ where: {id: equipmentId},
+ data : {name: name, categoryId: categoryId, image: image},
+ include: {
+ category: true,
+ items: true
+ }
})
revalidatePath("/");
- return equipment;
+ return {type: "success", data: equipment};
}
-export async function addLoan (borrower : string, start : string, end : string, unitId : number, phone? : string, email? : string) {
- const dateStart = new Date(start);
- const dateEnd = new Date(end);
+export async function addBorrower(name: string, phone?: string | null, email?: string | null, borrowerId?: number) : Promise> {
const user = await getUser();
+ if (!user) {return {type: "error", message: "Could not find a valid user"}}
+ let borrower : Borrower | null = null;
- if (!user) {alert("Could not find a valid user"); return}
- let borrowerUser = await prisma.borrower.findUnique({
- where: {
- phone: phone
+ // If phone or email is actually empty, set it to null
+ if (phone?.trim() === "") {phone = null}
+ if (email?.trim() === "") {email = null}
+
+ // If borrowerId is provided, update the borrower with the provided information
+ if (borrowerId) {
+ borrower = await prisma.borrower.findUnique({where:{id: borrowerId}})
+ /* TODO: Potentially unsafe. The function can in theory be called with an unrelated id updating the wrong borrower
+ Since the unique values phone and mail can change they can't be used to verify the borrower.
+ A possible solution is to compare the old values with what is currently stored in the database.
+ But it shouldn't really be a big deal as there is now way for the client to abuse it.*/
+ if (!borrower) {return {type: "error", message: "Could not find borrower with id " + borrowerId}}
+
+
+ if (phone && borrower.phone !== phone) {
+ console.log("Phone number has changed, checking for duplicates")
+ if (await prisma.borrower.findUnique({where:{phone: phone}})) {
+ console.log("Borrower with phone number already exists")
+
+ return {type: "error", message: `A borrower with the same phone number already exists.`}
+ }
+ } else if (email && borrower.email !== email) {
+ if (await prisma.borrower.findUnique({where:{email: email}}))
+ return {type: "error", message: `A borrower with the same email already exists.`}
}
- })
- if (!borrowerUser) {
- borrowerUser = await prisma.borrower.create({
+
+ borrower = await prisma.borrower.update(
+ {
+ where: {id: borrowerId},
+ data: {name: name, phone: phone, email: email}
+ }
+ )
+ return {type: "success", data: borrower};
+ }
+
+ if (phone) {
+ borrower = await prisma.borrower.findUnique({where:{phone: phone}})
+ if (borrower && borrower.name !== name) {
+ return {type: "error", message: `A borrower with the same phone number already exists (${borrower.name}). Please try again.`}
+ }
+ } else if (email) {
+ borrower = await prisma.borrower.findUnique({where:{email: email}})
+ if (borrower && borrower.name !== name) {
+ return {type: "error", message: `A borrower with the same email already exists (${borrower.name}). Please try again.`}
+ }
+ } else {
+ return {type: "error", message: "No borrower phone/email provided"}
+ }
+
+ if (!borrower) {
+ borrower = await prisma.borrower.create({
data: {
- name: borrower,
+ name: name,
phone: phone,
email: email,
+ status: "Active",
note: "",
creationDate: new Date(),
}
})
}
+ return {type: "success", data: borrower};
+}
+
+export async function updateLoan (loanId: number, start : Date, end : Date, borrowerName : string, borrowerId : number, unitId : number, phone? : string | null, email? : string | null) : Promise> {
+ if (await getUser() === null) {return {type:"error", message: "Could not find a valid user"}}
+
+ // Check if the loan exists
+ const currentLoan = await prisma.loan.findUnique({where: {id: loanId}})
+ if (!currentLoan) {return {type: "error", message: "Could not find corresponding loan in database"}}
+
+ // Find the connected borrower and update borrower details if needed
+ const res = await addBorrower(borrowerName, phone, email, borrowerId)
+ if (res.type !== "success") {return {type: "error", message: res.message}}
+
+ // If the user has changed the unit being loaned, check that this is unit is available
+ if (currentLoan.itemId !== unitId) {
+ const newUnit = await prisma.item.findUnique({where: {id: unitId}})
+ if (!newUnit) {return {type: "error", message: "Could not find corresponding unit in database"}}
+ if (newUnit.status !== "Available") {return {type: "error", message: "The selected unit is not available"}}
+
+ await prisma.item.update({
+ where: {id: currentLoan.itemId},
+ data: {status: "Available", activeLoanId: null}
+ })
+
+ await prisma.item.update({
+ where: {id: unitId},
+ data: {status: "Unavailable", activeLoanId: null}
+ })
+ }
+
+ const loan = await prisma.loan.update({
+ where: {id: loanId},
+ data: {
+ startDate: start,
+ endDate: end,
+ borrowerId: res.data.id,
+ itemId: unitId
+ },
+ include: {
+ borrower: true,
+ item: {
+ include: {
+ equipment: {
+ include: {
+ category: true,
+ items: {
+ include: {
+ loans: true,
+ activeLoan: true
+ }
+ }
+
+ }
+ }
+ }
+ }
+ }
+ })
+ revalidatePath("/loans");
+ return {type: "success", data: loan};
+
+}
+
+export async function addLoan (borrowerName : string, start : string, end : string, unitId : number, phone : string | null, email : string | null) : Promise> {
+ const dateStart = new Date(start);
+ const dateEnd = new Date(end);
+ const user = await getUser();
+ if (!user) {
+ return {type: "error", message: "Could not find a valid user"}
+ }
+
+ const res = await addBorrower(borrowerName, phone, email)
+ if (res.type !== "success") {
+ return {type: "error", message: res.message}
+ }
const loan = await prisma.loan.create({
data: {
startDate: dateStart,
endDate: dateEnd,
status: "Active",
- borrowerId: borrowerUser.id,
+ borrowerId: res.data.id,
userId: user.id,
itemId: unitId
}
})
- const item = await prisma.item.update({
- where: {
- id: unitId
- },
- data: {
- status: "Unavailable",
- activeLoanId: loan.id
- }
+ await prisma.item.update({
+ where: {id: unitId},
+ data: {status: "Unavailable", activeLoanId: loan.id}
})
- console.log(item.activeLoan);
revalidatePath("/");
- return loan;
+ return {type: "success", data: loan};
}
export async function getUser() {
@@ -181,16 +290,25 @@ export async function getUser() {
if (session) {
const tSession = await prisma.session.findUnique({
where: { id: session.id },
- include: {
- user: true
- }
+ include: {user: true}
})
return tSession?.user;
- }
+ } else {
+ return null;
+ }
}
export async function deleteLoan(id: number) {
+ const loan = await prisma.loan.findUnique({
+ where: {id: id}
+ })
+ if (!loan) {return}
+
+ await prisma.item.update({
+ where: {activeLoanId: loan.id},
+ data: {status: "Available", activeLoanId: null}
+ })
await prisma.loan.delete({
where: {
id: id
@@ -200,13 +318,14 @@ export async function deleteLoan(id: number) {
}
export async function returnLoan(id: number) {
- await prisma.loan.update({
- where: {
- id: id
- },
- data: {
- status: "Returned"
- }
+ const loan = await prisma.loan.update({
+ where: {id: id},
+ data: {status: "Returned"}
+ })
+ await prisma.item.update({
+ where: {activeLoanId: loan.id},
+ data: {status: "Available", activeLoanId: null}
})
+
revalidatePath("/loans");
}
\ No newline at end of file
diff --git a/labman/src/middleware.ts b/labman/src/middleware.ts
index 4f3883c..707c1ef 100644
--- a/labman/src/middleware.ts
+++ b/labman/src/middleware.ts
@@ -22,4 +22,4 @@ export async function middleware(req: NextRequest) {
return NextResponse.next();
}
-export const config = {matcher: ["/", "/users"]};
\ No newline at end of file
+export const config = {matcher: ["/", "/users", "/loans"]};
\ No newline at end of file
diff --git a/labman/src/types/Loan.ts b/labman/src/types/Loan.ts
new file mode 100644
index 0000000..67d8fd0
--- /dev/null
+++ b/labman/src/types/Loan.ts
@@ -0,0 +1,66 @@
+import {Equipment} from "@/types/inventory";
+
+export type LoanActions = {
+ deleteLoan: (id: number) => void;
+ returnLoan: (id: number) => void;
+}
+
+export type Loan = {
+ id: number;
+ startDate: Date;
+ endDate: Date;
+ status: string;
+
+ borrower: {
+ id: number;
+ name: string;
+ phone?: string | null;
+ email?: string | null
+ note?: string | null
+ creationDate: Date;
+
+ }
+ item: {
+ id: number;
+ equipmentId: number;
+ status: string;
+ createdAt: Date;
+ notes: string[];
+ errors: string[];
+ equipment: Equipment;
+ }
+}
+
+type Borrower = {
+ id: number;
+ name: string;
+ phone?: string | null;
+ email?: string | null
+ note?: string | null
+ creationDate: Date;
+}
+
+type Item = {
+ id: number;
+ equipment: Equipment;
+}
+
+export class LoanClass {
+ constructor(
+ public id: number,
+ public status: string,
+ public startDate: Date,
+ public endDate: Date,
+ public borrower : Borrower,
+ public item : Item,
+ private actions: LoanActions
+ ) {}
+
+ return() {
+ this.actions.returnLoan(this.id);
+ }
+
+ delete() {
+ this.actions.deleteLoan(this.id);
+ }
+}
\ No newline at end of file
diff --git a/labman/src/types/User.ts b/labman/src/types/User.ts
new file mode 100644
index 0000000..5ac5cb3
--- /dev/null
+++ b/labman/src/types/User.ts
@@ -0,0 +1,19 @@
+export type UserActions = {
+ deleteUser: (id: number) => void;
+};
+
+export class UserClass {
+ constructor(
+ public id: number,
+ public username: string,
+ public createdAt: Date,
+ public latestActivity: Date,
+ private actions: UserActions,
+ public status: string | null
+ ) {}
+
+
+ delete() {
+ this.actions.deleteUser(this.id);
+ }
+}
\ No newline at end of file
diff --git a/labman/src/types/inventory.ts b/labman/src/types/inventory.ts
index 464ad0e..cd90895 100644
--- a/labman/src/types/inventory.ts
+++ b/labman/src/types/inventory.ts
@@ -3,7 +3,7 @@
export type Equipment = {
id: number;
name: string;
- image: string;
+ image: string | null;
category: {
id: number;
name: string;
@@ -23,4 +23,34 @@ export type Equipment = {
}[]
}
+export type EquipmentWithCategoryAndItems = {
+ id: number;
+ name: string;
+ image: string | null;
+ category: {
+ id: number;
+ name: string;
+ }
+ createdAt: Date;
+ items: {
+ id: number;
+ equipmentId: number;
+ status: string;
+ createdAt: Date;
+ notes: string[];
+ errors: string[];
+ activeLoanId: number | null;
+
+ }[]
+}
+
+export type Unit = {
+ id: number;
+ equipmentId: number;
+ status: string;
+ createdAt: Date;
+ notes: string[];
+ errors: string[];
+};
+
// TODO: Difference between null and undefined and ? means optional
\ No newline at end of file
diff --git a/labman/tests/example.spec.ts b/labman/tests/example.spec.ts
index 6282e8c..81ac64e 100644
--- a/labman/tests/example.spec.ts
+++ b/labman/tests/example.spec.ts
@@ -10,13 +10,13 @@ test('has title', async ({ page }) => {
test('login', async ({ page }) => {
await page.goto('http://localhost:3000/login');
- // Click the get started link.
+
await page.getByPlaceholder("Username").fill("test");
await page.getByPlaceholder("Password").fill("1234");
await page.getByRole('button', { name: 'Login' }).click();
- // Expects page to have a heading with the name of Installation.
+
await expect(page.getByRole('heading', { name: 'Inventory' })).toBeVisible();
});