This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
node_modules
|
||||
.git
|
||||
.env
|
||||
npm-debug.log
|
||||
Dockerfile
|
||||
Dockerfile.frontend
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
dist
|
||||
build
|
||||
.next
|
||||
.nuxt
|
||||
.cache
|
||||
@@ -0,0 +1,6 @@
|
||||
DATABASE_URL="file:./dev.db"
|
||||
# Optional API key for DVLA vehicle lookup (UK plates).
|
||||
DVLA_API_KEY=
|
||||
# Optional API key for MotorCheck/Cartell-style Irish vehicle lookup (ROI plates).
|
||||
# When omitted, Irish registration lookups return the year parsed from the reg format.
|
||||
MOTORCHECK_API_KEY=
|
||||
@@ -0,0 +1,2 @@
|
||||
DATABASE_URL="file:./prisma/dev.db"
|
||||
DVLA_API_KEY=
|
||||
@@ -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"
|
||||
+10
@@ -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","dev"]
|
||||
@@ -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;"]
|
||||
@@ -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.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Project notes
|
||||
|
||||
- dashboard: [[src/app/page.tsx]] lists open orders from GET /api/orders and finished orders from GET /api/orders?status=finished; order cards navigate to order detail; close order via PATCH /api/orders/:id; uses [[src/components/Navbar.tsx]] for sticky header/settings menu.
|
||||
- car edit/delete: cars list rows in [[src/app/cars/page.tsx]] are clickable cards; [[src/app/cars/[id]/page.tsx]] offers Edit/Save and Delete, posting PATCH/DELETE to [[src/app/api/cars/[id]/route.ts]].
|
||||
- car create/lookup: [[src/app/orders/new/page.tsx]] → POST /api/cars with `lookup: true` → [[src/lib/vehicle-lookup.ts]] (DVLA for UK plates; ROI parser / MotorCheck for Irish plates); reg input uses a ref so programmatic fills work.
|
||||
- order detail + tasks: [[src/app/orders/[id]/page.tsx]] uses SWR to GET /api/orders/:id; task toggle/edit/delete via /api/tasks/:id; pricing recalculated server-side in PATCH; parts modal posts to /api/parts; close order via PATCH.
|
||||
- pricing: task total = selected saved parts (qty × price) + laborHours × hourlyRate, manual `price` overrides; order total stored in [[prisma/schema.prisma]] Order.total and recalculated in [[src/app/api/tasks/[id]/route.ts]].
|
||||
- parts library: [[src/app/parts/page.tsx]] → /api/parts and /api/scrape-part using [[src/lib/scrape.ts]] (Cheerio on fetched page); add-part modal supports scraping a URL first. Parts page now has two tabs: Global (default) and Car Specific. Car-specific tab includes a car reg search field and shows only parts linked to that car.
|
||||
- parts API: [[src/app/api/parts/route.ts]] GET supports `includeGlobal` query param; when `carId` is passed with `includeGlobal=true`, it returns both car-specific and global parts; with `includeGlobal=false`, it returns only car-specific parts. Default without `carId` shows only global parts.
|
||||
- parts in task edit: [[src/app/orders/[id]/page.tsx]] when editing a task, the available parts list defaults to car-specific parts only (via `includeGlobal=false`). A "Show global parts" checkbox toggles `showGlobalParts` state to also include global parts in the list.
|
||||
- quick tasks: seeded in [[prisma/seed.js]], managed in [[src/app/quick-tasks/page.tsx]], used when adding tasks in order detail; useCount incremented on add/finish.
|
||||
- print: [[src/app/orders/[id]/print/page.tsx]] fetches GET /api/print/:id with type=service|invoice, day/range/prices params. Service report shows labour per task column, no prices by default. Invoice shows tasks list with Parts/Labour totals separated at bottom (no per-task labour column). Print stylesheet hides `.no-print`.
|
||||
- car history: [[src/app/api/cars/[id]/history/route.ts]] returns finished tasks flat (no prices); [[src/app/cars/[id]/history/page.tsx]] bundles same-title tasks; [[src/app/cars/[id]/history/print/page.tsx]] is the printable bundled view.
|
||||
- navbar: [[src/components/Navbar.tsx]] sticky header on all pages, includes settings menu panel with site name field and quick-tabs toggles; links Dashboard/Cars/Parts/Quick tasks and New order button.
|
||||
- database: Prisma `DATABASE_URL` in [[.env]] is `file:./dev.db`, resolved relative to [[prisma/schema.prisma]] → `prisma/dev.db`.
|
||||
- test ids: data-testid attributes added to dashboard search/clear, order cards, reg input/lookup, car details fields, car edit/save/delete, car cards/search, task add/edit/toggle/delete, parts modal/scrape/fields, print and close-order buttons.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Regression test plan
|
||||
|
||||
- Dashboard search + clear: type a reg/owner in the dashboard search box → matching open orders shown with no console errors; click the × clear button → search resets and all open orders shown.
|
||||
- Dashboard order card layout: open order cards split header link from status area; `order-card-progress-{id}` contains "No tasks added yet." or "X/Y done"; `order-card-status-{id}` contains "No tasks"/"All tasks done"/"N open"; Close button is full-width below.
|
||||
- Quick task with parts and pricing: create a quick task with default labor hours/rate and a template part (e.g., oil filter); add it to an order → task appears with the part and pricing populated; order total updates.
|
||||
- Private task parts stay private: create a quick task with a non-global part; the part does not appear in the global Parts library unless explicitly marked global.
|
||||
- Order task edit with multiple template parts: add a quick task that requires oil (litres) and an oil filter; both parts appear under the task and count toward the total.
|
||||
- Car detail shows performed tasks: each recent order card on /cars/[id] lists the task titles with status indicators (finished or open).
|
||||
- Parts library table: /parts page renders a `<table data-testid="parts-table">` with `<tr data-testid="part-row-{id}">` rows, name, manufacturer, price, supplier link and delete action for each saved part.
|
||||
- New order with lookup: enter a reg on /orders/new, click Look up → car created/loaded; create order → redirected to order detail with car/owner data.
|
||||
- New order from car page: on /cars/[id] click New order → order created for that car and redirect to order detail (no reg lookup form).
|
||||
- ROI reg lookup: enter an Irish reg on /orders/new, click Look up → year parsed from the reg and pre-filled.
|
||||
- Add quick task: in order detail click Add task, pick from quick list or type custom → Add button enabled and task appears after save.
|
||||
- Finish task: click the circle/check icon on a to-do task → task turns green and shows finished date/time.
|
||||
- Attach parts and price: edit a task, select saved parts, set hours/rate → price updates; order total updates.
|
||||
- Print by day/range: open order print, choose day or range and include/exclude prices → printed document lists car, owner, completed tasks and totals.
|
||||
- Car history bundles + print: on /cars/[id]/history, tasks are grouped by title with a count; click a bundle to expand/collapse; prices are not shown; click Print → opens clean /cars/[id]/history/print page where tasks are still bundled and no prices appear.
|
||||
- Car search: on /cars, search by reg or owner → matching cars and their service history shown.
|
||||
- Car edit/delete: on /cars/[id] click Edit, change a field, Save → updated details shown; click Delete, confirm → car removed and redirected to Dashboard (/).
|
||||
- Cars list click: click a car row on /cars → navigated to that car detail page.
|
||||
- Parts scrape: on /parts, enter supplier URL, click Scrape → name/manufacturer populated if page parseable; save persists part.
|
||||
# Regression test plan
|
||||
|
||||
- Dashboard search + clear: type a reg/owner in the dashboard search box (data-testid=dashboard-search) → matching open orders shown (data-testid=order-card-{id}) with no 404 console errors; click the × clear button (data-testid=dashboard-search-clear) → search resets and all open orders shown.
|
||||
- New order with lookup: enter a reg on /orders/new (#reg-input), click Look up (data-testid=lookup-button) → car details form appears (data-testid=car-details-form); click Create order (data-testid=create-order-button) → redirected to order detail with car/owner data.
|
||||
- New order from car page: on /cars/[id] click New order (data-testid=car-new-order-button) → creates order and redirects to order detail.
|
||||
- ROI reg lookup: on /orders/new, enter an Irish reg (e.g. 191D12345) and click Look up → year notice (data-testid=year-prefill-notice) appears and year input is filled.
|
||||
- Add quick task: on /orders/[id] click Add task (data-testid=show-add-task), type or pick a quick task, click Add task (data-testid=add-task-button) → task appears in list.
|
||||
- Finish task: on /orders/[id] click the toggle circle (data-testid=task-toggle-{id}) → task marked finished, order total updates, all done highlights Close order green (data-testid=close-order-button).
|
||||
- Attach parts and price: on /orders/[id] click edit (data-testid=task-edit-{id}), set labour hours/rate (data-testid=task-labor-hours / task-hourly-rate), pick parts and qty, click Save (data-testid=task-edit-save) → price recalculated and saved.
|
||||
- Print by day/range: on /orders/[id] click Print (data-testid=order-print-button), choose day or range, confirm → print page opens.
|
||||
- Car history bundles + print: on /cars/[id] click History (data-testid=car-history-link), view bundled tasks; click Print → printable history page opens.
|
||||
- Car search on /cars: type in the search box (data-testid=car-search) → matching car cards shown (data-testid=car-card-{id}).
|
||||
- Car edit/delete: on /cars/[id] click Edit (data-testid=car-edit-button), change a field (data-testid=car-*-input), click Save (data-testid=car-edit-save) → saved; click Delete (data-testid=car-delete-button) → car removed.
|
||||
- Cars list click navigation: on /cars click a car card (data-testid=car-card-{id}) → navigates to /cars/{id}.
|
||||
- Parts scrape: on /parts click Add part (data-testid=parts-add-button), paste a URL (data-testid=scrape-url-input), click Scrape (data-testid=scrape-button) → name/manufacturer/price populated, click Add (data-testid=part-modal-save) → saved to library.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Node
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Next.js
|
||||
.next
|
||||
coverage
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Deployment
|
||||
/deployment
|
||||
|
||||
# Prisma (DB file itself should not be baked into image; mounted via volume)
|
||||
prisma/*.db
|
||||
prisma/*.db-journal
|
||||
|
||||
# Scripts (not needed in image)
|
||||
scripts/
|
||||
@@ -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=
|
||||
@@ -0,0 +1,54 @@
|
||||
# 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
|
||||
|
||||
# Copy entrypoint and make it executable
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/deployment/entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
@@ -0,0 +1,59 @@
|
||||
# Deployment Guide — Staging
|
||||
|
||||
## Quick start (on the staging server)
|
||||
|
||||
1. SSH to the staging server and clone the repo (or copy the project folder).
|
||||
2. Go into the `deployment/` directory.
|
||||
3. Copy the staging environment file:
|
||||
```bash
|
||||
cp .env.staging .env
|
||||
# edit .env to fill in any API keys you need
|
||||
```
|
||||
4. Run the deploy script:
|
||||
```bash
|
||||
./deploy.sh
|
||||
```
|
||||
5. The app is available on:
|
||||
- **Nginx (HTTP):** http://localhost
|
||||
- **Next.js (direct):** http://localhost:3000
|
||||
|
||||
## What the script does
|
||||
|
||||
- Builds the Docker image from the project root using the `Dockerfile`.
|
||||
- Starts two containers:
|
||||
- `andris-app` — the Next.js app (port 3000, exposed only on localhost).
|
||||
- `andris-nginx` — reverse proxy on ports 80 / 443.
|
||||
- SQLite database is stored in a Docker volume (`app-db`) so data survives restarts.
|
||||
- On first boot the entrypoint auto-initialises the database (`prisma db push`) and seeds the admin user.
|
||||
|
||||
## Updating the app
|
||||
|
||||
```bash
|
||||
cd deployment
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
This rebuilds the image with your latest source code and recreates the containers.
|
||||
|
||||
## Files overview
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Dockerfile` | Multi-stage build (Next.js standalone) |
|
||||
| `docker-compose.yml` | Production services: app + nginx |
|
||||
| `docker-compose.override.yml` | Optional override for local dev |
|
||||
| `nginx/nginx.conf` | Nginx main config |
|
||||
| `nginx/default.conf` | Nginx site / reverse proxy config |
|
||||
| `.env.staging` | Staging environment template |
|
||||
| `deploy.sh` | One-command build & deploy script |
|
||||
| `entrypoint.sh` | Container entrypoint (DB init + app start) |
|
||||
|
||||
## Health check
|
||||
|
||||
A `GET /api/health` endpoint returns `{"status":"ok"}`.
|
||||
The Docker `healthcheck` uses it every 30 s.
|
||||
|
||||
## Notes
|
||||
|
||||
- The `.dockerignore` at the project root prevents the dev database, `node_modules`, and `.env` from being baked into the image.
|
||||
- For a real domain, update `server_name` in `nginx/default.conf` and mount your TLS certificates.
|
||||
Executable
+29
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,35 @@
|
||||
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: let Nginx serve directly if mounted,
|
||||
# otherwise proxied through Next.js is fine.
|
||||
location /_next/static {
|
||||
proxy_pass http://app:3000;
|
||||
proxy_cache_valid 200 365d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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:
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
swcMinify: true,
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
+14
@@ -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";
|
||||
}
|
||||
}
|
||||
Generated
+2094
File diff suppressed because it is too large
Load Diff
@@ -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": "next build",
|
||||
"start": "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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
Binary file not shown.
@@ -0,0 +1,129 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
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?
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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); });
|
||||
Vendored
BIN
Binary file not shown.
@@ -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");
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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<string, unknown> = {};
|
||||
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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ status: "ok" }, { status: 200 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<string, unknown> = {};
|
||||
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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<HistoryData>(`/api/cars/${id}/history`, fetcher);
|
||||
const [bundle, setBundle] = useState(true);
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
|
||||
const expandAll = () => {
|
||||
if (!grouped) return;
|
||||
const all: Record<string, boolean> = {};
|
||||
for (const g of grouped) all[g.title] = true;
|
||||
setExpanded(all);
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
if (!grouped) return;
|
||||
const all: Record<string, boolean> = {};
|
||||
for (const g of grouped) all[g.title] = false;
|
||||
setExpanded(all);
|
||||
};
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
if (!history || !bundle) return null;
|
||||
const map = new Map<string, HistoryData["tasks"]>();
|
||||
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 <Loading />;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-12">
|
||||
<header className="bg-brand-700 text-white shadow no-print">
|
||||
<div className="max-w-5xl mx-auto px-4 py-4">
|
||||
<h1 className="text-xl font-bold flex items-center gap-2">
|
||||
<Car className="w-6 h-6" /> {history.car.regNumber} — Service history
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-4 py-6">
|
||||
<div className="no-print">
|
||||
<BackLink href={`/cars/${history.car.id}`} label="Car" />
|
||||
</div>
|
||||
|
||||
<section className="bg-white rounded-lg shadow p-5 mb-6">
|
||||
<h2 className="text-lg font-bold mb-3 flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" /> Car details
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 md:grid-cols-3 gap-4 text-sm">
|
||||
<Info label="Make" value={history.car.make} />
|
||||
<Info label="Model" value={history.car.model} />
|
||||
<Info label="Colour" value={history.car.color} />
|
||||
<Info label="Year" value={history.car.year ? String(history.car.year) : undefined} />
|
||||
<Info label="Owner" value={history.car.ownerName} />
|
||||
<Info label="Mileage" value={history.car.mileage != null ? history.car.mileage.toLocaleString() : undefined} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-white rounded-lg shadow p-5">
|
||||
<div className="flex items-center justify-between mb-4 no-print">
|
||||
<h2 className="text-lg font-bold flex items-center gap-2">
|
||||
<Wrench className="w-5 h-5" /> Service tasks
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{bundle && grouped && (
|
||||
<>
|
||||
<button
|
||||
onClick={expandAll}
|
||||
className="px-3 py-1.5 text-sm border rounded-md hover:bg-steel-50 inline-flex items-center gap-1"
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" /> Expand all
|
||||
</button>
|
||||
<button
|
||||
onClick={collapseAll}
|
||||
className="px-3 py-1.5 text-sm border rounded-md hover:bg-steel-50 inline-flex items-center gap-1"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" /> Collapse all
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setBundle((b) => !b)}
|
||||
className={`px-3 py-1.5 text-sm border rounded-md inline-flex items-center gap-1 ${
|
||||
bundle
|
||||
? "bg-brand-100 border-brand-300 text-brand-800"
|
||||
: "hover:bg-steel-50"
|
||||
}`}
|
||||
aria-pressed={bundle}
|
||||
>
|
||||
<PackageOpen className="w-4 h-4" />
|
||||
{bundle ? "Ungroup" : "Bundle same tasks"}
|
||||
</button>
|
||||
<Link
|
||||
href={`/cars/${history.car.id}/history/print`}
|
||||
className="px-3 py-1.5 text-sm border rounded-md hover:bg-steel-50 inline-flex items-center gap-1"
|
||||
>
|
||||
<Printer className="w-4 h-4" /> Print
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg font-bold mb-4 print-only flex items-center gap-2">
|
||||
<Wrench className="w-5 h-5" /> Service tasks
|
||||
</h2>
|
||||
|
||||
{history.tasks.length === 0 ? (
|
||||
<p className="text-steel-500">No service history yet.</p>
|
||||
) : bundle && grouped ? (
|
||||
<div className="space-y-3">
|
||||
{grouped.map((group) => {
|
||||
const isOpen = expanded[group.title] ?? false;
|
||||
return (
|
||||
<div key={group.title} className="border rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleGroup(group.title)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 bg-steel-50 hover:bg-steel-100 text-left no-print"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isOpen ? (
|
||||
<ChevronDown className="w-4 h-4 text-steel-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-steel-500" />
|
||||
)}
|
||||
<span className="font-semibold">{group.title}</span>
|
||||
<span className="text-xs bg-brand-100 text-brand-800 px-2 py-0.5 rounded-full">
|
||||
{group.count}
|
||||
</span>
|
||||
<span className="text-xs text-steel-500 hidden sm:inline">
|
||||
latest {formatDate(getLatestDate(group.tasks))}
|
||||
{getLatestMileage(group.tasks) != null
|
||||
? ` — ${getLatestMileage(group.tasks)!.toLocaleString()} mi`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className={`px-4 py-3 ${isOpen ? "block" : "hidden"} no-print`}>
|
||||
<ul className="space-y-2">
|
||||
{group.tasks.map((task) => (
|
||||
<TaskItem key={task.id} task={task} showLink />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 print-only">
|
||||
<p className="font-semibold mb-2">
|
||||
{group.title}{" "}
|
||||
<span className="text-xs font-normal text-steel-500">({group.count})</span>
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{group.tasks.map((task) => (
|
||||
<li key={task.id} className="text-sm text-steel-700">
|
||||
{formatDate(task.finishedAt || task.createdAt)} — Order #{task.orderId}
|
||||
{task.orderMileage != null ? ` — ${task.orderMileage.toLocaleString()} mi` : ""}
|
||||
{task.notes ? ` — ${task.notes}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{history.tasks.map((task) => (
|
||||
<TaskItem key={task.id} task={task} showLink />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<div className="text-xs text-steel-500 uppercase">{label}</div>
|
||||
<div className="font-medium">{value || "—"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskItem({
|
||||
task,
|
||||
showLink,
|
||||
}: {
|
||||
task: HistoryData["tasks"][number];
|
||||
showLink?: boolean;
|
||||
}) {
|
||||
const date = task.finishedAt || task.createdAt;
|
||||
return (
|
||||
<li className="flex items-start gap-3 text-sm border-l-2 border-brand-200 pl-3">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{task.title}</div>
|
||||
{task.notes && <div className="text-steel-600 mt-0.5 italic">{task.notes}</div>}
|
||||
<div className="text-xs text-steel-500 flex flex-wrap items-center gap-1 mt-0.5">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatDate(date)} — Order #{task.orderId}
|
||||
{task.orderMileage != null && ` — ${task.orderMileage.toLocaleString()} mi`}
|
||||
</span>
|
||||
{showLink && (
|
||||
<Link
|
||||
href={`/orders/${task.orderId}`}
|
||||
data-testid={`open-order-${task.orderId}`}
|
||||
className="text-brand-600 hover:underline no-print"
|
||||
>
|
||||
Open
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -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<HistoryData>(`/api/cars/${id}/history`, fetcher);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
if (!history) return [];
|
||||
const map = new Map<string, HistoryData["tasks"]>();
|
||||
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 <Loading />;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="max-w-5xl mx-auto px-8 py-8">
|
||||
<div className="flex items-center justify-between mb-8 no-print">
|
||||
<Link
|
||||
href={`/cars/${history.car.id}/history`}
|
||||
className="inline-flex items-center gap-1 text-sm text-steel-600 hover:text-brand-600"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Back to history
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-md text-sm inline-flex items-center gap-2 hover:bg-brand-700"
|
||||
>
|
||||
<Printer className="w-4 h-4" /> Print
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<header className="border-b pb-6 mb-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Car className="w-8 h-8 text-brand-600" />
|
||||
<h1 className="text-2xl font-bold">{history.car.regNumber} — Service history</h1>
|
||||
</div>
|
||||
<div className="grid sm:grid-cols-2 md:grid-cols-4 gap-4 text-sm text-steel-700">
|
||||
<Info label="Make" value={history.car.make} />
|
||||
<Info label="Model" value={history.car.model} />
|
||||
<Info label="Colour" value={history.car.color} />
|
||||
<Info label="Year" value={history.car.year ? String(history.car.year) : undefined} />
|
||||
<Info label="Owner" value={history.car.ownerName} />
|
||||
<Info label="Phone" value={history.car.ownerPhone} />
|
||||
<Info label="Email" value={history.car.ownerEmail} />
|
||||
<Info label="Mileage" value={history.car.mileage != null ? history.car.mileage.toLocaleString() : undefined} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg font-bold mb-4 flex items-center gap-2">
|
||||
<Wrench className="w-5 h-5 text-steel-600" /> Service tasks
|
||||
</h2>
|
||||
|
||||
{history.tasks.length === 0 ? (
|
||||
<p className="text-steel-500">No service history yet.</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{grouped.map((group) => (
|
||||
<div key={group.title}>
|
||||
<h3 className="font-bold text-steel-900 mb-2 border-b pb-1">
|
||||
{group.title}{" "}
|
||||
<span className="text-sm font-normal text-steel-500">({group.count})</span>
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{group.tasks.map((task) => (
|
||||
<li key={task.id} className="text-sm text-steel-700 flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 text-steel-500">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatDate(task.finishedAt || task.createdAt)}
|
||||
</span>
|
||||
<span>Order #{task.orderId}</span>
|
||||
{task.orderMileage != null && <span>— {task.orderMileage.toLocaleString()} mi</span>}
|
||||
{task.notes && <span>— {task.notes}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<footer className="mt-12 pt-6 border-t text-xs text-steel-400 print-only">
|
||||
Printed from Andris Car Service on {new Date().toLocaleDateString("en-IE")}.
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Info({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-steel-500 uppercase">{label}</div>
|
||||
<div className="font-medium">{value || "—"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<CarWithRelations>(
|
||||
`/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<Partial<CarWithRelations>>({});
|
||||
|
||||
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 <Loading />;
|
||||
if (!("id" in car)) {
|
||||
return (
|
||||
<div className="min-h-screen pb-12">
|
||||
<Navbar title="Car" subtitle="Not found" />
|
||||
<main className="max-w-5xl mx-auto px-4 py-6">
|
||||
<BackLink href="/cars" label="Cars" />
|
||||
<p className="text-steel-600">This car does not exist or has been removed.</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-12">
|
||||
<Navbar title={car.regNumber} subtitle="Car details" />
|
||||
|
||||
<main className="max-w-5xl mx-auto px-4 py-6">
|
||||
<BackLink href="/cars" label="Cars" />
|
||||
|
||||
<section className="bg-white rounded-lg shadow p-5 mb-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-brand-100 text-brand-700 p-3 rounded-xl">
|
||||
<Car className="w-7 h-7" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-steel-800">
|
||||
{car.regNumber}
|
||||
</div>
|
||||
<p className="text-sm text-steel-500">
|
||||
{car.make || "—"} {car.model || ""}{" "}
|
||||
{car.year && `(${car.year})`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-testid="car-header-actions"
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
{editing ? (
|
||||
<>
|
||||
<button
|
||||
data-testid="car-edit-cancel"
|
||||
onClick={() => setEditing(false)}
|
||||
className="touch-target px-4 border rounded-xl hover:bg-steel-50 flex items-center gap-2 text-base"
|
||||
>
|
||||
<X className="w-5 h-5" /> Cancel
|
||||
</button>
|
||||
<button
|
||||
data-testid="car-edit-save"
|
||||
onClick={saveCar}
|
||||
disabled={saving}
|
||||
className="touch-target px-4 bg-brand-600 text-white rounded-xl hover:bg-brand-700 disabled:opacity-50 flex items-center gap-2 text-base"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-5 h-5" />
|
||||
)}
|
||||
Save
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
data-testid="car-edit-button"
|
||||
onClick={startEdit}
|
||||
className="touch-target px-4 border rounded-xl hover:bg-steel-50 flex items-center gap-2 text-base"
|
||||
>
|
||||
<Pencil className="w-5 h-5" /> Edit
|
||||
</button>
|
||||
<button
|
||||
data-testid="car-delete-button"
|
||||
onClick={deleteCar}
|
||||
disabled={deleting}
|
||||
className="touch-target px-4 border border-red-300 text-red-700 rounded-xl hover:bg-red-50 disabled:opacity-50 flex items-center gap-2 text-base"
|
||||
>
|
||||
{deleting ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-5 h-5" />
|
||||
)}
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-steel-100 pt-4 mb-5">
|
||||
<div
|
||||
data-testid="car-quick-links"
|
||||
className="flex flex-wrap gap-2"
|
||||
>
|
||||
<Link
|
||||
href={`/cars/${car.id}/history`}
|
||||
data-testid="car-history-link"
|
||||
className="touch-target px-4 border rounded-xl hover:bg-steel-50 inline-flex items-center gap-2 text-base bg-steel-50"
|
||||
>
|
||||
<Wrench className="w-5 h-5" /> History
|
||||
</Link>
|
||||
<button
|
||||
data-testid="car-new-order-button"
|
||||
onClick={createOrderForCar}
|
||||
disabled={creating}
|
||||
className="touch-target bg-brand-600 text-white px-4 rounded-xl text-base flex items-center gap-2 hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{creating ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Plus className="w-5 h-5" />
|
||||
)}
|
||||
New order
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<form
|
||||
onSubmit={saveCar}
|
||||
className="grid sm:grid-cols-2 md:grid-cols-3 gap-5 text-base"
|
||||
>
|
||||
<Field
|
||||
testId="car-reg-input"
|
||||
label="Reg"
|
||||
value={form.regNumber ?? ""}
|
||||
onChange={(v) => setForm((f) => ({ ...f, regNumber: v }))}
|
||||
/>
|
||||
<Field
|
||||
testId="car-make-input"
|
||||
label="Make"
|
||||
value={form.make ?? ""}
|
||||
onChange={(v) => setForm((f) => ({ ...f, make: v }))}
|
||||
/>
|
||||
<Field
|
||||
testId="car-model-input"
|
||||
label="Model"
|
||||
value={form.model ?? ""}
|
||||
onChange={(v) => setForm((f) => ({ ...f, model: v }))}
|
||||
/>
|
||||
<Field
|
||||
testId="car-color-input"
|
||||
label="Colour"
|
||||
value={form.color ?? ""}
|
||||
onChange={(v) => setForm((f) => ({ ...f, color: v }))}
|
||||
/>
|
||||
<Field
|
||||
testId="car-year-input"
|
||||
label="Year"
|
||||
value={form.year ? String(form.year) : ""}
|
||||
onChange={(v) =>
|
||||
setForm((f) => ({ ...f, year: v ? Number(v) : null }))
|
||||
}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
<Field
|
||||
testId="car-owner-input"
|
||||
label="Owner"
|
||||
value={form.ownerName ?? ""}
|
||||
onChange={(v) => setForm((f) => ({ ...f, ownerName: v }))}
|
||||
/>
|
||||
<Field
|
||||
testId="car-phone-input"
|
||||
label="Phone"
|
||||
value={form.ownerPhone ?? ""}
|
||||
onChange={(v) => setForm((f) => ({ ...f, ownerPhone: v }))}
|
||||
/>
|
||||
<Field
|
||||
testId="car-email-input"
|
||||
label="Email"
|
||||
value={form.ownerEmail ?? ""}
|
||||
onChange={(v) => setForm((f) => ({ ...f, ownerEmail: v }))}
|
||||
/>
|
||||
<Field
|
||||
testId="car-vin-input"
|
||||
label="VIN"
|
||||
value={form.vin ?? ""}
|
||||
onChange={(v) => setForm((f) => ({ ...f, vin: v }))}
|
||||
/>
|
||||
<Field
|
||||
testId="car-engine-input"
|
||||
label="Engine no."
|
||||
value={form.engineNumber ?? ""}
|
||||
onChange={(v) => setForm((f) => ({ ...f, engineNumber: v }))}
|
||||
/>
|
||||
<Field
|
||||
testId="car-mileage-input"
|
||||
label="Mileage"
|
||||
value={form.mileage != null ? String(form.mileage) : ""}
|
||||
onChange={(v) =>
|
||||
setForm((f) => ({ ...f, mileage: v ? Number(v) : null }))
|
||||
}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
<div className="sm:col-span-2 md:col-span-3">
|
||||
<label
|
||||
htmlFor="car-notes-input"
|
||||
className="text-sm text-steel-500 uppercase"
|
||||
>
|
||||
Notes
|
||||
</label>
|
||||
<textarea
|
||||
id="car-notes-input"
|
||||
data-testid="car-notes-input"
|
||||
value={form.notes ?? ""}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, notes: e.target.value }))
|
||||
}
|
||||
className="w-full mt-1 border rounded-lg px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid sm:grid-cols-2 md:grid-cols-3 gap-5 text-base">
|
||||
<Info label="Make" value={car.make} />
|
||||
<Info label="Model" value={car.model} />
|
||||
<Info label="Colour" value={car.color} />
|
||||
<Info
|
||||
label="Year"
|
||||
value={car.year ? String(car.year) : undefined}
|
||||
/>
|
||||
<Info label="Owner" value={car.ownerName} />
|
||||
<Info label="Phone" value={car.ownerPhone} />
|
||||
<Info label="Email" value={car.ownerEmail} />
|
||||
<Info label="VIN" value={car.vin} />
|
||||
<Info label="Engine no." value={car.engineNumber} />
|
||||
<Info
|
||||
label="Mileage"
|
||||
value={car.mileage != null ? String(car.mileage) : undefined}
|
||||
/>
|
||||
</div>
|
||||
{car.notes && (
|
||||
<div className="mt-5 text-base text-steel-600 bg-steel-50 p-4 rounded-lg">
|
||||
{car.notes}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="bg-white rounded-lg shadow p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold flex items-center gap-2">
|
||||
<Wrench className="w-5 h-5" /> Recent orders
|
||||
</h2>
|
||||
<span className="text-sm text-steel-500">
|
||||
{car.orders.length} order{car.orders.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{car.orders.length === 0 ? (
|
||||
<p className="text-steel-500">No service history yet.</p>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{car.orders.map((order) => (
|
||||
<div
|
||||
key={order.id}
|
||||
data-testid={`recent-order-${order.id}`}
|
||||
className="border rounded-xl p-4 hover:border-brand-300 transition-colors flex flex-col"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-bold">
|
||||
Order #{order.id} — {formatDate(order.createdAt)}
|
||||
</div>
|
||||
<div className="text-sm text-steel-500">
|
||||
{
|
||||
order.tasks.filter((t) => t.status === "finished")
|
||||
.length
|
||||
}
|
||||
/{order.tasks.length} tasks done
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/orders/${order.id}`}
|
||||
data-testid={`open-order-${order.id}`}
|
||||
className="touch-target px-3 border border-brand-300 text-brand-700 rounded-lg hover:bg-brand-50 text-base inline-flex items-center shrink-0"
|
||||
>
|
||||
{order.tasks.length > 0 &&
|
||||
order.tasks.every((t) => t.status === "finished")
|
||||
? "Finish"
|
||||
: "Open"}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{order.tasks.length > 0 ? (
|
||||
<ul className="mt-3 text-sm text-steel-600 space-y-1">
|
||||
{order.tasks.map((t) => (
|
||||
<li
|
||||
key={t.id}
|
||||
data-testid={`recent-task-${t.id}`}
|
||||
data-task-status={t.status}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${t.status === "finished" ? "bg-green-500" : "bg-steel-300"}`}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
t.status === "finished"
|
||||
? "line-through text-steel-400"
|
||||
: "truncate"
|
||||
}
|
||||
>
|
||||
{t.title}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-3 text-sm text-steel-400">
|
||||
No tasks recorded.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Info({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-sm text-steel-500 uppercase">{label}</div>
|
||||
<div className="font-medium">{value || "—"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
inputMode,
|
||||
testId,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
inputMode?: React.HTMLAttributes<HTMLInputElement>["inputMode"];
|
||||
testId?: string;
|
||||
}) {
|
||||
const id =
|
||||
testId ||
|
||||
"car-field-" + label.toLowerCase().replace(/\s+/g, "-").replace(/\./g, "");
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id} className="text-sm text-steel-500 uppercase">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
data-testid={testId}
|
||||
type={inputMode === "numeric" ? "number" : "text"}
|
||||
inputMode={inputMode}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full mt-1 border rounded-lg px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { Search, Car, ChevronRight } 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 CarsPage() {
|
||||
const router = useRouter();
|
||||
const [q, setQ] = useState("");
|
||||
const { data: cars, error } = useSWR<CarWithRelations[]>(`/api/cars?q=${encodeURIComponent(q)}`, fetcher, { keepPreviousData: true });
|
||||
|
||||
if (error) return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar title="Cars" />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6">
|
||||
<BackLink href="/" label="Dashboard" />
|
||||
<div className="text-red-700 bg-red-50 p-4 rounded-lg">Failed to load cars. Please try again.</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar title="Cars" />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6">
|
||||
<BackLink href="/" label="Dashboard" />
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Car className="w-6 h-6 text-brand-600" /> Cars {cars != null && <span className="text-steel-400 text-lg font-normal">({cars.length})</span>}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-6">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-steel-400" />
|
||||
<input
|
||||
type="text"
|
||||
data-testid="car-search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Search reg, owner, make or model..."
|
||||
className="w-full pl-11 pr-4 py-3.5 border border-steel-300 rounded-xl text-base focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{cars === undefined ? (
|
||||
<Loading />
|
||||
) : cars.length === 0 ? (
|
||||
<div className="text-center text-steel-500 py-12">No cars found.</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{cars.map((car) => (
|
||||
<button
|
||||
data-testid={`car-row-${car.id}`}
|
||||
key={car.id}
|
||||
onClick={() => router.push(`/cars/${car.id}`)}
|
||||
className="text-left bg-white rounded-xl shadow p-4 sm:p-5 border border-steel-200 hover:border-brand-300 hover:shadow-md transition-all active:scale-[0.99]"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div data-testid={`car-card-reg-${car.id}`} className="text-2xl font-bold tracking-wide text-steel-800">{car.regNumber}</div>
|
||||
<span className="inline-flex items-center gap-1 text-brand-600 text-sm font-medium">
|
||||
View <ChevronRight className="w-5 h-5" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-y-3 gap-x-4 text-sm">
|
||||
<div>
|
||||
<div className="text-steel-500 text-xs uppercase">Make / model</div>
|
||||
<div className="font-medium text-steel-800">{car.make || "—"} {car.model || ""}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-steel-500 text-xs uppercase">Year</div>
|
||||
<div className="font-medium text-steel-800">{car.year || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-steel-500 text-xs uppercase">Owner</div>
|
||||
<div className="font-medium text-steel-800 truncate">{car.ownerName || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-steel-500 text-xs uppercase">Mileage</div>
|
||||
<div className="font-medium text-steel-800">{car.mileage != null ? car.mileage.toLocaleString() : "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-steel-100 flex items-center justify-between text-sm text-steel-500">
|
||||
<span>{car.orders.length} job{car.orders.length === 1 ? "" : "s"}</span>
|
||||
<span>Updated {formatDate(car.updatedAt)}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply antialiased;
|
||||
}
|
||||
input, select, textarea, button {
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1;
|
||||
}
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="email"],
|
||||
input[type="url"],
|
||||
input[type="number"],
|
||||
input[type="search"],
|
||||
input[type="tel"],
|
||||
input[type="date"],
|
||||
select,
|
||||
textarea {
|
||||
@apply bg-white text-steel-900 border-steel-300;
|
||||
}
|
||||
html.dark input[type="text"],
|
||||
html.dark input[type="password"],
|
||||
html.dark input[type="email"],
|
||||
html.dark input[type="url"],
|
||||
html.dark input[type="number"],
|
||||
html.dark input[type="search"],
|
||||
html.dark input[type="tel"],
|
||||
html.dark input[type="date"],
|
||||
html.dark select,
|
||||
html.dark textarea {
|
||||
background-color: #2a2e38 !important;
|
||||
color: #e2e6ed !important;
|
||||
border-color: #3a3f4b !important;
|
||||
}
|
||||
html.dark input[type="checkbox"],
|
||||
html.dark input[type="radio"] {
|
||||
accent-color: #ea580c;
|
||||
}
|
||||
html.dark input::placeholder,
|
||||
html.dark textarea::placeholder {
|
||||
color: #8c96a6 !important;
|
||||
}
|
||||
html.dark .bg-white {
|
||||
background-color: #242830 !important;
|
||||
color: #e2e6ed !important;
|
||||
}
|
||||
html.dark .bg-steel-50 {
|
||||
background-color: #242830 !important;
|
||||
}
|
||||
html.dark .bg-steel-100 {
|
||||
background-color: #2a2e38 !important;
|
||||
}
|
||||
html.dark .text-steel-700 {
|
||||
color: #c8cdd5 !important;
|
||||
}
|
||||
html.dark .text-steel-600 {
|
||||
color: #a0a8b5 !important;
|
||||
}
|
||||
html.dark .text-steel-500 {
|
||||
color: #7a8290 !important;
|
||||
}
|
||||
a {
|
||||
@apply transition-colors duration-150;
|
||||
}
|
||||
html:not(.dark) .text-steel-500 {
|
||||
color: #4b5563 !important;
|
||||
}
|
||||
html:not(.dark) .text-steel-400 {
|
||||
color: #5a6575 !important;
|
||||
}
|
||||
html:not(.dark) .text-steel-300 {
|
||||
color: #6b7685 !important;
|
||||
}
|
||||
}
|
||||
|
||||
html.dark body {
|
||||
background-color: #1a1d24;
|
||||
color: #e2e6ed;
|
||||
}
|
||||
|
||||
html.dark .bg-white {
|
||||
background-color: #242830 !important;
|
||||
}
|
||||
html.dark .bg-steel-50 {
|
||||
background-color: #1e2128 !important;
|
||||
}
|
||||
html.dark .bg-steel-100 {
|
||||
background-color: #2a2e38 !important;
|
||||
}
|
||||
html.dark .bg-steel-800 {
|
||||
background-color: #111318 !important;
|
||||
}
|
||||
html.dark .text-steel-800 {
|
||||
color: #e2e6ed !important;
|
||||
}
|
||||
html.dark .text-steel-700 {
|
||||
color: #c8cdd5 !important;
|
||||
}
|
||||
html.dark .text-steel-600 {
|
||||
color: #a0a8b5 !important;
|
||||
}
|
||||
html.dark .text-steel-500 {
|
||||
color: #7a8290 !important;
|
||||
}
|
||||
html.dark .border-steel-200 {
|
||||
border-color: #3a3f4b !important;
|
||||
}
|
||||
html.dark .border-steel-100 {
|
||||
border-color: #2a2e38 !important;
|
||||
}
|
||||
html.dark .hover\:bg-steel-50:hover {
|
||||
background-color: #2a2e38 !important;
|
||||
}
|
||||
html.dark .hover\:bg-steel-100:hover {
|
||||
background-color: #323842 !important;
|
||||
}
|
||||
html.dark .hover\:bg-steel-200:hover {
|
||||
background-color: #3a3f4b !important;
|
||||
}
|
||||
html.dark .hover\:text-brand-600:hover {
|
||||
color: #f97316 !important;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.print-only {
|
||||
display: none;
|
||||
}
|
||||
.touch-target {
|
||||
@apply min-h-[48px] min-w-[48px] inline-flex items-center justify-center;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
input, select, textarea, button, a {
|
||||
font-size: 16px;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.touch-target {
|
||||
@apply min-h-[52px] min-w-[52px];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
.print-only {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import "./globals.css";
|
||||
import RootProviders from "@/components/RootProviders";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Andris Car Service",
|
||||
description: "Order management dashboard for Andris car service",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className="bg-steel-100 text-steel-900 dark:bg-steel-900 dark:text-steel-100 min-h-screen transition-colors">
|
||||
<RootProviders>{children}</RootProviders>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Car, Loader2 } from "lucide-react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function login(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const res = await fetch("/api/auth", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "login", username, password }),
|
||||
});
|
||||
const json = await res.json();
|
||||
setLoading(false);
|
||||
if (res.ok) {
|
||||
localStorage.setItem("user", JSON.stringify(json));
|
||||
router.push("/");
|
||||
} else {
|
||||
setError(json.error || "Login failed");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-steel-100 dark:bg-steel-900 px-4">
|
||||
<div className="w-full max-w-sm bg-white dark:bg-steel-800 rounded-xl shadow-xl p-6">
|
||||
<div className="text-center mb-6">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-brand-600 rounded-lg mb-3">
|
||||
<Car className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold dark:text-steel-100">Andris Car Service</h1>
|
||||
<p className="text-sm text-steel-500 dark:text-steel-400">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={login} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-steel-700 dark:text-steel-300 mb-1">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-steel-200 dark:border-steel-600 rounded-lg text-base dark:bg-steel-700 dark:text-steel-100"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-steel-700 dark:text-steel-300 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-steel-200 dark:border-steel-600 rounded-lg text-base dark:bg-steel-700 dark:text-steel-100"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="touch-target w-full bg-brand-600 text-white rounded-xl font-medium flex items-center justify-center gap-2 hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function NotFound() {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
router.replace("/");
|
||||
}, [router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import useSWR from "swr";
|
||||
import { ArrowLeft, Printer } from "lucide-react";
|
||||
import { formatCurrency, formatDate, formatDateTime } from "@/lib/format";
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||
|
||||
export default function PrintPage() {
|
||||
const { id } = useParams();
|
||||
const sp = useSearchParams();
|
||||
const query = sp.toString();
|
||||
const { data } = useSWR(`/api/print/${id}?${query}`, fetcher);
|
||||
|
||||
if (!data) return <div className="p-8">Loading print view...</div>;
|
||||
if (data.error || !data.order || !data.order.car)
|
||||
return <div className="p-8 text-steel-500">Order not found.</div>;
|
||||
|
||||
const { order, tasks, totalParts, totalLabour, total, type } = data;
|
||||
const isInvoice = type === "invoice";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<style jsx global>{`
|
||||
.no-print-hover:hover { color: #fff; }
|
||||
.no-print-btn:hover { background-color: #f5f7fa; }
|
||||
.print-table th, .print-table td { padding: 6px 8px; vertical-align: top; }
|
||||
.print-table th { border-bottom: 1px solid #ccc; text-align: left; }
|
||||
.print-table td { border-bottom: 1px solid #eee; }
|
||||
`}</style>
|
||||
<header className="bg-brand-700 text-white shadow no-print">
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href={`/orders/${id}`}
|
||||
className="inline-flex items-center gap-1 text-sm text-white/90 no-print-hover"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Back to order
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-white text-brand-700 rounded-md text-sm no-print-btn"
|
||||
>
|
||||
<Printer className="w-4 h-4" /> Print
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-w-3xl mx-auto p-8 print:p-0">
|
||||
<div className="text-center mb-6 print:mt-0">
|
||||
<h1 className="text-2xl font-bold">Andris Car Service</h1>
|
||||
<p className="text-steel-500">{isInvoice ? "Invoice" : "Service report"}</p>
|
||||
</div>
|
||||
|
||||
<section className="mb-6 border-b pb-4">
|
||||
<h2 className="font-bold text-lg mb-2">Vehicle</h2>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>Reg: <strong>{order.car.regNumber}</strong></div>
|
||||
<div>Make / model: {order.car.make} {order.car.model}</div>
|
||||
<div>Colour: {order.car.color || "—"}</div>
|
||||
<div>Year: {order.car.year || "—"}</div>
|
||||
<div>VIN: {order.car.vin || "—"}</div>
|
||||
<div>Engine no.: {order.car.engineNumber || "—"}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-6 border-b pb-4">
|
||||
<h2 className="font-bold text-lg mb-2">Owner</h2>
|
||||
<div className="text-sm">
|
||||
<p><strong>{order.car.ownerName || "—"}</strong></p>
|
||||
<p>Phone: {order.car.ownerPhone || "—"}</p>
|
||||
<p>Email: {order.car.ownerEmail || "—"}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-6">
|
||||
<h2 className="font-bold text-lg mb-2">
|
||||
Completed tasks
|
||||
{sp.get("day") && <span className="text-sm font-normal text-steel-500 ml-2">on {formatDate(sp.get("day"))}</span>}
|
||||
{sp.get("from") && (
|
||||
<span className="text-sm font-normal text-steel-500 ml-2">
|
||||
{formatDate(sp.get("from"))} – {formatDate(sp.get("to"))}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<p className="text-steel-500">No completed tasks match the selected dates.</p>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full text-sm print-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left">Task</th>
|
||||
<th className="text-left">Finished</th>
|
||||
{!isInvoice && <th className="text-left">Parts</th>}
|
||||
{isInvoice && <th className="text-right">Parts cost</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((task: any) => {
|
||||
const taskPartsCost = task.parts.reduce(
|
||||
(s: number, p: any) => s + (p.price || 0) * p.qty,
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<tr key={task.id}>
|
||||
<td>{task.title}</td>
|
||||
<td>{formatDateTime(task.finishedAt)}</td>
|
||||
{!isInvoice && (
|
||||
<td>
|
||||
{task.parts.length > 0 ? (
|
||||
<ul className="list-disc pl-4">
|
||||
{task.parts.map((p: any) => (
|
||||
<li key={p.id}>{p.part.name} x{p.qty}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{isInvoice && (
|
||||
<td className="text-right font-semibold">{formatCurrency(taskPartsCost)}</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Invoice summary */}
|
||||
{isInvoice && tasks.length > 0 && (
|
||||
<div className="mt-4 space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>Parts total</span>
|
||||
<span className="font-semibold">{formatCurrency(totalParts)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Labour total</span>
|
||||
<span className="font-semibold">{formatCurrency(totalLabour)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-bold text-base border-t pt-2 mt-2">
|
||||
<span>Total</span>
|
||||
<span>{formatCurrency(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Car, ArrowLeft, Search, Loader2 } from "lucide-react";
|
||||
import BackLink from "@/components/BackLink";
|
||||
import { VehicleLookupResult } from "@/lib/vehicle-lookup";
|
||||
|
||||
export default function NewOrderPage() {
|
||||
const router = useRouter();
|
||||
const [reg, setReg] = useState("");
|
||||
const [lookingUp, setLookingUp] = useState(false);
|
||||
const [lookupMessage, setLookupMessage] = useState("");
|
||||
const regInputRef = useRef<HTMLInputElement>(null);
|
||||
const [car, setCar] = useState<{
|
||||
id?: number;
|
||||
regNumber: string;
|
||||
make?: string;
|
||||
model?: string;
|
||||
color?: string;
|
||||
year?: number;
|
||||
ownerName?: string;
|
||||
ownerPhone?: string;
|
||||
ownerEmail?: string;
|
||||
vin?: string;
|
||||
engineNumber?: string;
|
||||
notes?: string;
|
||||
} | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
make: "",
|
||||
model: "",
|
||||
color: "",
|
||||
year: "",
|
||||
ownerName: "",
|
||||
ownerPhone: "",
|
||||
ownerEmail: "",
|
||||
vin: "",
|
||||
engineNumber: "",
|
||||
mileage: "",
|
||||
notes: "",
|
||||
});
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [existingOpenOrder, setExistingOpenOrder] = useState<
|
||||
{ id: number; status: string; createdAt: string } | null | undefined
|
||||
>(undefined);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (car) {
|
||||
formRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}, [car]);
|
||||
|
||||
async function lookupReg() {
|
||||
const currentReg = regInputRef.current?.value.trim() || reg;
|
||||
if (!currentReg) return;
|
||||
setReg(currentReg);
|
||||
setLookingUp(true);
|
||||
setLookupMessage("");
|
||||
const res = await fetch(
|
||||
`/api/lookup?reg=${encodeURIComponent(currentReg)}`,
|
||||
);
|
||||
const json = await res.json();
|
||||
let lookupData: VehicleLookupResult | undefined;
|
||||
if (json.found && json.data) lookupData = json.data;
|
||||
|
||||
const carRes = await fetch("/api/cars", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
regNumber: currentReg,
|
||||
lookup: true,
|
||||
make: lookupData?.make,
|
||||
model: lookupData?.model,
|
||||
color: lookupData?.color,
|
||||
year: lookupData?.year,
|
||||
}),
|
||||
});
|
||||
const carJson = await carRes.json();
|
||||
setLookingUp(false);
|
||||
|
||||
if (carJson.error) {
|
||||
setLookupMessage(carJson.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (carJson.id) {
|
||||
const ordersRes = await fetch(`/api/orders?carId=${carJson.id}`);
|
||||
const ordersJson = await ordersRes.json();
|
||||
const openOrder = ordersJson?.find?.((o: { status: string }) => o.status === "open");
|
||||
if (openOrder) {
|
||||
setExistingOpenOrder(openOrder);
|
||||
} else {
|
||||
setExistingOpenOrder(null);
|
||||
}
|
||||
}
|
||||
|
||||
setCar(carJson);
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
make: carJson.make || "",
|
||||
model: carJson.model || "",
|
||||
color: carJson.color || "",
|
||||
year: carJson.year ? String(carJson.year) : "",
|
||||
ownerName: carJson.ownerName || "",
|
||||
ownerPhone: carJson.ownerPhone || "",
|
||||
ownerEmail: carJson.ownerEmail || "",
|
||||
vin: carJson.vin || "",
|
||||
engineNumber: carJson.engineNumber || "",
|
||||
mileage: carJson.mileage ? String(carJson.mileage) : "",
|
||||
notes: carJson.notes || "",
|
||||
}));
|
||||
setLookupMessage(
|
||||
json.found
|
||||
? "Vehicle details found via lookup."
|
||||
: "No automatic lookup available — please enter details manually.",
|
||||
);
|
||||
}
|
||||
|
||||
function addToExistingOrder() {
|
||||
if (!existingOpenOrder) return;
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = `/orders/${existingOpenOrder.id}`;
|
||||
} else {
|
||||
router.push(`/orders/${existingOpenOrder.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createOrder(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!car?.id) return;
|
||||
setCreating(true);
|
||||
|
||||
try {
|
||||
const carRes = await fetch(`/api/cars/${car.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
make: form.make,
|
||||
model: form.model,
|
||||
color: form.color,
|
||||
year: form.year ? Number(form.year) : null,
|
||||
ownerName: form.ownerName,
|
||||
ownerPhone: form.ownerPhone,
|
||||
ownerEmail: form.ownerEmail,
|
||||
vin: form.vin,
|
||||
engineNumber: form.engineNumber,
|
||||
mileage: form.mileage ? Number(form.mileage) : null,
|
||||
notes: form.notes,
|
||||
}),
|
||||
});
|
||||
const carJson = await carRes.json();
|
||||
const finalCarId = carJson?.id ?? car.id;
|
||||
|
||||
const orderRes = await fetch("/api/orders", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ carId: finalCarId }),
|
||||
});
|
||||
const orderJson = await orderRes.json();
|
||||
|
||||
if (orderJson.id) {
|
||||
// Keep overlay active during navigation; use hard navigation if client router stalls
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = `/orders/${orderJson.id}`;
|
||||
} else {
|
||||
router.push(`/orders/${orderJson.id}`);
|
||||
}
|
||||
} else {
|
||||
setCreating(false);
|
||||
}
|
||||
} catch {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<header className="bg-steel-800 text-white shadow">
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-brand-600 p-2 rounded-lg shadow-sm">
|
||||
<Car className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h1 className="text-lg sm:text-xl font-bold">New order</h1>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-steel-200 hover:text-white whitespace-nowrap"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-3xl mx-auto px-4 sm:px-6 py-6">
|
||||
<BackLink href="/" label="Dashboard" />
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-5 sm:p-6 mb-6 border border-steel-200">
|
||||
<label
|
||||
htmlFor="reg-input"
|
||||
className="block text-sm font-medium text-steel-700 mb-2"
|
||||
>
|
||||
Registration number
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={regInputRef}
|
||||
id="reg-input"
|
||||
data-testid="reg-input"
|
||||
type="text"
|
||||
value={reg}
|
||||
onChange={(e) => setReg(e.target.value)}
|
||||
placeholder="e.g. 191D12345"
|
||||
className="flex-1 px-3 py-2.5 border border-steel-300 rounded-lg text-base focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1 uppercase"
|
||||
/>
|
||||
<button
|
||||
data-testid="lookup-button"
|
||||
onClick={lookupReg}
|
||||
disabled={lookingUp || !reg.trim()}
|
||||
className="touch-target bg-brand-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-brand-500 disabled:opacity-50 whitespace-nowrap"
|
||||
>
|
||||
{lookingUp ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="w-4 h-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">Look up</span>
|
||||
</button>
|
||||
</div>
|
||||
{lookupMessage && (
|
||||
<p
|
||||
data-testid="lookup-message"
|
||||
className={`mt-2 text-sm ${lookupMessage.includes("No automatic") ? "text-warning-600" : "text-green-600"}`}
|
||||
>
|
||||
{lookupMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{car && (
|
||||
<form
|
||||
ref={formRef}
|
||||
data-testid="car-details-form"
|
||||
onSubmit={createOrder}
|
||||
className="relative bg-white rounded-lg shadow p-5 sm:p-6 border border-steel-200"
|
||||
>
|
||||
<h2 className="text-lg font-bold mb-4 text-steel-800">
|
||||
Car details
|
||||
</h2>
|
||||
|
||||
{existingOpenOrder && (
|
||||
<div className="mb-5 bg-warning-50 border border-warning-200 rounded-xl p-4">
|
||||
<div className="text-sm text-warning-800 mb-3">
|
||||
This car already has an open order
|
||||
{existingOpenOrder.createdAt && (
|
||||
<>{" "}
|
||||
(
|
||||
{new Date(
|
||||
existingOpenOrder.createdAt,
|
||||
).toLocaleDateString("en-IE", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
)
|
||||
</>
|
||||
)}
|
||||
. Add to it instead of creating a new order?
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="add-to-existing-order-button"
|
||||
onClick={addToExistingOrder}
|
||||
className="touch-target bg-brand-600 text-white px-4 py-2 rounded-lg hover:bg-brand-500"
|
||||
>
|
||||
Open existing order
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="create-new-order-anyway-button"
|
||||
onClick={() => setExistingOpenOrder(null)}
|
||||
className="touch-target bg-steel-100 text-steel-700 px-4 py-2 rounded-lg hover:bg-steel-200"
|
||||
>
|
||||
Create new order
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{form.year && (
|
||||
<div
|
||||
data-testid="year-prefill-notice"
|
||||
className="mb-4 p-3 bg-brand-50 border border-brand-100 rounded-lg text-sm text-brand-800"
|
||||
>
|
||||
Year <strong>{form.year}</strong> pre-filled from registration.
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-4 sm:grid-cols-2 mb-6">
|
||||
<Field
|
||||
testId="make-input"
|
||||
label="Make"
|
||||
value={form.make}
|
||||
onChange={(v) => setForm({ ...form, make: v })}
|
||||
/>
|
||||
<Field
|
||||
testId="model-input"
|
||||
label="Model"
|
||||
value={form.model}
|
||||
onChange={(v) => setForm({ ...form, model: v })}
|
||||
/>
|
||||
<Field
|
||||
testId="color-input"
|
||||
label="Colour"
|
||||
value={form.color}
|
||||
onChange={(v) => setForm({ ...form, color: v })}
|
||||
/>
|
||||
<Field
|
||||
testId="year-input"
|
||||
label="Year"
|
||||
value={form.year}
|
||||
onChange={(v) => setForm({ ...form, year: v })}
|
||||
type="number"
|
||||
/>
|
||||
<Field
|
||||
testId="owner-name-input"
|
||||
label="Owner name"
|
||||
value={form.ownerName}
|
||||
onChange={(v) => setForm({ ...form, ownerName: v })}
|
||||
/>
|
||||
<Field
|
||||
testId="phone-input"
|
||||
label="Phone"
|
||||
value={form.ownerPhone}
|
||||
onChange={(v) => setForm({ ...form, ownerPhone: v })}
|
||||
/>
|
||||
<Field
|
||||
testId="email-input"
|
||||
label="Email"
|
||||
value={form.ownerEmail}
|
||||
onChange={(v) => setForm({ ...form, ownerEmail: v })}
|
||||
type="email"
|
||||
/>
|
||||
<Field
|
||||
testId="vin-input"
|
||||
label="VIN"
|
||||
value={form.vin}
|
||||
onChange={(v) => setForm({ ...form, vin: v })}
|
||||
/>
|
||||
<Field
|
||||
testId="engine-input"
|
||||
label="Engine number"
|
||||
value={form.engineNumber}
|
||||
onChange={(v) => setForm({ ...form, engineNumber: v })}
|
||||
/>
|
||||
<Field
|
||||
testId="mileage-input"
|
||||
label="Mileage"
|
||||
value={form.mileage}
|
||||
onChange={(v) => setForm({ ...form, mileage: v })}
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label
|
||||
htmlFor="notes-input"
|
||||
className="block text-sm font-medium text-steel-700 mb-1"
|
||||
>
|
||||
Notes
|
||||
</label>
|
||||
<textarea
|
||||
id="notes-input"
|
||||
data-testid="notes-input"
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2.5 border border-steel-300 rounded-lg text-base focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col-reverse sm:flex-row justify-end gap-3">
|
||||
<Link
|
||||
href="/"
|
||||
className={`touch-target text-center px-4 py-2.5 border border-steel-300 rounded-lg hover:bg-steel-50 text-steel-700 ${creating ? "pointer-events-none opacity-50" : ""}`}
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="create-order-button"
|
||||
disabled={creating}
|
||||
className="touch-target bg-brand-600 text-white px-6 py-2.5 rounded-lg hover:bg-brand-500 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{creating && <Loader2 className="w-5 h-5 animate-spin" />}
|
||||
{creating ? "Creating order..." : "Create order"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
<div className="absolute inset-0 bg-white/80 rounded-lg flex flex-col items-center justify-center text-steel-600">
|
||||
<Loader2 className="w-10 h-10 animate-spin text-brand-600 mb-3" />
|
||||
<p className="font-medium">Creating order, please wait…</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
type = "text",
|
||||
testId,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
type?: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
const id =
|
||||
testId ||
|
||||
label.toLowerCase().replace(/\s+/g, "-").replace(/\//g, "") + "-input";
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="block text-sm font-medium text-steel-700 mb-1"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
data-testid={testId}
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full px-3 py-2.5 border border-steel-300 rounded-lg text-base focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import {
|
||||
Search,
|
||||
Wrench,
|
||||
CheckCircle2,
|
||||
X,
|
||||
Car,
|
||||
Package,
|
||||
List,
|
||||
Plus,
|
||||
ArrowRight,
|
||||
Clock,
|
||||
Printer,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { OrderWithCar } from "@/types";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import Navbar from "@/components/Navbar";
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||
|
||||
const BIG_ACTIONS = (openCount: number) => [
|
||||
{
|
||||
id: "new-order",
|
||||
href: "/orders/new",
|
||||
label: "New Order",
|
||||
icon: Plus,
|
||||
color: "bg-brand-600 text-white",
|
||||
},
|
||||
{
|
||||
id: "cars",
|
||||
href: "/cars",
|
||||
label: "Cars",
|
||||
icon: Car,
|
||||
color: "bg-sky-600 text-white",
|
||||
},
|
||||
{
|
||||
id: "parts",
|
||||
href: "/parts",
|
||||
label: "Parts",
|
||||
icon: Package,
|
||||
color: "bg-purple-600 text-white",
|
||||
},
|
||||
{
|
||||
id: "total-open-orders",
|
||||
href: "/",
|
||||
label: `${openCount} open order${openCount === 1 ? "" : "s"}`,
|
||||
icon: Wrench,
|
||||
color: "bg-emerald-600 text-white",
|
||||
},
|
||||
];
|
||||
|
||||
export default function DashboardPage() {
|
||||
const {
|
||||
data: orders,
|
||||
isLoading,
|
||||
mutate,
|
||||
} = useSWR<OrderWithCar[]>("/api/orders", fetcher);
|
||||
const { data: finishedOrders, isLoading: finishedLoading } = useSWR<
|
||||
OrderWithCar[]
|
||||
>("/api/orders?status=finished", fetcher);
|
||||
const [search, setSearch] = useState("");
|
||||
const router = useRouter();
|
||||
|
||||
const openOrders = orders?.filter((o) => o.status === "open") ?? [];
|
||||
const searchTerm = search.toLowerCase();
|
||||
const filtered = openOrders.filter(
|
||||
(o) =>
|
||||
o.car.regNumber.toLowerCase().includes(searchTerm) ||
|
||||
(o.car.ownerName?.toLowerCase() ?? "").includes(searchTerm) ||
|
||||
(o.car.make?.toLowerCase() ?? "").includes(searchTerm) ||
|
||||
(o.car.model?.toLowerCase() ?? "").includes(searchTerm),
|
||||
);
|
||||
|
||||
// Recently active open orders (by updatedAt), keep same length cap for large grids
|
||||
const sortedFiltered = useMemo(() => {
|
||||
return [...filtered].sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
);
|
||||
}, [filtered]);
|
||||
|
||||
function clearSearch() {
|
||||
setSearch("");
|
||||
}
|
||||
|
||||
const [closeConfirmId, setCloseConfirmId] = useState<number | null>(null);
|
||||
const [closeMileage, setCloseMileage] = useState("");
|
||||
const [closing, setClosing] = useState(false);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
async function closeOrder(id: number, mileage?: number | null) {
|
||||
setClosing(true);
|
||||
await fetch(`/api/orders/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "closed", mileage: mileage ?? null }),
|
||||
});
|
||||
setClosing(false);
|
||||
setCloseConfirmId(null);
|
||||
setCloseMileage("");
|
||||
mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-steel-50">
|
||||
<Navbar title="Andris Car Service" subtitle="Workshop" />
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-6">
|
||||
{/* App-like hero: big tappable action tiles */}
|
||||
<section className="grid grid-cols-2 md:grid-cols-4 gap-3 sm:gap-4 mb-8">
|
||||
{BIG_ACTIONS(openOrders.length).map((action) => (
|
||||
<Link
|
||||
key={action.id}
|
||||
href={action.href}
|
||||
data-testid={`big-action-${action.id}`}
|
||||
className={`group relative overflow-hidden rounded-2xl ${action.color} p-4 sm:p-5 shadow-lg active:scale-[0.98] transition-transform focus:outline-none focus:ring-4 focus:ring-brand-300 focus:ring-offset-2 min-h-[120px] sm:min-h-[140px] flex flex-col justify-between`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<action.icon className="w-8 h-8 sm:w-10 sm:h-10 opacity-90" />
|
||||
<ArrowRight className="w-6 h-6 opacity-60 group-hover:translate-x-1 transition-transform" />
|
||||
</div>
|
||||
<div className="text-lg sm:text-xl font-bold leading-tight">
|
||||
{action.label}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* Open orders workspace */}
|
||||
<section className="mb-10">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2 text-steel-800">
|
||||
<Wrench className="w-7 h-7 text-brand-600" /> Open orders
|
||||
</h2>
|
||||
|
||||
<div className="relative w-full sm:w-80">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-steel-400" />
|
||||
<input
|
||||
type="text"
|
||||
data-testid="dashboard-search"
|
||||
placeholder="Search reg, owner, make or model..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
clearSearch();
|
||||
}
|
||||
}}
|
||||
className="pl-10 pr-12 py-3 border border-steel-300 rounded-xl w-full text-base focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dashboard-search-clear"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
clearSearch();
|
||||
}}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 touch-target p-2 rounded-full bg-steel-100 text-steel-600"
|
||||
aria-label="Clear search"
|
||||
title="Clear search"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-steel-600 mb-3">
|
||||
{searchTerm ? (
|
||||
<>
|
||||
Searching “{searchTerm}” — {filtered.length} result
|
||||
{filtered.length === 1 ? "" : "s"}
|
||||
</>
|
||||
) : (
|
||||
<>Showing all {openOrders.length} open order{openOrders.length === 1 ? "" : "s"}</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="bg-white rounded-2xl shadow p-8 text-center text-steel-500 text-lg">
|
||||
Loading…
|
||||
</div>
|
||||
) : openOrders.length === 0 ? (
|
||||
<Link
|
||||
href="/orders/new"
|
||||
className="block bg-white rounded-2xl shadow p-8 text-center text-steel-600 hover:bg-brand-50 transition-colors"
|
||||
>
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-brand-100 text-brand-600 flex items-center justify-center mb-4">
|
||||
<Plus className="w-8 h-8" />
|
||||
</div>
|
||||
<p className="text-xl font-semibold text-steel-800">
|
||||
No open orders
|
||||
</p>
|
||||
<p className="mt-1">Tap here to create a new order</p>
|
||||
</Link>
|
||||
) : filtered.length === 0 ? (
|
||||
<div
|
||||
data-testid="no-search-results"
|
||||
className="bg-white rounded-2xl shadow p-8 text-center text-steel-600"
|
||||
>
|
||||
<p className="text-lg">
|
||||
No orders match “{searchTerm}”.
|
||||
</p>
|
||||
<button
|
||||
onClick={clearSearch}
|
||||
className="mt-3 inline-flex items-center gap-2 text-brand-600 font-semibold"
|
||||
>
|
||||
<X className="w-5 h-5" /> Clear search
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{sortedFiltered.map((order) => {
|
||||
const finished = order.tasks.filter(
|
||||
(t) => t.status === "finished",
|
||||
).length;
|
||||
const remaining = order.tasks.length - finished;
|
||||
const allDone = order.tasks.length > 0 && remaining === 0;
|
||||
return (
|
||||
<div
|
||||
key={order.id}
|
||||
data-testid={`order-card-${order.id}`}
|
||||
className="relative bg-white rounded-2xl shadow p-0 overflow-hidden border border-steel-200 hover:shadow-xl hover:border-brand-300 transition-all active:scale-[0.99] w-full flex flex-col"
|
||||
>
|
||||
{/* Main body – clickable link to order detail */}
|
||||
<Link
|
||||
href={`/orders/${order.id}`}
|
||||
className="block p-4 pb-0"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div className="min-w-0">
|
||||
<span className="text-2xl sm:text-3xl font-bold tracking-wide text-steel-800 leading-none">
|
||||
{order.car.regNumber}
|
||||
</span>
|
||||
<div className="text-sm text-steel-600 mt-0.5 truncate">
|
||||
{order.car.ownerName || "No owner recorded"}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-bold uppercase ${
|
||||
remaining > 0
|
||||
? "bg-red-100 text-red-700"
|
||||
: order.tasks.length > 0
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-steel-100 text-steel-600"
|
||||
}`}
|
||||
>
|
||||
<Clock className="w-3 h-3" />
|
||||
{order.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-steel-500 mb-3">
|
||||
{order.car.make} {order.car.model}{" "}
|
||||
{order.car.year && `(${order.car.year})`}
|
||||
</div>
|
||||
|
||||
{/* Task count + list with status dots */}
|
||||
<div className="flex items-center gap-2 mb-2"
|
||||
>
|
||||
<span
|
||||
data-testid={`order-card-progress-${order.id}`}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-bold ${
|
||||
remaining > 0
|
||||
? "bg-amber-50 text-amber-700 border border-amber-200"
|
||||
: "bg-green-50 text-green-700 border border-green-200"
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-extrabold">{finished}/{order.tasks.length}</span> done
|
||||
</span>
|
||||
{remaining > 0 ? (
|
||||
<span
|
||||
data-testid={`order-card-status-${order.id}`}
|
||||
className="text-xs font-bold text-red-600 inline-flex items-center gap-1"
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
|
||||
{remaining} open
|
||||
</span>
|
||||
) : order.tasks.length > 0 ? (
|
||||
<span
|
||||
data-testid={`order-card-status-${order.id}`}
|
||||
className="text-xs font-bold text-green-700 inline-flex items-center gap-1"
|
||||
>
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
All done
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
data-testid={`order-card-status-${order.id}`}
|
||||
className="text-xs font-bold text-steel-500 inline-flex items-center gap-1"
|
||||
>
|
||||
No tasks
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{order.tasks.length > 0 && (
|
||||
<ul
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-steel-600 mb-4"
|
||||
data-testid={`order-card-tasks-${order.id}`}
|
||||
>
|
||||
{order.tasks.map((t) => (
|
||||
<li key={t.id} className="flex items-center gap-1.5 truncate max-w-[160px]">
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full shrink-0 ${
|
||||
t.status === "finished"
|
||||
? "bg-green-500"
|
||||
: "bg-red-400"
|
||||
}`}
|
||||
/>
|
||||
{t.title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Bottom action bar */}
|
||||
<div className="mt-auto grid grid-cols-1 gap-2 px-4 pb-4 pt-1">
|
||||
<div className="grid grid-cols-[1fr_auto_auto] gap-2">
|
||||
<Link
|
||||
href={`/orders/${order.id}`}
|
||||
data-testid={`open-order-card-${order.id}`}
|
||||
className="inline-flex items-center justify-center touch-target px-3 py-2.5 rounded-xl text-sm font-bold bg-brand-600 text-white hover:bg-brand-700 active:scale-[0.99] transition-transform shadow-sm"
|
||||
>
|
||||
Details
|
||||
</Link>
|
||||
<Link
|
||||
href={`/orders/${order.id}/print?type=service&prices=false`}
|
||||
data-testid={`print-service-order-card-${order.id}`}
|
||||
className="inline-flex items-center justify-center touch-target px-3 py-2.5 rounded-xl text-sm font-bold bg-steel-100 text-steel-700 hover:bg-steel-200 active:scale-[0.99] transition-transform gap-1"
|
||||
title="Service report"
|
||||
>
|
||||
<Printer className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Report</span>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/orders/${order.id}/print?type=invoice&prices=true`}
|
||||
data-testid={`print-invoice-order-card-${order.id}`}
|
||||
className="inline-flex items-center justify-center touch-target px-3 py-2.5 rounded-xl text-sm font-bold bg-steel-100 text-steel-700 hover:bg-steel-200 active:scale-[0.99] transition-transform gap-1"
|
||||
title="Invoice"
|
||||
>
|
||||
<Printer className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Invoice</span>
|
||||
</Link>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`close-order-card-${order.id}`}
|
||||
onClick={() => {
|
||||
setCloseMileage(order.car.mileage ? String(order.car.mileage) : "");
|
||||
setCloseConfirmId(order.id);
|
||||
}}
|
||||
className={`touch-target w-full px-3 py-2.5 rounded-xl text-sm font-bold shadow-sm active:scale-[0.99] transition-transform ${
|
||||
allDone
|
||||
? "bg-green-600 text-white hover:bg-green-700"
|
||||
: "bg-steel-100 text-steel-700 hover:bg-steel-200"
|
||||
}`}
|
||||
>
|
||||
{allDone ? "Finish order" : "Close order"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Previous orders */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2 text-steel-800">
|
||||
<CheckCircle2 className="w-7 h-7 text-green-600" /> Previous orders
|
||||
</h2>
|
||||
{finishedOrders && finishedOrders.length > 0 && (
|
||||
<Link
|
||||
href="/?status=finished"
|
||||
className="text-sm font-semibold text-brand-600 hover:text-brand-700 flex items-center gap-1"
|
||||
>
|
||||
View all <ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{finishedLoading ? (
|
||||
<div className="bg-white rounded-2xl shadow p-8 text-center text-steel-500 text-lg">
|
||||
Loading…
|
||||
</div>
|
||||
) : !finishedOrders || finishedOrders.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-6 text-steel-500">
|
||||
No previous orders.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{finishedOrders.map((order) => (
|
||||
<div
|
||||
key={order.id}
|
||||
data-testid={`finished-order-card-${order.id}`}
|
||||
className="text-left bg-white rounded-2xl shadow p-0 border border-steel-200 hover:border-green-300 hover:shadow-lg transition-all active:scale-[0.99] w-full overflow-hidden"
|
||||
>
|
||||
<Link
|
||||
href={`/orders/${order.id}`}
|
||||
data-testid={`finished-order-card-link-${order.id}`}
|
||||
className="block p-5 pb-0"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<span className="text-2xl sm:text-3xl font-bold text-steel-800">
|
||||
{order.car.regNumber}
|
||||
</span>
|
||||
<span className="shrink-0 text-green-700 bg-green-100 px-2.5 py-1 rounded-full text-xs font-bold uppercase">
|
||||
closed
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-base mb-4">
|
||||
<div className="text-steel-700 font-medium">
|
||||
{formatDate(order.createdAt)}
|
||||
</div>
|
||||
<div className="text-steel-600">
|
||||
{order.car.ownerName || "No owner recorded"}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="px-5 pb-5 pt-0 flex items-center justify-end gap-2">
|
||||
<Link
|
||||
href={`/orders/${order.id}/print?type=service&prices=false`}
|
||||
data-testid={`print-service-finished-order-${order.id}`}
|
||||
className="touch-target px-3 py-2 rounded-lg border border-steel-200 text-steel-600 hover:bg-steel-50 text-sm inline-flex items-center gap-1"
|
||||
title="Service report"
|
||||
>
|
||||
<Printer className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Report</span>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/orders/${order.id}/print?type=invoice&prices=true`}
|
||||
data-testid={`print-invoice-finished-order-${order.id}`}
|
||||
className="touch-target px-3 py-2 rounded-lg border border-steel-200 text-steel-600 hover:bg-steel-50 text-sm inline-flex items-center gap-1"
|
||||
title="Invoice"
|
||||
>
|
||||
<Printer className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Invoice</span>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/orders/${order.id}`}
|
||||
data-testid={`open-finished-order-${order.id}`}
|
||||
className="touch-target px-3 py-2 rounded-lg border border-brand-300 text-brand-700 hover:bg-brand-50 text-sm font-semibold inline-flex items-center"
|
||||
>
|
||||
Open
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Close/Finish order confirmation modal */}
|
||||
{isClient && closeConfirmId && (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={() => setCloseConfirmId(null)} />
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
await closeOrder(closeConfirmId, closeMileage ? Number(closeMileage) : null);
|
||||
}}
|
||||
className="relative w-full sm:max-w-sm bg-white rounded-xl shadow-xl p-6 space-y-4"
|
||||
>
|
||||
<h2 className="text-xl font-bold">
|
||||
{openOrders.find(o => o.id === closeConfirmId)?.tasks.every(t => t.status === "finished")
|
||||
? "Finish order"
|
||||
: "Close order"}
|
||||
</h2>
|
||||
{
|
||||
(() => {
|
||||
const order = openOrders.find(o => o.id === closeConfirmId);
|
||||
if (!order) return null;
|
||||
const remaining = order.tasks.filter(t => t.status !== "finished").length;
|
||||
return remaining > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-amber-700 font-semibold">⚠️ {remaining} task{remaining === 1 ? "" : "s"} not finished</p>
|
||||
<p className="text-steel-600">Some tasks are still open. Are you sure you want to close this order?</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-steel-600">All tasks are complete. Finish this order?</p>
|
||||
);
|
||||
})()
|
||||
}
|
||||
<div>
|
||||
<label className="block text-sm text-steel-500 mb-1">Mileage (optional)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={closeMileage}
|
||||
onChange={(e) => setCloseMileage(e.target.value)}
|
||||
placeholder="e.g. 85000"
|
||||
className="w-full px-4 py-3 border rounded-lg text-base"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button type="button" onClick={() => setCloseConfirmId(null)} className="touch-target px-4 border rounded-xl">Cancel</button>
|
||||
<button type="submit" disabled={closing} className="touch-target bg-brand-600 text-white px-4 rounded-xl">
|
||||
{closing ? <Loader2 className="w-5 h-5 animate-spin" /> : "Confirm"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import useSWR from "swr";
|
||||
import {
|
||||
Package,
|
||||
Plus,
|
||||
Loader2,
|
||||
Trash2,
|
||||
Edit2,
|
||||
Globe,
|
||||
ExternalLink,
|
||||
Search,
|
||||
Car,
|
||||
} from "lucide-react";
|
||||
import { PartWithCar } from "@/types";
|
||||
|
||||
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());
|
||||
|
||||
type Tab = "global" | "car";
|
||||
|
||||
export default function PartsPage() {
|
||||
const [tab, setTab] = useState<Tab>("global");
|
||||
const [carReg, setCarReg] = useState("");
|
||||
const [searchingCar, setSearchingCar] = useState(false);
|
||||
const [selectedCar, setSelectedCar] = useState<{
|
||||
id: number;
|
||||
regNumber: string;
|
||||
make: string | null;
|
||||
model: string | null;
|
||||
} | null>(null);
|
||||
|
||||
const globalUrl = tab === "global" ? `/api/parts` : null;
|
||||
const carUrl = tab === "car" && selectedCar ? `/api/parts?carId=${selectedCar.id}&includeGlobal=false` : null;
|
||||
|
||||
function fmtPrice(amount?: number | null) {
|
||||
if (amount == null) return "€0.00";
|
||||
return `€${amount.toFixed(2)}`;
|
||||
}
|
||||
|
||||
const { data: globalParts, mutate: mutateGlobal } = useSWR<PartWithCar[]>(globalUrl, fetcher);
|
||||
const { data: carParts, mutate: mutateCar } = useSWR<PartWithCar[]>(carUrl, fetcher);
|
||||
|
||||
const parts = tab === "global" ? globalParts : carParts;
|
||||
const mutate = tab === "global" ? mutateGlobal : mutateCar;
|
||||
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [editingPart, setEditingPart] = useState<PartWithCar | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [manufacturer, setManufacturer] = useState("");
|
||||
const [price, setPrice] = useState("");
|
||||
const [scrapeUrl, setScrapeUrl] = useState("");
|
||||
const [scraping, setScraping] = useState(false);
|
||||
|
||||
async function searchCar() {
|
||||
if (!carReg.trim()) return;
|
||||
setSearchingCar(true);
|
||||
const res = await fetch(`/api/cars?q=${encodeURIComponent(carReg.trim())}`);
|
||||
const cars = await res.json();
|
||||
if (Array.isArray(cars) && cars.length > 0) {
|
||||
setSelectedCar(cars[0]);
|
||||
} else {
|
||||
alert("No car found with that registration.");
|
||||
setSelectedCar(null);
|
||||
}
|
||||
setSearchingCar(false);
|
||||
}
|
||||
|
||||
async function scrapePart() {
|
||||
if (!scrapeUrl.trim()) return;
|
||||
setScraping(true);
|
||||
try {
|
||||
const res = await fetch("/api/scrape-part", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: scrapeUrl }),
|
||||
});
|
||||
const info = await res.json();
|
||||
if (!res.ok || info.error) {
|
||||
alert(info.error || "Could not extract part details from this URL.");
|
||||
} else {
|
||||
if (info.name) setName(info.name);
|
||||
if (info.manufacturer) setManufacturer(info.manufacturer);
|
||||
if (info.price != null) setPrice(String(info.price));
|
||||
if (!info.name && !info.manufacturer && info.price == null) {
|
||||
alert("No extractable details found on this page.");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
alert("Scrape request failed.");
|
||||
}
|
||||
setScraping(false);
|
||||
}
|
||||
|
||||
async function addPart(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const res = await fetch("/api/parts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
manufacturer,
|
||||
purchasePrice: Number(price) || 0,
|
||||
supplierLink: scrapeUrl || null,
|
||||
isGlobal: tab === "global",
|
||||
carId: tab === "car" && selectedCar ? selectedCar.id : undefined,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: "Failed to add part" }));
|
||||
alert(err.error || "Failed to add part");
|
||||
return;
|
||||
}
|
||||
mutate();
|
||||
setAdding(false);
|
||||
setName("");
|
||||
setManufacturer("");
|
||||
setPrice("");
|
||||
setScrapeUrl("");
|
||||
}
|
||||
|
||||
async function removePart(id: number) {
|
||||
await fetch(`/api/parts/${id}`, { method: "DELETE" });
|
||||
mutate();
|
||||
}
|
||||
|
||||
function startEditPart(part: PartWithCar) {
|
||||
setEditingPart(part);
|
||||
setName(part.name);
|
||||
setManufacturer(part.manufacturer || "");
|
||||
setPrice(String(part.purchasePrice || 0));
|
||||
setScrapeUrl(part.supplierLink || part.url || "");
|
||||
}
|
||||
|
||||
async function savePartEdit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!editingPart) return;
|
||||
const res = await fetch("/api/parts", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: editingPart.id,
|
||||
name,
|
||||
manufacturer,
|
||||
purchasePrice: Number(price) || 0,
|
||||
supplierLink: scrapeUrl || null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: "Failed to update part" }));
|
||||
alert(err.error || "Failed to update part");
|
||||
return;
|
||||
}
|
||||
mutate();
|
||||
setEditingPart(null);
|
||||
setName("");
|
||||
setManufacturer("");
|
||||
setPrice("");
|
||||
setScrapeUrl("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" data-testid="parts-page">
|
||||
<style jsx global>{`
|
||||
.part-row:hover { background-color: #f5f7fa; }
|
||||
.part-link:hover { text-decoration: underline; }
|
||||
.btn-primary:hover { background-color: #4b6b3c; }
|
||||
.tab-inactive:hover { background-color: #c8cdd5; }
|
||||
.btn-delete:hover { background-color: #fef2f2; }
|
||||
`}</style>
|
||||
<Navbar title="Parts" />
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-6">
|
||||
<BackLink href="/" label="Dashboard" />
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Package className="w-6 h-6 text-brand-600" /> Parts library
|
||||
</h1>
|
||||
<button
|
||||
data-testid="parts-add-button"
|
||||
onClick={() => setAdding(true)}
|
||||
className="touch-target bg-brand-600 text-white px-4 rounded-xl flex items-center gap-2 text-base btn-primary"
|
||||
>
|
||||
<Plus className="w-5 h-5" /> Add part
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setTab("global")}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-semibold ${
|
||||
tab === "global"
|
||||
? "bg-brand-600 text-white"
|
||||
: "bg-steel-100 text-steel-700 tab-inactive"
|
||||
}`}
|
||||
>
|
||||
<Globe className="w-4 h-4 inline mr-1" /> Global
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab("car")}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-semibold ${
|
||||
tab === "car"
|
||||
? "bg-brand-600 text-white"
|
||||
: "bg-steel-100 text-steel-700 tab-inactive"
|
||||
}`}
|
||||
>
|
||||
<Car className="w-4 h-4 inline mr-1" /> Car Specific
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Car search */}
|
||||
{tab === "car" && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-steel-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search car reg..."
|
||||
value={carReg}
|
||||
onChange={(e) => setCarReg(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && searchCar()}
|
||||
className="w-full pl-9 pr-4 py-2 border rounded-lg text-base"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={searchCar}
|
||||
disabled={searchingCar || !carReg.trim()}
|
||||
className="touch-target px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-semibold disabled:opacity-50"
|
||||
>
|
||||
{searchingCar ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
"Search"
|
||||
)}
|
||||
</button>
|
||||
{selectedCar && (
|
||||
<span className="text-sm text-steel-600">
|
||||
Showing parts for{" "}
|
||||
<Link
|
||||
href={`/cars/${selectedCar.id}`}
|
||||
className="text-brand-600 font-semibold part-link"
|
||||
>
|
||||
{selectedCar.regNumber}
|
||||
{(selectedCar.make || selectedCar.model) && (
|
||||
<span className="text-steel-500">
|
||||
{" "}({selectedCar.make || ""} {selectedCar.model || ""})
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{tab === "car" && !selectedCar ? (
|
||||
<div className="text-center text-steel-500 py-12">
|
||||
Search for a car registration to view its specific parts.
|
||||
</div>
|
||||
) : parts === undefined ? (
|
||||
<Loading />
|
||||
) : parts.length === 0 ? (
|
||||
<div className="text-center text-steel-500 py-12">
|
||||
{tab === "global"
|
||||
? "No global parts saved yet."
|
||||
: `No specific parts for ${selectedCar?.regNumber} yet.`}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl shadow overflow-hidden border border-steel-200 overflow-x-auto">
|
||||
<table
|
||||
className="w-full min-w-[640px] text-sm"
|
||||
data-testid="parts-table"
|
||||
>
|
||||
<thead className="bg-steel-100">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-semibold text-steel-700 whitespace-nowrap">
|
||||
Name
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-steel-700 whitespace-nowrap">
|
||||
Manufacturer
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-steel-700 whitespace-nowrap">
|
||||
Price
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-steel-700 whitespace-nowrap">
|
||||
Supplier
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-steel-700 whitespace-nowrap">
|
||||
Scope
|
||||
</th>
|
||||
<th className="px-4 py-3 w-16"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-steel-100">
|
||||
{parts.map((part) => (
|
||||
<tr
|
||||
key={part.id}
|
||||
data-testid={`part-row-${part.id}`}
|
||||
className="part-row"
|
||||
>
|
||||
<td
|
||||
className="px-4 py-3 font-medium text-steel-800 whitespace-nowrap"
|
||||
data-testid={`part-name-${part.id}`}
|
||||
>
|
||||
{part.name}
|
||||
</td>
|
||||
<td
|
||||
className="px-4 py-3 text-steel-600 whitespace-nowrap"
|
||||
data-testid={`part-manufacturer-${part.id}`}
|
||||
>
|
||||
{part.manufacturer || "—"}
|
||||
</td>
|
||||
<td
|
||||
className="px-4 py-3 font-semibold text-steel-800 whitespace-nowrap"
|
||||
data-testid={`part-price-${part.id}`}
|
||||
>
|
||||
{fmtPrice(part.purchasePrice)}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
{part.supplierLink || part.url ? (
|
||||
<Link
|
||||
href={part.supplierLink || part.url || "#"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-brand-600 part-link"
|
||||
data-testid={`part-link-${part.id}`}
|
||||
>
|
||||
<Globe className="w-4 h-4" /> Link
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-steel-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
{part.isGlobal ? (
|
||||
<span className="inline-flex items-center gap-1 text-steel-600">
|
||||
<Globe className="w-4 h-4" /> Global
|
||||
</span>
|
||||
) : part.car ? (
|
||||
<Link
|
||||
href={`/cars/${part.carId}`}
|
||||
className="inline-flex items-center gap-1 text-brand-600 part-link"
|
||||
>
|
||||
<Car className="w-4 h-4 shrink-0" />
|
||||
<span>
|
||||
{part.car.regNumber}
|
||||
{(part.car.make || part.car.model) && (
|
||||
<span className="text-steel-500">
|
||||
{" "}({part.car.make || ""} {part.car.model || ""})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-steel-400">Private</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right w-16">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`part-edit-${part.id}`}
|
||||
onClick={() => startEditPart(part)}
|
||||
className="touch-target p-2 text-steel-600 btn-neutral rounded-lg"
|
||||
aria-label="Edit part"
|
||||
>
|
||||
<Edit2 className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`part-delete-${part.id}`}
|
||||
onClick={() => removePart(part.id)}
|
||||
className="touch-target p-2 text-red-600 btn-delete rounded-lg"
|
||||
aria-label="Delete part"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add / Edit modal */}
|
||||
{(adding || editingPart) && (
|
||||
<div className="fixed inset-0 z-40 flex items-end sm:items-center justify-end sm:justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40"
|
||||
onClick={() => {
|
||||
setAdding(false);
|
||||
setEditingPart(null);
|
||||
}}
|
||||
/>
|
||||
<form
|
||||
onSubmit={editingPart ? savePartEdit : addPart}
|
||||
className="relative w-full sm:max-w-md bg-white rounded-t-xl sm:rounded-xl shadow-xl p-6 space-y-4 max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
<h2 className="text-xl font-bold">
|
||||
{editingPart
|
||||
? "Edit part"
|
||||
: `Add ${tab === "global" ? "global" : "car-specific"} part`}
|
||||
{tab === "car" && selectedCar && !editingPart ? (
|
||||
<span className="block text-sm font-normal text-steel-500 mt-1">
|
||||
for {selectedCar.regNumber}
|
||||
</span>
|
||||
) : null}
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
data-testid="scrape-url-input"
|
||||
type="url"
|
||||
placeholder="Paste supplier URL..."
|
||||
value={scrapeUrl}
|
||||
onChange={(e) => setScrapeUrl(e.target.value)}
|
||||
className="flex-1 px-4 py-3 border rounded-lg text-base"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="scrape-button"
|
||||
onClick={scrapePart}
|
||||
disabled={scraping || !scrapeUrl.trim()}
|
||||
className="touch-target px-4 bg-steel-700 text-white rounded-lg flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
{scraping ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<ExternalLink className="w-5 h-5" />
|
||||
)}
|
||||
Scrape
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
data-testid="part-name-input"
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-4 py-3 border rounded-lg text-base"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
data-testid="part-manufacturer-input"
|
||||
type="text"
|
||||
placeholder="Manufacturer"
|
||||
value={manufacturer}
|
||||
onChange={(e) => setManufacturer(e.target.value)}
|
||||
className="w-full px-4 py-3 border rounded-lg text-base"
|
||||
/>
|
||||
<input
|
||||
data-testid="part-price-input"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="Price €"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
className="w-full px-4 py-3 border rounded-lg text-base"
|
||||
required
|
||||
/>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="part-modal-cancel"
|
||||
onClick={() => {
|
||||
setAdding(false);
|
||||
setEditingPart(null);
|
||||
}}
|
||||
className="touch-target px-4 border rounded-xl text-base"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="part-modal-save"
|
||||
className="touch-target bg-brand-600 text-white px-4 rounded-xl flex items-center gap-2 text-base"
|
||||
>
|
||||
<Plus className="w-5 h-5" /> Add
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import {
|
||||
List,
|
||||
Plus,
|
||||
Trash2,
|
||||
Save,
|
||||
X,
|
||||
Edit2,
|
||||
Package,
|
||||
Globe,
|
||||
} from "lucide-react";
|
||||
import { QuickTaskWithParts } from "@/types";
|
||||
import { formatCurrency } 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());
|
||||
|
||||
type TemplatePart = {
|
||||
id?: number;
|
||||
name: string;
|
||||
qty: number;
|
||||
price: number;
|
||||
isGlobal: boolean;
|
||||
};
|
||||
|
||||
function blankPart(): TemplatePart {
|
||||
return { name: "", qty: 1, price: 0, isGlobal: false };
|
||||
}
|
||||
|
||||
export default function QuickTasksPage() {
|
||||
const { data: tasks, mutate } = useSWR<QuickTaskWithParts[]>(
|
||||
"/api/quick-tasks",
|
||||
fetcher,
|
||||
);
|
||||
const [newTask, setNewTask] = useState("");
|
||||
const [newLaborHours, setNewLaborHours] = useState("0");
|
||||
const [newHourlyRate, setNewHourlyRate] = useState("50");
|
||||
const [newPrice, setNewPrice] = useState("");
|
||||
const [newParts, setNewParts] = useState<TemplatePart[]>([blankPart()]);
|
||||
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
const [editLaborHours, setEditLaborHours] = useState("0");
|
||||
const [editHourlyRate, setEditHourlyRate] = useState("50");
|
||||
const [editPrice, setEditPrice] = useState("");
|
||||
const [editParts, setEditParts] = useState<TemplatePart[]>([blankPart()]);
|
||||
|
||||
function startEdit(task: QuickTaskWithParts) {
|
||||
setEditingId(task.id);
|
||||
setEditTitle(task.title);
|
||||
setEditLaborHours(String(task.laborHours));
|
||||
setEditHourlyRate(String(task.hourlyRate));
|
||||
setEditPrice(task.price != null ? String(task.price) : "");
|
||||
setEditParts(task.parts.length > 0 ? task.parts : [blankPart()]);
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditingId(null);
|
||||
setEditTitle("");
|
||||
setEditLaborHours("0");
|
||||
setEditHourlyRate("50");
|
||||
setEditPrice("");
|
||||
setEditParts([blankPart()]);
|
||||
}
|
||||
|
||||
async function add(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!newTask.trim()) return;
|
||||
const res = await fetch("/api/quick-tasks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: newTask,
|
||||
laborHours: Number(newLaborHours),
|
||||
hourlyRate: Number(newHourlyRate),
|
||||
price: newPrice ? Number(newPrice) : null,
|
||||
parts: newParts.filter((p) => p.name.trim()),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const info = await res.json().catch(() => ({}));
|
||||
alert(info.error || "Failed to save quick task");
|
||||
return;
|
||||
}
|
||||
mutate();
|
||||
setNewTask("");
|
||||
setNewLaborHours("0");
|
||||
setNewHourlyRate("50");
|
||||
setNewPrice("");
|
||||
setNewParts([blankPart()]);
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editingId) return;
|
||||
await fetch("/api/quick-tasks", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: editingId,
|
||||
title: editTitle,
|
||||
laborHours: Number(editLaborHours),
|
||||
hourlyRate: Number(editHourlyRate),
|
||||
price: editPrice ? Number(editPrice) : null,
|
||||
parts: editParts.filter((p) => p.name.trim()),
|
||||
}),
|
||||
});
|
||||
mutate();
|
||||
cancelEdit();
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
await fetch("/api/quick-tasks", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
mutate();
|
||||
}
|
||||
|
||||
async function removeEmptyTasks() {
|
||||
const empty = tasks?.filter((t) => !t.title.trim()) || [];
|
||||
for (const t of empty) {
|
||||
await fetch("/api/quick-tasks", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: t.id }),
|
||||
});
|
||||
}
|
||||
mutate();
|
||||
}
|
||||
|
||||
function updatePart(
|
||||
list: TemplatePart[],
|
||||
idx: number,
|
||||
field: keyof TemplatePart,
|
||||
value: string | boolean,
|
||||
) {
|
||||
const next = [...list];
|
||||
const part = { ...next[idx] };
|
||||
if (field === "isGlobal") {
|
||||
part.isGlobal = !!value;
|
||||
} else if (field === "name") {
|
||||
part.name = String(value);
|
||||
} else {
|
||||
part[field] = Number(value) || 0;
|
||||
}
|
||||
next[idx] = part;
|
||||
return next;
|
||||
}
|
||||
|
||||
function addPartSlot(
|
||||
list: TemplatePart[],
|
||||
setter: (p: TemplatePart[]) => void,
|
||||
) {
|
||||
setter([...list, blankPart()]);
|
||||
}
|
||||
|
||||
function removePartSlot(
|
||||
list: TemplatePart[],
|
||||
setter: (p: TemplatePart[]) => void,
|
||||
idx: number,
|
||||
) {
|
||||
if (list.length <= 1) {
|
||||
setter([blankPart()]);
|
||||
} else {
|
||||
setter(list.filter((_, i) => i !== idx));
|
||||
}
|
||||
}
|
||||
|
||||
const estimatedTotal = (
|
||||
hours: number,
|
||||
rate: number,
|
||||
parts: TemplatePart[],
|
||||
) => {
|
||||
return parts.reduce((s, p) => s + p.qty * p.price, 0) + hours * rate;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-12">
|
||||
<Navbar title="Quick tasks" />
|
||||
<main className="max-w-4xl mx-auto px-4 py-6">
|
||||
<BackLink href="/" label="Dashboard" />
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<List className="w-6 h-6 text-brand-600" /> Quick tasks
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={add}
|
||||
className="bg-white rounded-lg shadow p-4 mb-6 space-y-4"
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newTask}
|
||||
onChange={(e) => setNewTask(e.target.value)}
|
||||
placeholder="Task title..."
|
||||
data-testid="quick-task-title-input"
|
||||
className="px-4 py-3 border rounded-lg text-base"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={newLaborHours}
|
||||
onChange={(e) => setNewLaborHours(e.target.value)}
|
||||
placeholder="Hours"
|
||||
data-testid="quick-task-hours-input"
|
||||
className="px-3 py-3 border rounded-lg text-base"
|
||||
title="Labor hours"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={newHourlyRate}
|
||||
onChange={(e) => setNewHourlyRate(e.target.value)}
|
||||
placeholder="Rate €"
|
||||
data-testid="quick-task-rate-input"
|
||||
className="px-3 py-3 border rounded-lg text-base"
|
||||
title="Hourly rate €"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={newPrice}
|
||||
onChange={(e) => setNewPrice(e.target.value)}
|
||||
placeholder="Manual €"
|
||||
data-testid="quick-task-price-input"
|
||||
className="px-3 py-3 border rounded-lg text-base"
|
||||
title="Manual price override €"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="quick-task-add-button"
|
||||
className="touch-target bg-brand-600 text-white px-4 rounded-xl flex items-center justify-center gap-2 text-base"
|
||||
>
|
||||
<Plus className="w-5 h-5" /> Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-3">
|
||||
<p className="text-sm font-medium text-steel-600 mb-2 flex items-center gap-2">
|
||||
<Package className="w-4 h-4" /> Default parts
|
||||
</p>
|
||||
{newParts.map((part, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="grid grid-cols-1 sm:grid-cols-5 gap-2 mb-2 items-start sm:items-center"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={part.name}
|
||||
onChange={(e) =>
|
||||
setNewParts(
|
||||
updatePart(newParts, idx, "name", e.target.value),
|
||||
)
|
||||
}
|
||||
placeholder="Part name"
|
||||
className="px-3 py-2 border rounded-lg text-base"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
step="1"
|
||||
min="1"
|
||||
value={part.qty}
|
||||
onChange={(e) =>
|
||||
setNewParts(
|
||||
updatePart(newParts, idx, "qty", e.target.value),
|
||||
)
|
||||
}
|
||||
placeholder="Qty"
|
||||
className="px-3 py-2 border rounded-lg text-base"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={part.price}
|
||||
onChange={(e) =>
|
||||
setNewParts(
|
||||
updatePart(newParts, idx, "price", e.target.value),
|
||||
)
|
||||
}
|
||||
placeholder="Price €"
|
||||
className="px-3 py-2 border rounded-lg text-base"
|
||||
/>
|
||||
<label className="flex items-center gap-1 text-sm text-steel-600 whitespace-nowrap cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={part.isGlobal}
|
||||
onChange={(e) =>
|
||||
setNewParts(
|
||||
updatePart(newParts, idx, "isGlobal", e.target.checked),
|
||||
)
|
||||
}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<Globe className="w-4 h-4" /> Global
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePartSlot(newParts, setNewParts, idx)}
|
||||
className="touch-target text-red-600 hover:bg-red-50 p-2 rounded-lg justify-self-start"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addPartSlot(newParts, setNewParts)}
|
||||
className="text-sm text-brand-600 hover:text-brand-700 font-medium"
|
||||
>
|
||||
+ Add another part
|
||||
</button>
|
||||
<p className="text-sm text-steel-500 mt-2">
|
||||
Estimated:{" "}
|
||||
{formatCurrency(
|
||||
estimatedTotal(
|
||||
Number(newLaborHours) || 0,
|
||||
Number(newHourlyRate) || 50,
|
||||
newParts,
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{tasks === undefined ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-steel-100">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 min-w-[120px]">Task</th>
|
||||
<th className="text-left px-4 py-3">Pricing</th>
|
||||
<th className="text-left px-4 py-3">Default parts</th>
|
||||
<th className="text-left px-4 py-3">Used</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks
|
||||
.filter((t) => t.title.trim())
|
||||
.map((task) => (
|
||||
<tr key={task.id} className="border-t hover:bg-steel-50">
|
||||
<td className="px-4 py-3 align-top">
|
||||
{editingId === task.id ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg text-base"
|
||||
/>
|
||||
) : (
|
||||
<span className="font-medium">{task.title}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
{editingId === task.id ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={editLaborHours}
|
||||
onChange={(e) =>
|
||||
setEditLaborHours(e.target.value)
|
||||
}
|
||||
className="px-2 py-2 border rounded-lg text-base"
|
||||
title="Hours"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={editHourlyRate}
|
||||
onChange={(e) =>
|
||||
setEditHourlyRate(e.target.value)
|
||||
}
|
||||
className="px-2 py-2 border rounded-lg text-base"
|
||||
title="Rate €"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={editPrice}
|
||||
onChange={(e) => setEditPrice(e.target.value)}
|
||||
className="px-2 py-2 border rounded-lg text-base"
|
||||
placeholder="Manual €"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-steel-600">
|
||||
{task.laborHours}h ×{" "}
|
||||
{formatCurrency(task.hourlyRate)}/h
|
||||
{task.price != null && (
|
||||
<div className="text-steel-500">
|
||||
Manual: {formatCurrency(task.price)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
{editingId === task.id ? (
|
||||
<div className="space-y-2">
|
||||
{editParts.map((part, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={part.name}
|
||||
onChange={(e) =>
|
||||
setEditParts(
|
||||
updatePart(
|
||||
editParts,
|
||||
idx,
|
||||
"name",
|
||||
e.target.value,
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder="Part name"
|
||||
className="flex-1 px-2 py-2 border rounded-lg text-base"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
step="1"
|
||||
min="1"
|
||||
value={part.qty}
|
||||
onChange={(e) =>
|
||||
setEditParts(
|
||||
updatePart(
|
||||
editParts,
|
||||
idx,
|
||||
"qty",
|
||||
e.target.value,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="w-16 px-2 py-2 border rounded-lg text-base"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={part.price}
|
||||
onChange={(e) =>
|
||||
setEditParts(
|
||||
updatePart(
|
||||
editParts,
|
||||
idx,
|
||||
"price",
|
||||
e.target.value,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="w-20 px-2 py-2 border rounded-lg text-base"
|
||||
/>
|
||||
<label className="flex items-center gap-1 text-xs text-steel-600 whitespace-nowrap cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={part.isGlobal}
|
||||
onChange={(e) =>
|
||||
setEditParts(
|
||||
updatePart(
|
||||
editParts,
|
||||
idx,
|
||||
"isGlobal",
|
||||
e.target.checked,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<Globe className="w-3 h-3" />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
removePartSlot(editParts, setEditParts, idx)
|
||||
}
|
||||
className="touch-target text-red-600 hover:bg-red-50 p-1 rounded-lg"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
addPartSlot(editParts, setEditParts)
|
||||
}
|
||||
className="text-xs text-brand-600 hover:text-brand-700 font-medium"
|
||||
>
|
||||
+ Add part
|
||||
</button>
|
||||
</div>
|
||||
) : task.parts.filter((p) => p.name.trim()).length >
|
||||
0 ? (
|
||||
<ul className="text-steel-600 space-y-1">
|
||||
{task.parts
|
||||
.filter((p) => p.name.trim())
|
||||
.map((p) => (
|
||||
<li
|
||||
key={p.id}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{p.isGlobal && (
|
||||
<Globe className="w-3 h-3 text-brand-600" />
|
||||
)}
|
||||
{p.name} × {p.qty} @ {formatCurrency(p.price)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<span className="text-steel-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">{task.useCount}</td>
|
||||
<td className="px-4 py-3 align-top text-right whitespace-nowrap">
|
||||
{editingId === task.id ? (
|
||||
<div className="flex gap-1 justify-end">
|
||||
<button
|
||||
onClick={saveEdit}
|
||||
className="touch-target text-green-700 hover:bg-green-50 p-2 rounded-lg"
|
||||
title="Save"
|
||||
>
|
||||
<Save className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEdit}
|
||||
className="touch-target text-steel-600 hover:bg-steel-50 p-2 rounded-lg"
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-1 justify-end">
|
||||
<button
|
||||
onClick={() => startEdit(task)}
|
||||
className="touch-target text-brand-600 hover:bg-brand-50 p-2 rounded-lg"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit2 className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => remove(task.id)}
|
||||
className="touch-target text-red-600 hover:bg-red-50 p-2 rounded-lg"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
|
||||
export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname === "/login") {
|
||||
setChecked(true);
|
||||
return;
|
||||
}
|
||||
const user = localStorage.getItem("user");
|
||||
if (!user) {
|
||||
router.push("/login");
|
||||
} else {
|
||||
setChecked(true);
|
||||
}
|
||||
}, [pathname, router]);
|
||||
|
||||
if (!checked) {
|
||||
return <div className="min-h-screen bg-steel-100 dark:bg-steel-900" />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
export default function BackLink({ href = "/", label = "Back" }: { href?: string; label?: string }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
prefetch={false}
|
||||
className="inline-flex touch-target items-center gap-1 text-sm text-steel-600 hover:text-brand-600 mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> {label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-12 text-steel-400">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "@/lib/theme";
|
||||
import {
|
||||
Home,
|
||||
Search,
|
||||
Package,
|
||||
List,
|
||||
Plus,
|
||||
Car,
|
||||
Settings,
|
||||
X,
|
||||
Menu,
|
||||
LogOut,
|
||||
Wrench,
|
||||
Sun,
|
||||
Moon,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
|
||||
export default function Navbar({
|
||||
title,
|
||||
subtitle,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [showChangePw, setShowChangePw] = useState(false);
|
||||
const [currentPw, setCurrentPw] = useState("");
|
||||
const [newPw, setNewPw] = useState("");
|
||||
const [pwError, setPwError] = useState("");
|
||||
const [pwOk, setPwOk] = useState("");
|
||||
|
||||
function handleLogout() {
|
||||
localStorage.removeItem("user");
|
||||
window.location.href = "/login";
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: "dashboard",
|
||||
href: "/",
|
||||
icon: <Home className="w-5 h-5" />,
|
||||
label: "Dashboard",
|
||||
},
|
||||
{
|
||||
id: "cars",
|
||||
href: "/cars",
|
||||
icon: <Search className="w-5 h-5" />,
|
||||
label: "Cars",
|
||||
},
|
||||
{
|
||||
id: "parts",
|
||||
href: "/parts",
|
||||
icon: <Package className="w-5 h-5" />,
|
||||
label: "Parts",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-40 bg-steel-800 text-white shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 flex items-center justify-between gap-4">
|
||||
<Link href="/" className="flex items-center gap-3 min-w-0">
|
||||
<div className="bg-brand-600 p-2 rounded-lg shadow-sm shrink-0">
|
||||
<Car className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-base sm:text-lg font-bold truncate">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-steel-300 truncate hidden sm:block">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Link
|
||||
href="/orders/new"
|
||||
data-testid="new-order-link"
|
||||
className="touch-target bg-brand-600 text-white px-3 sm:px-4 rounded-lg font-medium flex items-center gap-2 hover:bg-brand-500 shadow-sm whitespace-nowrap"
|
||||
aria-label="New order"
|
||||
>
|
||||
<Plus className="w-5 h-5" />{" "}
|
||||
<span className="hidden sm:inline">New order</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className="touch-target p-2 rounded-lg hover:bg-steel-700"
|
||||
aria-label="Settings"
|
||||
>
|
||||
<Settings className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="bg-steel-700 border-t border-steel-600">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6">
|
||||
<div className="flex overflow-x-auto">
|
||||
{tabs.map((tab) => {
|
||||
const active =
|
||||
pathname === tab.href || pathname.startsWith(`${tab.href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
data-testid={`nav-${tab.id}`}
|
||||
className={`touch-target flex items-center gap-2 px-4 sm:px-5 text-base font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
active
|
||||
? "border-brand-400 text-white"
|
||||
: "border-transparent text-steel-300 hover:text-white hover:bg-steel-600"
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{menuOpen && (
|
||||
<nav className="bg-steel-700 border-t border-steel-600 lg:hidden">
|
||||
<div className="max-w-7xl mx-auto px-4 py-2 space-y-1">
|
||||
{tabs.map((tab) => {
|
||||
const active =
|
||||
pathname === tab.href || pathname.startsWith(`${tab.href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className={`touch-target flex items-center gap-3 px-4 rounded-lg text-base font-medium ${
|
||||
active
|
||||
? "bg-steel-600 text-white"
|
||||
: "text-steel-300 hover:bg-steel-600 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{settingsOpen && (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40"
|
||||
onClick={() => setSettingsOpen(false)}
|
||||
/>
|
||||
<div className="absolute right-4 sm:right-6 top-14 sm:top-16 bg-white dark:bg-steel-800 rounded-xl shadow-xl w-full max-w-sm p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||
<Settings className="w-5 h-5" /> Settings
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setSettingsOpen(false)}
|
||||
className="touch-target p-2 rounded-lg hover:bg-steel-100"
|
||||
aria-label="Close settings"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-steel-700 mb-1">
|
||||
Site name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={title}
|
||||
className="w-full px-4 py-3 border rounded-lg text-base"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Link
|
||||
href="/quick-tasks"
|
||||
onClick={() => setSettingsOpen(false)}
|
||||
className="touch-target flex items-center gap-2 px-4 py-3 border border-steel-200 rounded-lg text-steel-700 hover:bg-steel-50 text-base"
|
||||
>
|
||||
<List className="w-5 h-5" /> Quick tasks
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<button
|
||||
onClick={() => toggleTheme()}
|
||||
className="touch-target w-full flex items-center gap-2 px-4 py-3 border border-steel-200 rounded-lg text-steel-700 hover:bg-steel-50 text-base"
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<> <Sun className="w-5 h-5" /> <span>Switch to light</span> </>
|
||||
) : (
|
||||
<> <Moon className="w-5 h-5" /> <span>Switch to dark</span> </>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-steel-100">
|
||||
{!showChangePw ? (
|
||||
<button
|
||||
onClick={() => setShowChangePw(true)}
|
||||
className="touch-target w-full flex items-center gap-2 px-4 py-3 text-steel-600 hover:bg-steel-50 rounded-lg text-base"
|
||||
>
|
||||
<Lock className="w-5 h-5" /> Change password
|
||||
</button>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
setPwError("");
|
||||
setPwOk("");
|
||||
const user = JSON.parse(localStorage.getItem("user") || "{}");
|
||||
const res = await fetch("/api/auth", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "change-password", username: user.username, password: currentPw, newPassword: newPw }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (res.ok) {
|
||||
setPwOk("Password updated");
|
||||
setCurrentPw("");
|
||||
setNewPw("");
|
||||
setTimeout(() => setShowChangePw(false), 1500);
|
||||
} else {
|
||||
setPwError(json.error || "Failed");
|
||||
}
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="text-sm font-medium text-steel-700">Change password</div>
|
||||
{pwError && <div className="text-sm text-red-600">{pwError}</div>}
|
||||
{pwOk && <div className="text-sm text-green-600">{pwOk}</div>}
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Current password"
|
||||
value={currentPw}
|
||||
onChange={(e) => setCurrentPw(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg text-base"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="New password"
|
||||
value={newPw}
|
||||
onChange={(e) => setNewPw(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg text-base"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => setShowChangePw(false)} className="touch-target flex-1 px-3 py-2 border rounded-lg text-sm">Cancel</button>
|
||||
<button type="submit" className="touch-target flex-1 px-3 py-2 bg-brand-600 text-white rounded-lg text-sm">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="touch-target w-full flex items-center gap-2 px-4 py-3 text-steel-600 hover:bg-steel-50 rounded-lg text-base mt-2"
|
||||
>
|
||||
<LogOut className="w-5 h-5" /> Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider } from "@/lib/theme";
|
||||
import AuthGuard from "@/components/AuthGuard";
|
||||
|
||||
export default function RootProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthGuard>{children}</AuthGuard>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function jsonResponse(data: unknown, status = 200) {
|
||||
return NextResponse.json(data, { status });
|
||||
}
|
||||
|
||||
export function errorResponse(message: string, status = 400) {
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
|
||||
export async function getJsonBody(req: Request) {
|
||||
try {
|
||||
return await req.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export function formatCurrency(amount: number) {
|
||||
return `€${amount.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function formatDateTime(date?: Date | string | null) {
|
||||
if (!date) return "—";
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
return d.toLocaleString("en-IE", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDate(date?: Date | string | null) {
|
||||
if (!date) return "—";
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
return d.toLocaleDateString("en-IE", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const globalForPrisma = global as unknown as { prisma?: PrismaClient };
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as cheerio from "cheerio";
|
||||
|
||||
export interface PartInfo {
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
price?: number;
|
||||
}
|
||||
|
||||
export async function scrapePartInfo(url: string): Promise<PartInfo | null> {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
||||
},
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const html = await res.text();
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const title =
|
||||
$("title").text().trim() ||
|
||||
$('meta[property="og:title"]').attr("content") ||
|
||||
$("h1").first().text().trim();
|
||||
|
||||
const brandMeta =
|
||||
$('meta[property="product:brand"]').attr("content") ||
|
||||
$("[data-brand]").first().attr("data-brand") ||
|
||||
$(".brand").first().text().trim();
|
||||
|
||||
// Price extraction from common e-commerce patterns
|
||||
let price: number | undefined;
|
||||
|
||||
// 1. Structured data (JSON-LD)
|
||||
const jsonLd = $("script[type='application/ld+json']").toArray();
|
||||
for (const el of jsonLd) {
|
||||
try {
|
||||
const raw = $(el).text().trim();
|
||||
const data = JSON.parse(raw);
|
||||
const offers = Array.isArray(data.offers) ? data.offers[0] : data.offers;
|
||||
if (offers?.price) {
|
||||
price = parseFloat(offers.price);
|
||||
if (!Number.isNaN(price)) break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 2. Meta tags
|
||||
if (!price) {
|
||||
const metaPrice =
|
||||
$('meta[property="product:price:amount"]').attr("content") ||
|
||||
$("[data-price]").first().attr("data-price") ||
|
||||
$("[data-product-price]").first().attr("data-product-price") ||
|
||||
$("[itemprop='price']").first().attr("content");
|
||||
if (metaPrice) {
|
||||
const parsed = parseFloat(metaPrice.replace(/[^\d.]/g, ""));
|
||||
if (!Number.isNaN(parsed)) price = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. DOM text
|
||||
if (!price) {
|
||||
const selectors = [
|
||||
".price-current",
|
||||
".price .amount",
|
||||
".current-price",
|
||||
".product-price",
|
||||
"[class*='price']",
|
||||
];
|
||||
for (const sel of selectors) {
|
||||
const text = $(sel).first().text().trim();
|
||||
if (text) {
|
||||
const digits = text.replace(/[^\d.,]/g, "").replace(",", ".");
|
||||
const parsed = parseFloat(digits);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
price = parsed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: title || undefined,
|
||||
manufacturer: brandMeta || undefined,
|
||||
price,
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn("Scrape failed", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useState, ReactNode } from "react";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
toggleTheme: () => void;
|
||||
setTheme: (t: Theme) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType>({
|
||||
theme: "light",
|
||||
toggleTheme: () => {},
|
||||
setTheme: () => {},
|
||||
});
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>("light");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const saved = localStorage.getItem("theme") as Theme | null;
|
||||
if (saved === "dark" || saved === "light") {
|
||||
setThemeState(saved);
|
||||
document.documentElement.classList.toggle("dark", saved === "dark");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setTheme = (t: Theme) => {
|
||||
setThemeState(t);
|
||||
localStorage.setItem("theme", t);
|
||||
document.documentElement.classList.toggle("dark", t === "dark");
|
||||
};
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme === "light" ? "dark" : "light");
|
||||
};
|
||||
|
||||
if (!mounted) {
|
||||
return <div className="bg-steel-100 text-steel-900 min-h-screen">{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
export interface VehicleLookupResult {
|
||||
make?: string;
|
||||
model?: string;
|
||||
color?: string;
|
||||
year?: number;
|
||||
}
|
||||
|
||||
function normalizeReg(reg: string): string {
|
||||
return reg
|
||||
.replace(/\s+/g, "")
|
||||
.replace(/[-]/g, "")
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
function isUkReg(reg: string): boolean {
|
||||
return /^[A-Z]{2}[0-9]{2}[A-Z]{3}$/.test(reg);
|
||||
}
|
||||
|
||||
function isIrishReg(reg: string): boolean {
|
||||
// Current Irish format: YY-CC-NNNNNN, e.g. 191D12345 or 241D12345.
|
||||
// YY = year suffix (last two digits) + half-year (1 or 2).
|
||||
// CC = county/city code (1-2 letters), e.g. D, C, L, KE, WW.
|
||||
// NNNNNN = sequential number (1-6 digits).
|
||||
return /^\d{1}[0-9]{2}[A-Z]{1,2}\d{1,6}$/.test(reg);
|
||||
}
|
||||
|
||||
function parseIrishRegYear(reg: string): number | undefined {
|
||||
// 131/132 -> 2013, 191/192 -> 2019, 241/242 -> 2024, etc.
|
||||
const century = Number(reg.slice(0, 1)) >= 1 && Number(reg.slice(0, 2)) < 50 ? 2000 : 1900;
|
||||
return century + Number(reg.slice(0, 2));
|
||||
}
|
||||
|
||||
async function lookupUkVehicle(reg: string): Promise<VehicleLookupResult | undefined> {
|
||||
const dvlaApiKey = process.env.DVLA_API_KEY;
|
||||
if (!dvlaApiKey) return undefined;
|
||||
|
||||
const res = await fetch(
|
||||
`https://driver-vehicle-licensing.api.gov.uk/vehicle-enquiry/v1/vehicles`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": dvlaApiKey,
|
||||
},
|
||||
body: JSON.stringify({ registrationNumber: reg }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return undefined;
|
||||
|
||||
const json = await res.json();
|
||||
return {
|
||||
make: json.make,
|
||||
model: json.model,
|
||||
color: json.colour,
|
||||
year: json.yearOfManufacture,
|
||||
};
|
||||
}
|
||||
|
||||
async function lookupIrishVehicle(reg: string): Promise<VehicleLookupResult | undefined> {
|
||||
// Real-time Irish lookups require a paid provider such as MotorCheck or Cartell.
|
||||
// If a provider key is configured, call it here. Otherwise we extract the year from
|
||||
// the registration format to pre-fill at least one field.
|
||||
const providerKey = process.env.MOTORCHECK_API_KEY;
|
||||
if (providerKey) {
|
||||
try {
|
||||
// Placeholder for a MotorCheck/Cartell-style endpoint — replace with real URL.
|
||||
const res = await fetch(
|
||||
`https://api.motorcheck.ie/vehicle/lookup?reg=${encodeURIComponent(reg)}`,
|
||||
{ headers: { Authorization: `Bearer ${providerKey}` } }
|
||||
);
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
return {
|
||||
make: json.make,
|
||||
model: json.model,
|
||||
color: json.colour ?? json.color,
|
||||
year: json.year,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("MotorCheck lookup failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
const year = parseIrishRegYear(reg);
|
||||
return year ? { year } : undefined;
|
||||
}
|
||||
|
||||
export async function lookupVehicle(
|
||||
reg: string
|
||||
): Promise<{ found: boolean; data?: VehicleLookupResult; source: string }> {
|
||||
const normalized = normalizeReg(reg);
|
||||
if (!normalized) return { found: false, source: "none" };
|
||||
|
||||
if (isUkReg(normalized)) {
|
||||
const data = await lookupUkVehicle(normalized);
|
||||
if (data) return { found: true, source: "dvla", data };
|
||||
}
|
||||
|
||||
if (isIrishReg(normalized)) {
|
||||
const data = await lookupIrishVehicle(normalized);
|
||||
if (data?.year || data?.make)
|
||||
return {
|
||||
found: true,
|
||||
source: data.make ? "motorcheck" : "roi-parser",
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
return { found: false, source: "manual" };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Car, Order, Task, Part, QuickTask, TaskPart, QuickTaskPart } from "@prisma/client";
|
||||
|
||||
export type CarWithRelations = Car & {
|
||||
orders: OrderWithTasks[];
|
||||
parts: Part[];
|
||||
};
|
||||
|
||||
export type OrderWithCar = Order & {
|
||||
car: Car & { parts: Part[] };
|
||||
tasks: TaskWithParts[];
|
||||
};
|
||||
|
||||
export type OrderWithTasks = Order & {
|
||||
tasks: TaskWithParts[];
|
||||
};
|
||||
|
||||
export type TaskWithParts = Task & {
|
||||
parts: (TaskPart & { part: Part })[];
|
||||
};
|
||||
|
||||
export type PartWithCar = Part & {
|
||||
car: { id: number; regNumber: string; make: string | null; model: string | null } | null;
|
||||
};
|
||||
|
||||
export type QuickTaskWithParts = QuickTask & {
|
||||
parts: QuickTaskPart[];
|
||||
};
|
||||
|
||||
export type HistoryTask = {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
finishedAt: Date | string | null;
|
||||
createdAt: Date | string;
|
||||
notes: string | null;
|
||||
orderId: number;
|
||||
orderDate: Date | string;
|
||||
orderMileage: number | null;
|
||||
};
|
||||
|
||||
export type HistoryData = {
|
||||
car: {
|
||||
id: number;
|
||||
regNumber: string;
|
||||
make: string | null;
|
||||
model: string | null;
|
||||
color: string | null;
|
||||
year: number | null;
|
||||
ownerName: string | null;
|
||||
ownerPhone: string | null;
|
||||
ownerEmail: string | null;
|
||||
mileage: number | null;
|
||||
vin: string | null;
|
||||
engineNumber: string | null;
|
||||
notes: string | null;
|
||||
};
|
||||
tasks: HistoryTask[];
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/app/**/*.{js,ts,jsx,tsx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: "#fff7ed",
|
||||
100: "#ffedd5",
|
||||
200: "#fed7aa",
|
||||
500: "#ea580c",
|
||||
600: "#c2410c",
|
||||
700: "#9a3412",
|
||||
900: "#7c2d12",
|
||||
},
|
||||
steel: {
|
||||
50: "#f8fafc",
|
||||
100: "#f1f5f9",
|
||||
200: "#e2e8f0",
|
||||
300: "#cbd5e1",
|
||||
400: "#94a3b8",
|
||||
500: "#64748b",
|
||||
600: "#475569",
|
||||
700: "#334155",
|
||||
800: "#1e293b",
|
||||
900: "#0f172a",
|
||||
},
|
||||
warning: {
|
||||
50: "#fefce8",
|
||||
100: "#fef9c3",
|
||||
500: "#eab308",
|
||||
600: "#ca8a04",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user