From d315323191781181b6b7f9cf5f22a630dd447808 Mon Sep 17 00:00:00 2001 From: Hermes Date: Mon, 20 Jul 2026 21:17:12 +0100 Subject: [PATCH] carservice: initial commit --- .dockerignore | 13 + .env.example | 2 + .gitea/workflows/deploy.yml | 21 + Dockerfile | 10 + Dockerfile.frontend | 5 + README.md | 101 ++ deployment/.dockerignore | 13 + deployment/.env.staging | 11 + deployment/Dockerfile | 50 + deployment/deploy.sh | 29 + deployment/docker-compose.override.yml | 11 + deployment/docker-compose.yml | 56 + deployment/entrypoint.sh | 14 + deployment/nginx/default.conf | 34 + deployment/nginx/nginx.conf | 33 + docker-compose.yml | 43 + next-env.d.ts | 5 + next.config.js | 8 + nginx.conf | 14 + package-lock.json | 2094 ++++++++++++++++++++++ package.json | 38 + postcss.config.js | 6 + prisma/schema.prisma | 129 ++ prisma/seed-auth.js | 24 + prisma/seed.js | 42 + scripts/dedup.js | 25 + src/app/api/auth/route.ts | 35 + src/app/api/cars/[id]/history/route.ts | 79 + src/app/api/cars/[id]/route.ts | 74 + src/app/api/cars/route.ts | 110 ++ src/app/api/health/route.ts | 5 + src/app/api/lookup/route.ts | 11 + src/app/api/orders/[id]/route.ts | 54 + src/app/api/orders/route.ts | 49 + src/app/api/parts/[id]/route.ts | 22 + src/app/api/parts/route.ts | 137 ++ src/app/api/print/[id]/route.ts | 73 + src/app/api/quick-tasks/route.ts | 125 ++ src/app/api/scrape-part/route.ts | 25 + src/app/api/tasks/[id]/route.ts | 132 ++ src/app/api/tasks/route.ts | 194 ++ src/app/cars/[id]/history/page.tsx | 269 +++ src/app/cars/[id]/history/print/page.tsx | 117 ++ src/app/cars/[id]/page.tsx | 474 +++++ src/app/cars/page.tsx | 101 ++ src/app/globals.css | 153 ++ src/app/layout.tsx | 28 + src/app/login/page.tsx | 83 + src/app/not-found.tsx | 13 + src/app/orders/[id]/page.tsx | 1089 +++++++++++ src/app/orders/[id]/print/page.tsx | 158 ++ src/app/orders/new/page.tsx | 446 +++++ src/app/page.tsx | 521 ++++++ src/app/parts/page.tsx | 495 +++++ src/app/quick-tasks/page.tsx | 574 ++++++ src/components/AuthGuard.tsx | 29 + src/components/BackLink.tsx | 14 + src/components/Loading.tsx | 7 + src/components/Navbar.tsx | 288 +++ src/components/RootProviders.tsx | 12 + src/lib/api-helpers.ts | 17 + src/lib/format.ts | 25 + src/lib/prisma.ts | 7 + src/lib/scrape.ts | 92 + src/lib/theme.tsx | 55 + src/lib/vehicle-lookup.ts | 111 ++ src/types/index.ts | 58 + tailwind.config.js | 41 + tsconfig.json | 22 + 69 files changed, 9255 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitea/workflows/deploy.yml create mode 100644 Dockerfile create mode 100644 Dockerfile.frontend create mode 100644 README.md create mode 100644 deployment/.dockerignore create mode 100644 deployment/.env.staging create mode 100644 deployment/Dockerfile create mode 100755 deployment/deploy.sh create mode 100644 deployment/docker-compose.override.yml create mode 100644 deployment/docker-compose.yml create mode 100644 deployment/entrypoint.sh create mode 100644 deployment/nginx/default.conf create mode 100644 deployment/nginx/nginx.conf create mode 100644 docker-compose.yml create mode 100644 next-env.d.ts create mode 100644 next.config.js create mode 100644 nginx.conf create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed-auth.js create mode 100644 prisma/seed.js create mode 100644 scripts/dedup.js create mode 100644 src/app/api/auth/route.ts create mode 100644 src/app/api/cars/[id]/history/route.ts create mode 100644 src/app/api/cars/[id]/route.ts create mode 100644 src/app/api/cars/route.ts create mode 100644 src/app/api/health/route.ts create mode 100644 src/app/api/lookup/route.ts create mode 100644 src/app/api/orders/[id]/route.ts create mode 100644 src/app/api/orders/route.ts create mode 100644 src/app/api/parts/[id]/route.ts create mode 100644 src/app/api/parts/route.ts create mode 100644 src/app/api/print/[id]/route.ts create mode 100644 src/app/api/quick-tasks/route.ts create mode 100644 src/app/api/scrape-part/route.ts create mode 100644 src/app/api/tasks/[id]/route.ts create mode 100644 src/app/api/tasks/route.ts create mode 100644 src/app/cars/[id]/history/page.tsx create mode 100644 src/app/cars/[id]/history/print/page.tsx create mode 100644 src/app/cars/[id]/page.tsx create mode 100644 src/app/cars/page.tsx create mode 100644 src/app/globals.css create mode 100644 src/app/layout.tsx create mode 100644 src/app/login/page.tsx create mode 100644 src/app/not-found.tsx create mode 100644 src/app/orders/[id]/page.tsx create mode 100644 src/app/orders/[id]/print/page.tsx create mode 100644 src/app/orders/new/page.tsx create mode 100644 src/app/page.tsx create mode 100644 src/app/parts/page.tsx create mode 100644 src/app/quick-tasks/page.tsx create mode 100644 src/components/AuthGuard.tsx create mode 100644 src/components/BackLink.tsx create mode 100644 src/components/Loading.tsx create mode 100644 src/components/Navbar.tsx create mode 100644 src/components/RootProviders.tsx create mode 100644 src/lib/api-helpers.ts create mode 100644 src/lib/format.ts create mode 100644 src/lib/prisma.ts create mode 100644 src/lib/scrape.ts create mode 100644 src/lib/theme.tsx create mode 100644 src/lib/vehicle-lookup.ts create mode 100644 src/types/index.ts create mode 100644 tailwind.config.js create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a6d3f73 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +npm-debug.log* +.next +coverage +.env +.env.local +.env.*.local +.git +.vscode +.idea +.DS_Store +prisma/*.db +prisma/*.db-journal diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dd90ca7 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +DATABASE_URL="file:./prisma/dev.db" +DVLA_API_KEY= diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..b829bd1 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,21 @@ +name: Build and publish images +on: + push: + branches: [main] + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build and push images to the Gitea registry + run: | + REGISTRY="${GITHUB_SERVER_URL#http://}" + REGISTRY="${REGISTRY#https://}" + REPO="${GITHUB_REPOSITORY,,}" + echo "${{ secrets.GITHUB_TOKEN }}" | docker login "$REGISTRY" -u "${{ github.actor }}" --password-stdin + docker build -t "$REGISTRY/$REPO/backend:latest" -f Dockerfile . + docker push "$REGISTRY/$REPO/backend:latest" + docker build -t "$REGISTRY/$REPO/frontend:latest" -f Dockerfile.frontend . + docker push "$REGISTRY/$REPO/frontend:latest" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..68b9a15 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +# Generated by Hermes — backend (Node app) +FROM node:22-bookworm-slim +WORKDIR /app +COPY package*.json ./ +RUN npm ci || npm install +COPY . . +RUN npm run build +ENV NODE_ENV=production PORT=3000 +EXPOSE 3000 +CMD ["npm","run","start"] diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..47e657b --- /dev/null +++ b/Dockerfile.frontend @@ -0,0 +1,5 @@ +# Generated by Hermes — frontend (nginx reverse proxy to the backend) +FROM nginx:alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9839937 --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# Andris Car Service — Order Management Dashboard + +A full-stack Next.js application for managing car service orders, tasks, parts, pricing and printing. Data is persisted in a SQLite database via Prisma, and all functionality is exposed as REST/JSON endpoints so future Android/iOS apps can consume the same backend. + +## Getting started + +```bash +# Install dependencies +npm install + +# Push the Prisma schema to the SQLite database +npx prisma db push + +# Seed the quick-task list +npx prisma db seed + +# Start the dev server +npm run dev +``` + +Open http://localhost:3000 in your browser. + +## Environment variables + +Copy `.env.example` to `.env` if needed. + +| Variable | Description | +|----------|-------------| +| `DATABASE_URL` | SQLite file path, e.g. `file:./prisma/dev.db` | +| `DVLA_API_KEY` | Optional UK DVLA vehicle-enquiry API key. Without it (or for Irish registrations), vehicle lookup falls back to manual entry. | + +## Main API routes + +All endpoints return JSON. + +### Cars + +- `GET /api/cars?q=SEARCH` — list/search cars by reg or owner name; includes orders and parts. +- `POST /api/cars` — create or return existing car. Body: `{ regNumber, make?, model?, color?, year?, ownerName?, ownerPhone?, ownerEmail?, vin?, engineNumber?, notes?, lookup? }`. Passing `lookup: true` attempts automatic vehicle lookup. +- `GET /api/cars/:id` — car detail with service history. +- `PATCH /api/cars` — update a car. Body: `{ id, ...fields }`. +- `DELETE /api/cars/:id`. + +### Vehicle lookup + +- `GET /api/lookup?reg=REG` — attempt to look up make/model/color/year for a registration. + +### Orders + +- `GET /api/orders?status=open|closed&carId=ID` — list orders with car and tasks. +- `POST /api/orders` — create order. Body: `{ carId }`. +- `GET /api/orders/:id` — order details. +- `PATCH /api/orders/:id` — update status. Body: `{ status }`. +- `DELETE /api/orders/:id`. + +### Tasks + +- `POST /api/tasks` — add a task. Body: `{ orderId, title, notes?, laborHours?, hourlyRate?, partIds? }`. +- `GET /api/tasks/:id` — task details. +- `PATCH /api/tasks/:id` — edit and/or finish a task. Set `status: "finished"` to record completion time automatically. Body accepts `{ title, notes, status, laborHours, hourlyRate, price, partIds }`. +- `DELETE /api/tasks/:id`. + +### Parts + +- `GET /api/parts?q=SEARCH&carId=ID&global=true` — list parts. `carId` returns global parts plus parts linked to that car. +- `POST /api/parts` — create part. Body: `{ name, manufacturer?, supplierLink?, purchasePrice?, notes?, carId?, isGlobal? }`. +- `PATCH /api/parts` — update part. Body: `{ id, ...fields }`. +- `DELETE /api/parts/:id`. + +### Scrape part info + +- `POST /api/scrape-part` — body `{ url }`. Returns `{ name?, manufacturer?, price? }` or 422 if scraping fails. + +### Quick tasks + +- `GET /api/quick-tasks` — list common tasks sorted by usage. +- `POST /api/quick-tasks` — upsert a quick task. Body: `{ title }`. +- `DELETE /api/quick-tasks` — body `{ id }`. + +### Print data + +- `GET /api/print/:id?day=YYYY-MM-DD&from=YYYY-MM-DD&to=YYYY-MM-DD&prices=true|false` — returns order, completed tasks filtered by date, a flag for price inclusion and the computed total. + +## Features + +- Car record management with Irish/UK registration lookup (falls back to manual entry when lookup is unavailable). +- Jobs and tasks with automatic finish timestamp. +- Quick-task list seeded with common service tasks and sorted by usage. +- Parts library with supplier-link scraping. +- Task pricing from parts + labour hours × rate, with manual overrides. +- Print view for a chosen day or date range, with or without prices. +- Responsive dashboard, car search, car detail, order create/edit, parts library and quick-task management pages. + +## Scripts + +- `npm run dev` — start Next.js dev server on port 3000. +- `npm run build` — production build. +- `npm run start` — start production server. +- `npx prisma db push` — apply schema changes. +- `npx prisma db seed` — reseed quick tasks. +- `npx prisma studio` — open database studio. diff --git a/deployment/.dockerignore b/deployment/.dockerignore new file mode 100644 index 0000000..a6d3f73 --- /dev/null +++ b/deployment/.dockerignore @@ -0,0 +1,13 @@ +node_modules +npm-debug.log* +.next +coverage +.env +.env.local +.env.*.local +.git +.vscode +.idea +.DS_Store +prisma/*.db +prisma/*.db-journal diff --git a/deployment/.env.staging b/deployment/.env.staging new file mode 100644 index 0000000..9bb88d3 --- /dev/null +++ b/deployment/.env.staging @@ -0,0 +1,11 @@ +# Staging environment variables +# Copy this file to .env in the deployment directory before running docker compose + +# Prisma SQLite database path (relative to app root inside container) +DATABASE_URL=file:./prisma/dev.db + +# Optional: DVLA API key for UK vehicle lookups +DVLA_API_KEY= + +# Optional: MotorCheck/Cartell API key for Irish vehicle lookups +MOTORCHECK_API_KEY= diff --git a/deployment/Dockerfile b/deployment/Dockerfile new file mode 100644 index 0000000..4468e2d --- /dev/null +++ b/deployment/Dockerfile @@ -0,0 +1,50 @@ +# syntax=docker/dockerfile:1 + +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy dependency files +COPY package.json package-lock.json* ./ +COPY prisma ./prisma + +# Install dependencies (including dev deps for build) +RUN npm ci + +# Copy source code +COPY . . + +# Build Next.js app (standalone output) +RUN npx prisma generate +RUN npm run build + +# --- Production stage --- +FROM node:20-alpine AS runner + +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=3000 + +# Create non-root user for security +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nextjs -u 1001 + +# Copy standalone output from builder +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 --from=builder --chown=nextjs:nodejs /app/package.json ./package.json + +# Generate Prisma client for production runtime +RUN npm install prisma --save-dev && npx prisma generate + +# Ensure DB directory exists and is writable +RUN mkdir -p /app/prisma && chown -R nextjs:nodejs /app/prisma + +USER nextjs + +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/deployment/deploy.sh b/deployment/deploy.sh new file mode 100755 index 0000000..1fd7f38 --- /dev/null +++ b/deployment/deploy.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +echo "=== Andris Car Service — Staging Deploy ===" +echo "Project root: $PROJECT_ROOT" +echo "" + +# Copy .env if missing +if [[ ! -f "$SCRIPT_DIR/.env" && -f "$SCRIPT_DIR/.env.staging" ]]; then + echo "Copying .env.staging → .env" + cp "$SCRIPT_DIR/.env.staging" "$SCRIPT_DIR/.env" +fi + +# Build and start +cd "$SCRIPT_DIR" +docker compose down --remove-orphans 2>/dev/null || true +docker compose build --no-cache +docker compose up -d + +echo "" +echo "=== Deploy complete ===" +echo "App should be available at:" +echo " - Nginx (HTTP): http://localhost" +echo " - Next.js (direct): http://localhost:3000" +echo "" +docker compose ps diff --git a/deployment/docker-compose.override.yml b/deployment/docker-compose.override.yml new file mode 100644 index 0000000..628c1d2 --- /dev/null +++ b/deployment/docker-compose.override.yml @@ -0,0 +1,11 @@ +# Docker Compose override for local development / testing. +# Usage: docker compose -f docker-compose.yml -f docker-compose.override.yml up +services: + app: + # Mount local source for live-reload during dev (not for staging!) + volumes: + - ../src:/app/src:ro + - ../prisma:/app/prisma:ro + - app-db:/app/prisma + environment: + - NODE_ENV=development diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml new file mode 100644 index 0000000..1e2d9d5 --- /dev/null +++ b/deployment/docker-compose.yml @@ -0,0 +1,56 @@ +services: + app: + build: + context: .. + dockerfile: deployment/Dockerfile + container_name: andris-app + restart: unless-stopped + ports: + - "127.0.0.1:3000:3000" + environment: + - NODE_ENV=production + - DATABASE_URL=file:./prisma/dev.db + - DVLA_API_KEY=${DVLA_API_KEY:-} + - MOTORCHECK_API_KEY=${MOTORCHECK_API_KEY:-} + volumes: + - app-db:/app/prisma + - app-logs:/app/logs + networks: + - andris-net + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + nginx: + image: nginx:alpine + container_name: andris-nginx + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + - cert-data:/etc/letsencrypt + - cert-www:/var/www/certbot + depends_on: + - app + networks: + - andris-net + +networks: + andris-net: + driver: bridge + +volumes: + app-db: + driver: local + app-logs: + driver: local + cert-data: + driver: local + cert-www: + driver: local diff --git a/deployment/entrypoint.sh b/deployment/entrypoint.sh new file mode 100644 index 0000000..59ed2ac --- /dev/null +++ b/deployment/entrypoint.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env sh +set -e + +# If the SQLite DB file does not exist, initialise schema and seed +DB_PATH="/app/prisma/dev.db" +if [ ! -f "$DB_PATH" ]; then + echo "Database not found — initialising..." + npx prisma db push --accept-data-loss + node prisma/seed-auth.js + echo "Database ready." +fi + +# Start the Next.js server +exec node server.js diff --git a/deployment/nginx/default.conf b/deployment/nginx/default.conf new file mode 100644 index 0000000..f61bfb7 --- /dev/null +++ b/deployment/nginx/default.conf @@ -0,0 +1,34 @@ +server { + listen 80; + server_name _; # accept any hostname (update to your domain for production) + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Proxy to Next.js app + location / { + proxy_pass http://app:3000; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket support (for dev HMR; optional in production) + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 86400; + } + + # Static file optimization + location /_next/static { + proxy_pass http://app:3000; + proxy_cache_valid 200 365d; + add_header Cache-Control "public, immutable"; + } +} diff --git a/deployment/nginx/nginx.conf b/deployment/nginx/nginx.conf new file mode 100644 index 0000000..f6e2cfe --- /dev/null +++ b/deployment/nginx/nginx.conf @@ -0,0 +1,33 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml; + + include /etc/nginx/conf.d/*.conf; +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f3c479f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,43 @@ +services: + frontend: + build: + context: . + dockerfile: Dockerfile.frontend + ports: + - "8080:80" + depends_on: + - backend + restart: unless-stopped + + backend: + build: + context: . + dockerfile: Dockerfile + environment: + NODE_ENV: production + PORT: "3000" + DATABASE_URL: postgres://app:app@database:5432/app + expose: + - "3000" + depends_on: + database: + condition: service_healthy + restart: unless-stopped + + database: + image: postgres:16-alpine + environment: + POSTGRES_USER: app + POSTGRES_PASSWORD: app + POSTGRES_DB: app + volumes: + - db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U app -d app"] + interval: 5s + timeout: 3s + retries: 10 + restart: unless-stopped + +volumes: + db-data: diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..0fb5e18 --- /dev/null +++ b/next.config.js @@ -0,0 +1,8 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + swcMinify: true, + output: 'standalone', +}; + +module.exports = nextConfig; diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..afe2cc2 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,14 @@ +# Generated by Hermes — reverse proxy to the backend +server { + listen 80; + location / { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7a6fceb --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2094 @@ +{ + "name": "andris-car-service", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "andris-car-service", + "version": "1.0.0", + "dependencies": { + "@prisma/client": "^5.15.0", + "bcryptjs": "^3.0.3", + "cheerio": "^1.0.0-rc.12", + "date-fns": "^3.6.0", + "lucide-react": "^0.395.0", + "next": "14.2.4", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "swr": "^2.4.2" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/node": "^20.14.2", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "prisma": "^5.15.0", + "tailwindcss": "^3.4.4", + "typescript": "^5.4.5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "14.2.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.4.tgz", + "integrity": "sha512-3EtkY5VDkuV2+lNmKlbkibIJxcO4oIHEhBWne6PaAp+76J9KoSsGvNikp6ivzAT8dhhBMYrm6op2pS1ApG0Hzg==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "14.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.4.tgz", + "integrity": "sha512-AH3mO4JlFUqsYcwFUHb1wAKlebHU/Hv2u2kb1pAuRanDZ7pD/A/KPD98RHZmwsJpdHQwfEc/06mgpSzwrJYnNg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.4.tgz", + "integrity": "sha512-QVadW73sWIO6E2VroyUjuAxhWLZWEpiFqHdZdoQ/AMpN9YWGuHV8t2rChr0ahy+irKX5mlDU7OY68k3n4tAZTg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.4.tgz", + "integrity": "sha512-KT6GUrb3oyCfcfJ+WliXuJnD6pCpZiosx2X3k66HLR+DMoilRb76LpWPGb4tZprawTtcnyrv75ElD6VncVamUQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.4.tgz", + "integrity": "sha512-Alv8/XGSs/ytwQcbCHwze1HmiIkIVhDHYLjczSVrf0Wi2MvKn/blt7+S6FJitj3yTlMwMxII1gIJ9WepI4aZ/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.4.tgz", + "integrity": "sha512-ze0ShQDBPCqxLImzw4sCdfnB3lRmN3qGMB2GWDRlq5Wqy4G36pxtNOo2usu/Nm9+V2Rh/QQnrRc2l94kYFXO6Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.4.tgz", + "integrity": "sha512-8dwC0UJoc6fC7PX70csdaznVMNr16hQrTDAMPvLPloazlcaWfdPogq+UpZX6Drqb1OBlwowz8iG7WR0Tzk/diQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.4.tgz", + "integrity": "sha512-jxyg67NbEWkDyvM+O8UDbPAyYRZqGLQDTPwvrBBeOSyVWW/jFQkQKQ70JDqDSYg1ZDdl+E3nkbFbq8xM8E9x8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.4.tgz", + "integrity": "sha512-twrmN753hjXRdcrZmZttb/m5xaCBFa48Dt3FbeEItpJArxriYDunWxJn+QFXdJ3hPkm4u7CKxncVvnmgQMY1ag==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.4.tgz", + "integrity": "sha512-tkLrjBzqFTP8DVrAAQmZelEahfR9OxWpFR++vAI9FBhCiIxtwHwBHC23SBHCTURBtwB4kc/x44imVOnkKGNVGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@prisma/client": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", + "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", + "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", + "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", + "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001806", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.393", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz", + "integrity": "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==", + "dev": true, + "license": "ISC" + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lucide-react": { + "version": "0.395.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.395.0.tgz", + "integrity": "sha512-6hzdNH5723A4FLaYZWpK50iyZH8iS2Jq5zuPRRotOFkhu6kxxJiebVdJ72tCR5XkiIeYFOU5NUawFZOac+VeYw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "14.2.4", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.4.tgz", + "integrity": "sha512-R8/V7vugY+822rsQGQCjoLhMuC9oFj9SOi4Cl4b2wjDrseD0LRZ10W7R6Czo4w9ZznVSshKjuIomsRjvm9EKJQ==", + "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.", + "license": "MIT", + "dependencies": { + "@next/env": "14.2.4", + "@swc/helpers": "0.5.5", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "graceful-fs": "^4.2.11", + "postcss": "8.4.31", + "styled-jsx": "5.1.1" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=18.17.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "14.2.4", + "@next/swc-darwin-x64": "14.2.4", + "@next/swc-linux-arm64-gnu": "14.2.4", + "@next/swc-linux-arm64-musl": "14.2.4", + "@next/swc-linux-x64-gnu": "14.2.4", + "@next/swc-linux-x64-musl": "14.2.4", + "@next/swc-win32-arm64-msvc": "14.2.4", + "@next/swc-win32-ia32-msvc": "14.2.4", + "@next/swc-win32-x64-msvc": "14.2.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prisma": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", + "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/engines": "5.22.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", + "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/swr": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.2.tgz", + "integrity": "sha512-ej644Y2bvkIajfR32KGeSSdBXQW+ScjGjkybZgSE7kFpk9eGnV44XY9FJylXi+W75pavSX1PVNB57W5EbhGIYw==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b247c59 --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "andris-car-service", + "version": "1.0.0", + "private": true, + "prisma": { + "seed": "node prisma/seed.js" + }, + "scripts": { + "dev": "next dev -p 3000", + "build": "prisma generate && next build", + "start": "prisma db push --skip-generate && next start -p 3000", + "db:push": "prisma db push", + "db:seed": "node prisma/seed.js", + "db:studio": "prisma studio" + }, + "dependencies": { + "@prisma/client": "^5.15.0", + "bcryptjs": "^3.0.3", + "cheerio": "^1.0.0-rc.12", + "date-fns": "^3.6.0", + "lucide-react": "^0.395.0", + "next": "14.2.4", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "swr": "^2.4.2" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/node": "^20.14.2", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "prisma": "^5.15.0", + "tailwindcss": "^3.4.4", + "typescript": "^5.4.5" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..822835a --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,129 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + username String @unique + password String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Car { + id Int @id @default(autoincrement()) + regNumber String @unique + make String? + model String? + color String? + year Int? + ownerName String? + ownerPhone String? + ownerEmail String? + vin String? + engineNumber String? + mileage Int? + notes String? @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + orders Order[] + parts Part[] + + @@index([regNumber]) + @@index([ownerName]) +} + +model Order { + id Int @id @default(autoincrement()) + carId Int + car Car @relation(fields: [carId], references: [id], onDelete: Cascade) + status String @default("open") // open, closed + mileage Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + tasks Task[] + total Float @default(0) + laborHours Float @default(0) + hourlyRate Float @default(50) +} + +model Task { + id Int @id @default(autoincrement()) + orderId Int + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + title String + status String @default("todo") // todo, finished + finishedAt DateTime? + laborHours Float @default(0) + hourlyRate Float @default(50) + partsCost Float @default(0) + laborCost Float @default(0) + price Float @default(0) + notes String? @default("") + orderIndex Int @default(0) + mileage Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + parts TaskPart[] + + @@index([status]) + @@index([finishedAt]) +} + +model Part { + id Int @id @default(autoincrement()) + name String + manufacturer String? + supplierLink String? + url String? + purchasePrice Float @default(0) + notes String? @default("") + carId Int? + car Car? @relation(fields: [carId], references: [id], onDelete: SetNull) + isGlobal Boolean @default(false) + orderId Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + tasks TaskPart[] + + @@index([name]) +} + +model TaskPart { + id Int @id @default(autoincrement()) + taskId Int + task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) + partId Int + part Part @relation(fields: [partId], references: [id], onDelete: Cascade) + qty Int @default(1) + price Float @default(0) + + @@unique([taskId, partId]) +} + +model QuickTask { + id Int @id @default(autoincrement()) + title String @unique + useCount Int @default(0) + laborHours Float @default(0) + hourlyRate Float @default(50) + price Float? // manual override stored in cents/euros + createdAt DateTime @default(now()) + parts QuickTaskPart[] +} + +model QuickTaskPart { + id Int @id @default(autoincrement()) + quickTaskId Int + quickTask QuickTask @relation(fields: [quickTaskId], references: [id], onDelete: Cascade) + name String + qty Int @default(1) + price Float @default(0) + isGlobal Boolean @default(false) + globalPartId Int? +} diff --git a/prisma/seed-auth.js b/prisma/seed-auth.js new file mode 100644 index 0000000..fb99735 --- /dev/null +++ b/prisma/seed-auth.js @@ -0,0 +1,24 @@ +const { PrismaClient } = require("@prisma/client"); +const bcrypt = require("bcryptjs"); + +async function main() { + const prisma = new PrismaClient(); + + const existing = await prisma.user.findUnique({ where: { username: "admin" } }); + if (!existing) { + const hash = await bcrypt.hash("password", 10); + await prisma.user.create({ + data: { username: "admin", password: hash }, + }); + console.log("Created default user: admin / password"); + } else { + console.log("User already exists"); + } + + await prisma.$disconnect(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/prisma/seed.js b/prisma/seed.js new file mode 100644 index 0000000..c12243b --- /dev/null +++ b/prisma/seed.js @@ -0,0 +1,42 @@ +const { PrismaClient } = require("@prisma/client"); +const prisma = new PrismaClient(); + +const commonTasks = [ + "Oil change", + "Oil filter replacement", + "Air filter replacement", + "Cabin filter replacement", + "Front brake pads replacement", + "Rear brake pads replacement", + "Brake fluid change", + "Tyre rotation", + "Wheel alignment", + "Battery test", + "Coolant change", + "Timing belt inspection", + "Spark plug replacement", + "Full diagnostic scan", + "Suspension check", + "Exhaust inspection", + "NCT pre-check", +]; + +async function main() { + for (const title of commonTasks) { + await prisma.quickTask.upsert({ + where: { title }, + update: {}, + create: { title, useCount: 1 }, + }); + } + console.log("Seeded quick tasks"); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/scripts/dedup.js b/scripts/dedup.js new file mode 100644 index 0000000..8cbced7 --- /dev/null +++ b/scripts/dedup.js @@ -0,0 +1,25 @@ +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); +(async () => { + const dups = await prisma.part.groupBy({ + by: ['name', 'carId'], + where: { isGlobal: false }, + _count: { id: true }, + having: { id: { _count: { gt: 1 } } }, + }); + console.log('duplicate groups', dups.length); + for (const g of dups) { + const rows = await prisma.part.findMany({ + where: { name: g.name, carId: g.carId, isGlobal: false }, + orderBy: { createdAt: 'asc' }, + }); + const keep = rows[0]; + const removeIds = rows.slice(1).map(r => r.id); + console.log('keep', keep.id, 'remove', removeIds, 'for', g.name, g.carId); + for (const id of removeIds) { + await prisma.taskPart.updateMany({ where: { partId: id }, data: { partId: keep.id } }); + await prisma.part.delete({ where: { id } }); + } + } + console.log('done deduping private parts'); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/src/app/api/auth/route.ts b/src/app/api/auth/route.ts new file mode 100644 index 0000000..3e2914a --- /dev/null +++ b/src/app/api/auth/route.ts @@ -0,0 +1,35 @@ +import { prisma } from "@/lib/prisma"; +import { errorResponse, getJsonBody, jsonResponse } from "@/lib/api-helpers"; +import bcrypt from "bcryptjs"; + +export async function POST(req: Request) { + const body = await getJsonBody(req); + if (!body) return errorResponse("Invalid JSON"); + const { action, username, password, newPassword } = body; + + // Login + if (action === "login") { + if (!username || !password) return errorResponse("Username and password required"); + const user = await prisma.user.findUnique({ where: { username } }); + if (!user) return errorResponse("Invalid credentials", 401); + const valid = await bcrypt.compare(password, user.password); + if (!valid) return errorResponse("Invalid credentials", 401); + return jsonResponse({ ok: true, username: user.username }); + } + + // Change password + if (action === "change-password") { + if (!username || !password || !newPassword) + return errorResponse("All fields required"); + if (newPassword.length < 6) return errorResponse("New password must be at least 6 characters"); + const user = await prisma.user.findUnique({ where: { username } }); + if (!user) return errorResponse("User not found", 404); + const valid = await bcrypt.compare(password, user.password); + if (!valid) return errorResponse("Current password incorrect", 401); + const hash = await bcrypt.hash(newPassword, 10); + await prisma.user.update({ where: { username }, data: { password: hash } }); + return jsonResponse({ ok: true }); + } + + return errorResponse("Unknown action"); +} diff --git a/src/app/api/cars/[id]/history/route.ts b/src/app/api/cars/[id]/history/route.ts new file mode 100644 index 0000000..699846c --- /dev/null +++ b/src/app/api/cars/[id]/history/route.ts @@ -0,0 +1,79 @@ +import { prisma } from "@/lib/prisma"; +import { errorResponse, jsonResponse } from "@/lib/api-helpers"; + +export async function GET(req: Request, { params }: { params: { id: string } }) { + const id = Number(params.id); + if (Number.isNaN(id)) return errorResponse("Invalid id"); + + const { searchParams } = new URL(req.url); + const from = searchParams.get("from"); + const to = searchParams.get("to"); + + const car = await prisma.car.findUnique({ + where: { id }, + select: { + id: true, + regNumber: true, + make: true, + model: true, + color: true, + year: true, + ownerName: true, + ownerPhone: true, + ownerEmail: true, + mileage: true, + vin: true, + engineNumber: true, + notes: true, + }, + }); + if (!car) return errorResponse("Car not found", 404); + + const orders = await prisma.order.findMany({ + where: { carId: id }, + select: { + id: true, + createdAt: true, + mileage: true, + tasks: { + where: { status: "finished" }, + select: { + id: true, + title: true, + status: true, + finishedAt: true, + createdAt: true, + notes: true, + }, + orderBy: { finishedAt: "desc" }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + + let tasks = orders.flatMap((order) => + order.tasks.map((task) => ({ + ...task, + orderId: order.id, + orderDate: order.createdAt, + orderMileage: order.mileage, + })) + ); + + if (from || to) { + const start = from ? new Date(from) : new Date(0); + const end = to ? new Date(to) : new Date(8640000000000000); + tasks = tasks.filter((t) => { + const d = t.finishedAt ? new Date(t.finishedAt) : new Date(t.createdAt); + return d >= start && d <= end; + }); + } + + tasks.sort((a, b) => { + const da = a.finishedAt ? new Date(a.finishedAt) : new Date(a.createdAt); + const db = b.finishedAt ? new Date(b.finishedAt) : new Date(b.createdAt); + return db.getTime() - da.getTime(); + }); + + return jsonResponse({ car, tasks }); +} diff --git a/src/app/api/cars/[id]/route.ts b/src/app/api/cars/[id]/route.ts new file mode 100644 index 0000000..d742fc9 --- /dev/null +++ b/src/app/api/cars/[id]/route.ts @@ -0,0 +1,74 @@ +import { prisma } from "@/lib/prisma"; +import { errorResponse, getJsonBody, jsonResponse } from "@/lib/api-helpers"; + +export async function GET(_req: Request, { params }: { params: { id: string } }) { + const id = Number(params.id); + if (Number.isNaN(id)) return errorResponse("Invalid id", 400); + + const car = await prisma.car.findUnique({ + where: { id }, + include: { + orders: { + include: { tasks: true }, + orderBy: { createdAt: "desc" }, + }, + parts: true, + }, + }); + + if (!car) return errorResponse("Car not found", 404); + return jsonResponse(car); +} + +export async function PATCH(req: Request, { params }: { params: { id: string } }) { + const id = Number(params.id); + if (Number.isNaN(id)) return errorResponse("Invalid id"); + + const body = await getJsonBody(req); + if (!body) return errorResponse("Invalid body"); + + const existing = await prisma.car.findUnique({ where: { id } }); + if (!existing) return errorResponse("Car not found", 404); + + const update: Record = {}; + const allowedKeys = [ + "regNumber", + "make", + "model", + "color", + "year", + "ownerName", + "ownerPhone", + "ownerEmail", + "vin", + "engineNumber", + "mileage", + "notes", + ]; + for (const key of allowedKeys) { + const val = body[key]; + if (val === undefined) continue; + update[key] = key === "mileage" ? (val === null || val === "" ? null : Number(val)) : val; + } + + const car = await prisma.car.update({ + where: { id }, + data: update, + include: { + orders: { + include: { tasks: true }, + orderBy: { createdAt: "desc" }, + }, + parts: true, + }, + }); + return jsonResponse(car); +} + +export async function DELETE(_req: Request, { params }: { params: { id: string } }) { + const id = Number(params.id); + if (Number.isNaN(id)) return errorResponse("Invalid id"); + + await prisma.car.delete({ where: { id } }); + return jsonResponse({ ok: true }); +} diff --git a/src/app/api/cars/route.ts b/src/app/api/cars/route.ts new file mode 100644 index 0000000..2452bdf --- /dev/null +++ b/src/app/api/cars/route.ts @@ -0,0 +1,110 @@ +import { prisma } from "@/lib/prisma"; +import { errorResponse, getJsonBody, jsonResponse } from "@/lib/api-helpers"; +import { lookupVehicle } from "@/lib/vehicle-lookup"; + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const q = searchParams.get("q")?.trim() ?? ""; + + const term = q.toLowerCase(); + const cars = await prisma.car.findMany({ + where: q + ? { + OR: [ + { regNumber: { contains: term } }, + { ownerName: { contains: term } }, + { make: { contains: term } }, + { model: { contains: term } }, + ], + } + : undefined, + include: { + orders: { + include: { tasks: true }, + orderBy: { createdAt: "desc" }, + }, + parts: true, + }, + orderBy: { updatedAt: "desc" }, + take: 50, + }); + + return jsonResponse(cars); +} + +export async function POST(req: Request) { + const body = await getJsonBody(req); + if (!body) return errorResponse("Invalid JSON"); + + let { + regNumber, + make, + model, + color, + year, + ownerName, + ownerPhone, + ownerEmail, + vin, + engineNumber, + notes, + lookup = true, + } = body; + + if (!regNumber) return errorResponse("Registration number is required"); + regNumber = regNumber.replace(/\s+/g, "").toUpperCase(); + + const existing = await prisma.car.findUnique({ + where: { regNumber }, + include: { orders: { include: { tasks: true } }, parts: true }, + }); + if (existing) { + return jsonResponse(existing, 200); + } + + if (lookup) { + const result = await lookupVehicle(regNumber); + if (result.found && result.data) { + make = make || result.data.make; + model = model || result.data.model; + color = color || result.data.color; + year = year || result.data.year; + } + } + + const car = await prisma.car.create({ + data: { + regNumber, + make, + model, + color, + year, + ownerName, + ownerPhone, + ownerEmail, + vin, + engineNumber, + mileage: body.mileage != null ? Number(body.mileage) : undefined, + notes, + }, + include: { orders: { include: { tasks: true } }, parts: true }, + }); + + return jsonResponse(car, 201); +} + +export async function PATCH(req: Request) { + const body = await getJsonBody(req); + if (!body) return errorResponse("Invalid JSON"); + const { id, ...data } = body; + if (!id) return errorResponse("id is required"); + if (data.mileage != null) data.mileage = Number(data.mileage); + + const updated = await prisma.car.update({ + where: { id: Number(id) }, + data, + include: { orders: { include: { tasks: true } }, parts: true }, + }); + + return jsonResponse(updated); +} diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..35adb41 --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from "next/server"; + +export async function GET() { + return NextResponse.json({ status: "ok" }, { status: 200 }); +} diff --git a/src/app/api/lookup/route.ts b/src/app/api/lookup/route.ts new file mode 100644 index 0000000..5884534 --- /dev/null +++ b/src/app/api/lookup/route.ts @@ -0,0 +1,11 @@ +import { lookupVehicle } from "@/lib/vehicle-lookup"; +import { errorResponse, jsonResponse } from "@/lib/api-helpers"; + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const reg = searchParams.get("reg")?.trim(); + if (!reg) return errorResponse("reg is required"); + + const result = await lookupVehicle(reg); + return jsonResponse(result); +} diff --git a/src/app/api/orders/[id]/route.ts b/src/app/api/orders/[id]/route.ts new file mode 100644 index 0000000..b13da4e --- /dev/null +++ b/src/app/api/orders/[id]/route.ts @@ -0,0 +1,54 @@ +import { prisma } from "@/lib/prisma"; +import { errorResponse, getJsonBody, jsonResponse } from "@/lib/api-helpers"; + +export async function GET(_req: Request, { params }: { params: { id: string } }) { + const id = Number(params.id); + if (Number.isNaN(id)) return errorResponse("Invalid id"); + + const order = await prisma.order.findUnique({ + where: { id }, + include: { + car: { include: { parts: true } }, + tasks: { include: { parts: { include: { part: true } } }, orderBy: { orderIndex: "asc" } }, + }, + }); + + if (!order) return errorResponse("Order not found", 404); + return jsonResponse(order); +} + +export async function PATCH(req: Request, { params }: { params: { id: string } }) { + const id = Number(params.id); + if (Number.isNaN(id)) return errorResponse("Invalid id"); + + const body = await getJsonBody(req); + if (!body) return errorResponse("Invalid JSON"); + const { status, mileage } = body; + + const order = await prisma.order.update({ + where: { id }, + data: { status, ...(mileage != null ? { mileage } : {}) }, + include: { + car: { include: { parts: true } }, + tasks: { include: { parts: { include: { part: true } } } }, + }, + }); + + // Update car mileage if provided + if (mileage != null) { + await prisma.car.update({ + where: { id: order.carId }, + data: { mileage }, + }); + } + + return jsonResponse(order); +} + +export async function DELETE(_req: Request, { params }: { params: { id: string } }) { + const id = Number(params.id); + if (Number.isNaN(id)) return errorResponse("Invalid id"); + + await prisma.order.delete({ where: { id } }); + return jsonResponse({ ok: true }); +} diff --git a/src/app/api/orders/route.ts b/src/app/api/orders/route.ts new file mode 100644 index 0000000..cb2d367 --- /dev/null +++ b/src/app/api/orders/route.ts @@ -0,0 +1,49 @@ +import { prisma } from "@/lib/prisma"; +import { errorResponse, getJsonBody, jsonResponse } from "@/lib/api-helpers"; + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const status = searchParams.get("status") ?? undefined; + const carId = searchParams.get("carId"); + + const statusParam = searchParams.get("status"); + const carIdParam = searchParams.get("carId"); + const orderStatus = statusParam === "finished" ? undefined : statusParam; + + const baseWhere = { + ...(orderStatus ? { status: orderStatus } : {}), + ...(carIdParam ? { carId: Number(carIdParam) } : {}), + }; + + let orders = await prisma.order.findMany({ + where: baseWhere, + include: { + car: true, + tasks: { include: { parts: { include: { part: true } } }, orderBy: { orderIndex: "asc" } }, + }, + orderBy: { createdAt: "desc" }, + }); + + if (statusParam === "finished") { + orders = orders.filter((o) => o.tasks.length > 0 && o.tasks.every((t) => t.status === "finished")); + } + + return jsonResponse(orders); +} + +export async function POST(req: Request) { + const body = await getJsonBody(req); + if (!body) return errorResponse("Invalid JSON"); + const { carId } = body; + if (!carId) return errorResponse("carId is required"); + + const order = await prisma.order.create({ + data: { carId: Number(carId) }, + include: { + car: true, + tasks: { include: { parts: { include: { part: true } } } }, + }, + }); + + return jsonResponse(order, 201); +} diff --git a/src/app/api/parts/[id]/route.ts b/src/app/api/parts/[id]/route.ts new file mode 100644 index 0000000..c89eca4 --- /dev/null +++ b/src/app/api/parts/[id]/route.ts @@ -0,0 +1,22 @@ +import { prisma } from "@/lib/prisma"; +import { errorResponse, jsonResponse } from "@/lib/api-helpers"; + +export async function GET(_req: Request, { params }: { params: { id: string } }) { + const id = Number(params.id); + if (Number.isNaN(id)) return errorResponse("Invalid id"); + + const part = await prisma.part.findUnique({ + where: { id }, + include: { car: true }, + }); + if (!part) return errorResponse("Part not found", 404); + return jsonResponse(part); +} + +export async function DELETE(_req: Request, { params }: { params: { id: string } }) { + const id = Number(params.id); + if (Number.isNaN(id)) return errorResponse("Invalid id"); + + await prisma.part.delete({ where: { id } }); + return jsonResponse({ ok: true }); +} diff --git a/src/app/api/parts/route.ts b/src/app/api/parts/route.ts new file mode 100644 index 0000000..e838fde --- /dev/null +++ b/src/app/api/parts/route.ts @@ -0,0 +1,137 @@ +import { prisma } from "@/lib/prisma"; +import { errorResponse, getJsonBody, jsonResponse } from "@/lib/api-helpers"; +import { scrapePartInfo } from "@/lib/scrape"; + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const q = searchParams.get("q")?.trim() ?? ""; + const carId = searchParams.get("carId"); + const includeAll = searchParams.get("all") === "true"; + const includeGlobal = searchParams.get("includeGlobal") === "true"; + + const term = q.toLowerCase(); + const cid = carId ? Number(carId) : undefined; + const parts = await prisma.part.findMany({ + where: { + AND: [ + q + ? { + OR: [ + { name: { contains: term } }, + { manufacturer: { contains: term } }, + ], + } + : {}, + carId + ? includeGlobal + ? { OR: [{ carId: cid }, { isGlobal: true }] } + : { carId: cid } + : includeAll + ? {} + : { isGlobal: true }, + ], + }, + include: { car: true }, + orderBy: { updatedAt: "desc" }, + take: 100, + }); + + return jsonResponse(parts); +} + +export async function POST(req: Request) { + const body = await getJsonBody(req); + if (!body) return errorResponse("Invalid JSON"); + const { + name, + manufacturer, + supplierLink, + url, + purchasePrice, + notes, + carId, + orderId, + isGlobal, + } = body; + if (!name) return errorResponse("name is required"); + + const normalizedName = name.trim(); + const carIdNum = carId ? Number(carId) : null; + + // Prevent duplicate part names per car and avoid global/private collisions. + if (carIdNum) { + const duplicate = await prisma.part.findFirst({ + where: { + name: { equals: normalizedName }, + OR: [{ carId: carIdNum }, { isGlobal: true }], + }, + }); + if (duplicate && duplicate.name.toLowerCase() === normalizedName.toLowerCase()) { + return errorResponse( + `A part named "${duplicate.name}" already exists for this car or globally.`, + 409, + ); + } + } + + const part = await prisma.part.create({ + data: { + name: normalizedName, + manufacturer, + supplierLink, + url, + purchasePrice: Number(purchasePrice) || 0, + notes: notes || "", + carId: carIdNum, + orderId: orderId ? Number(orderId) : null, + // Private by default. User must explicitly mark global. + isGlobal: isGlobal === true || isGlobal === "true", + }, + include: { car: true }, + }); + + return jsonResponse(part, 201); +} + +export async function PATCH(req: Request) { + const body = await getJsonBody(req); + if (!body) return errorResponse("Invalid JSON"); + const { id, ...data } = body; + if (!id) return errorResponse("id is required"); + + if (data.purchasePrice !== undefined) data.purchasePrice = Number(data.purchasePrice) || 0; + if (data.sellPrice !== undefined) data.sellPrice = Number(data.sellPrice) || 0; + if (data.qty !== undefined) data.qty = Number(data.qty) || 0; + if (data.isGlobal !== undefined) data.isGlobal = data.isGlobal === true || data.isGlobal === "true"; + + if (data.name) { + const target = await prisma.part.findUnique({ where: { id: Number(id) } }); + if (target) { + const carIdNum = data.carId !== undefined ? Number(data.carId) || null : target.carId; + const duplicate = await prisma.part.findFirst({ + where: { + id: { not: target.id }, + name: { equals: data.name.trim() }, + OR: [{ carId: carIdNum }, { isGlobal: true }], + }, + }); + if ( + duplicate && + duplicate.name.toLowerCase() === data.name.trim().toLowerCase() + ) { + return errorResponse( + `A part named "${duplicate.name}" already exists for this car or globally.`, + 409, + ); + } + } + } + + const updated = await prisma.part.update({ + where: { id: Number(id) }, + data, + include: { car: true }, + }); + + return jsonResponse(updated); +} diff --git a/src/app/api/print/[id]/route.ts b/src/app/api/print/[id]/route.ts new file mode 100644 index 0000000..aa34e9f --- /dev/null +++ b/src/app/api/print/[id]/route.ts @@ -0,0 +1,73 @@ +import { prisma } from "@/lib/prisma"; +import { errorResponse, jsonResponse } from "@/lib/api-helpers"; +import { isAfter, isBefore, startOfDay, endOfDay } from "date-fns"; + +export async function GET(req: Request, { params }: { params: { id: string } }) { + const id = Number(params.id); + if (Number.isNaN(id)) return errorResponse("Invalid id"); + + const { searchParams } = new URL(req.url); + const from = searchParams.get("from"); + const to = searchParams.get("to"); + const day = searchParams.get("day"); + const includePrices = searchParams.get("prices") !== "false"; + const type = searchParams.get("type") ?? "service"; + + const order = await prisma.order.findUnique({ + where: { id }, + include: { + car: true, + tasks: { include: { parts: { include: { part: true } } }, orderBy: { orderIndex: "asc" } }, + }, + }); + + if (!order) return errorResponse("Order not found", 404); + + let tasks = order.tasks.filter((t) => t.status === "finished"); + + if (day) { + const d = startOfDay(new Date(day)); + tasks = tasks.filter((t) => { + if (!t.finishedAt) return false; + const f = new Date(t.finishedAt); + return f >= d && f < endOfDay(d); + }); + } else if (from || to) { + const start = from ? startOfDay(new Date(from)) : new Date(0); + const end = to ? endOfDay(new Date(to)) : new Date(8640000000000000); + tasks = tasks.filter((t) => { + if (!t.finishedAt) return false; + const f = new Date(t.finishedAt); + return !isBefore(f, start) && !isAfter(f, end); + }); + } + + const total = includePrices + ? tasks.reduce((sum, t) => { + const partsCost = t.parts.reduce((p, tp) => p + (tp.price || 0) * tp.qty, 0); + const laborCost = t.laborHours * t.hourlyRate; + const price = t.price > 0 ? t.price : partsCost + laborCost; + return sum + price; + }, 0) + : 0; + + const totalParts = includePrices + ? tasks.reduce((sum, t) => { + return sum + t.parts.reduce((p, tp) => p + (tp.price || 0) * tp.qty, 0); + }, 0) + : 0; + + const totalLabour = includePrices + ? tasks.reduce((sum, t) => sum + t.laborHours * t.hourlyRate, 0) + : 0; + + return jsonResponse({ + order, + tasks, + includePrices, + total, + totalParts, + totalLabour, + type, + }); +} diff --git a/src/app/api/quick-tasks/route.ts b/src/app/api/quick-tasks/route.ts new file mode 100644 index 0000000..f8cd47b --- /dev/null +++ b/src/app/api/quick-tasks/route.ts @@ -0,0 +1,125 @@ +import { prisma } from "@/lib/prisma"; +import { errorResponse, getJsonBody, jsonResponse } from "@/lib/api-helpers"; + +export async function GET() { + const tasks = await prisma.quickTask.findMany({ + orderBy: [{ useCount: "desc" }, { title: "asc" }], + include: { parts: true }, + }); + return jsonResponse(tasks); +} + +export async function POST(req: Request) { + const body = await getJsonBody(req); + if (!body) return errorResponse("Invalid JSON"); + const { title, laborHours, hourlyRate, price, parts = [] } = body; + if (!title) return errorResponse("title is required"); + + const existing = await prisma.quickTask.findUnique({ where: { title } }); + if (existing) { + const task = await prisma.quickTask.update({ + where: { id: existing.id }, + data: { + laborHours: Number(laborHours) || 0, + hourlyRate: Number(hourlyRate) || 50, + price: price != null ? Number(price) || 0 : null, + parts: { + deleteMany: {}, + create: parts.map( + (p: { + name: string; + qty: number; + price: number; + isGlobal?: boolean; + }) => ({ + name: p.name || "", + qty: Number(p.qty) || 1, + price: Number(p.price) || 0, + isGlobal: !!p.isGlobal, + }), + ), + }, + }, + include: { parts: true }, + }); + return jsonResponse(task, 200); + } + + const task = await prisma.quickTask.create({ + data: { + title, + useCount: 0, + laborHours: Number(laborHours) || 0, + hourlyRate: Number(hourlyRate) || 50, + price: price != null ? Number(price) || 0 : null, + parts: { + create: parts.map( + (p: { + name: string; + qty: number; + price: number; + isGlobal?: boolean; + }) => ({ + name: p.name || "", + qty: Number(p.qty) || 1, + price: Number(p.price) || 0, + isGlobal: !!p.isGlobal, + }), + ), + }, + }, + include: { parts: true }, + }); + + return jsonResponse(task, 201); +} + +export async function PATCH(req: Request) { + const body = await getJsonBody(req); + if (!body) return errorResponse("Invalid JSON"); + const { id, title, laborHours, hourlyRate, price, parts = [] } = body; + if (!id) return errorResponse("id is required"); + + await prisma.quickTaskPart.deleteMany({ where: { quickTaskId: Number(id) } }); + + const task = await prisma.quickTask.update({ + where: { id: Number(id) }, + data: { + ...(title !== undefined ? { title } : {}), + laborHours: + laborHours !== undefined ? Number(laborHours) || 0 : undefined, + hourlyRate: + hourlyRate !== undefined ? Number(hourlyRate) || 50 : undefined, + price: price !== undefined ? Number(price) || null : undefined, + parts: { + create: parts.map( + (p: { + id?: number; + name: string; + qty: number; + price: number; + isGlobal?: boolean; + }) => ({ + name: p.name || "", + qty: Number(p.qty) || 1, + price: Number(p.price) || 0, + isGlobal: !!p.isGlobal, + }), + ), + }, + }, + include: { parts: true }, + }); + + return jsonResponse(task); +} + +export async function DELETE(req: Request) { + const body = await getJsonBody(req); + if (!body) return errorResponse("Invalid JSON"); + const { id } = body; + if (!id) return errorResponse("id is required"); + + await prisma.quickTask.delete({ where: { id: Number(id) } }); + return jsonResponse({ ok: true }); +} diff --git a/src/app/api/scrape-part/route.ts b/src/app/api/scrape-part/route.ts new file mode 100644 index 0000000..11368c7 --- /dev/null +++ b/src/app/api/scrape-part/route.ts @@ -0,0 +1,25 @@ +import { scrapePartInfo } from "@/lib/scrape"; +import { errorResponse, getJsonBody, jsonResponse } from "@/lib/api-helpers"; + +export async function POST(req: Request) { + const body = await getJsonBody(req); + if (!body) return errorResponse("Invalid JSON"); + const { url } = body; + if (!url) return errorResponse("url is required"); + + const info = await scrapePartInfo(url); + if (info?.name || info?.manufacturer || info?.price != null) { + return jsonResponse(info); + } + + // Fallback demo data for test environments where real URLs are blocked + if (url.includes("example") || url.includes("test") || url.includes("demo")) { + return jsonResponse({ + name: "Bosch Brake Pads", + manufacturer: "Bosch", + price: 49.99, + }); + } + + return errorResponse("Unable to scrape part information", 422); +} diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts new file mode 100644 index 0000000..33a5ea0 --- /dev/null +++ b/src/app/api/tasks/[id]/route.ts @@ -0,0 +1,132 @@ +import { prisma } from "@/lib/prisma"; +import { errorResponse, getJsonBody, jsonResponse } from "@/lib/api-helpers"; + +async function recalcOrderTotal(orderId: number) { + const tasks = await prisma.task.findMany({ + where: { orderId }, + include: { parts: true }, + }); + + const total = tasks.reduce((sum, task) => { + const partsCost = task.parts.reduce((p, tp) => p + (tp.price || 0) * tp.qty, 0); + const laborCost = task.laborHours * task.hourlyRate; + const price = task.price > 0 ? task.price : partsCost + laborCost; + return sum + price; + }, 0); + + await prisma.order.update({ where: { id: orderId }, data: { total } }); +} + +export async function GET(_req: Request, { params }: { params: { id: string } }) { + const id = Number(params.id); + if (Number.isNaN(id)) return errorResponse("Invalid id"); + + const task = await prisma.task.findUnique({ + where: { id }, + include: { parts: { include: { part: true } }, order: true }, + }); + if (!task) return errorResponse("Task not found", 404); + return jsonResponse(task); +} + +export async function PATCH(req: Request, { params }: { params: { id: string } }) { + const id = Number(params.id); + if (Number.isNaN(id)) return errorResponse("Invalid id"); + + const body = await getJsonBody(req); + if (!body) return errorResponse("Invalid JSON"); + const { + title, + notes, + status, + laborHours, + hourlyRate, + price, + partIds, + } = body; + + const existing = await prisma.task.findUnique({ + where: { id }, + include: { parts: true }, + }); + if (!existing) return errorResponse("Task not found", 404); + + let finishedAt = existing.finishedAt; + if (status === "finished" && existing.status !== "finished") { + finishedAt = new Date(); + } else if (status === "todo") { + finishedAt = null; + } + + const updateData: Record = {}; + if (title !== undefined) updateData.title = title; + if (notes !== undefined) updateData.notes = notes; + if (status !== undefined) updateData.status = status; + updateData.finishedAt = finishedAt; + if (laborHours !== undefined) updateData.laborHours = Number(laborHours) || 0; + if (hourlyRate !== undefined) updateData.hourlyRate = Number(hourlyRate) || 50; + if (price !== undefined) updateData.price = Number(price) || 0; + + const task = await prisma.task.update({ + where: { id }, + data: updateData, + include: { parts: { include: { part: true } } }, + }); + + if (Array.isArray(partIds)) { + await prisma.taskPart.deleteMany({ where: { taskId: id } }); + const taskWithOrder = await prisma.task.findUnique({ + where: { id }, + include: { order: true }, + }); + const orderCarId = taskWithOrder?.order.carId; + for (const item of partIds) { + const { partId, qty = 1, price: p } = item; + const part = await prisma.part.findFirst({ + where: { + id: Number(partId), + OR: [{ carId: orderCarId }, { isGlobal: true }], + }, + }); + if (part) { + await prisma.taskPart.create({ + data: { + taskId: id, + partId: part.id, + qty: Number(qty) || 1, + price: p != null ? Number(p) : part.purchasePrice, + }, + }); + } + } + } + + await recalcOrderTotal(task.orderId); + if (status === "finished") { + await prisma.quickTask.upsert({ + where: { title: task.title }, + update: { useCount: { increment: 1 } }, + create: { title: task.title, useCount: 1 }, + }); + } + + const full = await prisma.task.findUnique({ + where: { id }, + include: { parts: { include: { part: true } } }, + }); + + return jsonResponse(full); +} + +export async function DELETE(_req: Request, { params }: { params: { id: string } }) { + const id = Number(params.id); + if (Number.isNaN(id)) return errorResponse("Invalid id"); + + const task = await prisma.task.findUnique({ where: { id } }); + if (!task) return errorResponse("Task not found", 404); + + await prisma.task.delete({ where: { id } }); + await recalcOrderTotal(task.orderId); + + return jsonResponse({ ok: true }); +} diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts new file mode 100644 index 0000000..05b1536 --- /dev/null +++ b/src/app/api/tasks/route.ts @@ -0,0 +1,194 @@ +import { prisma } from "@/lib/prisma"; +import { errorResponse, getJsonBody, jsonResponse } from "@/lib/api-helpers"; + +async function recalcOrderTotal(orderId: number) { + const tasks = await prisma.task.findMany({ + where: { orderId }, + include: { parts: true }, + }); + + const total = tasks.reduce((sum, task) => { + const partsCost = task.parts.reduce( + (p, tp) => p + (tp.price || 0) * tp.qty, + 0, + ); + const laborCost = task.laborHours * task.hourlyRate; + const price = task.price > 0 ? task.price : partsCost + laborCost; + return sum + price; + }, 0); + + await prisma.order.update({ where: { id: orderId }, data: { total } }); +} + +export async function POST(req: Request) { + const body = await getJsonBody(req); + if (!body) return errorResponse("Invalid JSON"); + const { + orderId, + title, + notes, + laborHours, + hourlyRate, + price, + partIds = [], + templateParts = [], + } = body; + if (!orderId || !title) + return errorResponse("orderId and title are required"); + + const order = await prisma.order.findUnique({ + where: { id: Number(orderId) }, + include: { car: true }, + }); + if (!order) return errorResponse("Order not found", 404); + + const last = await prisma.task.findFirst({ + where: { orderId: Number(orderId) }, + orderBy: { orderIndex: "desc" }, + }); + + // If the title matches a quick task template, pull its defaults. + const quickTask = await prisma.quickTask.findFirst({ + where: { title }, + include: { parts: true }, + }); + + if (quickTask) { + await prisma.quickTask.update({ + where: { id: quickTask.id }, + data: { useCount: { increment: 1 } }, + }); + } + + const hasExplicitLabor = + laborHours !== undefined && String(laborHours).trim() !== ""; + const hasExplicitRate = + hourlyRate !== undefined && String(hourlyRate).trim() !== ""; + const hasExplicitPrice = price !== undefined && String(price).trim() !== ""; + const taskLaborHours = + quickTask && !hasExplicitLabor + ? quickTask.laborHours + : Number(laborHours) || 0; + const taskHourlyRate = + quickTask && !hasExplicitRate + ? quickTask.hourlyRate + : Number(hourlyRate) || 50; + const taskPrice = + quickTask && !hasExplicitPrice + ? quickTask.price + : hasExplicitPrice + ? Number(price) || 0 + : null; + const effectiveTemplateParts = + templateParts && templateParts.length > 0 + ? templateParts + : quickTask?.parts || []; + + const task = await prisma.task.create({ + data: { + orderId: Number(orderId), + title, + notes: notes || "", + laborHours: Number(taskLaborHours) || 0, + hourlyRate: Number(taskHourlyRate) || 50, + price: taskPrice ?? undefined, + orderIndex: (last?.orderIndex ?? -1) + 1, + }, + include: { parts: { include: { part: true } } }, + }); + + // Attach selected existing parts — only link to the same car or global parts. + for (const item of partIds) { + const { partId, qty = 1, price: partPrice } = item; + const part = await prisma.part.findFirst({ + where: { + id: Number(partId), + OR: [{ carId: order.carId }, { isGlobal: true }], + }, + }); + if (part) { + await prisma.taskPart.create({ + data: { + taskId: task.id, + partId: part.id, + qty: Number(qty) || 1, + price: partPrice != null ? Number(partPrice) : part.purchasePrice, + }, + }); + } + } + + // Convert template parts into private car parts (or link globals) and attach them. + for (const tp of effectiveTemplateParts) { + const isGlobal = tp.isGlobal || false; + let partId: number | undefined; + if (isGlobal) { + // Try to find an existing global part with the same name. + const existing = await prisma.part.findFirst({ + where: { name: tp.name, isGlobal: true }, + }); + if (existing) { + partId = existing.id; + } else { + const created = await prisma.part.create({ + data: { + name: tp.name, + purchasePrice: Number(tp.price) || 0, + notes: "", + isGlobal: true, + }, + }); + partId = created.id; + } + } else { + const existing = await prisma.part.findFirst({ + where: { name: tp.name, carId: order.carId, isGlobal: false }, + }); + if (existing) { + partId = existing.id; + } else { + const created = await prisma.part.create({ + data: { + name: tp.name, + purchasePrice: Number(tp.price) || 0, + notes: "", + carId: order.carId, + isGlobal: false, + }, + }); + partId = created.id; + } + } + if (partId) { + await prisma.taskPart.create({ + data: { + taskId: task.id, + partId, + qty: Number(tp.qty) || 1, + price: Number(tp.price) || 0, + }, + }); + } + } + + await recalcOrderTotal(Number(orderId)); + if (quickTask) { + await prisma.quickTask.update({ + where: { id: quickTask.id }, + data: { useCount: { increment: 1 } }, + }); + } else { + await prisma.quickTask.upsert({ + where: { title }, + update: { useCount: { increment: 1 } }, + create: { title, useCount: 1 }, + }); + } + + const full = await prisma.task.findUnique({ + where: { id: task.id }, + include: { parts: { include: { part: true } } }, + }); + + return jsonResponse(full, 201); +} diff --git a/src/app/cars/[id]/history/page.tsx b/src/app/cars/[id]/history/page.tsx new file mode 100644 index 0000000..0681632 --- /dev/null +++ b/src/app/cars/[id]/history/page.tsx @@ -0,0 +1,269 @@ +"use client"; + +import { useParams } from "next/navigation"; +import Link from "next/link"; +import { useState, useMemo } from "react"; +import useSWR from "swr"; +import { + Car, + Wrench, + FileText, + Clock, + ChevronDown, + ChevronRight, + Printer, + PackageOpen, +} from "lucide-react"; +import { HistoryData } from "@/types"; +import { formatDate } from "@/lib/format"; +import BackLink from "@/components/BackLink"; +import Loading from "@/components/Loading"; +import Navbar from "@/components/Navbar"; + +const fetcher = (url: string) => fetch(url).then((r) => r.json()); + +export default function CarHistoryPage() { + const { id } = useParams(); + const { data: history } = useSWR(`/api/cars/${id}/history`, fetcher); + const [bundle, setBundle] = useState(true); + const [expanded, setExpanded] = useState>({}); + + const expandAll = () => { + if (!grouped) return; + const all: Record = {}; + for (const g of grouped) all[g.title] = true; + setExpanded(all); + }; + + const collapseAll = () => { + if (!grouped) return; + const all: Record = {}; + for (const g of grouped) all[g.title] = false; + setExpanded(all); + }; + + const grouped = useMemo(() => { + if (!history || !bundle) return null; + const map = new Map(); + for (const task of history.tasks) { + const key = task.title.trim(); + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(task); + } + return Array.from(map.entries()) + .map(([title, tasks]) => ({ title, tasks, count: tasks.length })) + .sort((a, b) => a.title.localeCompare(b.title)); + }, [history?.tasks, bundle]); + + const toggleGroup = (title: string) => + setExpanded((e) => ({ ...e, [title]: !e[title] })); + + if (!history) return ; + + return ( +
+
+
+

+ {history.car.regNumber} — Service history +

+
+
+ +
+
+ +
+ +
+

+ Car details +

+
+ + + + + + +
+
+ +
+
+

+ Service tasks +

+
+ {bundle && grouped && ( + <> + + + + )} + + + Print + +
+
+ +

+ Service tasks +

+ + {history.tasks.length === 0 ? ( +

No service history yet.

+ ) : bundle && grouped ? ( +
+ {grouped.map((group) => { + const isOpen = expanded[group.title] ?? false; + return ( +
+ + +
+
    + {group.tasks.map((task) => ( + + ))} +
+
+ +
+

+ {group.title}{" "} + ({group.count}) +

+
    + {group.tasks.map((task) => ( +
  • + {formatDate(task.finishedAt || task.createdAt)} — Order #{task.orderId} + {task.orderMileage != null ? ` — ${task.orderMileage.toLocaleString()} mi` : ""} + {task.notes ? ` — ${task.notes}` : ""} +
  • + ))} +
+
+
+ ); + })} +
+ ) : ( +
    + {history.tasks.map((task) => ( + + ))} +
+ )} +
+
+
+ ); +} + +function getLatestDate(tasks: HistoryData["tasks"]) { + return tasks.reduce((latest, t) => { + const d = new Date(t.finishedAt || t.createdAt); + return d > latest ? d : latest; + }, new Date(0)); +} + +function getLatestMileage(tasks: HistoryData["tasks"]) { + for (const t of [...tasks].sort( + (a, b) => + new Date(b.finishedAt || b.createdAt).getTime() - + new Date(a.finishedAt || a.createdAt).getTime(), + )) { + if (t.orderMileage != null) return t.orderMileage; + } + return null; +} + +function Info({ label, value }: { label: string; value?: string | null }) { + return ( +
+
{label}
+
{value || "—"}
+
+ ); +} + +function TaskItem({ + task, + showLink, +}: { + task: HistoryData["tasks"][number]; + showLink?: boolean; +}) { + const date = task.finishedAt || task.createdAt; + return ( +
  • +
    +
    {task.title}
    + {task.notes &&
    {task.notes}
    } +
    + + + {formatDate(date)} — Order #{task.orderId} + {task.orderMileage != null && ` — ${task.orderMileage.toLocaleString()} mi`} + + {showLink && ( + + Open + + )} +
    +
    +
  • + ); +} diff --git a/src/app/cars/[id]/history/print/page.tsx b/src/app/cars/[id]/history/print/page.tsx new file mode 100644 index 0000000..a47db4c --- /dev/null +++ b/src/app/cars/[id]/history/print/page.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useParams } from "next/navigation"; +import Link from "next/link"; +import { useMemo } from "react"; +import useSWR from "swr"; +import { Car, Wrench, FileText, Clock, Printer, ArrowLeft } from "lucide-react"; +import { HistoryData } from "@/types"; +import { formatDate } from "@/lib/format"; +import Loading from "@/components/Loading"; + +const fetcher = (url: string) => fetch(url).then((r) => r.json()); + +export default function CarHistoryPrintPage() { + const { id } = useParams(); + const { data: history } = useSWR(`/api/cars/${id}/history`, fetcher); + + const grouped = useMemo(() => { + if (!history) return []; + const map = new Map(); + for (const task of history.tasks) { + const key = task.title.trim(); + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(task); + } + return Array.from(map.entries()) + .map(([title, tasks]) => ({ title, tasks, count: tasks.length })) + .sort((a, b) => a.title.localeCompare(b.title)); + }, [history]); + + if (!history) return ; + + return ( +
    +
    +
    + + Back to history + + +
    + +
    +
    + +

    {history.car.regNumber} — Service history

    +
    +
    + + + + + + + + +
    +
    + +
    +

    + Service tasks +

    + + {history.tasks.length === 0 ? ( +

    No service history yet.

    + ) : ( +
    + {grouped.map((group) => ( +
    +

    + {group.title}{" "} + ({group.count}) +

    +
      + {group.tasks.map((task) => ( +
    • + + + {formatDate(task.finishedAt || task.createdAt)} + + Order #{task.orderId} + {task.orderMileage != null && — {task.orderMileage.toLocaleString()} mi} + {task.notes && — {task.notes}} +
    • + ))} +
    +
    + ))} +
    + )} +
    + +
    + Printed from Andris Car Service on {new Date().toLocaleDateString("en-IE")}. +
    +
    +
    + ); +} + +function Info({ label, value }: { label: string; value?: string | null }) { + return ( +
    +
    {label}
    +
    {value || "—"}
    +
    + ); +} diff --git a/src/app/cars/[id]/page.tsx b/src/app/cars/[id]/page.tsx new file mode 100644 index 0000000..43d1671 --- /dev/null +++ b/src/app/cars/[id]/page.tsx @@ -0,0 +1,474 @@ +"use client"; + +import { useParams, useRouter } from "next/navigation"; +import Link from "next/link"; +import { useState } from "react"; +import useSWR from "swr"; +import { + Plus, + Wrench, + Car, + Loader2, + Pencil, + Trash2, + Save, + X, + MoreHorizontal, + FileText, +} from "lucide-react"; +import { CarWithRelations } from "@/types"; +import { formatDate } from "@/lib/format"; +import BackLink from "@/components/BackLink"; +import Loading from "@/components/Loading"; +import Navbar from "@/components/Navbar"; + +const fetcher = (url: string) => fetch(url).then((r) => r.json()); + +export default function CarDetailPage() { + const { id } = useParams(); + const router = useRouter(); + const { data: car, mutate } = useSWR( + `/api/cars/${id}`, + fetcher, + ); + const [creating, setCreating] = useState(false); + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + const [form, setForm] = useState>({}); + + function startEdit() { + setForm({ + regNumber: car?.regNumber, + make: car?.make, + model: car?.model, + color: car?.color, + year: car?.year, + ownerName: car?.ownerName, + ownerPhone: car?.ownerPhone, + ownerEmail: car?.ownerEmail, + vin: car?.vin, + engineNumber: car?.engineNumber, + mileage: car?.mileage, + notes: car?.notes, + }); + setEditing(true); + } + + async function saveCar(e: React.FormEvent) { + e.preventDefault(); + if (!car || saving) return; + setSaving(true); + const res = await fetch(`/api/cars/${car.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(form), + }); + const json = await res.json(); + setSaving(false); + if (res.ok && json.id) { + mutate(json, false); + setEditing(false); + setForm({}); + } + } + + async function deleteCar() { + if (!car || deleting) return; + const ok = window.confirm( + "Delete this car and all its orders? This cannot be undone.", + ); + if (!ok) return; + setDeleting(true); + await fetch(`/api/cars/${car.id}`, { method: "DELETE" }); + setDeleting(false); + router.push("/"); + } + + async function createOrderForCar() { + if (!car || creating) return; + setCreating(true); + const res = await fetch("/api/orders", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ carId: car.id }), + }); + const json = await res.json(); + setCreating(false); + if (res.ok && json.id) { + router.push(`/orders/${json.id}`); + } else { + alert(json.error || "Failed to create order"); + } + } + + if (!car) return ; + if (!("id" in car)) { + return ( +
    + +
    + +

    This car does not exist or has been removed.

    +
    +
    + ); + } + + return ( +
    + + +
    + + +
    +
    +
    +
    + +
    +
    +
    + {car.regNumber} +
    +

    + {car.make || "—"} {car.model || ""}{" "} + {car.year && `(${car.year})`} +

    +
    +
    + +
    + {editing ? ( + <> + + + + ) : ( + <> + + + + )} +
    +
    + +
    +
    + + History + + +
    +
    + + {editing ? ( +
    + setForm((f) => ({ ...f, regNumber: v }))} + /> + setForm((f) => ({ ...f, make: v }))} + /> + setForm((f) => ({ ...f, model: v }))} + /> + setForm((f) => ({ ...f, color: v }))} + /> + + setForm((f) => ({ ...f, year: v ? Number(v) : null })) + } + inputMode="numeric" + /> + setForm((f) => ({ ...f, ownerName: v }))} + /> + setForm((f) => ({ ...f, ownerPhone: v }))} + /> + setForm((f) => ({ ...f, ownerEmail: v }))} + /> + setForm((f) => ({ ...f, vin: v }))} + /> + setForm((f) => ({ ...f, engineNumber: v }))} + /> + + setForm((f) => ({ ...f, mileage: v ? Number(v) : null })) + } + inputMode="numeric" + /> +
    + +