27 Commits

Author SHA1 Message Date
Pepe Ziberi
a53f77c97c fix(db): comprehensive symbol recovery + safety fixes
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 22m1s
2026-05-20 15:05:44 +02:00
Pepe Ziberi
fdd928720a docs: add incident report for symbol loss + recovery script 2026-05-20 13:58:37 +02:00
Pepe Ziberi
5adadd246e fix(seed): prevent cascade-deletion of tenant_symbols on container restart
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 12m23s
2026-05-20 11:44:12 +02:00
Pepe Ziberi
4b92df8fea feat(schema): Phase 1 Symbol Architecture — SymbolTemplate, TenantCategory, TenantSymbol refactor + seed + migration scripts
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 14m40s
2026-05-20 11:11:32 +02:00
Pepe Ziberi
9b96de0a21 feat: Präsentationsmodus (Schloss-Button) + Version 1.3.5
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 16m59s
2026-05-20 08:35:52 +02:00
Pepe Ziberi
902e730cd3 trigger: force Gitea Actions run
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 11m20s
2026-05-19 23:20:55 +02:00
Pepe Ziberi
1f891ab057 fix: track Pepe_Avatar.mp4 and allow public/*.mp4 in git 2026-05-19 23:19:57 +02:00
Pepe Ziberi
165109fc65 fix(docker): copy public folder after standalone to ensure assets are present
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 8m51s
2026-05-19 22:45:18 +02:00
Pepe Ziberi
b6fbe38f60 fix(docker): add missing Next.js runtime deps to runner image
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 10m11s
2026-05-19 22:06:09 +02:00
Pepe Ziberi
053ae3729a fix(ci): revert REGISTRY_PASSWORD back to REGISTRY_TOKEN
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 20m23s
2026-05-19 21:16:54 +02:00
Pepe Ziberi
8207366362 chore(release): bump version to 1.3.4
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m26s
2026-05-19 21:03:06 +02:00
Pepe Ziberi
29217e883b refactor(ci): switch to Watchtower-only auto-update, remove Portainer webhook 2026-05-19 20:26:43 +02:00
Pepe Ziberi
0f635033c2 feat(ci): trigger Portainer webhook after successful image push 2026-05-19 20:12:16 +02:00
Pepe Ziberi
805559efc3 perf(ci): add Docker registry cache to speed up builds 2026-05-19 20:07:37 +02:00
Pepe Ziberi
5d46200905 chore(release): bump version to 1.3.3
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 14m19s
2026-05-19 19:54:14 +02:00
Pepe Ziberi
5c353a0da8 fix(ci): remove GitHub Actions cache from Gitea workflow
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 15m4s
2026-05-19 19:21:35 +02:00
Pepe Ziberi
ba6f095dc0 perf(docker): optimize runner stage build time
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 15m0s
2026-05-19 18:59:03 +02:00
Pepe Ziberi
362a7e4666 chore(ci): setup Gitea Actions + Portainer auto-deploy pipeline
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-05-19 18:07:56 +02:00
Pepe Ziberi
63a57dcb7c ci: switch to Portainer auto-build (like mein118.ch) 2026-04-26 19:40:57 +02:00
Pepe Ziberi
62a5a56dea ci: update deploy workflow for existing runner
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
2026-04-26 19:27:35 +02:00
Pepe Ziberi
eb8566423f ci: add action for portainer deployment
Some checks failed
Build and Deploy to Portainer / build-and-deploy (push) Has been cancelled
2026-03-29 07:00:07 +02:00
Pepe Ziberi
1f508bca74 v1.3.2: SEO fixes + map bugfixes
SEO:
- Landing page converted to Server Component (SSR)
- Extracted NavAuthButtons + ContactForm as client islands
- Removed fake aggregateRating from JSON-LD
- Added FAQPage JSON-LD schema (7 questions)
- Extended sitemap: /datenschutz, /spenden, /demo

Map fixes:
- WebGL context lost recovery (black tiles after inactivity)
- Page visibility handler for tile reload on tab switch
- Arrow direction: geographic bearing instead of screen angle
- All markers rotationAlignment viewport->map (geographic orientation)
- DEL key now deletes selected lines/polygons/arrows (not just symbols)
- Default drawing color: black
2026-03-03 23:33:04 +01:00
Pepe Ziberi
708bdf6be0 v1.3.1: Fix symbol loading, DEL key, SOMA/Pendenzen in rapport, improved onboarding, org settings tab, logo upload 2026-02-25 22:28:10 +01:00
Pepe Ziberi
5917fa88ad v1.3.0: Refactoring Phase 3+4, Symbol-Verwaltung Redesign, Schlauch-Labels Fix
- Refactoring: Error Boundaries, apiFetch Wrapper, Socket Status-Tracking
- Refactoring: UI Kontrast (theme-aware colors), unused imports bereinigt
- Symbol-Verwaltung: Neues Split-Panel (Meine Symbole + Bibliothek)
- Symbol-Verwaltung: Umbenennen (TLF rot/blau), Duplikate erlaubt
- Symbol-Verwaltung: Karten-Sidebar zeigt eigene Symbole bevorzugt
- Schlauch-Labels: Groessere Schrift (13px/10px), verschiebbar (Drag)
- Schema: TenantSymbol customName, sortOrder, unique constraint entfernt
- Open Source Referenz entfernt (kostenloses Projekt)
2026-02-25 00:06:39 +01:00
Pepe Ziberi
8ddeb7b377 v1.2.2: Fix Nominatim CSP, Tenant Admin kann eigene Symbole hochladen 2026-02-24 22:43:05 +01:00
Pepe Ziberi
f480905bb9 hotfix: fetchData erst nach user.role laden (Mandanten verschwunden) 2026-02-24 22:17:12 +01:00
Pepe Ziberi
18398e559c v1.2.1: Fix Ctrl+Z/Y vertauscht, 401-Fehler TENANT_ADMIN, Symbole groesser, Spenden-Tab verbessert 2026-02-24 21:51:23 +01:00
68 changed files with 6287 additions and 2487 deletions

13
.env.docker Normal file
View File

@@ -0,0 +1,13 @@
# Dummy environment for Docker build stage
# These values are only needed so Next.js can compile during docker build
# Runtime values are injected via docker-compose environment
DATABASE_URL=postgresql://lageplan:lageplan_secret@db:5432/lageplan
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=build-time-secret-not-used-at-runtime
MINIO_ENDPOINT=minio
MINIO_PORT=9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin123
MINIO_BUCKET=lageplan-icons
MINIO_USE_SSL=false
MINIO_PUBLIC_URL=http://localhost:9000

View File

@@ -27,3 +27,8 @@ MINIO_PUBLIC_URL=http://localhost:9002
# Web App # Web App
WEB_PORT=3000 WEB_PORT=3000
NODE_ENV=development NODE_ENV=development
# --- CI/CD / Registry (nur für Portainer Deployment) ---
# Gitea Registry Login für Watchtower (automatische Image-Updates)
GITEA_REGISTRY_USER=adminpepe
GITEA_REGISTRY_PASS=dein_gitea_token_oder_passwort

View File

@@ -0,0 +1,49 @@
name: Build and Push Docker Image
on:
push:
branches:
- main
- master
workflow_dispatch:
env:
REGISTRY: git.purepixel.ch
IMAGE: git.purepixel.ch/adminpepe/lageplan
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
tags: |
type=raw,value=latest
type=sha,prefix=,suffix=,format=short
- name: Build and push
id: docker_build
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.IMAGE }}:cache
cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max

5
.gitignore vendored
View File

@@ -49,7 +49,9 @@ prisma/migrations/*
# Large binary files (do not commit) # Large binary files (do not commit)
*.tar *.tar
*.zip *.zip
*.mp4 # Allow MP4s in public folder (used by the app)
!public/*.mp4
!public/**/*.mp4
lageplan-web.tar lageplan-web.tar
# Reference materials (keep locally, not in git) # Reference materials (keep locally, not in git)
@@ -58,4 +60,3 @@ Reglement_*/
# Stack env (contains secrets) # Stack env (contains secrets)
stack.env stack.env
.env.docker

View File

@@ -26,34 +26,41 @@ RUN npm run build
# Stage 3: Runner # Stage 3: Runner
FROM node:20-alpine AS runner FROM node:20-alpine AS runner
RUN apk add --no-cache openssl RUN apk add --no-cache openssl
WORKDIR /app
ENV NODE_ENV production ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1 ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs && \
RUN adduser --system --uid 1001 nextjs adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public WORKDIR /app
COPY --from=builder /app/.next/standalone ./ # Fast: only chown the /app directory itself, not recursively
COPY --from=builder /app/.next/static ./.next/static RUN chown nextjs:nodejs /app
COPY --from=builder /app/.env ./.env
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
COPY --from=builder /app/node_modules/.bin/prisma ./node_modules/.bin/prisma
COPY --from=builder /app/node_modules/bcryptjs ./node_modules/bcryptjs
COPY --from=builder /app/node_modules/stripe ./node_modules/stripe
COPY --from=builder /app/package.json ./package.json
RUN npm install --omit=dev --legacy-peer-deps socket.io@4.7.4 @react-pdf/renderer@4.3.2 qrcode@1.5.4 --no-save
COPY server-custom.js ./server-custom.js
COPY docker-entrypoint.sh ./docker-entrypoint.sh
RUN chown -R nextjs:nodejs /app/node_modules
USER nextjs USER nextjs
# Install only the unbundled runtime deps needed by the custom server.
# Running as USER nextjs means files are already correctly owned — no slow chown -R needed afterwards.
RUN npm install --omit=dev --legacy-peer-deps socket.io@4.7.4 @react-pdf/renderer@4.3.2 qrcode@1.5.4 --no-save
COPY --chown=nextjs:nodejs --from=builder /app/.next/standalone ./
COPY --chown=nextjs:nodejs --from=builder /app/.next/static ./.next/static
# Ensure all public files (videos, images, etc.) are present in the runtime image
COPY --chown=nextjs:nodejs --from=builder /app/public ./public
COPY --chown=nextjs:nodejs --from=builder /app/.env ./.env
COPY --chown=nextjs:nodejs --from=builder /app/prisma ./prisma
COPY --chown=nextjs:nodejs --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --chown=nextjs:nodejs --from=builder /app/node_modules/prisma ./node_modules/prisma
COPY --chown=nextjs:nodejs --from=builder /app/node_modules/@prisma ./node_modules/@prisma
COPY --chown=nextjs:nodejs --from=builder /app/node_modules/bcryptjs ./node_modules/bcryptjs
COPY --chown=nextjs:nodejs --from=builder /app/node_modules/stripe ./node_modules/stripe
COPY --chown=nextjs:nodejs --from=builder /app/node_modules/next ./node_modules/next
COPY --chown=nextjs:nodejs --from=builder /app/node_modules/react ./node_modules/react
COPY --chown=nextjs:nodejs --from=builder /app/node_modules/react-dom ./node_modules/react-dom
COPY --chown=nextjs:nodejs --from=builder /app/package.json ./package.json
COPY --chown=nextjs:nodejs server-custom.js ./server-custom.js
COPY --chown=nextjs:nodejs docker-entrypoint.sh ./docker-entrypoint.sh
EXPOSE 3000 EXPOSE 3000
ENV PORT 3000 ENV PORT 3000
ENV HOSTNAME "0.0.0.0" ENV HOSTNAME "0.0.0.0"

View File

@@ -0,0 +1,139 @@
# Vorfall-Bericht: Symbol-Verlust (20. Mai 2026)
## Was ist passiert?
Alle Symbole in der App zeigen seit heute Morgen einen 404-Fehler (broken image icon). Die Symbole selbst sind aus der Datenbank gelöscht. Bereits erstellte Zeichnungen sind noch vorhanden, aber die Symbole darin sind ebenfalls broken.
---
## Chronologie der Ereignisse
### 1. Phase 1 Planung & Schema-Änderung (vor dem Vorfall)
Ich habe den Sprint A von **Phase 1 — Symbol-Architektur Redesign** begonnen. Dabei habe ich das Prisma-Schema erweitert:
- **Neue Tabellen**: `SymbolTemplate`, `TenantCategory`
- **Erweiterte Tabelle**: `TenantSymbol` um neue Spalten (`name`, `svgPath`, `isUploaded`, `categoryId`, etc.)
Diese Änderungen waren ausschließlich schema-seitig — es gab keine Löschoperationen.
### 2. Der eigentliche Bug (Cascade Delete)
Das Problem lag **NICHT** in den Schema-Änderungen, sondern in den bestehenden **Seed-Skripten**, die bei jedem Container-Start laufen:
**Dateien:**
- `prisma/seed.js`
- `prisma/seed-icons-only.js`
**Code (VOR dem Fix):**
```javascript
// Zeile ~73 in seed-icons-only.js
const deleted = await prisma.iconAsset.deleteMany({ where: { isSystem: true } })
console.log(`Deleted ${deleted.count} old system icons`)
```
Dieser Code hat **bei jedem Container-Start** alle System-Icons gelöscht und neu erstellt.
### 3. Warum das alles zerstört hat
Im Prisma-Schema gibt es folgende Relation:
```prisma
model TenantSymbol {
id String @id @default(uuid())
iconId String
icon IconAsset @relation(fields: [iconId], references: [id], onDelete: Cascade)
// ...
}
```
**Die `onDelete: Cascade`-Regel bedeutet:** Wenn ein `IconAsset` gelöscht wird, werden automatisch alle `TenantSymbol`-Einträge, die darauf verweisen, ebenfalls gelöscht.
**Ablauf bei jedem Container-Start:**
1. `seed-icons-only.js` läuft (weil Icon-Zahl < 100)
2. `deleteMany({ isSystem: true })` löscht alle System-Icons
3. `onDelete: Cascade` löscht alle `TenantSymbol`-Einträge
4. Neue Icons werden mit **neuen UUIDs** erstellt
5. Bestehende Zeichnungen verweisen noch auf die **alten gelöschten UUIDs** → 404
### 4. Warum ist das jetzt aufgefallen?
Der Container wurde neu gestartet (neues Deployment), und das Seed-Skript ist gelaufen. Dabei wurden alle Symbole gelöscht. Dieser Bug existierte bereits vor meinen Änderungen in den Seed-Skripten, ist aber jetzt zum ersten Mal aufgetreten, weil:
- Vorher hat das Skript nur selten gegriffen (Icons waren schon genug da)
- Jetzt wurde der Container frisch gestartet, und die Bedingung `ICON_COUNT < 100` war true
---
## Auswirkungen
### Was ist weg?
- ❌ Alle `IconAsset`-Einträge (System-Symbole)
- ❌ Alle `TenantSymbol`-Einträge (mandantenspezifische Symbol-Aktivierungen)
- ❌ Alle Icon-Category-Zuordnungen
### Was ist noch da?
- ✅ Alle Projekte (Zeichnungen)
- ✅ Alle Features (Linien, Symbole, Texte in den Zeichnungen)
- ✅ Alle Journal-Einträge
- ✅ Alle Benutzer, Tenants, etc.
### Was ist mit den Zeichnungen?
Die Zeichnungen sind intakt, aber die Symbole darin zeigen 404. Jedes Symbol in einer Zeichnung speichert in den Properties:
```json
{
"iconId": "alte-gelöschte-uuid",
"imageUrl": ""
}
```
Da die `iconId` auf einen gelöschten Eintrag verweist, kann das Bild nicht mehr geladen werden.
---
## Fix (bereits gepusht)
**Commit:** `5adadd2`
**Änderungen:**
1. `deleteMany` aus `seed.js` und `seed-icons-only.js` entfernt
2. Stattdessen **Upsert-Logik**: Update by `fileKey`, create only if missing
3. Dadurch bleiben bestehende IDs erhalten, und es gibt keine Cascade-Löschungen mehr
4. `prisma/migrate.js` um neue Phase-1-Tabellen erweitert
**Das verhindert zukünftige Löschungen, stellt aber bereits gelöschte Daten NICHT wieder her.**
---
## Wiederherstellung
Um die Symbole und Zeichnungen wiederherzustellen, gibt es zwei Wege:
### Option A: Datenbank-Backup (empfohlen)
Falls ein `pg_dump` oder Snapshot vor dem 20. Mai existiert, können die Tabellen `icon_assets`, `icon_categories` und `tenant_symbols` daraus restored werden.
### Option B: Recovery-Skript
Ein Skript, das:
1. Alle System-Icons aus `public/signaturen/` neu in die DB einspielt
2. Für jeden Tenant die Standard-Symbole neu aktiviert
3. Die Zeichnungen scannt und die `iconId`-Referenzen auf die neuen IDs updated
---
## Lessons Learned
1. **Seed-Skripte dürfen niemals `deleteMany` auf verknüpfte Daten ausführen**
2. `onDelete: Cascade` ist gefährlich bei Daten, die von Benutzern referenziert werden
3. Container-Start-Skripte müssen idempotent sein (mehrfaches Ausführen = gleiches Ergebnis)
4. Vor Deployment-Änderungen sollte ein DB-Backup gemacht werden
---
## Nächste Schritte
1. [x] Recovery-Skript erstellt: `prisma/recover-symbols.js` (Sidebar/Admin)
2. [x] Recovery-Skript erstellt: `prisma/recover-features.js` (Zeichnungen)
3. [x] Renderer resilient gemacht: broken Symbole zeigen ⚠️ statt leeres Nichts
4. [x] `onDelete: Cascade``onDelete: SetNull` auf TenantSymbol.icon geändert
5. [x] Seed-Skripte auf Upsert umgestellt (Commit 5adadd2)
6. [ ] `recover-symbols.js` auf Server ausführen
7. [ ] `recover-features.js --dry-run` auf Server ausführen zur Analyse
8. [ ] Falls broken Features: User informieren (Symbole manuell neu platzieren)

View File

@@ -1,76 +1,154 @@
# Lageplan — Portainer Deployment # Lageplan — CI/CD & Portainer Deployment
## Architektur ## Übersicht
``` ```
Browser → :3000 (Web App) → intern: db:5432, minio:9000 ┌─────────────┐ Push ┌─────────────────┐ Build + Push ┌─────────────────┐
│ Dein PC │ ────────────► │ Gitea (git) │ ──────────────────► │ Gitea Registry │
│ (VS Code) │ │ git.purepixel │ │ (Docker Image) │
└─────────────┘ └─────────────────┘ └────────┬────────┘
│ Pull
┌─────────────────┐
│ Portainer │
│ (Stack Deploy) │
└─────────────────┘
``` ```
Nur **ein Port (3000)** muss exponiert werden. DB und MinIO laufen rein intern im Docker-Netzwerk. Icons werden über die Web-App gestreamt — kein direkter MinIO-Zugriff nötig. 1. **Push auf `main`** → Gitea Actions baut Docker Image
2. **Image wird gepusht** → Gitea Container Registry (`git.purepixel.ch`)
3. **Watchtower (in Portainer)** → prüft alle 60s auf neue Images und startet Container neu
--- ---
## Dateien ## Voraussetzungen
| Datei | Beschreibung | - Gitea läuft mit Container Registry aktiviert
|-------|-------------| - Gitea Actions Runner ist registriert (`deploy/docker-compose.runner.yml`)
| `lageplan-web-v1.0.0.tar` | Docker Image (~92 MB) | - Portainer Stack ist deployed mit korrekten Environment-Variablen
| `portainer-stack.yml` | Stack YAML für Portainer |
| `.env.example` | Environment Variables Vorlage |
--- ---
## Schritt 1: Image auf Server laden ## Schritt 1: Gitea Container Registry aktivieren
```bash In Gitea:
# Kopieren 1. **Admin-Konsole****Konfiguration****Pakete**
scp lageplan-web-v1.0.0.tar user@server:/tmp/ 2. **Container Registry** auf `Aktiviert` setzen
3. Speichern
# Auf dem Server laden Oder direkt in der `app.ini`:
docker load -i /tmp/lageplan-web-v1.0.0.tar ```ini
[packages]
ENABLED = true
# Prüfen [package.container_registry]
docker images | grep lageplan ENABLED = true
``` ```
--- ---
## Schritt 2: Stack in Portainer erstellen ## Schritt 2: Gitea Actions Runner registrieren
1. In Gitea: **Admin****Actions****Runners****Neuen Runner erstellen**
2. Token kopieren
3. In Portainer: Stack `gitea-runner` deployen mit [`deploy/docker-compose.runner.yml`](docker-compose.runner.yml)
4. Environment Variable `RUNNER_TOKEN` = das kopierte Token
---
## Schritt 3: Gitea Access Token erstellen
Das CI/CD Workflow braucht einen Token um Images in die Registry zu pushen:
1. Gitea → **Einstellungen****Anwendungen****Token erstellen**
2. Name: `registry-push`
3. Berechtigungen: `package:write` (mindestens)
4. Token kopieren und als **Repository Secret** hinterlegen:
- Repo → **Einstellungen****Secrets****Neues Secret**
- Name: `GITEA_TOKEN`
- Wert: das kopierte Token
---
## Schritt 4: Portainer Stack deployen
1. **Portainer**`Stacks``+ Add stack` 1. **Portainer**`Stacks``+ Add stack`
2. **Name**: `lageplan` 2. **Name**: `lageplan`
3. **Web editor**: Inhalt von `portainer-stack.yml` einfügen 3. **Build method**: `Repository`
4. **Environment variables** setzen: 4. **Git-URL**: `https://git.purepixel.ch/adminpepe/Lageplan.git`
5. **Compose path**: `docker-compose.portainer.yml`
6. **GitOps updates**: ✅ Aktivieren
7. **Mechanism**: `Webhook`
8. **Webhook URL kopieren** (für später)
| Variable | Wert | ### Environment Variables setzen:
|----------|------|
| `POSTGRES_USER` | `lageplan` |
| `POSTGRES_PASSWORD` | *(sicheres Passwort)* |
| `POSTGRES_DB` | `lageplan` |
| `MINIO_ROOT_USER` | `minioadmin` |
| `MINIO_ROOT_PASSWORD` | *(sicheres Passwort)* |
| `MINIO_BUCKET` | `lageplan-icons` |
| `WEB_PORT` | `3000` |
| `NEXTAUTH_URL` | `http://SERVER_IP:3000` |
| `NEXTAUTH_SECRET` | *(langer zufälliger String)* |
> **Tipp**: Secret generieren: `openssl rand -base64 32` | Variable | Wert | Beschreibung |
|----------|------|-------------|
| `POSTGRES_USER` | `lageplan` | DB User |
| `POSTGRES_PASSWORD` | *(sicheres Passwort)* | DB Passwort |
| `POSTGRES_DB` | `lageplan` | DB Name |
| `NEXTAUTH_URL` | `https://lageplan.ch` | Deine Domain |
| `NEXTAUTH_SECRET` | *(openssl rand -base64 32)* | Auth Secret |
| `MINIO_ROOT_USER` | `minioadmin` | MinIO User |
| `MINIO_ROOT_PASSWORD` | *(sicheres Passwort)* | MinIO Passwort |
| `MINIO_BUCKET` | `lageplan-icons` | Bucket Name |
| `MINIO_PUBLIC_URL` | `https://s3.deinedomain.ch` | MinIO externe URL |
| `GITEA_REGISTRY_USER` | `adminpepe` | Gitea User für Watchtower |
| `GITEA_REGISTRY_PASS` | *(Token oder Passwort)* | Gitea Passwort/Token |
5. **Deploy the stack** 9. **Deploy the stack**
--- ---
## Schritt 3: Datenbank initialisieren (einmalig) ## Schritt 5: Webhook in Gitea eintragen
In Portainer: Container `web` → Console → `/bin/sh`: Damit Portainer bei jedem Push automatisch neu deployed:
1. Gitea Repo → **Einstellungen****Webhooks****Neuer Webhook**`Gitea`
2. **Ziel-URL**: Die kopierte Portainer Webhook URL
3. **HTTP-Methode**: `POST`
4. **Trigger**: Nur `Push events` (oder auch `Branch filter: main`)
5. **Webhook aktivieren** → Hinzufügen
---
## Schritt 6: Erstes Deployment testen
1. Lokal einen Push auf `main` machen:
```bash ```bash
npx prisma db push git add .
npx prisma db seed git commit -m "Test CI/CD"
git push origin main
``` ```
2. In Gitea: **Actions** Tab → Build-Job sollte laufen
3. Wenn grün → Image wurde in Registry gepusht
4. Watchtower (in Portainer) holt neues Image innerhalb von 60s
5. App ist unter `NEXTAUTH_URL` erreichbar
Oder per SSH: ---
## Troubleshooting
### Gitea Actions startet nicht
- Prüfen ob Runner registriert ist: Gitea → Admin → Actions → Runners
- Runner muss `Idle` oder `Active` zeigen
### Image Push schlägt fehl (401 Unauthorized)
- `GITEA_TOKEN` Secret im Repo korrekt hinterlegt?
- Token hat Berechtigung `package:write`?
- Registry in Gitea aktiviert?
### Watchtower zieht kein neues Image
- `GITEA_REGISTRY_USER` und `GITEA_REGISTRY_PASS` in Portainer gesetzt?
- Image-Name in `docker-compose.portainer.yml` korrekt?
- Watchtower Logs prüfen: Portainer → Container `watchtower` → Logs
### App startet nicht / DB-Fehler
- Environment Variables in Portainer korrekt?
- `DATABASE_URL` wird automatisch gebaut, nur `POSTGRES_*` muss gesetzt werden
- Bei erstem Start: Prisma Migrations/Seed im Web-Container ausführen:
```bash ```bash
docker exec -it lageplan-web-1 npx prisma db push docker exec -it lageplan-web-1 npx prisma db push
docker exec -it lageplan-web-1 npx prisma db seed docker exec -it lageplan-web-1 npx prisma db seed
@@ -78,25 +156,13 @@ docker exec -it lageplan-web-1 npx prisma db seed
--- ---
## Schritt 4: Zugriff ## Manuelles Update (falls nötig)
- **Web App**: `http://SERVER_IP:3000`
- **Login**: `admin@lageplan.local` / `admin123`
---
## Update
Wenn Watchtower mal nicht greift:
```bash ```bash
# Lokal: neues Image bauen + exportieren # Auf dem Portainer-Host
docker compose build web docker pull git.purepixel.ch/adminpepe/lageplan:latest
docker tag lageplan-web:latest lageplan-web:v1.1.0 docker compose -f docker-compose.portainer.yml up -d web
docker save lageplan-web:v1.1.0 -o deploy/lageplan-web-v1.1.0.tar
# Server: laden
docker load -i lageplan-web-v1.1.0.tar
# Portainer: Stack → Editor → Image-Tag ändern → Update the stack
``` ```
--- ---

View File

@@ -0,0 +1,23 @@
##############################################
# Gitea Actions Runner — Portainer Stack
#
# In Portainer deployen:
# 1. Stacks → Add Stack → "gitea-runner"
# 2. Diese YAML einfügen
# 3. Deploy
##############################################
services:
runner:
image: gitea/act_runner:latest
restart: unless-stopped
environment:
GITEA_INSTANCE_URL: https://git.purepixel.ch
GITEA_RUNNER_REGISTRATION_TOKEN: ${RUNNER_TOKEN}
GITEA_RUNNER_NAME: lageplan-runner
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- runner_data:/data
volumes:
runner_data:

View File

@@ -7,3 +7,8 @@ MINIO_BUCKET=lageplan-icons
WEB_PORT=3000 WEB_PORT=3000
NEXTAUTH_URL=http://SERVER_IP:3000 NEXTAUTH_URL=http://SERVER_IP:3000
NEXTAUTH_SECRET=HIER_LANGEN_ZUFAELLIGEN_STRING_GENERIEREN NEXTAUTH_SECRET=HIER_LANGEN_ZUFAELLIGEN_STRING_GENERIEREN
MINIO_PUBLIC_URL=http://SERVER_IP:9000
# Gitea Registry Auth für Watchtower (automatische Image-Updates)
GITEA_REGISTRY_USER=adminpepe
GITEA_REGISTRY_PASS=HIER_GITEA_TOKEN_ODER_PASSWORT_SETZEN

View File

@@ -1,5 +1,5 @@
############################################## ##############################################
# Gitea — Lightweight Git Server # Gitea — Lightweight Git Server + Container Registry
# #
# Verwendung in Portainer: # Verwendung in Portainer:
# 1. Stacks → Add Stack → "Gitea" # 1. Stacks → Add Stack → "Gitea"
@@ -12,6 +12,10 @@
# 3. Repository "lageplan" erstellen # 3. Repository "lageplan" erstellen
# 4. Vom PC aus: git init → git remote add origin → git push # 4. Vom PC aus: git init → git remote add origin → git push
# #
# Container Registry aktivieren:
# 1. Gitea Admin → Konfiguration → Pakete → Container Registry aktivieren
# 2. Oder app.ini: [packages] ENABLED = true
#
# Daten werden in gitea_data persistiert. # Daten werden in gitea_data persistiert.
############################################## ##############################################
@@ -27,6 +31,9 @@ services:
- GITEA__server__ROOT_URL=https://git.purepixel.ch - GITEA__server__ROOT_URL=https://git.purepixel.ch
- GITEA__server__HTTP_PORT=3000 - GITEA__server__HTTP_PORT=3000
- GITEA__server__LFS_START_SERVER=true - GITEA__server__LFS_START_SERVER=true
# Container Registry aktivieren
- GITEA__packages__ENABLED=true
- GITEA__package__container_registry__ENABLED=true
volumes: volumes:
- gitea_data:/data - gitea_data:/data
- /etc/timezone:/etc/timezone:ro - /etc/timezone:/etc/timezone:ro
@@ -43,4 +50,3 @@ volumes:
networks: networks:
lageplan_lageplan-net: lageplan_lageplan-net:
external: true external: true

View File

@@ -1,21 +1,28 @@
############################################## ##############################################
# Lageplan — Portainer Stack Configuration # Lageplan — Portainer Stack (Watchtower Auto-Update)
# #
# Verwendung in Portainer: # Setup in Portainer:
# 1. Stacks → Add Stack # 1. Stacks → Add Stack → "Repository"
# 2. "Upload" oder diesen Inhalt einfügen # 2. Git-URL: https://git.purepixel.ch/adminpepe/Lageplan.git
# 3. Environment-Variablen setzen (siehe unten) # 3. Compose-Pfad: docker-compose.portainer.yml
# 4. Deploy # 4. "GitOps updates" aktivieren
# 5. Environment-Variablen setzen (siehe unten)
# 6. Deploy
#
# Danach: Push auf main → Gitea Actions baut Image →
# Watchtower erkennt neues Image und startet Container neu
# #
# Benötigte Environment-Variablen: # Benötigte Environment-Variablen:
# POSTGRES_USER (default: lageplan) # POSTGRES_USER (default: lageplan)
# POSTGRES_PASSWORD (ÄNDERN!) # POSTGRES_PASSWORD (ÄNDERN!)
# POSTGRES_DB (default: lageplan) # POSTGRES_DB (default: lageplan)
# NEXTAUTH_SECRET (ÄNDERN! — z.B. openssl rand -base64 32) # NEXTAUTH_SECRET (ÄNDERN! — z.B. openssl rand -base64 32)
# NEXTAUTH_URL (z.B. https://lageplan.example.com) # NEXTAUTH_URL (z.B. https://lageplan.ch)
# MINIO_ROOT_USER (default: minioadmin) # MINIO_ROOT_USER (default: minioadmin)
# MINIO_ROOT_PASSWORD (ÄNDERN!) # MINIO_ROOT_PASSWORD (ÄNDERN!)
# MINIO_PUBLIC_URL (z.B. https://s3.example.com) # MINIO_PUBLIC_URL (z.B. https://s3.example.com)
# GITEA_REGISTRY_USER (für Watchtower Registry-Auth)
# GITEA_REGISTRY_PASS (für Watchtower Registry-Auth)
############################################## ##############################################
services: services:
@@ -76,8 +83,9 @@ services:
- lageplan - lageplan
# ─── Lageplan Web App ────────────────────── # ─── Lageplan Web App ──────────────────────
# Image kommt aus Gitea Container Registry (gebaut via Gitea Actions)
web: web:
image: 192.168.1.183:3100/adminpepe/lageplan:latest image: git.purepixel.ch/adminpepe/lageplan:latest
restart: unless-stopped restart: unless-stopped
environment: environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-lageplan}:${POSTGRES_PASSWORD:-lageplan_secret}@db:5432/${POSTGRES_DB:-lageplan} DATABASE_URL: postgresql://${POSTGRES_USER:-lageplan}:${POSTGRES_PASSWORD:-lageplan_secret}@db:5432/${POSTGRES_DB:-lageplan}
@@ -99,6 +107,25 @@ services:
condition: service_healthy condition: service_healthy
networks: networks:
- lageplan - lageplan
labels:
- "com.centurylinklabs.watchtower.enable=true"
# ─── Watchtower (Auto-Restart bei neuen Images) ─
# Überwacht nur Container mit Label com.centurylinklabs.watchtower.enable=true
watchtower:
image: containrrr/watchtower
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
WATCHTOWER_POLL_INTERVAL: 60
WATCHTOWER_CLEANUP: "true"
WATCHTOWER_LABEL_ENABLE: "true"
# Gitea Registry Auth
REPO_USER: ${GITEA_REGISTRY_USER}
REPO_PASS: ${GITEA_REGISTRY_PASS}
networks:
- lageplan
volumes: volumes:
postgres_data: postgres_data:

View File

@@ -0,0 +1,269 @@
# Roadmap — Feedback Fabian (Mai 2026)
> **Quelle**: User-Feedback von Fabian, Führungsoffizier / Übungsleiter Feuerwehr.
> **Erhalten**: 20.05.2026
> **Status**: Geplant, noch nicht umgesetzt.
Dieses Dokument ist die strukturierte Roadmap aus Fabians Feedback. Jede KI / jeder Entwickler der an Lageplan arbeitet, soll diese Datei vor Beginn lesen, um Kontext und Priorisierung zu kennen.
---
## Originalfeedback (Zusammenfassung)
Fabian sucht eine Lösung um **Einsätze und Übungen einfach zu skizzieren und zu krokieren**. Lobt die App, gibt aber konkrete Verbesserungsvorschläge aus Anwendersicht:
1. **Einsatz vs. Übung unterscheiden** — bei Übungen kein Journal nötig, dafür Übungsziele/Auswertung
2. **Symbol-Bibliothek erweitern** — Warteraum, Rettungsachse, taktische Zeichen
3. **Symbol-Schönheitsfehler** — Treppe/Eingang mit Umriss, Absperrung
4. **Stockwerke/Geschosse** — eigene Werte eintragen
5. **Symbol-Kategorien aufräumen** — Motorspritze unter Organisation statt Geräte; Hydrant/Leitern uneinheitlich
6. **Linien mit Typ** — nach Zeichnen wählen: Rettungsachse (gestrichelt + R), Leitung (blau), Schlauch, etc.
7. **Multi-User Live** — eine Person zeichnet Karte, andere schreibt Journal gleichzeitig
8. **Rapport-/Lageansicht** — separate Sicht mit Pendenzen, aktueller Stand, wichtige Punkte für Führungsunterstützung (FU), darstellbar auf grossem Bildschirm; nicht alle Journaleinträge sichtbar für alle
---
## Phasen-Plan
### Phase 1 — Symbol-Architektur Redesign (3-4 Wochen) ⭐⭐
**Strategischer Umbau** statt nur Kategorien aufräumen. Begründung:
- Fabians Feedback zeigt, dass die aktuelle Kategorisierung uneinheitlich ist
- Symbol-Bibliothek ist zu starr — andere Anwender (THW, Sanität, ausländische Wehren) brauchen andere Symbole
- Mandantenfähigkeit ist zentrales Versprechen → muss auch für Symbole konsequent durchgezogen werden
#### 1.1 Neue Symbol-Architektur: Template-Import + 100% mandantenspezifisch
**Konzept**: Mandant startet mit leerer Bibliothek, importiert beim Onboarding (oder jederzeit) **kuratierte Vorlagen-Pakete** (Feuerwehr CH, THW, Sanität…). Importierte Symbole werden vollständig eigene Mandanten-Daten — umbenennbar, löschbar, kategorisierbar, ergänzbar.
**Datenmodell**:
```
TenantCategory (per-tenant, frei definierbar)
- id, tenantId, name, sortOrder, icon
TenantSymbol (per-tenant, ehemals "Meine Symbole")
- id, tenantId, categoryId → TenantCategory
- name, svgPath, sortOrder, customName
SymbolTemplate (global, read-only)
- id, packageId ("feuerwehr-ch", "thw", "sanitaet")
- categoryName, name, svgPath, tags
```
**Tasks**:
- [ ] Schema-Migration: `TenantCategory` neu, `TenantSymbol.categoryId` ergänzen, Standard-`Icon` zu `SymbolTemplate` umwandeln
- [ ] **Migration der bestehenden Tenants**: Auto-Import des `feuerwehr-ch` Pakets beim ersten Login, sodass nichts kaputt geht
- [ ] API: CRUD für `TenantCategory`, erweitert für `TenantSymbol`
- [ ] API: `GET /api/templates` — listet verfügbare Pakete
- [ ] API: `POST /api/templates/import` — importiert ausgewählte Symbole als TenantSymbols
- **Files**: `prisma/schema.prisma`, `src/app/api/tenant/categories/`, `src/app/api/tenant/symbols/`, `src/app/api/templates/`
#### 1.2 UX: Symbol-Manager im Admin
**Sidebar / Symbol-Verwaltung**:
```
┌─ Meine Symbole ─────────────┐
│ ┌ Fahrzeuge ─────────┐ │
│ │ 🚒 TLF │ │
│ │ 🚒 RW │ │
│ │ + Symbol │ │
│ └────────────────────┘ │
│ ┌ Wasser ────────────┐ │
│ │ 🟦 Hydrant │ │
│ └────────────────────┘ │
│ [+ Kategorie] │
│ [📦 Vorlagen importieren] │
└─────────────────────────────┘
```
**Tasks**:
- [ ] Admin-Tab: Kategorien anlegen/umbenennen/sortieren (Drag & Drop)
- [ ] Symbole pro Kategorie verwalten (Drag & Drop zwischen Kategorien)
- [ ] Eigene SVG-Uploads direkt einer Kategorie zuordnen
- [ ] Import-Dialog: Pakete-Auswahl mit Vorschau, granulare Symbol-Auswahl
- [ ] Mehrfach-Import desselben Symbols erlaubt (z.B. "TLF rot" + "TLF blau" auf gleicher SVG-Basis)
- **Files**: `src/components/admin/icons-tab.tsx` (umbauen), neuer `import-templates-dialog.tsx`
#### 1.3 Vorlagen-Pakete kuratieren
Aus dem aktuellen Symbol-Bestand werden die ersten Pakete:
- [ ] 📦 **Feuerwehr Schweiz** — taktische Zeichen CH (aktueller Bestand, sauber kategorisiert)
- [ ] 📦 **Sanität / Rettungsdienst** — falls Symbole vorhanden, sonst Phase 2
- [ ] 📦 **Polizei / Verkehr** — als Stub für später
- [ ] Innerhalb der Pakete: **fehlende taktische Zeichen ergänzen**:
- Warteraum
- Rettungsachse
- Sammelplatz
- Verletztennest / Eingang Verletzter
- Einsatzleitung (EL) / Kommandoposten
- Bereitstellungsraum
- [ ] **Symbol-Schönheitsfehler im Paket** beheben:
- Treppe — Umriss entfernen
- Eingang — Umriss entfernen
- Absperrung — Seitenlinien aufräumen
- **Files**: `prisma/seed/symbol-templates.ts`, `public/icons/`
#### 1.4 Stockwerke/Geschosse editierbar
- [ ] Symbol-Property: `instanceLabel` (Freitext, pro Symbol-Instanz auf der Karte)
- [ ] Bei Gebäude-Symbolen: optionales Textfeld für Geschosszahl (z.B. "EG+3")
- [ ] Anzeige als Badge auf dem Symbol
- [ ] Editierbar via Doppelklick oder Sidebar
- **Files**: `src/types/`, `map-view.tsx` Symbol-Renderer, ggf. DrawFeature.properties erweitern
---
### Phase 1.5 — Quick Polish (parallel oder danach, 1 Woche) 🔥
Kleinkram der nicht in den Architektur-Umbau passt aber wichtig ist.
- [ ] Default-Linientyp / -farbe pro Mandant konfigurierbar
- [ ] Symbol-Suche im Sidebar (Volltext über Name, Tags, Kategorie)
- [ ] Häufig-benutzte Symbole oben anzeigen (Recent / Favoriten)
---
### Phase 2 — Übungsmodus (2-3 Wochen) ⭐
Neuer Projekt-Typ, eigene Logik.
#### 2.1 Projekt-Typ Auswahl
- [ ] Bei "Neues Projekt": Auswahl `Einsatz | Übung`
- [ ] DB-Schema: `Project.type: 'einsatz' | 'uebung'`
- [ ] UI-Anpassung in `app/page.tsx`
#### 2.2 Übungs-Metadaten
- [ ] Felder: Übungstitel, Datum, **Übungsziele (Liste)**
- [ ] Übungsleiter, Teilnehmer (optional)
- [ ] Eigene Sidebar-Sektion für Übungsdaten
#### 2.3 Journal ausblenden bei Übung
- [ ] Bei `type === 'uebung'`: Journal-Tab → "Übungsauswertung"
- [ ] Karte bleibt voll funktionsfähig
- [ ] Krokierung kann **im Voraus** erstellt und mit Übungsleitern geteilt werden (bestehender Share-Link)
#### 2.4 Übungsauswertung
- [ ] Checkliste **Übungsziele** mit Status: `erreicht | teilweise | nicht erreicht`
- [ ] Notizen / Erkenntnisse pro Ziel
- [ ] Gesamtbewertung / Erkenntnisse
- [ ] **PDF-Export** für Debriefing (analog Rapport)
- **Files**: neue Komponente `uebungsauswertung-tab.tsx`, neuer PDF-Renderer
---
### Phase 3 — Linien mit Typ (1 Woche) ⭐
Smart Lines mit vordefinierten Stilen.
#### 3.1 Linientypen definieren
- [ ] **Schlauch** (default, aktuelle Linie)
- [ ] **Rettungsachse** — gestrichelt, Label "R"
- [ ] **Leitung** — blau
- [ ] **Absperrung** — rot gestrichelt
- [ ] **Frei** — Standard ohne Vorlage
#### 3.2 UX
- [ ] Nach Zeichnen einer Linie: kleines Popup "Was ist das?"
- [ ] Auswahl per Klick → Linie wird automatisch gestylt
- [ ] In Sidebar pro Linie nachträglich änderbar (Dropdown)
- [ ] Tastatur-Shortcut für Typ-Wechsel?
#### 3.3 Daten-Modell
- [ ] `DrawFeature` erweitern: `lineCategory: 'hose' | 'rescue' | 'pipe' | 'barrier' | 'free'`
- [ ] Default-Stile als Konstante (Farbe, Strich, Label)
- **Files**: `src/types/`, `tool-store.ts`, `map-view.tsx`
---
### Phase 4 — Rapport-/Lageansicht (Führungsunterstützung) (2-3 Wochen) ⭐
Eigenes Modul, hoher Mehrwert für FU.
#### 4.1 Sichtbarkeits-Stufen pro Journaleintrag
- [ ] DB: `JournalEntry.visibility: 'intern' | 'rapport' | 'public'`
- [ ] UI: Dropdown beim Erstellen / Editieren
- [ ] Migration: bestehende Einträge → `intern` (sicher)
#### 4.2 Rapport-Modus (Display)
- [ ] Neuer Tab/Modus: **Rapport-Ansicht**
- [ ] Sektionen:
- **Aktuelle Lage** (Freitext, prominentes Feld)
- **Pendenzen** (Liste mit Status: offen/erledigt)
- **Wichtige Punkte / Befehle**
- **Mittel / Personal** (aktueller Stand)
- [ ] Grosse Schrift, kontrastreich (Beamer-tauglich)
- [ ] Auto-Refresh via Socket.IO
#### 4.3 Display-Sharing
- [ ] Read-only Token-Link (wie bestehende Rapport-URL)
- [ ] Vollbild-Modus
- [ ] Optional: Auswahl welche Sektionen sichtbar
#### 4.4 Berechtigungen
- [ ] Rolle "Rapport-Viewer" — sieht nur Rapport, nicht Karte / Journal
- [ ] Berechtigung pro Projekt vergebbar
---
### Phase 5 — Multi-User Live-Editing polish (optional, später)
Realtime-Sync läuft schon via Socket.IO — hier nur Verbesserungen.
- [ ] **Live-Cursor** anderer User auf Karte (mit Name)
- [ ] **Presence-Anzeige** ("Fabian schreibt im Journal", "Pepe zeichnet")
- [ ] **Soft-Locks** während Symbol bewegt wird
- [ ] **Conflict Resolution** bei gleichzeitigem Edit testen
---
## Priorisierungs-Matrix
| Phase | Aufwand | Impact | Priorität | Empfohlene Reihenfolge |
|-------|---------|--------|-----------|------------------------|
| 1 — Symbol-Architektur Redesign | L (3-4 W) | Sehr Hoch | ⭐⭐ Höchste | **1.** |
| 1.5 — Quick Polish | S | Mittel | 🔥 Hoch | parallel zu 1 |
| 3 — Linien-Typen | M | Hoch | ⭐ Hoch | **2.** |
| 4 — Rapport-Ansicht | L | Sehr Hoch (FU) | ⭐ Sehr Hoch | **3.** |
| 2 — Übungsmodus | L | Sehr Hoch | ⭐ Sehr Hoch | **4.** |
| 5 — Multi-User polish | M | Mittel | 📅 Mittel | später |
**Begründung der Reihenfolge**:
1. **Phase 1 zuerst** — Architektur-Umbau, weil alles andere darauf aufbaut. Sobald TenantSymbols + Kategorien sauber strukturiert sind, sind 1.3 (fehlende Symbole) und 1.4 (Stockwerke) trivial. Auch Phase 2 (Übungsmodus) profitiert: Übungs-Pakete als eigene Templates möglich.
2. **Phase 3 (Linien-Typen)** — kleine Erweiterung, hoher UX-Gewinn, unabhängig von Phase 1.
3. **Phase 4 (Rapport-Ansicht)** — USP gegenüber anderen Krokier-Tools, FU-Funktion fehlt überall sonst.
4. **Phase 2 (Übungsmodus)** — grosses Update; baut konzeptuell auf Phase 4 auf (Übungsauswertung ≈ Rapport).
5. **Phase 5** — Polish, kein User wartet darauf.
**Risiko-Hinweis Phase 1**:
- Schema-Migration von `Icon` (global) zu `SymbolTemplate` + Auto-Import muss **wasserdicht** sein, damit existierende Mandanten ihre Symbole nicht verlieren
- Vor Deploy: vollständiges DB-Backup (PostgreSQL) und MinIO-Backup (Icon-Files)
- Auf Staging testen falls möglich, sonst kleinen Test-Tenant zuerst migrieren
---
## Hinweise für Entwickler / KIs
- **Stack**: Next.js 15, React 19, MapLibre GL JS, Prisma, PostgreSQL, MinIO, Socket.IO, Zustand
- **Sprache**: Schweizerdeutsch / Hochdeutsch (Code englisch, UI deutsch)
- **Bei Symbol-Änderungen**: SVGs in `public/icons/` + Symbol-Definitionen + ggf. DB-Migration
- **Bei Schema-Änderungen**: Prisma Migration erstellen + DB-Backup vor Deploy
- **Vor Deploy**: Build testen (`npx next build`), commit + push, Portainer baut automatisch via Webhook
- **Realtime**: bei DB-Änderungen, die alle User sehen müssen, Socket-Event triggern
---
## Status-Tracking
> Status pro Aufgabe oben in Checkboxen. Hier kurzer Überblick:
- [ ] **Phase 1** — nicht gestartet
- [ ] **Phase 2** — nicht gestartet
- [ ] **Phase 3** — nicht gestartet
- [ ] **Phase 4** — nicht gestartet
- [ ] **Phase 5** — nicht gestartet
---
**Letztes Update**: 2026-05-20
**Verantwortlich**: Pepe (adminpepe)

View File

@@ -51,7 +51,7 @@ const nextConfig = {
"style-src 'self' 'unsafe-inline'", "style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https://*.tile.openstreetmap.org https://api.maptiler.com https://server.arcgisonline.com https://*.geo.admin.ch http://localhost:9000 http://minio:9000", "img-src 'self' data: blob: https://*.tile.openstreetmap.org https://api.maptiler.com https://server.arcgisonline.com https://*.geo.admin.ch http://localhost:9000 http://minio:9000",
"font-src 'self' data:", "font-src 'self' data:",
"connect-src 'self' ws: wss: https://api.maptiler.com https://*.tile.openstreetmap.org https://api.open-meteo.com https://server.arcgisonline.com https://*.geo.admin.ch", "connect-src 'self' ws: wss: https://api.maptiler.com https://*.tile.openstreetmap.org https://nominatim.openstreetmap.org https://api.open-meteo.com https://server.arcgisonline.com https://*.geo.admin.ch",
"frame-ancestors 'self'", "frame-ancestors 'self'",
"base-uri 'self'", "base-uri 'self'",
"form-action 'self'", "form-action 'self'",

36
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "lageplan", "name": "lageplan",
"version": "1.0.1", "version": "1.3.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lageplan", "name": "lageplan",
"version": "1.0.1", "version": "1.3.2",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.1.0", "@dnd-kit/core": "^6.1.0",
@@ -53,7 +53,8 @@
"tailwind-merge": "^2.2.1", "tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"zod": "^3.22.4" "zod": "^3.22.4",
"zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
@@ -10566,6 +10567,35 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
},
"node_modules/zustand": {
"version": "5.0.11",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
"integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
} }
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "lageplan", "name": "lageplan",
"version": "1.2.0", "version": "1.3.5",
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation", "description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
"private": true, "private": true,
"scripts": { "scripts": {
@@ -14,7 +14,10 @@
"db:migrate:prod": "prisma migrate deploy", "db:migrate:prod": "prisma migrate deploy",
"db:seed": "npx tsx prisma/seed.ts", "db:seed": "npx tsx prisma/seed.ts",
"db:studio": "prisma studio", "db:studio": "prisma studio",
"postinstall": "prisma generate" "postinstall": "prisma generate",
"repair:features:dry-run": "node prisma/repair-features.js --dry-run",
"repair:features:apply": "node prisma/repair-features.js --apply",
"recover:symbols": "node prisma/recover-symbols.js"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.1.0", "@dnd-kit/core": "^6.1.0",
@@ -61,7 +64,8 @@
"tailwind-merge": "^2.2.1", "tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"zod": "^3.22.4" "zod": "^3.22.4",
"zustand": "^5.0.11"
}, },
"prisma": { "prisma": {
"seed": "node prisma/seed.js" "seed": "node prisma/seed.js"

View File

@@ -0,0 +1,370 @@
# Phase 1 — Symbol-Architektur Redesign: Detaillierter Plan
> Basierend auf: `docs/roadmap-feedback-fabian.md`
> Ziel: Mandantenspezifische Symbol-Bibliothek mit Template-Import, eigener Kategorisierung und vollständiger Entkopplung von globalen Icons.
---
## 1. Ziel & Konzept
**Problem heute:**
- `TenantSymbol` verweist auf `IconAsset` (global). Mandant kann zwar `customName` setzen, aber nicht die Kategorie ändern, das SVG bearbeiten oder Symbole aus verschiedenen Paketen frei mischen.
- Kategorien (`IconCategory`) sind global mit `tenantId` Override — uneinheitlich.
- Keine Möglichkeit, Vorlagen-Pakete (Feuerwehr CH, THW, Sanität) als Unit zu importieren.
**Lösung:**
- `SymbolTemplate` = globale, read-only Vorlagen (je Paket). Wird einmalig aus bestehenden `public/signaturen/` und `IconAsset` generiert.
- `TenantCategory` = pro Mandant, frei anlegbar/umbenennbar/sortierbar.
- `TenantSymbol` = pro Mandant, **vollständig eigenständig** (`name`, `svgPath`, `categoryId`). Kein Verweis mehr auf globale `IconAsset`.
- Mandant startet mit leerer Bibliothek und importiert Pakete nach Bedarf.
---
## 2. Datenmodell-Änderungen
### 2.1 Neue Modelle
```prisma
model SymbolTemplate {
id String @id @default(uuid())
packageId String // z.B. "feuerwehr-ch"
packageName String // z.B. "Feuerwehr Schweiz"
categoryName String // z.B. "Fahrzeuge"
name String
svgPath String // Relativer Pfad in public/ oder SVG-Inhalt
tags String[] @default([])
sortOrder Int @default(0)
@@index([packageId])
@@map("symbol_templates")
}
model TenantCategory {
id String @id @default(uuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
name String
sortOrder Int @default(0)
icon String? // Optional: Emoji/Lucide-Icon-Name für UI
symbols TenantSymbol[]
@@unique([tenantId, name])
@@index([tenantId])
@@map("tenant_categories")
}
```
### 2.2 Bestehendes `TenantSymbol` umbauen
**Vorher:**
```prisma
model TenantSymbol {
id String @id @default(uuid())
customName String?
sortOrder Int @default(0)
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
iconId String
icon IconAsset @relation(fields: [iconId], references: [id], onDelete: Cascade)
}
```
**Nachher:**
```prisma
model TenantSymbol {
id String @id @default(uuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
categoryId String
category TenantCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
name String // endgültiger Anzeigename (kann aus Template importiert oder custom sein)
svgPath String // z.B. "signaturen/TLF.svg" oder tenant-spezifischer MinIO-Key
sortOrder Int @default(0)
isUploaded Boolean @default(false) // true = eigener Upload, false = aus Template importiert
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Legacy-Feld für Migration (nach erfolgreichem Deploy entfernen)
migratedFromIconId String?
@@index([tenantId])
@@index([categoryId])
@@map("tenant_symbols")
}
```
> **Hinweis:** `IconAsset`, `IconCategory` und `hiddenIconIds` bleiben vorerst bestehen (read-only Legacy), werden aber nicht mehr für neue Features verwendet. In einer späteren Phase können sie entfernt werden.
---
## 3. Migrationstrategie
### 3.1 Vor dem Deploy
- **PostgreSQL-Backup** erstellen
- **MinIO-Backup** der Icon-Dateien
- Auf Staging testen (falls vorhanden)
### 3.2 Schritt-für-Schritt-Migration (in einer Transaktion)
1. **Neue Tabellen anlegen** (`SymbolTemplate`, `TenantCategory`, neue `TenantSymbol`-Spalten)
2. **SymbolTemplate füllen:**
- Alle `public/signaturen/*.svg` einlesen
- Bestehende `IconAsset` mit `isSystem = true` als `feuerwehr-ch` Paket überführen
- Zuordnung: `categoryName` aus `IconCategory.name`
3. **TenantCategory pro Tenant anlegen:**
- Für jeden Tenant eine Default-Kategorie "Meine Symbole" erstellen
- Optional: Weitere Kategorien aus `IconCategory` ableiten (nur wenn `tenantId` gesetzt)
4. **TenantSymbol migrieren:**
- Für jeden bestehenden `TenantSymbol`-Eintrag:
- `name` = `customName || icon.name`
- `svgPath` = `icon.fileKey`
- `categoryId` = Default-Kategorie des Tenants (oder `icon.categoryId` wenn passend)
- `migratedFromIconId` = `iconId` (für Nachvollziehbarkeit)
5. **App-Code auf neue Modelle umstellen**
6. **Alte Relation `TenantSymbol.icon` und `TenantSymbol.iconId` entfernen** (nach erfolgreichem Live-Test)
### 3.3 Rollback-Plan
Falls etwas schiefgeht: Backup wiederherstellen. Die Migration ist idempotent (neue Tabellen, alte bleiben erhalten).
---
## 4. API-Design
### 4.1 Templates
```
GET /api/templates
→ { packages: [{ id, name, description, symbolCount, previewUrls }] }
GET /api/templates?packageId=feuerwehr-ch
→ { packageId, packageName, categories: [{ categoryName, symbols: [{ id, name, svgPath, tags }] }] }
POST /api/templates/import
Body: { packageId: "feuerwehr-ch", symbolIds?: ["id1", "id2"] }
→ importiert ausgewählte Symbole als TenantSymbols (oder alle wenn symbolIds fehlt)
→ antwortet mit [{ tenantSymbolId, name, categoryId }]
```
### 4.2 Tenant Categories (Admin)
```
GET /api/tenant/categories
→ [{ id, name, sortOrder, icon, symbolCount }]
POST /api/tenant/categories
Body: { name, sortOrder?, icon? }
→ { id, name, sortOrder, icon }
PATCH /api/tenant/categories/:id
Body: { name?, sortOrder?, icon? }
→ updated category
DELETE /api/tenant/categories/:id
→ 204 (nur erlaubt wenn leer, sonst 409)
```
### 4.3 Tenant Symbols (Admin + Sidebar)
```
GET /api/tenant/symbols
→ { categories: [{ id, name, sortOrder, symbols: [{ id, name, svgPath, sortOrder, isUploaded }] }] }
// Gruppiert nach TenantCategory
POST /api/tenant/symbols
Body: { templateId } // Import aus Template
Body: { name, svgPath, categoryId } // Manuelle Erstellung
→ { id, name, svgPath, categoryId, sortOrder }
POST /api/tenant/symbols/upload
Multipart: { file (SVG/PNG), name, categoryId }
→ uploaded TenantSymbol
PATCH /api/tenant/symbols/:id
Body: { name?, categoryId?, sortOrder? }
→ updated
DELETE /api/tenant/symbols/:id
→ 204
```
### 4.4 Icon-Serving (unchanged path für Kompatibilität)
```
GET /api/icons/:tenantSymbolId/image
→ Liest `TenantSymbol.svgPath` und serviert Datei (aus public/ oder MinIO)
```
---
## 5. Frontend-Änderungen
### 5.1 Admin — Symbol-Manager (`src/components/admin/symbol-manager.tsx`)
**Umbau in 3 Bereiche:**
```
┌─ Symbol-Manager ─────────────────────────────────┐
│ │
│ [+ Kategorie anlegen] [📦 Vorlagen importieren] │
│ │
│ ┌─ Fahrzeuge ─────────────┐ [⋮] [✎] [🗑] │
│ │ 🚒 TLF [✎] [🗑] [↕] │
│ │ 🚒 RW [✎] [🗑] [↕] │
│ │ [+ Symbol hinzufügen] │
│ └──────────────────────────┘ │
│ ┌─ Wasser ─────────────────┐ [⋮] [✎] [🗑] │
│ │ 🟦 Hydrant [✎] [🗑] [↕] │
│ └──────────────────────────┘ │
│ │
│ [Eigenes SVG hochladen] │
│ │
└────────────────────────────────────────────────────┘
```
**Features:**
- Kategorien per Drag & Drop sortieren (`@dnd-kit` oder native)
- Symbole zwischen Kategorien verschieben (Drag & Drop)
- Kategorie anlegen/umbenennen/löschen (nur wenn leer)
- Symbol umbenennen, Kategorie ändern, löschen
- "Vorlagen importieren" öffnet Dialog mit Paket-Vorschau
### 5.2 Admin — Import-Dialog (`src/components/admin/import-templates-dialog.tsx`)
```
┌─ Vorlagen importieren ─────────────────────────────┐
│ │
│ [📦 Feuerwehr Schweiz] [📦 THW] [📦 Sanität] │
│ │
│ ┌─ Feuerwehr Schweiz ───────────────────────────┐│
│ │ Kategorie: Fahrzeuge (3 Symbole) ││
│ │ ☑️ TLF ☑️ RW ☐ DLK ││
│ │ Kategorie: Wasser (5 Symbole) ││
│ │ ☑️ Hydrant ☑️ Löschwasser ││
│ │ ││
│ │ [Alle auswählen] [Ausgewählte importieren] ││
│ └────────────────────────────────────────────────┘│
│ │
└────────────────────────────────────────────────────┘
```
### 5.3 Sidebar / LeftToolbar — Symbol-Palette umbauen
Aktuell gibt es zwei Komponenten:
- `LeftToolbar` = Zeichenwerkzeuge (Bleistift, Linie, etc.)
- Symbol-Palette = vermutlich in `map-view.tsx` oder separater Komponente
**Ziel:** Die Symbol-Palette (die Symbole die auf die Karte gezogen werden) muss nach `TenantCategory` gruppiert werden.
Da die Symbol-Palette vermutlich inline in `map-view.tsx` oder einer anderen Komponente ist, suchen und extrahieren in eine eigene `SymbolPalette`-Komponente:
```tsx
// src/components/map/symbol-palette.tsx
interface SymbolPaletteProps {
categories: TenantCategoryWithSymbols[]
onSymbolDragStart: (symbol: TenantSymbol) => void
canEdit: boolean
}
```
**Layout:**
- Collapsible Kategorien (wie aktuell in Symbol-Manager)
- Symbole als Grid pro Kategorie
- Search-Input oben (Volltext über Name + Tags)
- Recent/Favoriten-Sektion (später in Phase 1.5)
### 5.4 DrawFeature — `instanceLabel` für Stockwerke (Phase 1.4)
Erweiterung des `properties`-Objekts:
```ts
interface SymbolProperties {
iconId: string
scale: number
rotation: number
instanceLabel?: string // z.B. "EG+3", "Wohngebäude A"
}
```
Renderer: Badge auf dem Symbol-Overlay (MapLibre Marker oder CSS-Overlay).
Edit: Doppelklick auf Symbol → kleiner Inline-Edit oder Dialog.
---
## 6. Dateien: Create / Modify / Delete
### Neue Dateien
| Pfad | Beschreibung |
|------|-------------|
| `prisma/migrations/2026xxxx_symbol_architecture/` | Prisma Migration |
| `prisma/seed-symbol-templates.ts` | Seed-Skript: `public/signaturen/*.svg``SymbolTemplate` |
| `src/app/api/templates/route.ts` | GET /api/templates |
| `src/app/api/templates/import/route.ts` | POST /api/templates/import |
| `src/app/api/tenant/categories/route.ts` | CRUD TenantCategory |
| `src/app/api/tenant/categories/[id]/route.ts` | PATCH/DELETE einzelne Kategorie |
| `src/components/admin/import-templates-dialog.tsx` | Import-Dialog UI |
| `src/components/map/symbol-palette.tsx` | Extrahierte Symbol-Palette |
### Zu modifizierende Dateien
| Pfad | Änderung |
|------|----------|
| `prisma/schema.prisma` | Neue Modelle + TenantSymbol umbauen |
| `src/app/api/tenant/symbols/route.ts` | Refactor: Gruppierung nach Category, Upload, CRUD |
| `src/app/api/icons/route.ts` | Legacy-Modus, ggf. auf TenantSymbol umleiten |
| `src/components/admin/symbol-manager.tsx` | Vollständiger Umbau mit Kategorie-Verwaltung |
| `src/app/app/page.tsx` | Symbol-Palette Props anpassen |
| `src/components/map/map-view.tsx` | Symbol-Rendering mit instanceLabel Badge |
| `src/types/index.ts` | Neue Typen: `TenantCategory`, `TenantSymbol`, `SymbolTemplate` |
---
## 7. Ausführungsreihenfolge (Execution Order)
### Sprint A — Schema & Daten (Woche 1)
1. `prisma/schema.prisma` erweitern (`SymbolTemplate`, `TenantCategory`, `TenantSymbol` Refactor)
2. Prisma Migration erstellen & testen (`npx prisma migrate dev`)
3. `prisma/seed-symbol-templates.ts` schreiben (feuerwehr-ch Paket aus `public/signaturen/`)
4. Migration-Skript für bestehende Tenants (Default-Kategorie + TenantSymbol-Migration)
5. Build testen, auf Staging deployen
### Sprint B — API (Woche 1-2)
6. `GET /api/templates` + `POST /api/templates/import`
7. `CRUD /api/tenant/categories`
8. `Refactor /api/tenant/symbols` (Gruppierung, Upload, Kategorie-Zuordnung)
9. `GET /api/icons/:id/image` an TenantSymbol anpassen
### Sprint C — Admin UI (Woche 2-3)
10. Symbol-Manager: Kategorie-Verwaltung (anlegen/umbenennen/löschen/sortieren)
11. Symbol-Manager: Import-Dialog (Paket-Vorschau, granulare Auswahl)
12. Symbol-Manager: Eigenen SVG-Upload mit Kategorie-Zuordnung
13. Symbol-Manager: Drag & Drop (Kategorien sortieren, Symbole verschieben)
### Sprint D — Frontend Sidebar & Polish (Woche 3-4)
14. `SymbolPalette`-Komponente extrahieren und nach Kategorien gruppieren
15. Symbol-Suche in Sidebar (Volltext)
16. `instanceLabel` / Stockwerke implementieren (Phase 1.4)
17. Häufig-benutzte Symbole (Recent) — Phase 1.5
18. End-to-End-Test, Deploy
---
## 8. Risiken & Entscheidungen
| Thema | Option A (empfohlen) | Option B |
|-------|---------------------|----------|
| **SVG-Speicherort** | `svgPath` = relativer Pfad in `public/signaturen/` (klein, schnell, kein MinIO nötig für Templates) | `svgPath` = MinIO-Key (konsistent mit Uploads, aber Overhead) |
| **TenantSymbol Uploads** | Eigenes MinIO-Bucket `tenant-{id}/symbols/` | In DB als Text speichern (Base64 oder SVG-String) |
| **Migration alter Tenants** | Auto-Import feuerwehr-ch Paket + Default-Kategorie | Manuelle Migration pro Tenant |
| **LeftToolbar vs SymbolPalette** | SymbolPalette als separate Komponente neben LeftToolbar | In LeftToolbar integrieren |
**Empfehlung:**
- Templates: `public/signaturen/` Pfade in DB (read-only, kein MinIO-Overhead)
- Uploads: MinIO `tenant-{id}/symbols/`
- Migration: Vollautomatisch beim ersten Login nach Deploy (kein manueller Eingriff)
---
*Plan erstellt: 2026-05-20*
*Nächster Schritt: Genehmigung durch Pepe, dann Sprint A starten*

View File

@@ -0,0 +1,112 @@
/**
* Post-Migration Script: Migrate existing TenantSymbols to new architecture.
*
* Run AFTER applying the schema migration (20260520_symbol_architecture).
*
* Steps:
* 1. Create a default "Meine Symbole" TenantCategory for each tenant that has TenantSymbols
* 2. Migrate existing TenantSymbols: set name, svgPath, categoryId, migratedFromIconId
* 3. Verify consistency
*
* Usage:
* npx ts-node prisma/migrate-tenant-symbols.ts
*/
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
console.log('🔧 Migrating existing TenantSymbols...')
// Step 1: Get all tenants that have existing tenantSymbols
const tenantsWithSymbols = await (prisma as any).tenantSymbol.groupBy({
by: ['tenantId'],
_count: { id: true },
})
console.log(`Found ${tenantsWithSymbols.length} tenants with existing symbols`)
let categoriesCreated = 0
let symbolsMigrated = 0
for (const group of tenantsWithSymbols) {
const tenantId = group.tenantId
// Step 2: Create default category for this tenant
let defaultCategory = await (prisma as any).tenantCategory.findFirst({
where: { tenantId, name: 'Meine Symbole' },
})
if (!defaultCategory) {
defaultCategory = await (prisma as any).tenantCategory.create({
data: {
tenantId,
name: 'Meine Symbole',
sortOrder: 0,
},
})
categoriesCreated++
}
// Step 3: Migrate all tenantSymbols for this tenant
const tenantSymbols = await (prisma as any).tenantSymbol.findMany({
where: { tenantId },
include: { icon: true },
})
for (const ts of tenantSymbols) {
const updates: any = {}
// name = customName || icon.name
if (!ts.name) {
updates.name = ts.customName || ts.icon?.name || 'Unbenannt'
}
// svgPath = icon.fileKey
if (!ts.svgPath && ts.icon?.fileKey) {
updates.svgPath = ts.icon.fileKey
}
// categoryId = default category
if (!ts.categoryId) {
updates.categoryId = defaultCategory.id
}
// migratedFromIconId = current iconId
if (!ts.migratedFromIconId) {
updates.migratedFromIconId = ts.iconId
}
if (Object.keys(updates).length > 0) {
await (prisma as any).tenantSymbol.update({
where: { id: ts.id },
data: updates,
})
symbolsMigrated++
}
}
}
console.log(`✅ Done: ${categoriesCreated} categories created, ${symbolsMigrated} symbols migrated`)
// Verification
const unmappedCount = await (prisma as any).tenantSymbol.count({
where: { categoryId: null },
})
if (unmappedCount > 0) {
console.warn(`⚠️ ${unmappedCount} tenantSymbols still have no categoryId!`)
} else {
console.log('✅ All tenantSymbols have a category assigned')
}
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@@ -2,6 +2,12 @@
* Database migration script using PrismaClient raw SQL. * Database migration script using PrismaClient raw SQL.
* Does NOT require the Prisma CLI (npx prisma) — only the runtime client. * Does NOT require the Prisma CLI (npx prisma) — only the runtime client.
* Safe to run multiple times (all statements are idempotent). * Safe to run multiple times (all statements are idempotent).
*
* SAFETY RULES:
* - NO deleteMany / DELETE / TRUNCATE on icon_assets, icon_categories,
* tenant_symbols, or features. These contain user data.
* - All operations must be idempotent (safe to re-run).
* - In production, destructive operations are blocked.
*/ */
const { PrismaClient } = require('@prisma/client') const { PrismaClient } = require('@prisma/client')
@@ -121,10 +127,9 @@ async function migrate() {
await prisma.$executeRawUnsafe(`UPDATE tenants SET "subscriptionStatus" = 'ACTIVE' WHERE "subscriptionStatus" = 'TRIAL'`) await prisma.$executeRawUnsafe(`UPDATE tenants SET "subscriptionStatus" = 'ACTIVE' WHERE "subscriptionStatus" = 'TRIAL'`)
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
// ─── Step 6: Clean up orphan users ─── // ─── Step 6: Detect orphan users (log only, no deletion) ───
console.log(' [6/7] Cleaning up orphan users...') console.log(' [6/7] Checking for orphan users...')
try { try {
// Find users who are NOT SERVER_ADMIN and have NO tenant membership
const orphans = await prisma.user.findMany({ const orphans = await prisma.user.findMany({
where: { where: {
role: { not: 'SERVER_ADMIN' }, role: { not: 'SERVER_ADMIN' },
@@ -133,22 +138,15 @@ async function migrate() {
select: { id: true, email: true, name: true }, select: { id: true, email: true, name: true },
}) })
if (orphans.length > 0) { if (orphans.length > 0) {
console.log(` Found ${orphans.length} orphan user(s):`) console.log(` ⚠️ Found ${orphans.length} orphan user(s) (NOT deleting — manual review required):`)
for (const o of orphans) { for (const o of orphans) {
console.log(` - ${o.email} (${o.name})`) console.log(` - ${o.email} (${o.name})`)
} }
// Delete orphan users and their related data
await prisma.user.deleteMany({
where: {
id: { in: orphans.map(o => o.id) },
},
})
console.log(` 🗑️ ${orphans.length} orphan user(s) removed`)
} else { } else {
console.log(' No orphan users found') console.log(' No orphan users found')
} }
} catch (e) { } catch (e) {
console.log(' Orphan cleanup skipped:', e.message) console.log(' Orphan check skipped:', e.message)
} }
// ─── Step 7: Backfill logoFileKey from logoUrl ─── // ─── Step 7: Backfill logoFileKey from logoUrl ───
@@ -222,7 +220,7 @@ async function migrate() {
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(), id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
"isActive" BOOLEAN NOT NULL DEFAULT true, "isActive" BOOLEAN NOT NULL DEFAULT true,
"tenantId" TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, "tenantId" TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
"iconId" TEXT NOT NULL REFERENCES icon_assets(id) ON DELETE CASCADE, "iconId" TEXT REFERENCES icon_assets(id) ON DELETE SET NULL,
UNIQUE("tenantId", "iconId") UNIQUE("tenantId", "iconId")
) )
`) `)
@@ -231,6 +229,98 @@ async function migrate() {
console.log(' tenant_symbols table skipped:', e.message) console.log(' tenant_symbols table skipped:', e.message)
} }
// ─── Step 13: Create symbol_templates table ───
console.log(' [13] Creating symbol_templates table...')
try {
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS symbol_templates (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
"fileKey" TEXT NOT NULL,
"originalFilename" TEXT NOT NULL,
"displayName" TEXT,
"categoryName" TEXT,
"svgPath" TEXT,
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE("fileKey")
)
`)
console.log(' symbol_templates table created (or already exists)')
} catch (e) {
console.log(' symbol_templates table skipped:', e.message)
}
// ─── Step 14: Create tenant_categories table ───
console.log(' [14] Creating tenant_categories table...')
try {
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS tenant_categories (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
"description" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"tenantId" TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE
)
`)
console.log(' tenant_categories table created (or already exists)')
} catch (e) {
console.log(' tenant_categories table skipped:', e.message)
}
// ─── Step 15: Extend tenant_symbols with Phase 1 columns ───
console.log(' [15] Extending tenant_symbols with Phase 1 columns...')
const tenantSymbolColumns = [
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "name" TEXT`,
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "svgPath" TEXT`,
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "isUploaded" BOOLEAN NOT NULL DEFAULT false`,
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "categoryId" TEXT REFERENCES tenant_categories(id) ON DELETE SET NULL`,
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "migratedFromIconId" TEXT`,
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP`,
`ALTER TABLE tenant_symbols ADD COLUMN IF NOT EXISTS "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP`,
]
let tsAdded = 0
for (const sql of tenantSymbolColumns) {
try { await prisma.$executeRawUnsafe(sql); tsAdded++ } catch (e) { /* ignore */ }
}
console.log(` ${tsAdded}/${tenantSymbolColumns.length} tenant_symbol columns added`)
// ─── Step 16: Fix tenant_symbols FK (CASCADE → SET NULL) ───
console.log(' [16] Fixing tenant_symbols.iconId FK (CASCADE → SET NULL)...')
try {
// Make iconId nullable
await prisma.$executeRawUnsafe(`ALTER TABLE tenant_symbols ALTER COLUMN "iconId" DROP NOT NULL`)
// Drop old cascade FK and recreate with SET NULL
await prisma.$executeRawUnsafe(`
DO $$ BEGIN
-- Drop existing FK constraint (name varies)
ALTER TABLE tenant_symbols DROP CONSTRAINT IF EXISTS "tenant_symbols_iconId_fkey";
ALTER TABLE tenant_symbols DROP CONSTRAINT IF EXISTS "tenant_symbols_iconId_icon_assets_id_fk";
-- Recreate with SET NULL
ALTER TABLE tenant_symbols
ADD CONSTRAINT "tenant_symbols_iconId_fkey"
FOREIGN KEY ("iconId") REFERENCES icon_assets(id) ON DELETE SET NULL;
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'FK fix skipped: %', SQLERRM;
END $$;
`)
console.log(' ✅ tenant_symbols.iconId FK is now ON DELETE SET NULL')
} catch (e) {
console.log(' FK fix skipped:', e.message)
}
// ─── Step 17: Drop unique constraint on tenant_symbols(tenantId, iconId) ───
console.log(' [17] Dropping UNIQUE(tenantId, iconId) on tenant_symbols...')
try {
await prisma.$executeRawUnsafe(`ALTER TABLE tenant_symbols DROP CONSTRAINT IF EXISTS "tenant_symbols_tenantId_iconId_key"`)
console.log(' ✅ Unique constraint dropped (duplicates now allowed)')
} catch (e) {
console.log(' Unique constraint drop skipped:', e.message)
}
console.log('✅ Database migrations complete') console.log('✅ Database migrations complete')
} }

151
prisma/recover-features.js Normal file
View File

@@ -0,0 +1,151 @@
/**
* Recovery-Skript für Zeichnungen (Features) nach Symbol-Verlust
*
* Problem: Features verweisen auf gelöschte iconId-UUIDs.
* Die imageUrl ist "/api/icons/{alte-uuid}/image" → 404.
*
* Lösung:
* Die alte UUID→SVG-Zuordnung ist verloren. ABER:
* - Alle bestehenden IconAssets wurden per Upsert neu erstellt (gleiche fileKeys)
* - Jedes Feature hat noch die alte iconId in properties
* - Die Sidebar liefert jetzt NEUE iconIds
*
* Dieses Skript:
* 1. Listet alle broken Symbol-Features (iconId zeigt auf gelöschtes Icon)
* 2. Für Features wo imageUrl auf /signaturen/ zeigt: direkt fixbar
* 3. Für Features mit /api/icons/{uuid}/image: UUID ist verloren → Renderer zeigt ⚠️
* 4. Zählt und listet betroffene Projekte
*
* Der Renderer wurde angepasst (map-view.tsx): broken Symbole zeigen jetzt
* ein ⚠️-Platzhalter statt nichts. User können das Symbol manuell ersetzen.
*
* Ausführung:
* DATABASE_URL=... node prisma/recover-features.js [--dry-run]
*/
const { PrismaClient } = require('@prisma/client')
const prisma = new PrismaClient()
const DRY_RUN = process.argv.includes('--dry-run')
async function main() {
console.log('🔧 FEATURE RECOVERY — Analyse & Reparatur')
console.log(` Mode: ${DRY_RUN ? '🔍 DRY RUN' : '⚡ LIVE'}`)
console.log('')
// ─── 1. Lade alle aktuellen IconAssets ───
const allIcons = await prisma.iconAsset.findMany({
select: { id: true, name: true, fileKey: true },
})
console.log(`📦 ${allIcons.length} IconAssets in der DB`)
const iconIdSet = new Set(allIcons.map(i => i.id))
const fileKeyToIcon = {}
for (const icon of allIcons) {
fileKeyToIcon[icon.fileKey] = icon
}
// ─── 2. Lade alle Symbol-Features ───
const features = await prisma.feature.findMany({
where: { type: 'symbol' },
include: { project: { select: { id: true, name: true, tenantId: true } } },
})
console.log(`🖼️ ${features.length} Symbol-Features gefunden`)
console.log('')
let ok = 0
let fixedViaFileKey = 0
let broken = 0
const brokenByProject = {}
for (const feature of features) {
const props = feature.properties || {}
const iconId = props.iconId || ''
const imageUrl = props.imageUrl || ''
// ─── Check 1: iconId still exists? ───
if (iconId && iconIdSet.has(iconId)) {
ok++
continue
}
// ─── Check 2: imageUrl points to static /signaturen/ file? ───
const sigMatch = imageUrl.match(/\/signaturen\/(.+\.svg)/)
if (sigMatch) {
const fileKey = `signaturen/${sigMatch[1]}`
const matchingIcon = fileKeyToIcon[fileKey]
if (matchingIcon) {
// Fix: Update iconId to new valid ID
const newProps = {
...props,
iconId: matchingIcon.id,
imageUrl: `/api/icons/${matchingIcon.id}/image`,
_recovered: true,
_oldIconId: iconId,
}
if (!DRY_RUN) {
await prisma.feature.update({
where: { id: feature.id },
data: { properties: newProps },
})
}
fixedViaFileKey++
console.log(`${feature.id}${matchingIcon.name} (via fileKey)`)
continue
}
}
// ─── Check 3: imageUrl is /api/icons/{uuid}/image? ───
// The uuid in the URL = old iconId = deleted → can't resolve
// Mark as broken
broken++
const projName = feature.project?.name || feature.projectId
if (!brokenByProject[projName]) brokenByProject[projName] = []
brokenByProject[projName].push({
featureId: feature.id,
iconId,
imageUrl,
})
}
console.log('')
console.log('═══════════════════════════════════════════')
console.log(` ✅ OK (iconId existiert): ${ok}`)
console.log(` 🔧 Gefixt (via fileKey): ${fixedViaFileKey}`)
console.log(` ❌ Broken (UUID verloren): ${broken}`)
console.log(` 📊 Total: ${features.length}`)
console.log('═══════════════════════════════════════════')
if (broken > 0) {
console.log('')
console.log('❌ BROKEN FEATURES pro Projekt:')
for (const [projName, items] of Object.entries(brokenByProject)) {
console.log(` 📄 "${projName}": ${items.length} Symbole`)
for (const item of items) {
console.log(` - Feature ${item.featureId} (iconId: ${item.iconId.substring(0, 8)}...)`)
}
}
console.log('')
console.log('💡 Diese Symbole zeigen jetzt ein ⚠️ auf der Karte.')
console.log(' User müssen das Symbol manuell löschen und neu platzieren.')
console.log(' (Select-Mode → Symbol anklicken → DEL → neues Symbol aus Sidebar ziehen)')
}
if (broken === 0 && fixedViaFileKey === 0) {
console.log('')
console.log('🎉 Alle Symbol-Features sind OK! Keine Reparatur nötig.')
}
if (DRY_RUN) {
console.log('')
console.log('🔍 DRY RUN — Keine Änderungen geschrieben.')
}
}
main()
.then(async () => { await prisma.$disconnect() })
.catch(async (e) => {
console.error('❌ Fehler:', e)
await prisma.$disconnect()
process.exit(1)
})

258
prisma/recover-symbols.js Normal file
View File

@@ -0,0 +1,258 @@
/**
* Recovery-Skript für gelöschte/neu erstellte Symbole
*
* Stellt sicher, dass alle Icons aus public/signaturen/ als IconAssets
* in der DB vorhanden sind und für alle Tenants aktiviert sind.
*
* Unterstützt Dry-Run und Apply-Modus.
*
* Ausführung:
* Analyse: node prisma/recover-symbols.js --dry-run
* Anwenden: node prisma/recover-symbols.js --apply
*/
const { PrismaClient } = require('@prisma/client')
const fs = require('fs')
const path = require('path')
const prisma = new PrismaClient()
const IS_DRY_RUN = process.argv.includes('--dry-run')
const IS_APPLY = process.argv.includes('--apply')
const IS_PROD = process.env.NODE_ENV === 'production'
// ─── Safety Guards ─────────────────────────────────────────────────────────
if (!IS_DRY_RUN && !IS_APPLY) {
console.error('❌ Fehler: Bitte --dry-run oder --apply angeben')
console.error('')
console.error(' Analyse: node prisma/recover-symbols.js --dry-run')
console.error(' Anwenden: node prisma/recover-symbols.js --apply')
process.exit(1)
}
if (IS_DRY_RUN && IS_APPLY) {
console.error('❌ Fehler: --dry-run und --apply können nicht zusammen verwendet werden')
process.exit(1)
}
if (IS_APPLY && IS_PROD) {
console.log('⚠️ Production-Umgebung erkannt. Fortfahren in 3 Sekunden...')
await new Promise(r => setTimeout(r, 3000))
}
console.log(IS_DRY_RUN ? '🔍 DRY-RUN MODUS' : '🔧 APPLY-MODUS')
// ─── Kategorie-Definitionen ────────────────────────────────────────────────
const catDefs = [
{ name: 'Feuer / Brand', description: 'Feuer, Brand, Brandschutz', sortOrder: 1,
patterns: ['Feuer', 'Brand', 'Explosion', 'Entrauchung', 'Rauch', 'Kamin', 'Luefter', 'Sprinkler'] },
{ name: 'Wasser', description: 'Wasser, Hydranten, Leitungen', sortOrder: 2,
patterns: ['Wasser', 'Hydrant', 'Reservoir', 'Druckleitung', 'Druckleistung', 'Transportleitung',
'Schieber', 'Wasserloeschposten', 'Wasserversorgung', 'Wasserleitung', 'Ueberschwemmung',
'Offener_Wasserverlauf', 'Moeglicher_Wasserbezugsort', 'Stehendes_Gewaesser',
'Kleinloeschgeraet', 'Gefahr_durch_Loeschen_mit_Wasser'] },
{ name: 'Gefahren / Stoffe', description: 'Gefahrstoffe, Chemie, ABC', sortOrder: 3,
patterns: ['Gefaehrlich', 'Chemie', 'Chemikalien', 'Biologisch', 'Radioaktiv', 'Gas', 'Elektri',
'Elektrotableau', 'Achtung', 'Leitungsdaehte'] },
{ name: 'Rettung / Personen', description: 'Rettung, Sanität, Personen', sortOrder: 4,
patterns: ['Rettung', 'Retten', 'Sanitaet', 'Patienten', 'Sammelplatz', 'Sammelstelle',
'Totensammelstelle', 'Sprungretter', 'Helikopterlandeplatz'] },
{ name: 'Leitern / Geräte', description: 'Leitern, Fahrzeuge, Geräte', sortOrder: 5,
patterns: ['Leiter', 'Anhaengeleiter', 'Strebenleiter', 'ADL', 'TLF', 'HRF', 'SWHP'] },
{ name: 'Gebäude / Schäden', description: 'Gebäude, Zerstörung, Schäden', sortOrder: 6,
patterns: ['Beschaedigung', 'Teilzerstoerung', 'Totalzerstoerung', 'Decke_eingestuerzt',
'Umfassungswaende', 'AnzahlGeschosse', 'Eingang', 'Treppen', 'Lift', 'Bruecke',
'Rutschgebiet', 'Strasse', 'Bahnlin'] },
{ name: 'Einsatzführung', description: 'Führung, Organisation, Kommunikation', sortOrder: 7,
patterns: ['Einsatzleiter', 'KP_Font', 'Offizier', 'Beobachtungsposten', 'Kontrollstelle',
'Funk', 'Informationszentrum', 'Medienkontaktstelle', 'Fernsignaltableau',
'Materialdepot', 'Schluesseldepot', 'Abschnitt', 'Absperrung'] },
{ name: 'Organisationen', description: 'Feuerwehr, Polizei, Armee, Zivilschutz', sortOrder: 8,
patterns: ['Feuerwehr', 'Polizei', 'Armee', 'Zivilschutz', 'Chemiewehr', 'MS_de', 'Anmarsch'] },
{ name: 'Entwicklung / Taktik', description: 'Entwicklungsgrenzen, Ausbreitung', sortOrder: 9,
patterns: ['Entwicklungsgrenze', 'Horizontale_Entwicklung', 'Vertikale_Entwicklung',
'VertikaleEntwicklung', 'Unfall'] },
{ name: 'Verschiedenes', description: 'Weitere Signaturen', sortOrder: 10,
patterns: ['Massstab', 'Nordrichtung', 'Windrichtung'] },
]
function findCategory(filename) {
for (const def of catDefs) {
for (const pattern of def.patterns) {
if (filename.includes(pattern)) return def.name
}
}
return 'Verschiedenes'
}
// ─── Main ──────────────────────────────────────────────────────────────────
async function recover() {
console.log('\n🚨 SYMBOL RECOVERY')
console.log('────────────────────────────────────────')
const SVG_DIR = path.join(__dirname, '..', 'public', 'signaturen')
if (!fs.existsSync(SVG_DIR)) {
console.error('❌ SVG-Verzeichnis nicht gefunden:', SVG_DIR)
process.exit(1)
}
const files = fs.readdirSync(SVG_DIR).filter(f => f.endsWith('.svg')).sort()
console.log(`\n📁 ${files.length} SVG-Dateien in public/signaturen/ gefunden`)
// ─── 1. Kategorien Upserten ───
console.log('\n[1/4] Kategorien...')
const catMap = {}
for (const def of catDefs) {
const catId = def.name.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()
if (IS_APPLY) {
const cat = await prisma.iconCategory.upsert({
where: { id: catId },
update: { name: def.name, description: def.description, sortOrder: def.sortOrder, isGlobal: true },
create: { id: catId, name: def.name, description: def.description, sortOrder: def.sortOrder, isGlobal: true, tenantId: null },
})
catMap[def.name] = cat.id
} else {
catMap[def.name] = catId
console.log(` 📝 ${def.name} (ID: ${catId})`)
}
}
if (IS_APPLY) console.log(`${catDefs.length} Kategorien upserted`)
// ─── 2. IconAssets Upserten ───
console.log('\n[2/4] IconAssets...')
const iconMap = {} // fileKey -> { id, name }
let created = 0
let updated = 0
for (const file of files) {
let name = file.replace('.svg', '')
name = name.replace(/_de$/i, '').replace(/_DE$/i, '').replace(/-de$/i, '')
name = name.replace(/[_-]/g, ' ').replace(/\s+/g, ' ').trim()
const fileKey = `signaturen/${file}` // WICHTIG: Gleiches Format wie Seed!
const category = findCategory(file)
const categoryId = catMap[category]
if (IS_APPLY) {
const existing = await prisma.iconAsset.findFirst({ where: { fileKey } })
if (existing) {
await prisma.iconAsset.update({
where: { id: existing.id },
data: { name, categoryId, mimeType: 'image/svg+xml', isSystem: true, isActive: true, width: 48, height: 48 },
})
iconMap[fileKey] = { id: existing.id, name }
updated++
} else {
const icon = await prisma.iconAsset.create({
data: { name, categoryId, fileKey, mimeType: 'image/svg+xml', isSystem: true, width: 48, height: 48 },
})
iconMap[fileKey] = { id: icon.id, name }
created++
}
} else {
// Dry-Run: Simuliere Lookup
const existing = await prisma.iconAsset.findFirst({ where: { fileKey }, select: { id: true, name: true } })
if (existing) {
iconMap[fileKey] = { id: existing.id, name: existing.name }
updated++
} else {
iconMap[fileKey] = { id: `(neu: ${fileKey})`, name }
created++
}
}
}
console.log(` ${IS_APPLY ? '✅' : '📝'} ${created} neu, ${updated} aktualisiert, ${files.length} total`)
// ─── 3. TenantSymbols erstellen ───
console.log('\n[3/4] TenantSymbols...')
const tenants = await prisma.tenant.findMany({ select: { id: true, name: true } })
let tsCreated = 0
let tsExisting = 0
for (const tenant of tenants) {
let tenantNew = 0
let tenantExist = 0
for (const [fileKey, icon] of Object.entries(iconMap)) {
if (IS_APPLY) {
// Prüfe ob bereits existiert
const existing = await prisma.tenantSymbol.findFirst({
where: { tenantId: tenant.id, iconId: icon.id },
})
if (existing) {
tenantExist++
tsExisting++
} else {
await prisma.tenantSymbol.create({
data: { tenantId: tenant.id, iconId: icon.id, isActive: true },
})
tenantNew++
tsCreated++
}
} else {
// Dry-Run: Prüfe Existenz
const existing = await prisma.tenantSymbol.findFirst({
where: { tenantId: tenant.id, iconId: icon.id },
select: { id: true },
})
if (existing) {
tenantExist++
tsExisting++
} else {
tenantNew++
tsCreated++
}
}
}
console.log(` ${IS_APPLY ? '✅' : '📝'} ${tenant.name}: ${tenantNew} neu, ${tenantExist} bereits vorhanden`)
}
console.log(` Gesamt: ${tsCreated} neu, ${tsExisting} bereits vorhanden`)
// ─── 4. Feature-Analyse ───
console.log('\n[4/4] Feature-Analyse (Zeichnungen)...')
const features = await prisma.feature.findMany({
where: { type: 'symbol' },
select: { id: true, properties: true, projectId: true },
})
const validIconIds = new Set(Object.values(iconMap).map(i => i.id))
let validRefs = 0
let brokenRefs = 0
for (const f of features) {
const iconId = f.properties?.iconId
if (!iconId) {
validRefs++ // Keine Referenz = nicht kaputt
} else if (validIconIds.has(iconId)) {
validRefs++
} else {
brokenRefs++
}
}
console.log(` 📊 ${features.length} symbol-Features gefunden`)
console.log(`${validRefs} gültige/keine Referenzen`)
console.log(`${brokenRefs} kaputte Referenzen (nicht reparierbar ohne Backup)`)
if (brokenRefs > 0) {
console.log('\n ⚠️ HINWEIS: Kaputte Features können NICHT automatisch repariert werden.')
console.log(' Die alten iconId-UUIDs sind verloren.')
console.log(' Verwende: npm run repair:features:dry-run')
}
console.log('\n✅ RECOVERY ABgeschlossen')
if (IS_DRY_RUN) {
console.log(' Zum Anwenden: node prisma/recover-symbols.js --apply')
}
}
recover()
.then(async () => { await prisma.$disconnect() })
.catch(async (e) => {
console.error('❌ Fehler:', e)
await prisma.$disconnect()
process.exit(1)
})

263
prisma/repair-features.js Normal file
View File

@@ -0,0 +1,263 @@
/**
* Feature/Symbol-Repair Skript
*
* Problem: Features (Zeichnungen) speichern Symbol-Referenzen als iconId in
* properties JSONB. Nach dem Icon-Verlust zeigen diese auf gelöschte UUIDs.
*
* Dieses Skript versucht:
* 1. Alle symbol-Features zu scannen
* 2. Zu prüfen, ob die referenzierte iconId noch existiert
* 3. Falls nicht: via migratedFromIconId oder Dateinamen-Matching zu reparieren
*
* Ausführung:
* Dry-Run (nur Analyse, keine Änderungen):
* node prisma/repair-features.js --dry-run
*
* Apply (mit Backup und Reparatur):
* node prisma/repair-features.js --apply
*
* SAFETY:
* - In Production (NODE_ENV=production) nur mit --apply möglich
* - Vor Apply wird automatisch ein JSON-Backup erstellt
* - Keine Features werden gelöscht
* - Keine Projekte oder Tenant-Daten werden verändert
*/
const { PrismaClient } = require('@prisma/client')
const fs = require('fs')
const path = require('path')
const prisma = new PrismaClient()
const IS_DRY_RUN = process.argv.includes('--dry-run')
const IS_APPLY = process.argv.includes('--apply')
const IS_PROD = process.env.NODE_ENV === 'production'
// ─── Safety Guards ─────────────────────────────────────────────────────────
if (!IS_DRY_RUN && !IS_APPLY) {
console.error('❌ Fehler: Bitte --dry-run oder --apply angeben')
console.error('')
console.error(' Analyse-Modus (keine Änderungen):')
console.error(' node prisma/repair-features.js --dry-run')
console.error('')
console.error(' Reparatur-Modus (mit Backup):')
console.error(' node prisma/repair-features.js --apply')
process.exit(1)
}
if (IS_DRY_RUN && IS_APPLY) {
console.error('❌ Fehler: --dry-run und --apply können nicht zusammen verwendet werden')
process.exit(1)
}
if (IS_APPLY) {
console.log('⚠️ REPAIR-MODUS (mit Änderungen)')
if (IS_PROD) {
console.log(' Production-Umgebung erkannt. Fortfahren in 3 Sekunden...')
await new Promise(r => setTimeout(r, 3000))
}
} else {
console.log('🔍 DRY-RUN MODUS (keine Änderungen)')
}
// ─── Statistics ────────────────────────────────────────────────────────────
const stats = {
totalFeatures: 0,
validRefs: 0,
brokenRefs: 0,
repaired: 0,
notRepairable: 0,
details: [],
}
// ─── Main ──────────────────────────────────────────────────────────────────
async function repair() {
console.log('\n🔧 Feature-Repair gestartet')
console.log('────────────────────────────────────────')
// ─── 1. Lade alle gültigen Icons für schnelle Lookup ───
console.log('\n[1/5] Lade Icon-Lookup-Tabellen...')
const allIcons = await prisma.iconAsset.findMany({
select: { id: true, name: true, fileKey: true },
})
const iconById = new Map(allIcons.map(i => [i.id, i]))
const iconByFileKey = new Map(allIcons.map(i => [i.fileKey, i]))
// tenant_symbols mit migratedFromIconId
const tenantSymbols = await prisma.tenantSymbol.findMany({
select: { id: true, iconId: true, migratedFromIconId: true, customName: true },
})
const tsByMigratedId = new Map()
for (const ts of tenantSymbols) {
if (ts.migratedFromIconId) {
tsByMigratedId.set(ts.migratedFromIconId, ts)
}
}
console.log(`${allIcons.length} IconAssets geladen`)
console.log(`${tenantSymbols.length} TenantSymbols geladen`)
// ─── 2. Lade alle symbol-Features ───
console.log('\n[2/5] Scanne Features...')
const features = await prisma.feature.findMany({
where: { type: 'symbol' },
include: { project: { select: { id: true, title: true, tenantId: true } } },
orderBy: { createdAt: 'asc' },
})
stats.totalFeatures = features.length
console.log(` 📊 ${features.length} symbol-Features gefunden`)
// ─── 3. Analysiere jede Feature ───
console.log('\n[3/5] Analysiere Symbol-Referenzen...')
const toRepair = []
for (const feature of features) {
const props = feature.properties || {}
const iconId = props.iconId
const imageUrl = props.imageUrl || ''
// Keine iconId → nicht reparierbar, aber auch nicht unbedingt kaputt
if (!iconId) {
stats.validRefs++ // Oder neutral
continue
}
// Prüfe ob iconId noch existiert
const icon = iconById.get(iconId)
if (icon) {
stats.validRefs++
continue
}
// ❌ Broken reference
stats.brokenRefs++
// Versuche Reparatur-Strategien
let repairedId = null
let repairMethod = null
// Strategie 1: via migratedFromIconId
const ts = tsByMigratedId.get(iconId)
if (ts && ts.iconId && iconById.has(ts.iconId)) {
repairedId = ts.iconId
repairMethod = 'migratedFromIconId'
}
// Strategie 2: via imageUrl (extrahiere Dateinamen)
if (!repairedId && imageUrl) {
const match = imageUrl.match(/\/api\/icons\/([^\/]+)\/image/)
if (match) {
// Die alte iconId war in der URL, aber die ist ja gelöscht
// Wir können hier nicht viel machen
}
}
// Strategie 3: via Name-Matching (wenn iconId ein bekannter Name war)
// Nicht anwendbar, da iconId eine UUID ist
if (repairedId) {
stats.repaired++
toRepair.push({
featureId: feature.id,
projectId: feature.projectId,
projectTitle: feature.project?.title || '(unbekannt)',
oldIconId: iconId,
newIconId: repairedId,
repairMethod,
properties: props,
})
} else {
stats.notRepairable++
stats.details.push({
featureId: feature.id,
projectId: feature.projectId,
projectTitle: feature.project?.title || '(unbekannt)',
iconId,
reason: 'Keine Zuordnung möglich (iconId nicht in migratedFromIconId)',
properties: JSON.stringify(props).substring(0, 200),
})
}
}
// ─── 4. Ergebnis-Anzeige ───
console.log('\n[4/5] Ergebnis-Zusammenfassung')
console.log('────────────────────────────────────────')
console.log(` Gesamt geprüfte Features: ${stats.totalFeatures}`)
console.log(` ✅ Gültige Referenzen: ${stats.validRefs}`)
console.log(` ❌ Kaputte Referenzen: ${stats.brokenRefs}`)
console.log(` 🔧 Automatisch reparierbar: ${stats.repaired}`)
console.log(` ⚠️ Nicht reparierbar: ${stats.notRepairable}`)
if (stats.details.length > 0) {
console.log('\n Nicht reparierbare Features (erste 10):')
for (const d of stats.details.slice(0, 10)) {
console.log(` - Feature ${d.featureId} in "${d.projectTitle}"`)
console.log(` iconId: ${d.iconId}`)
console.log(` Grund: ${d.reason}`)
}
if (stats.details.length > 10) {
console.log(` ... und ${stats.details.length - 10} weitere`)
}
}
// ─── 5. Apply (nur wenn --apply) ───
if (IS_APPLY && toRepair.length > 0) {
console.log('\n[5/5] Wende Reparaturen an...')
// Backup erstellen
const backupPath = path.join(__dirname, `feature-backup-${Date.now()}.json`)
const backup = toRepair.map(r => ({
featureId: r.featureId,
oldIconId: r.oldIconId,
newIconId: r.newIconId,
repairMethod: r.repairMethod,
oldProperties: r.properties,
}))
fs.writeFileSync(backupPath, JSON.stringify(backup, null, 2))
console.log(` 💾 Backup erstellt: ${backupPath}`)
// Reparaturen durchführen
let applied = 0
for (const r of toRepair) {
try {
const newProps = {
...r.properties,
iconId: r.newIconId,
}
await prisma.feature.update({
where: { id: r.featureId },
data: { properties: newProps },
})
applied++
console.log(` ✅ Feature ${r.featureId}: ${r.oldIconId}${r.newIconId} (${r.repairMethod})`)
} catch (e) {
console.error(` ❌ Feature ${r.featureId}: Update fehlgeschlagen - ${e.message}`)
}
}
console.log(`\n${applied}/${toRepair.length} Reparaturen erfolgreich angewendet`)
} else if (IS_APPLY) {
console.log('\n[5/5] Keine Reparaturen notwendig.')
} else {
console.log('\n[5/5] DRY-RUN abgeschlossen (keine Änderungen).')
if (toRepair.length > 0) {
console.log(` Zum Anwenden: node prisma/repair-features.js --apply`)
}
}
console.log('\n✅ Feature-Repair abgeschlossen')
}
repair()
.then(async () => { await prisma.$disconnect() })
.catch(async (e) => {
console.error('❌ Fehler:', e)
await prisma.$disconnect()
process.exit(1)
})

View File

@@ -90,6 +90,7 @@ model Tenant {
iconCategories IconCategory[] iconCategories IconCategory[]
iconAssets IconAsset[] iconAssets IconAsset[]
tenantSymbols TenantSymbol[] tenantSymbols TenantSymbol[]
tenantCategories TenantCategory[]
upgradeRequests UpgradeRequest[] upgradeRequests UpgradeRequest[]
dictionaryEntries DictionaryEntry[] dictionaryEntries DictionaryEntry[]
rapports Rapport[] rapports Rapport[]
@@ -378,22 +379,67 @@ model UpgradeRequest {
@@map("upgrade_requests") @@map("upgrade_requests")
} }
// ─── Tenant Symbol Visibility ───────────────────────────── // ─── Tenant Symbol Collection ─────────────────────────────
model TenantSymbol { model TenantSymbol {
id String @id @default(uuid()) id String @id @default(uuid())
isActive Boolean @default(true) customName String?
sortOrder Int @default(0)
createdAt DateTime @default(now())
tenantId String tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
iconId String iconId String?
icon IconAsset @relation(fields: [iconId], references: [id], onDelete: Cascade) icon IconAsset? @relation(fields: [iconId], references: [id], onDelete: SetNull)
@@unique([tenantId, iconId]) // New fields for Phase 1 Symbol Architecture
categoryId String?
category TenantCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
name String? // Display name (migrated from customName || icon.name)
svgPath String? // e.g. "signaturen/TLF.svg" or tenant-specific MinIO key
isUploaded Boolean @default(false)
migratedFromIconId String?
@@index([tenantId])
@@index([categoryId])
@@map("tenant_symbols") @@map("tenant_symbols")
} }
// ─── Symbol Templates (global read-only packages) ─────────
model SymbolTemplate {
id String @id @default(uuid())
packageId String // e.g. "feuerwehr-ch"
packageName String // e.g. "Feuerwehr Schweiz"
categoryName String // e.g. "Fahrzeuge"
name String
svgPath String // relative path in public/ or MinIO key
tags String[] @default([])
sortOrder Int @default(0)
@@index([packageId])
@@map("symbol_templates")
}
// ─── Tenant Categories (per-tenant, user-managed) ─────────
model TenantCategory {
id String @id @default(uuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
name String
sortOrder Int @default(0)
icon String? // Optional emoji or Lucide icon name for UI
symbols TenantSymbol[]
@@unique([tenantId, name])
@@index([tenantId])
@@map("tenant_categories")
}
// ─── Dictionary (Global + Tenant word library) ──────────── // ─── Dictionary (Global + Tenant word library) ────────────
model DictionaryEntry { model DictionaryEntry {

View File

@@ -44,11 +44,10 @@ async function main() {
patterns: ['Massstab', 'Nordrichtung', 'Windrichtung'] }, patterns: ['Massstab', 'Nordrichtung', 'Windrichtung'] },
] ]
// Delete ALL old system icons (regardless of fileKey pattern) // NOTE: We intentionally do NOT delete old system icons here.
const deleted = await prisma.iconAsset.deleteMany({ // TenantSymbol rows reference IconAsset.id via foreign key.
where: { isSystem: true }, // Deleting would either break references (tenant symbols become 404s)
}) // or cascade-delete tenant symbols. Instead we upsert by fileKey.
console.log(`🗑️ ${deleted.count} old system icons removed`)
// Upsert global categories (preserves tenant categories) // Upsert global categories (preserves tenant categories)
const catMap = {} const catMap = {}
@@ -90,6 +89,7 @@ async function main() {
} }
let created = 0 let created = 0
let updated = 0
for (const file of svgFiles) { for (const file of svgFiles) {
let name = file.replace('.svg', '') let name = file.replace('.svg', '')
name = name.replace(/_de$/i, '').replace(/_DE$/i, '').replace(/-de$/i, '') name = name.replace(/_de$/i, '').replace(/_DE$/i, '').replace(/-de$/i, '')
@@ -99,7 +99,21 @@ async function main() {
const category = findCategory(file) const category = findCategory(file)
const existing = await prisma.iconAsset.findFirst({ where: { fileKey } }) const existing = await prisma.iconAsset.findFirst({ where: { fileKey } })
if (!existing) { if (existing) {
await prisma.iconAsset.update({
where: { id: existing.id },
data: {
name,
categoryId: category.id,
mimeType: 'image/svg+xml',
isSystem: true,
isActive: true,
width: 48,
height: 48,
},
})
updated++
} else {
await prisma.iconAsset.create({ await prisma.iconAsset.create({
data: { data: {
name, name,
@@ -115,7 +129,7 @@ async function main() {
} }
} }
console.log(`✅ FKS Signaturen: ${created} new SVG icons created (${svgFiles.length} total)`) console.log(`✅ FKS Signaturen: ${created} created, ${updated} updated (${svgFiles.length} total)`)
} }
main() main()

View File

@@ -0,0 +1,123 @@
/**
* Seed-Skript: Erstellt SymbolTemplate-Einträge aus bestehenden IconAsset (isSystem=true)
* oder aus dem Dateisystem (public/signaturen/*.svg).
*
* Ausführen:
* npx ts-node prisma/seed-symbol-templates.ts
* oder als Teil des Deployments via npx prisma db seed
*
* Idempotent: bereits existierende Templates werden übersprungen.
*/
import { PrismaClient } from '@prisma/client'
import { readdirSync, statSync } from 'fs'
import { join } from 'path'
const prisma = new PrismaClient()
const SIGNATUREN_DIR = join(process.cwd(), 'public', 'signaturen')
const PACKAGE_ID = 'feuerwehr-ch'
const PACKAGE_NAME = 'Feuerwehr Schweiz'
/**
* Versucht Kategorie-Namen aus bestehenden IconAsset / IconCategory abzuleiten.
* Fallback: "Sonstiges"
*/
async function getCategoryMapping(): Promise<Map<string, string>> {
const mapping = new Map<string, string>()
const iconAssets = await (prisma as any).iconAsset.findMany({
where: { isSystem: true },
include: { category: { select: { name: true } } },
})
for (const asset of iconAssets) {
const fileName = asset.fileKey.replace(/^signaturen\//, '').replace(/\.svg$/i, '')
const categoryName = asset.category?.name || 'Sonstiges'
mapping.set(fileName.toLowerCase(), categoryName)
}
return mapping
}
/**
* Liest alle .svg Dateien aus public/signaturen/
*/
function getSvgFiles(dir: string): string[] {
const files: string[] = []
try {
const entries = readdirSync(dir)
for (const entry of entries) {
const fullPath = join(dir, entry)
const stat = statSync(fullPath)
if (stat.isFile() && entry.endsWith('.svg')) {
files.push(entry)
}
}
} catch (err) {
console.warn('Could not read signaturen directory:', (err as Error).message)
}
return files.sort()
}
async function main() {
console.log('🌱 Seeding SymbolTemplate...')
const categoryMapping = await getCategoryMapping()
const svgFiles = getSvgFiles(SIGNATUREN_DIR)
console.log(`Found ${svgFiles.length} SVG files in public/signaturen/`)
console.log(`IconAsset mapping covers ${categoryMapping.size} entries`)
let created = 0
let skipped = 0
for (let i = 0; i < svgFiles.length; i++) {
const fileName = svgFiles[i]
const nameWithoutExt = fileName.replace(/\.svg$/i, '')
const displayName = nameWithoutExt
.replace(/_/g, ' ')
.replace(/-/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
const fileKey = `signaturen/${fileName}`
// Check if already exists
const existing = await (prisma as any).symbolTemplate.findFirst({
where: { svgPath: fileKey },
})
if (existing) {
skipped++
continue
}
const categoryName =
categoryMapping.get(nameWithoutExt.toLowerCase()) || 'Sonstiges'
await (prisma as any).symbolTemplate.create({
data: {
packageId: PACKAGE_ID,
packageName: PACKAGE_NAME,
categoryName,
name: displayName,
svgPath: fileKey,
tags: [nameWithoutExt.toLowerCase(), categoryName.toLowerCase()],
sortOrder: i,
},
})
created++
}
console.log(`✅ Done: ${created} created, ${skipped} skipped`)
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@@ -110,21 +110,15 @@ async function main() {
patterns: ['Massstab', 'Nordrichtung', 'Windrichtung'] }, patterns: ['Massstab', 'Nordrichtung', 'Windrichtung'] },
] ]
// Delete ALL old system icons (regardless of fileKey pattern) // NOTE: We intentionally do NOT delete old system icons here.
const deletedIcons = await prisma.iconAsset.deleteMany({ // TenantSymbol rows reference IconAsset.id via foreign key.
where: { isSystem: true }, // Deleting would either break references (tenant symbols become 404s)
}) // or cascade-delete tenant symbols. Instead we upsert by fileKey.
console.log(`🗑️ ${deletedIcons.count} old system icons removed`)
// Clean up empty global categories // NOTE: We intentionally do NOT delete any icon categories here.
const oldGlobalCats = await prisma.iconCategory.findMany({ where: { tenantId: null } }) // Tenant-specific categories may reference them, and deleting could
for (const oldCat of oldGlobalCats) { // orphan user data. Empty categories are harmless.
const remaining = await prisma.iconAsset.count({ where: { categoryId: oldCat.id } }) // Create or update global categories
if (remaining === 0) {
await prisma.iconCategory.delete({ where: { id: oldCat.id } }).catch(() => {})
}
}
// Create new global categories
const catMap = {} const catMap = {}
for (const def of catDefs) { for (const def of catDefs) {
const cat = await prisma.iconCategory.upsert({ const cat = await prisma.iconCategory.upsert({
@@ -163,6 +157,7 @@ async function main() {
} }
let created = 0 let created = 0
let updated = 0
for (const file of svgFiles) { for (const file of svgFiles) {
// Clean name: remove .svg, remove _de/_DE suffix // Clean name: remove .svg, remove _de/_DE suffix
let name = file.replace('.svg', '') let name = file.replace('.svg', '')
@@ -173,7 +168,21 @@ async function main() {
const category = findCategory(file) const category = findCategory(file)
const existing = await prisma.iconAsset.findFirst({ where: { fileKey } }) const existing = await prisma.iconAsset.findFirst({ where: { fileKey } })
if (!existing) { if (existing) {
await prisma.iconAsset.update({
where: { id: existing.id },
data: {
name,
categoryId: category.id,
mimeType: 'image/svg+xml',
isSystem: true,
isActive: true,
width: 48,
height: 48,
},
})
updated++
} else {
await prisma.iconAsset.create({ await prisma.iconAsset.create({
data: { data: {
name, name,
@@ -189,7 +198,7 @@ async function main() {
} }
} }
console.log(`✅ FKS Signaturen: ${created} new icons created (${svgFiles.length} total SVGs)`) console.log(`✅ FKS Signaturen: ${created} created, ${updated} updated (${svgFiles.length} total SVGs)`)
// Create a demo project // Create a demo project
const demoProject = await prisma.project.upsert({ const demoProject = await prisma.project.upsert({

BIN
public/Pepe_Avatar.mp4 Normal file

Binary file not shown.

BIN
remote.txt Normal file

Binary file not shown.

35
src/app/admin/error.tsx Normal file
View File

@@ -0,0 +1,35 @@
'use client'
import { AlertTriangle, RotateCcw, Home } from 'lucide-react'
import { Button } from '@/components/ui/button'
import Link from 'next/link'
export default function AdminError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-background p-8 text-center">
<AlertTriangle className="w-12 h-12 text-destructive mb-4" />
<h2 className="text-xl font-bold mb-2">Fehler im Admin-Bereich</h2>
<p className="text-sm text-muted-foreground mb-6 max-w-md">
{error.message || 'Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut.'}
</p>
<div className="flex gap-3">
<Button variant="outline" onClick={reset}>
<RotateCcw className="w-4 h-4 mr-2" />
Erneut versuchen
</Button>
<Button asChild>
<Link href="/app">
<Home className="w-4 h-4 mr-2" />
Zur App
</Link>
</Button>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@ const MAX_SIZE = 5 * 1024 * 1024 // 5MB
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const user = await getSession() const user = await getSession()
if (!user || !isAdmin(user.role)) { if (!user || (user.role !== 'SERVER_ADMIN' && user.role !== 'TENANT_ADMIN')) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 }) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
} }
@@ -55,27 +55,25 @@ export async function POST(req: NextRequest) {
// Generate safe filename // Generate safe filename
const ext = file.name.split('.').pop()?.toLowerCase() || 'png' const ext = file.name.split('.').pop()?.toLowerCase() || 'png'
const isTenantAdmin = user.role === 'TENANT_ADMIN'
const prefix = isTenantAdmin ? `tenant-${user.tenantId}/icons` : 'icons'
const safeFileName = `${uuidv4()}.${ext}` const safeFileName = `${uuidv4()}.${ext}`
const fileKey = `icons/${safeFileName}` const fileKey = `${prefix}/${safeFileName}`
// Upload to MinIO // Upload to MinIO
const buffer = Buffer.from(await file.arrayBuffer()) const buffer = Buffer.from(await file.arrayBuffer())
await uploadFile(fileKey, buffer, file.type) await uploadFile(fileKey, buffer, file.type)
// TENANT_ADMIN: icons get tenantId. SERVER_ADMIN: global icons (tenantId=null) // Save to DB
const tenantId = user.role === 'SERVER_ADMIN' ? null : user.tenantId || null
// Create database entry
const icon = await (prisma as any).iconAsset.create({ const icon = await (prisma as any).iconAsset.create({
data: { data: {
name: name.trim(), name: name.trim(),
fileKey,
mimeType: file.type,
categoryId, categoryId,
iconType: iconType as any, iconType: iconType as any,
isSystem: false, fileKey,
isActive: true, mimeType: file.type,
tenantId, isSystem: !isTenantAdmin, // true für Server Admin, false für Tenant Admin
tenantId: isTenantAdmin ? user.tenantId : null,
ownerId: user.id, ownerId: user.id,
}, },
include: { include: {

View File

@@ -6,20 +6,6 @@ export async function GET() {
try { try {
const user = await getSession() const user = await getSession()
// Build icon filter: global icons (tenantId=null) + tenant-specific icons
const iconFilter: any = { isActive: true }
if (user?.tenantId) {
iconFilter.OR = [
{ tenantId: null },
{ tenantId: user.tenantId },
]
delete iconFilter.isActive
iconFilter.AND = [{ isActive: true }]
} else {
// Server admin or no tenant: show all global icons
iconFilter.tenantId = null
}
// Filter categories: global (tenantId=null) + tenant-specific // Filter categories: global (tenantId=null) + tenant-specific
const categoryWhere: any = user?.tenantId const categoryWhere: any = user?.tenantId
? { OR: [{ tenantId: null }, { tenantId: user.tenantId }] } ? { OR: [{ tenantId: null }, { tenantId: user.tenantId }] }
@@ -32,41 +18,52 @@ export async function GET() {
icons: { icons: {
where: user?.tenantId where: user?.tenantId
? { isActive: true, OR: [{ tenantId: null }, { tenantId: user.tenantId }] } ? { isActive: true, OR: [{ tenantId: null }, { tenantId: user.tenantId }] }
: { isActive: true }, : { isActive: true, tenantId: null },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}, },
}, },
}) })
// Get tenant's hidden icon IDs (legacy) + TenantSymbol overrides // Get tenant's hidden icon IDs (legacy)
let hiddenIconIds: string[] = [] let hiddenIconIds: string[] = []
let deactivatedIconIds = new Set<string>()
if (user?.tenantId) { if (user?.tenantId) {
const [tenant, tenantSymbols] = await Promise.all([ const tenant = await (prisma as any).tenant.findUnique({
(prisma as any).tenant.findUnique({
where: { id: user.tenantId }, where: { id: user.tenantId },
select: { hiddenIconIds: true }, select: { hiddenIconIds: true },
}), })
(prisma as any).tenantSymbol.findMany({
where: { tenantId: user.tenantId, isActive: false },
select: { iconId: true },
}),
])
hiddenIconIds = tenant?.hiddenIconIds || [] hiddenIconIds = tenant?.hiddenIconIds || []
deactivatedIconIds = new Set(tenantSymbols.map((ts: any) => ts.iconId))
} }
const categoriesWithUrls = categories.map((cat: any) => ({ const categoriesWithUrls = categories.map((cat: any) => ({
...cat, ...cat,
icons: cat.icons icons: cat.icons
.filter((icon: any) => !hiddenIconIds.includes(icon.id) && !deactivatedIconIds.has(icon.id)) .filter((icon: any) => !hiddenIconIds.includes(icon.id))
.map((icon: any) => ({ .map((icon: any) => ({
...icon, ...icon,
url: `/api/icons/${icon.id}/image`, url: `/api/icons/${icon.id}/image`,
})), })),
})) }))
return NextResponse.json({ categories: categoriesWithUrls }) // Get tenant's custom symbol collection (with custom names)
let mySymbols: any[] = []
if (user?.tenantId) {
const tenantSymbols = await (prisma as any).tenantSymbol.findMany({
where: { tenantId: user.tenantId },
include: { icon: { select: { id: true, name: true, mimeType: true, iconType: true } } },
orderBy: { sortOrder: 'asc' },
})
mySymbols = tenantSymbols.map((ts: any) => ({
id: ts.icon.id,
tenantSymbolId: ts.id,
name: ts.customName || ts.icon.name,
customName: ts.customName,
mimeType: ts.icon.mimeType,
iconType: ts.icon.iconType,
url: `/api/icons/${ts.icon.id}/image`,
}))
}
return NextResponse.json({ categories: categoriesWithUrls, mySymbols })
} catch (error) { } catch (error) {
console.error('Error fetching icons:', error) console.error('Error fetching icons:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }) return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })

View File

@@ -17,9 +17,13 @@ export async function GET(req: NextRequest) {
id: true, id: true,
name: true, name: true,
slug: true, slug: true,
description: true,
contactEmail: true,
contactPhone: true,
address: true,
logoUrl: true,
plan: true, plan: true,
subscriptionStatus: true, subscriptionStatus: true,
contactEmail: true,
privacyAccepted: true, privacyAccepted: true,
privacyAcceptedAt: true, privacyAcceptedAt: true,
adminAccessAccepted: true, adminAccessAccepted: true,
@@ -39,3 +43,35 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }) return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
} }
} }
export async function PATCH(req: NextRequest) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role !== 'TENANT_ADMIN') return NextResponse.json({ error: 'Nur Admin' }, { status: 403 })
if (!user.tenantId) return NextResponse.json({ error: 'Kein Mandant' }, { status: 400 })
const body = await req.json()
const { name, description, contactEmail, contactPhone, address } = body
if (!name || !name.trim()) {
return NextResponse.json({ error: 'Name darf nicht leer sein' }, { status: 400 })
}
const updated = await (prisma as any).tenant.update({
where: { id: user.tenantId },
data: {
name: name.trim(),
description: description || null,
contactEmail: contactEmail || null,
contactPhone: contactPhone || null,
address: address || null,
},
})
return NextResponse.json({ tenant: updated })
} catch (error: any) {
console.error('[Tenant Info PATCH] Error:', error?.message)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { uploadFile, deleteFile } from '@/lib/minio'
export async function POST(req: NextRequest) {
try {
const user = await getSession()
if (!user || user.role !== 'TENANT_ADMIN') {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
if (!user.tenantId) {
return NextResponse.json({ error: 'Kein Mandant' }, { status: 400 })
}
const formData = await req.formData()
const file = formData.get('logo') as File
if (!file) {
return NextResponse.json({ error: 'Keine Datei hochgeladen' }, { status: 400 })
}
const validTypes = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp']
if (!validTypes.includes(file.type)) {
return NextResponse.json({ error: 'Ungültiges Dateiformat. Erlaubt: PNG, JPEG, SVG, WebP' }, { status: 400 })
}
if (file.size > 2 * 1024 * 1024) {
return NextResponse.json({ error: 'Datei zu gross (max. 2 MB)' }, { status: 400 })
}
const buffer = Buffer.from(await file.arrayBuffer())
const ext = file.name.split('.').pop() || 'png'
const fileKey = `logos/tenant-${user.tenantId}.${ext}`
await uploadFile(fileKey, buffer, file.type)
const logoServeUrl = `/api/admin/tenants/${user.tenantId}/logo/serve`
await (prisma as any).tenant.update({
where: { id: user.tenantId },
data: { logoFileKey: fileKey, logoUrl: logoServeUrl },
})
return NextResponse.json({ logoUrl: logoServeUrl })
} catch (error) {
console.error('Tenant logo upload error:', error)
return NextResponse.json({ error: 'Upload fehlgeschlagen' }, { status: 500 })
}
}
export async function DELETE(req: NextRequest) {
try {
const user = await getSession()
if (!user || user.role !== 'TENANT_ADMIN') {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
}
if (!user.tenantId) {
return NextResponse.json({ error: 'Kein Mandant' }, { status: 400 })
}
const tenant = await (prisma as any).tenant.findUnique({ where: { id: user.tenantId } })
if (tenant?.logoFileKey) {
try {
await deleteFile(tenant.logoFileKey)
} catch {}
}
await (prisma as any).tenant.update({
where: { id: user.tenantId },
data: { logoUrl: null, logoFileKey: null },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Tenant logo delete error:', error)
return NextResponse.json({ error: 'Löschen fehlgeschlagen' }, { status: 500 })
}
}

View File

@@ -2,82 +2,151 @@ import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db' import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
// GET: List all icons with their tenant-specific active status async function getTenantId() {
export async function GET() {
try {
const user = await getSession() const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }) if (!user) return { error: 'Nicht autorisiert', status: 401 }
if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') { if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 }) return { error: 'Keine Berechtigung', status: 403 }
}
if (!user.tenantId) return { error: 'Kein Mandant zugeordnet', status: 400 }
return { tenantId: user.tenantId }
} }
const tenantId = user.tenantId // GET: Returns library (all system icons) + tenant's own symbol collection
if (!tenantId) return NextResponse.json({ error: 'Kein Mandant zugeordnet' }, { status: 400 }) export async function GET() {
try {
const auth = await getTenantId()
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const { tenantId } = auth
// Get all system icons (active ones) // All system icons grouped by category (the library)
const icons = await (prisma as any).iconAsset.findMany({ const icons = await (prisma as any).iconAsset.findMany({
where: { isActive: true }, where: { isActive: true },
include: { category: { select: { id: true, name: true } } }, include: { category: { select: { id: true, name: true } } },
orderBy: [{ category: { sortOrder: 'asc' } }, { name: 'asc' }], orderBy: [{ category: { sortOrder: 'asc' } }, { name: 'asc' }],
}) })
// Get tenant-specific overrides const library = icons.map((icon: any) => ({
const overrides = await (prisma as any).tenantSymbol.findMany({
where: { tenantId },
})
const overrideMap = new Map(overrides.map((o: any) => [o.iconId, o.isActive]))
// Merge: default is active (true) unless override says otherwise
const symbols = icons.map((icon: any) => ({
id: icon.id, id: icon.id,
name: icon.name, name: icon.name,
fileKey: icon.fileKey,
mimeType: icon.mimeType, mimeType: icon.mimeType,
iconType: icon.iconType, iconType: icon.iconType,
categoryId: icon.categoryId, categoryId: icon.categoryId,
categoryName: icon.category?.name || 'Ohne Kategorie', categoryName: icon.category?.name || 'Ohne Kategorie',
isActive: overrideMap.has(icon.id) ? overrideMap.get(icon.id) : true,
})) }))
return NextResponse.json({ symbols }) // Tenant's own symbol collection
const tenantSymbols = await (prisma as any).tenantSymbol.findMany({
where: { tenantId },
include: { icon: { select: { id: true, name: true, mimeType: true, iconType: true, category: { select: { name: true } } } } },
orderBy: { sortOrder: 'asc' },
})
const mySymbols = tenantSymbols.map((ts: any) => ({
id: ts.id,
iconId: ts.iconId,
name: ts.customName || ts.icon.name,
customName: ts.customName,
baseName: ts.icon.name,
mimeType: ts.icon.mimeType,
iconType: ts.icon.iconType,
categoryName: ts.icon.category?.name || 'Ohne Kategorie',
sortOrder: ts.sortOrder,
}))
return NextResponse.json({ library, mySymbols })
} catch (error) { } catch (error) {
console.error('Error fetching tenant symbols:', error) console.error('Error fetching tenant symbols:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 }) return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
} }
} }
// PATCH: Update symbol visibility for the tenant (bulk) // POST: Add a symbol from the library to "my symbols"
export async function PATCH(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const user = await getSession() const auth = await getTenantId()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }) if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') { const { tenantId } = auth
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const tenantId = user.tenantId const { iconId, customName } = await req.json()
if (!tenantId) return NextResponse.json({ error: 'Kein Mandant zugeordnet' }, { status: 400 }) if (!iconId) return NextResponse.json({ error: 'iconId erforderlich' }, { status: 400 })
const { updates } = await req.json() // Get max sortOrder for this tenant
if (!Array.isArray(updates)) { const maxSort = await (prisma as any).tenantSymbol.aggregate({
return NextResponse.json({ error: 'updates Array erforderlich' }, { status: 400 }) where: { tenantId },
} _max: { sortOrder: true },
// Upsert each symbol override
await Promise.all(
updates.map((u: { iconId: string; isActive: boolean }) =>
(prisma as any).tenantSymbol.upsert({
where: { tenantId_iconId: { tenantId, iconId: u.iconId } },
update: { isActive: u.isActive },
create: { tenantId, iconId: u.iconId, isActive: u.isActive },
}) })
)
)
return NextResponse.json({ success: true }) const symbol = await (prisma as any).tenantSymbol.create({
data: {
tenantId,
iconId,
customName: customName || null,
sortOrder: (maxSort._max.sortOrder ?? -1) + 1,
},
include: { icon: { select: { name: true, mimeType: true, iconType: true, category: { select: { name: true } } } } },
})
return NextResponse.json({
id: symbol.id,
iconId: symbol.iconId,
name: symbol.customName || symbol.icon.name,
customName: symbol.customName,
baseName: symbol.icon.name,
mimeType: symbol.icon.mimeType,
iconType: symbol.icon.iconType,
categoryName: symbol.icon.category?.name || 'Ohne Kategorie',
sortOrder: symbol.sortOrder,
})
} catch (error) { } catch (error) {
console.error('Error updating tenant symbols:', error) console.error('Error adding tenant symbol:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
// PATCH: Rename a symbol or update sortOrder
export async function PATCH(req: NextRequest) {
try {
const auth = await getTenantId()
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const { tenantId } = auth
const { id, customName, sortOrder } = await req.json()
if (!id) return NextResponse.json({ error: 'id erforderlich' }, { status: 400 })
const data: any = {}
if (customName !== undefined) data.customName = customName || null
if (sortOrder !== undefined) data.sortOrder = sortOrder
await (prisma as any).tenantSymbol.updateMany({
where: { id, tenantId },
data,
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error updating tenant symbol:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
// DELETE: Remove a symbol from "my symbols"
export async function DELETE(req: NextRequest) {
try {
const auth = await getTenantId()
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const { tenantId } = auth
const { id } = await req.json()
if (!id) return NextResponse.json({ error: 'id erforderlich' }, { status: 400 })
await (prisma as any).tenantSymbol.deleteMany({
where: { id, tenantId },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting tenant symbol:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 }) return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
} }
} }

35
src/app/app/error.tsx Normal file
View File

@@ -0,0 +1,35 @@
'use client'
import { AlertTriangle, RotateCcw, Home } from 'lucide-react'
import { Button } from '@/components/ui/button'
import Link from 'next/link'
export default function AppError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-background p-8 text-center">
<AlertTriangle className="w-12 h-12 text-destructive mb-4" />
<h2 className="text-xl font-bold mb-2">Fehler in der Krokier-App</h2>
<p className="text-sm text-muted-foreground mb-6 max-w-md">
{error.message || 'Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut.'}
</p>
<div className="flex gap-3">
<Button variant="outline" onClick={reset}>
<RotateCcw className="w-4 h-4 mr-2" />
Erneut versuchen
</Button>
<Button asChild>
<Link href="/">
<Home className="w-4 h-4 mr-2" />
Startseite
</Link>
</Button>
</div>
</div>
)
}

View File

@@ -18,94 +18,73 @@ import { useAuth } from '@/components/providers/auth-provider'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { JournalView } from '@/components/journal/journal-view' import { JournalView } from '@/components/journal/journal-view'
import { jsPDF } from 'jspdf'
import { Lock, Unlock, Eye, AlertTriangle, WifiOff } from 'lucide-react' import { Lock, Unlock, Eye, AlertTriangle, WifiOff } from 'lucide-react'
import { getSocket, setSocketRoom } from '@/lib/socket'
import { CustomDragLayer } from '@/components/map/custom-drag-layer' import { CustomDragLayer } from '@/components/map/custom-drag-layer'
import { OnboardingTour, resetOnboardingTour } from '@/components/onboarding/onboarding-tour' import { OnboardingTour, resetOnboardingTour } from '@/components/onboarding/onboarding-tour'
import { addToSyncQueue, flushSyncQueue, getSyncQueue, isOnline as checkOnline } from '@/lib/offline-sync' import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'
import { useMapExport } from '@/hooks/use-map-export'
import { useAutoSave } from '@/hooks/use-auto-save'
import { useOfflineSync } from '@/hooks/use-offline-sync'
import { useRealtimeSync } from '@/hooks/use-realtime-sync'
import type { Project, DrawFeature, Feature, JournalEntry, DrawMode } from '@/types'
import { useToolStore } from '@/stores/tool-store'
import { useUIStore } from '@/stores/ui-store'
export interface Project { export type { DrawMode }
id: string
title: string
location?: string
description?: string
einsatzleiter?: string
journalfuehrer?: string
mapCenter: { lng: number; lat: number }
mapZoom: number
isLocked: boolean
editingById?: string | null
editingUserName?: string | null
editingStartedAt?: string | null
planImageKey?: string | null
planBounds?: { north: number; south: number; east: number; west: number } | null
createdAt: string
updatedAt: string
}
export interface DrawFeature {
id: string
type: string
geometry: {
type: string
coordinates: number[] | number[][] | number[][][]
}
properties: Record<string, unknown>
}
export type DrawMode =
| 'select'
| 'point'
| 'linestring'
| 'polygon'
| 'rectangle'
| 'circle'
| 'freehand'
| 'text'
| 'arrow'
| 'measure'
| 'dangerzone'
| 'eraser'
export default function AppPage() { export default function AppPage() {
const router = useRouter()
const { toast } = useToast() const { toast } = useToast()
const { user, tenant, loading: authLoading, logout } = useAuth() const { user, tenant, loading: authLoading, logout } = useAuth()
// Zustand Stores
const { activeTool: drawMode, setActiveTool: setDrawMode, activeColor: selectedColor, setActiveColor: setSelectedColor, lineWidth: selectedWidth, setLineWidth: setSelectedWidth } = useToolStore()
const { sidebarOpen: isSidebarOpen, setSidebarOpen: setIsSidebarOpen, sidebarTab: activeTab, setSidebarTab: setActiveTab } = useUIStore()
const [currentProject, setCurrentProject] = useState<Project | null>(null) const [currentProject, setCurrentProject] = useState<Project | null>(null)
const [features, setFeatures] = useState<DrawFeature[]>([]) const [features, setFeatures] = useState<DrawFeature[]>([])
const [drawMode, setDrawModeRaw] = useState<DrawMode>('select')
const setDrawMode = useCallback((mode: DrawMode) => {
setDrawModeRaw(mode)
}, [])
const [selectedColor, setSelectedColor] = useState('#000000')
const [selectedWidth, setSelectedWidth] = useState(3)
const [isProjectDialogOpen, setIsProjectDialogOpen] = useState(false) const [isProjectDialogOpen, setIsProjectDialogOpen] = useState(false)
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [isDeleteAllConfirmOpen, setIsDeleteAllConfirmOpen] = useState(false) const [isDeleteAllConfirmOpen, setIsDeleteAllConfirmOpen] = useState(false)
const [isFullscreen, setIsFullscreen] = useState(false) const [isFullscreen, setIsFullscreen] = useState(false)
const [auditLog, setAuditLog] = useState<{ time: string; action: string }[]>([]) const [auditLog, setAuditLog] = useState<{ time: string; action: string }[]>([])
const [isAuditOpen, setIsAuditOpen] = useState(false) const [isAuditOpen, setIsAuditOpen] = useState(false)
const [presentationLocked, setPresentationLocked] = useState(false)
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false) const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
const [activeTab, setActiveTab] = useState<'map' | 'journal'>('map')
const [lastMapScreenshot, setLastMapScreenshot] = useState<string>('') const [lastMapScreenshot, setLastMapScreenshot] = useState<string>('')
const [defaultSymbolScale, setDefaultSymbolScale] = useState(1.5) const [defaultSymbolScale, setDefaultSymbolScale] = useState(1.5)
// Onboarding tour // Onboarding tour
const [showTour, setShowTour] = useState(false) const [showTour, setShowTour] = useState(false)
// Live editing lock state // Ref to access the map for export
const [editingBy, setEditingBy] = useState<{ id: string; name: string; since: string } | null>(null) const mapRef = useRef<any>(null)
const [isEditingByMe, setIsEditingByMe] = useState(false)
const [editingLoading, setEditingLoading] = useState(false)
// Unique session ID per browser tab (survives re-renders, not page reload) // Undo/Redo history
const sessionIdRef = useRef<string>('') const undoStackRef = useRef<DrawFeature[][]>([])
if (!sessionIdRef.current) { const redoStackRef = useRef<DrawFeature[][]>([])
sessionIdRef.current = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
} // Ref to always have latest features (avoids stale closures in callbacks called via refs)
const featuresRef = useRef<DrawFeature[]>(features)
useEffect(() => { featuresRef.current = features }, [features])
// Ref for undo-draw-point (removes last point during line drawing)
const undoDrawPointRef = useRef<(() => boolean) | null>(null)
// Realtime sync: editing lock, socket.io, throttled broadcast
const {
editingBy, isEditingByMe, editingLoading,
socketRef, broadcastFeatures,
handleStartEditing, handleStopEditing,
} = useRealtimeSync({
currentProject,
user: user ? { id: user.id, name: user.name, role: user.role } : null,
featuresRef,
setFeatures,
toast: toast as any,
})
// Capture map screenshot when switching to journal tab (coordinate-based rendering) // Capture map screenshot when switching to journal tab (coordinate-based rendering)
const handleTabChange = useCallback(async (tab: 'map' | 'journal') => { const handleTabChange = useCallback(async (tab: 'map' | 'journal') => {
@@ -368,67 +347,8 @@ export default function AppPage() {
const [isLineLabelDialogOpen, setIsLineLabelDialogOpen] = useState(false) const [isLineLabelDialogOpen, setIsLineLabelDialogOpen] = useState(false)
const [pendingLineFeature, setPendingLineFeature] = useState<DrawFeature | null>(null) const [pendingLineFeature, setPendingLineFeature] = useState<DrawFeature | null>(null)
// Ref to access the map for export // Offline detection + sync queue management
const mapRef = useRef<any>(null) const { isOffline, syncQueueCount, setSyncQueueCount } = useOfflineSync({ toast: toast as any })
// Offline detection
const [isOffline, setIsOffline] = useState(false)
const [syncQueueCount, setSyncQueueCount] = useState(0)
useEffect(() => {
setIsOffline(!checkOnline())
setSyncQueueCount(getSyncQueue().length)
const goOffline = () => {
setIsOffline(true)
toast({ title: 'Offline-Modus', description: 'Änderungen werden lokal gespeichert und beim Reconnect synchronisiert.' })
}
const goOnline = async () => {
setIsOffline(false)
const queue = getSyncQueue()
if (queue.length > 0) {
toast({ title: 'Verbindung wiederhergestellt', description: `${queue.length} Änderung(en) werden synchronisiert...` })
const result = await flushSyncQueue()
setSyncQueueCount(getSyncQueue().length)
if (result.success > 0) {
toast({ title: 'Synchronisiert', description: `${result.success} Änderung(en) erfolgreich gespeichert.` })
}
if (result.failed > 0) {
toast({ title: 'Sync-Fehler', description: `${result.failed} Änderung(en) konnten nicht gespeichert werden.`, variant: 'destructive' })
}
} else {
toast({ title: 'Wieder online' })
}
}
window.addEventListener('offline', goOffline)
window.addEventListener('online', goOnline)
// Listen for SW sync messages
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data?.type === 'FLUSH_SYNC_QUEUE') {
flushSyncQueue().then(() => setSyncQueueCount(getSyncQueue().length))
}
})
}
return () => {
window.removeEventListener('offline', goOffline)
window.removeEventListener('online', goOnline)
}
}, [])
// Undo/Redo history
const undoStackRef = useRef<DrawFeature[][]>([])
const redoStackRef = useRef<DrawFeature[][]>([])
// Ref to always have latest features (avoids stale closures in callbacks called via refs)
const featuresRef = useRef<DrawFeature[]>(features)
useEffect(() => { featuresRef.current = features }, [features])
// Ref for undo-draw-point (removes last point during line drawing)
const undoDrawPointRef = useRef<(() => boolean) | null>(null)
// Audit trail helper // Audit trail helper
const addAudit = useCallback((action: string) => { const addAudit = useCallback((action: string) => {
@@ -447,8 +367,6 @@ export default function AppPage() {
}).catch(() => {}) }).catch(() => {})
}, []) }, [])
const router = useRouter()
// Redirect to login if not authenticated // Redirect to login if not authenticated
useEffect(() => { useEffect(() => {
if (!authLoading && !user) { if (!authLoading && !user) {
@@ -458,317 +376,19 @@ export default function AppPage() {
const roleCanEdit = user ? (user.role === 'SERVER_ADMIN' || user.role === 'TENANT_ADMIN' || user.role === 'OPERATOR') : false const roleCanEdit = user ? (user.role === 'SERVER_ADMIN' || user.role === 'TENANT_ADMIN' || user.role === 'OPERATOR') : false
// User can only edit if they have the role AND they hold the editing lock (or no one is editing) // User can only edit if they have the role AND they hold the editing lock (or no one is editing)
const canEdit = roleCanEdit && (isEditingByMe || !editingBy) const canEdit = !presentationLocked && roleCanEdit && (isEditingByMe || !editingBy)
const isReadOnly = !!editingBy && !isEditingByMe const isReadOnly = !!editingBy && !isEditingByMe
// ─── Editing Lock: Check status + Heartbeat + Polling ───────── // Auto-save: localStorage persistence + debounced API save + beacon on unload
useAutoSave({
const checkEditingStatus = useCallback(async (projectId: string) => { currentProject,
try { features,
const res = await fetch(`/api/projects/${projectId}/editing?sessionId=${sessionIdRef.current}`) featuresRef,
if (!res.ok) return mapRef,
const data = await res.json() socketRef,
if (data.editing) { isEditingByMe,
setEditingBy(data.editingBy) setSyncQueueCount,
setIsEditingByMe(data.isMe)
} else {
setEditingBy(null)
setIsEditingByMe(false)
}
} catch (e) {
console.warn('[Editing] Status check failed:', e)
}
}, [])
// Check editing status when project changes
useEffect(() => {
if (!currentProject?.id) {
setEditingBy(null)
setIsEditingByMe(false)
return
}
checkEditingStatus(currentProject.id)
}, [currentProject?.id, checkEditingStatus])
// Heartbeat: keep lock alive every 30s while I'm editing
useEffect(() => {
if (!currentProject?.id || !isEditingByMe) return
const interval = setInterval(async () => {
try {
await fetch(`/api/projects/${currentProject.id}/editing`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'heartbeat', sessionId: sessionIdRef.current }),
}) })
} catch (e) {
console.warn('[Heartbeat] Failed:', e)
}
}, 30000)
return () => clearInterval(interval)
}, [currentProject?.id, isEditingByMe])
// Socket.io: real-time sync for features, editing status, journal
const socketRef = useRef<any>(null)
const prevProjectIdRef = useRef<string | null>(null)
// Throttled socket broadcast for near-real-time sync
const lastEmitRef = useRef(0)
const emitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const currentProjectRef = useRef(currentProject)
useEffect(() => { currentProjectRef.current = currentProject }, [currentProject])
const broadcastFeatures = useCallback((feats: DrawFeature[]) => {
const proj = currentProjectRef.current
if (!socketRef.current || !proj?.id || !isEditingByMeRef.current) return
const now = Date.now()
const emit = () => {
socketRef.current?.emit('features-updated', {
projectId: proj!.id,
features: feats,
})
lastEmitRef.current = Date.now()
}
// Throttle: emit at most every 800ms for snappier sync
if (now - lastEmitRef.current > 800) {
emit()
} else {
if (emitTimerRef.current) clearTimeout(emitTimerRef.current)
emitTimerRef.current = setTimeout(emit, 800 - (now - lastEmitRef.current))
}
}, [])
const isEditingByMeRef = useRef(false)
// Keep ref in sync with state
useEffect(() => {
isEditingByMeRef.current = isEditingByMe
}, [isEditingByMe])
useEffect(() => {
if (!currentProject?.id) return
const socket = getSocket()
socketRef.current = socket
// Leave old room, join new room
if (prevProjectIdRef.current && prevProjectIdRef.current !== currentProject.id) {
socket.emit('leave-project', prevProjectIdRef.current)
}
socket.emit('join-project', currentProject.id)
setSocketRoom(currentProject.id)
prevProjectIdRef.current = currentProject.id
// Listen for features changes from other clients (only apply if NOT the editor)
const onFeaturesChanged = (data: { features: any[] }) => {
// Skip if I'm the one editing — my local state is the source of truth
if (isEditingByMeRef.current) {
console.log('[Socket.io] Ignoring features-changed (I am the editor)')
return
}
if (data.features && Array.isArray(data.features)) {
console.log('[Socket.io] Features updated from another client')
setFeatures(data.features)
}
}
// Listen for editing status changes from other clients
const onEditingStatus = (data: { editing: boolean; editingBy: any; sessionId: string }) => {
if (data.sessionId === sessionIdRef.current) return // ignore own events
if (data.editing && data.editingBy) {
setEditingBy(data.editingBy)
setIsEditingByMe(false)
} else {
setEditingBy(null)
setIsEditingByMe(false)
}
}
// Listen for journal changes — trigger a re-fetch in JournalView
const onJournalChanged = () => {
console.log('[Socket.io] Journal updated from another client')
window.dispatchEvent(new CustomEvent('journal-refresh'))
}
socket.on('features-changed', onFeaturesChanged)
socket.on('editing-status', onEditingStatus)
socket.on('journal-changed', onJournalChanged)
return () => {
socket.off('features-changed', onFeaturesChanged)
socket.off('editing-status', onEditingStatus)
socket.off('journal-changed', onJournalChanged)
}
}, [currentProject?.id])
// Fallback: check editing status on initial load and every 30s
useEffect(() => {
if (!currentProject?.id) return
checkEditingStatus(currentProject.id)
const interval = setInterval(() => checkEditingStatus(currentProject.id), 30000)
return () => clearInterval(interval)
}, [currentProject?.id, checkEditingStatus])
// Release lock on unmount / page close
useEffect(() => {
const release = () => {
if (currentProject?.id && isEditingByMe) {
const blob = new Blob([JSON.stringify({ action: 'stop', sessionId: sessionIdRef.current })], { type: 'application/json' })
navigator.sendBeacon(`/api/projects/${currentProject.id}/editing`, blob)
}
}
window.addEventListener('beforeunload', release)
return () => {
window.removeEventListener('beforeunload', release)
release()
}
}, [currentProject?.id, isEditingByMe])
const handleStartEditing = useCallback(async () => {
if (!currentProject?.id) return
setEditingLoading(true)
try {
const res = await fetch(`/api/projects/${currentProject.id}/editing`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'start', sessionId: sessionIdRef.current }),
})
if (!res.ok) {
const data = await res.json()
toast({ title: 'Gesperrt', description: data.error || 'Bearbeitung nicht möglich', variant: 'destructive' })
return
}
setIsEditingByMe(true)
const editingInfo = { id: user!.id, name: user!.name, since: new Date().toISOString() }
setEditingBy(editingInfo)
// Notify other clients
socketRef.current?.emit('editing-changed', {
projectId: currentProject.id,
editing: true,
editingBy: editingInfo,
sessionId: sessionIdRef.current,
})
toast({ title: 'Bearbeitung gestartet', description: 'Sie können jetzt zeichnen und Einträge erstellen.' })
} catch (e) {
toast({ title: 'Fehler', description: 'Konnte Bearbeitung nicht starten.', variant: 'destructive' })
} finally {
setEditingLoading(false)
}
}, [currentProject?.id, user, toast])
const handleStopEditing = useCallback(async () => {
if (!currentProject?.id) return
setEditingLoading(true)
try {
// Save features before releasing lock
const currentFeatures = featuresRef.current
await fetch(`/api/projects/${currentProject.id}/features`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ features: currentFeatures }),
})
// Release lock
await fetch(`/api/projects/${currentProject.id}/editing`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'stop', sessionId: sessionIdRef.current }),
})
setIsEditingByMe(false)
setEditingBy(null)
// Notify other clients: editing stopped + send final features
socketRef.current?.emit('editing-changed', {
projectId: currentProject.id,
editing: false,
editingBy: null,
sessionId: sessionIdRef.current,
})
socketRef.current?.emit('features-updated', {
projectId: currentProject.id,
features: currentFeatures,
})
toast({ title: 'Bearbeitung beendet', description: 'Änderungen gespeichert. Andere können jetzt bearbeiten.' })
} catch (e) {
toast({ title: 'Fehler', description: 'Konnte Bearbeitung nicht beenden.', variant: 'destructive' })
} finally {
setEditingLoading(false)
}
}, [currentProject?.id, toast])
// Persist features to localStorage on change (including empty array to reflect deletions)
useEffect(() => {
localStorage.setItem('lageplan-features', JSON.stringify(features))
}, [features])
// Auto-save to API — debounced 2s after every feature change + fallback interval
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const saveFeaturesToApi = useCallback(async () => {
if (!currentProject?.id) return
const url = `/api/projects/${currentProject.id}/features`
const mapInstance = mapRef.current
const body: any = { features: featuresRef.current }
if (mapInstance) {
const c = mapInstance.getCenter()
body.mapCenter = { lng: c.lng, lat: c.lat }
body.mapZoom = mapInstance.getZoom()
}
// If offline, queue the save for later sync
if (!navigator.onLine) {
addToSyncQueue(url, 'PUT', body)
setSyncQueueCount(getSyncQueue().length)
console.log('[Auto-Save] Offline — in Sync-Queue gespeichert')
return
}
try {
const res = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (res.ok) {
console.log('[Auto-Save] Features gespeichert')
socketRef.current?.emit('features-updated', {
projectId: currentProject.id,
features: featuresRef.current,
})
} else if (res.status === 404) {
console.warn('[Auto-Save] Projekt nicht in DB')
}
} catch (e) {
// Network error — queue for later
addToSyncQueue(url, 'PUT', body)
setSyncQueueCount(getSyncQueue().length)
console.warn('[Auto-Save] Netzwerkfehler — in Sync-Queue:', e)
}
}, [currentProject])
// Debounced save on every feature change (2s delay)
useEffect(() => {
if (!currentProject || !isEditingByMe) return
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(() => saveFeaturesToApi(), 2000)
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }
}, [features, currentProject, isEditingByMe, saveFeaturesToApi])
// Also save on page unload / tab switch
useEffect(() => {
const handleBeforeUnload = () => {
if (currentProject?.id && featuresRef.current.length > 0) {
const payload = JSON.stringify({ features: featuresRef.current })
navigator.sendBeacon(`/api/projects/${currentProject.id}/features`, new Blob([payload], { type: 'application/json' }))
}
}
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden' && currentProject?.id && isEditingByMe) {
saveFeaturesToApi()
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload)
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [currentProject, isEditingByMe, saveFeaturesToApi])
// Fullscreen toggle // Fullscreen toggle
const toggleFullscreen = useCallback(() => { const toggleFullscreen = useCallback(() => {
@@ -1066,56 +686,15 @@ export default function AppPage() {
// Keyboard shortcuts for tools // Keyboard shortcuts for tools
const [isShortcutHelpOpen, setIsShortcutHelpOpen] = useState(false) const [isShortcutHelpOpen, setIsShortcutHelpOpen] = useState(false)
useEffect(() => { useKeyboardShortcuts({
const handleKeyDown = (e: KeyboardEvent) => { featuresRef,
// Ignore when typing in inputs/textareas onUndo: handleUndo,
const tag = (e.target as HTMLElement)?.tagName onRedo: handleRedo,
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (e.target as HTMLElement)?.isContentEditable) return onSave: handleSaveProject,
onDelete: handleFeaturesChange,
// ? or F1 → help onToolChange: setDrawMode,
if (e.key === '?' || e.key === 'F1') { e.preventDefault(); setIsShortcutHelpOpen(true); return } onHelpOpen: useCallback(() => setIsShortcutHelpOpen(true), []),
})
// DEL / Backspace → delete selected feature(s)
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault()
// Remove all selected features
const current = featuresRef.current
const selected = current.filter(f => f.properties?._selected)
if (selected.length > 0) {
handleFeaturesChange(current.filter(f => !f.properties?._selected))
}
return
}
// Ctrl/Cmd shortcuts (CH keyboard: Z and Y are swapped)
if (e.ctrlKey || e.metaKey) {
if (e.key === 'z') { e.preventDefault(); handleRedo(); return }
if (e.key === 'y') { e.preventDefault(); handleUndo(); return }
if (e.key === 's') { e.preventDefault(); handleSaveProject(); return }
return
}
// Tool shortcuts (single key, no modifier)
const shortcuts: Record<string, DrawMode> = {
'v': 'select', 's': 'select',
'p': 'point',
'l': 'linestring',
'g': 'polygon',
'r': 'rectangle',
'c': 'circle',
'f': 'freehand',
'a': 'arrow',
't': 'text',
'e': 'eraser',
'm': 'measure',
'd': 'dangerzone',
}
const mode = shortcuts[e.key.toLowerCase()]
if (mode) { e.preventDefault(); setDrawMode(mode); return }
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleUndo, handleRedo, handleSaveProject, setDrawMode, handleFeaturesChange])
const handlePlanUpload = useCallback(() => { const handlePlanUpload = useCallback(() => {
if (!currentProject) return if (!currentProject) return
@@ -1180,312 +759,15 @@ export default function AppPage() {
setIsDeleteAllConfirmOpen(false) setIsDeleteAllConfirmOpen(false)
}, [toast, addAudit]) }, [toast, addAudit])
const handleExport = useCallback(async (format: 'png' | 'pdf') => { const { handleExport } = useMapExport({
const mapInstance = mapRef.current mapRef,
if (!mapInstance) { featuresRef,
toast({ title: 'Fehler', description: 'Karte nicht bereit.', variant: 'destructive' }) currentProject,
return tenant: tenant ? { id: tenant.id, name: tenant.name } : null,
} addAudit,
toast: toast as any,
try {
// 1. Get the MapLibre canvas (tiles + vector drawings)
const mapCanvas = mapInstance.getCanvas() as HTMLCanvasElement
const w = mapCanvas.width
const h = mapCanvas.height
// 2. Create composite canvas
const exportCanvas = document.createElement('canvas')
exportCanvas.width = w
exportCanvas.height = h
const ctx = exportCanvas.getContext('2d')!
ctx.drawImage(mapCanvas, 0, 0)
// 3. Draw symbols manually at correct size/rotation
const currentFeatures = featuresRef.current
// Derive actual pixel ratio from canvas vs container (more reliable than window.devicePixelRatio)
const container = mapInstance.getContainer()
const dpr = mapCanvas.width / container.offsetWidth
const zoom = mapInstance.getZoom()
// Symbol sizing: match the map rendering logic exactly
// In map-view.tsx: size = baseSize * scale * Math.pow(2, currentZoom - placementZoom)
const currentZoom = zoom
// Helper: load image as promise
const loadImage = (src: string): Promise<HTMLImageElement> => new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => resolve(img)
img.onerror = reject
img.src = src
}) })
// Draw symbol features
for (const f of currentFeatures.filter(f => f.type === 'symbol')) {
if (f.geometry.type !== 'Point') continue
const coords = f.geometry.coordinates as [number, number]
const pixel = mapInstance.project(coords)
const px = pixel.x * dpr
const py = pixel.y * dpr
const scale = (f.properties.scale as number) || 1
const rotation = (f.properties.rotation as number) || 0
const baseSize = 32
const placementZoom = (f.properties.placementZoom as number) || 17
const zoomFactor = Math.pow(2, currentZoom - placementZoom)
const size = Math.max(8, Math.min(400, baseSize * scale * zoomFactor)) * dpr
// Determine image source
const iconId = f.properties.iconId as string
const imageUrl = f.properties.imageUrl as string
let imgSrc = imageUrl || ''
if (!imgSrc && iconId) {
const { getSymbolById, getSymbolDataUri } = await import('@/lib/fw-symbols')
const sym = getSymbolById(iconId)
if (sym) imgSrc = getSymbolDataUri(sym)
}
if (imgSrc) {
try {
const img = await loadImage(imgSrc)
// Replicate CSS background-size: contain (preserve aspect ratio)
const imgAspect = img.naturalWidth / img.naturalHeight
let drawW = size
let drawH = size
if (imgAspect > 1) {
drawH = size / imgAspect
} else {
drawW = size * imgAspect
}
ctx.save()
ctx.translate(px, py)
ctx.rotate((rotation * Math.PI) / 180)
ctx.drawImage(img, -drawW / 2, -drawH / 2, drawW, drawH)
ctx.restore()
} catch (e) {
console.warn('[Export] Failed to load symbol image:', iconId, e)
}
}
}
// Draw arrowheads for arrow features
for (const f of currentFeatures.filter(f => f.type === 'arrow')) {
if (f.geometry.type !== 'LineString') continue
const lineCoords = f.geometry.coordinates as number[][]
if (lineCoords.length < 2) continue
const p1 = lineCoords[lineCoords.length - 2]
const p2 = lineCoords[lineCoords.length - 1]
const px1 = mapInstance.project(p1 as [number, number])
const px2 = mapInstance.project(p2 as [number, number])
const angle = Math.atan2(px2.y - px1.y, px2.x - px1.x)
const color = (f.properties.color as string) || '#000000'
const arrowSize = 14 * dpr
ctx.save()
ctx.translate(px2.x * dpr, px2.y * dpr)
ctx.rotate(angle + Math.PI / 2)
ctx.beginPath()
ctx.moveTo(0, -arrowSize)
ctx.lineTo(-arrowSize * 0.7, arrowSize * 0.3)
ctx.lineTo(arrowSize * 0.7, arrowSize * 0.3)
ctx.closePath()
ctx.fillStyle = color
ctx.fill()
ctx.restore()
}
// Draw line/polygon label markers at midpoints
for (const f of currentFeatures.filter(f => f.properties.label && (f.geometry.type === 'LineString' || f.geometry.type === 'Polygon'))) {
const label = f.properties.label as string
let midpoint: [number, number]
if (f.geometry.type === 'LineString') {
const coords = f.geometry.coordinates as number[][]
const midIdx = Math.floor(coords.length / 2)
if (coords.length === 2) {
midpoint = [(coords[0][0] + coords[1][0]) / 2, (coords[0][1] + coords[1][1]) / 2]
} else {
midpoint = coords[midIdx] as [number, number]
}
} else {
// Polygon: centroid of first ring
const ring = (f.geometry.coordinates as number[][][])[0]
const len = ring.length - 1
let cx = 0, cy = 0
for (let i = 0; i < len; i++) { cx += ring[i][0]; cy += ring[i][1] }
midpoint = [cx / len, cy / len]
}
const pixel = mapInstance.project(midpoint)
const px = pixel.x * dpr
const py = pixel.y * dpr
const fontSize = 13 * dpr
const isDanger = f.type === 'dangerzone'
const bgColor = isDanger ? 'rgba(220,38,38,0.85)' : 'rgba(0,0,0,0.75)'
const borderColor = isDanger ? '#dc2626' : 'rgba(255,255,255,0.5)'
ctx.save()
ctx.font = `bold ${fontSize}px system-ui, sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const metrics = ctx.measureText(label)
const padX = 7 * dpr
const padY = 3 * dpr
const boxW = metrics.width + padX * 2
const boxH = fontSize + padY * 2
const radius = 4 * dpr
// Background pill
ctx.fillStyle = bgColor
ctx.beginPath()
ctx.roundRect(px - boxW / 2, py - boxH / 2, boxW, boxH, radius)
ctx.fill()
// Border
ctx.strokeStyle = borderColor
ctx.lineWidth = 1.5 * dpr
ctx.beginPath()
ctx.roundRect(px - boxW / 2, py - boxH / 2, boxW, boxH, radius)
ctx.stroke()
// Text
ctx.fillStyle = '#ffffff'
ctx.fillText(label, px, py)
ctx.restore()
}
// Draw text features
for (const f of currentFeatures.filter(f => f.type === 'text')) {
if (f.geometry.type !== 'Point') continue
const coords = f.geometry.coordinates as [number, number]
const pixel = mapInstance.project(coords)
const px = pixel.x * dpr
const py = pixel.y * dpr
const text = (f.properties.text as string) || ''
const fontSize = ((f.properties.fontSize as number) || 14) * dpr
const color = (f.properties.color as string) || '#000000'
ctx.save()
ctx.font = `bold ${fontSize}px system-ui, sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
// White outline
ctx.strokeStyle = '#ffffff'
ctx.lineWidth = 3 * dpr
ctx.lineJoin = 'round'
ctx.strokeText(text, px, py)
// Fill
ctx.fillStyle = color
ctx.fillText(text, px, py)
ctx.restore()
}
const title = currentProject?.title || 'Lageplan'
const safeName = title.replace(/[^a-z0-9äöüÄÖÜß]/gi, '_')
if (format === 'png') {
const link = document.createElement('a')
link.download = `${safeName}.png`
link.href = exportCanvas.toDataURL('image/png')
link.click()
addAudit(`Export: ${safeName}.png`)
toast({ title: 'Exportiert', description: `${safeName}.png wurde heruntergeladen.` })
} else {
// PDF Export — rapport-style clean layout
const imgData = exportCanvas.toDataURL('image/png')
const now = new Date()
const dateStr = now.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
const timeStr = now.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
const locationStr = currentProject?.location || ''
const einsatzNr = (currentProject as any)?.einsatzNr || ''
const tenantLabel = tenant?.name || ''
// A4 landscape (mm)
const pdf = new jsPDF('l', 'mm', 'a4')
const pageW = pdf.internal.pageSize.getWidth() // 297
const pageH = pdf.internal.pageSize.getHeight() // 210
const m = 10 // margin
// ── Header section ──
const headerY = m
pdf.setFontSize(18)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(26, 26, 26)
pdf.text('Einsatz-Lageplan', m, headerY + 6)
pdf.setFontSize(9)
pdf.setFont('helvetica', 'normal')
pdf.setTextColor(107, 114, 128) // gray-500
pdf.text(`${tenantLabel}${tenantLabel ? ' · ' : ''}${title}`, m, headerY + 12)
// Right side: Einsatz-Nr + date
pdf.setFontSize(14)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(185, 28, 28) // red-700
if (einsatzNr) {
const nrW = pdf.getTextWidth(einsatzNr)
pdf.text(einsatzNr, pageW - m - nrW, headerY + 6)
}
pdf.setFontSize(9)
pdf.setFont('helvetica', 'normal')
pdf.setTextColor(107, 114, 128)
const dateLabel = `${dateStr} · ${timeStr}`
const dlW = pdf.getTextWidth(dateLabel)
pdf.text(dateLabel, pageW - m - dlW, headerY + 12)
// Divider line + red accent
const divY = headerY + 15
pdf.setDrawColor(26, 26, 26)
pdf.setLineWidth(0.8)
pdf.line(m, divY, pageW - m, divY)
pdf.setFillColor(185, 28, 28)
pdf.rect(m, divY, (pageW - 2 * m) * 0.3, 1, 'F')
// ── Map image ──
const mapTop = divY + 3
const mapBottom = pageH - m - 12 // leave space for footer
const mapAreaW = pageW - 2 * m
const mapAreaH = mapBottom - mapTop
// Fit map image into area while preserving aspect ratio
const imgAspect = w / h
const areaAspect = mapAreaW / mapAreaH
let drawW = mapAreaW
let drawH = mapAreaH
if (imgAspect > areaAspect) {
drawH = mapAreaW / imgAspect
} else {
drawW = mapAreaH * imgAspect
}
const mapX = m + (mapAreaW - drawW) / 2
const mapY = mapTop + (mapAreaH - drawH) / 2
// Light border around map
pdf.setDrawColor(229, 231, 235)
pdf.setLineWidth(0.3)
pdf.rect(mapX, mapY, drawW, drawH)
pdf.addImage(imgData, 'PNG', mapX, mapY, drawW, drawH)
// ── Footer ──
const footerY = pageH - m - 4
pdf.setFontSize(7)
pdf.setFont('helvetica', 'normal')
pdf.setTextColor(156, 163, 175) // gray-400
pdf.text(`Erstellt: ${dateStr} ${timeStr}${locationStr ? ' · Standort: ' + locationStr : ''} · Projekt: ${title}`, m, footerY)
const footerR = 'app.lageplan.ch'
const frW = pdf.getTextWidth(footerR)
pdf.text(footerR, pageW - m - frW, footerY)
pdf.save(`${safeName}.pdf`)
addAudit(`Export: ${safeName}.pdf`)
toast({ title: 'Exportiert', description: `${safeName}.pdf wurde heruntergeladen.` })
}
} catch (error) {
console.error('Export error:', error)
toast({ title: 'Fehler', description: 'Export fehlgeschlagen.', variant: 'destructive' })
}
}, [currentProject, toast])
// Show loading state while checking auth // Show loading state while checking auth
if (authLoading || !user) { if (authLoading || !user) {
return ( return (
@@ -1525,6 +807,14 @@ export default function AppPage() {
userRole={user?.role} userRole={user?.role}
onLogout={logout} onLogout={logout}
onStartTour={() => { resetOnboardingTour(); setShowTour(true) }} onStartTour={() => { resetOnboardingTour(); setShowTour(true) }}
presentationLocked={presentationLocked}
onTogglePresentationLock={() => {
const next = !presentationLocked
setPresentationLocked(next)
if (next && isEditingByMe) {
handleStopEditing()
}
}}
/> />
{/* Offline banner */} {/* Offline banner */}
@@ -1581,7 +871,7 @@ export default function AppPage() {
<span>Niemand bearbeitet gerade</span> <span>Niemand bearbeitet gerade</span>
</div> </div>
)} )}
<div className="flex items-center gap-2"> <div data-tour="edit-toggle" className="flex items-center gap-2">
{roleCanEdit && !isEditingByMe && !isReadOnly && ( {roleCanEdit && !isEditingByMe && !isReadOnly && (
<Button size="sm" variant="default" onClick={handleStartEditing} disabled={editingLoading}> <Button size="sm" variant="default" onClick={handleStartEditing} disabled={editingLoading}>
<Lock className="w-3.5 h-3.5 mr-1" /> <Lock className="w-3.5 h-3.5 mr-1" />
@@ -1602,7 +892,7 @@ export default function AppPage() {
{/* Map view — always mounted, hidden via CSS to preserve state */} {/* Map view — always mounted, hidden via CSS to preserve state */}
<div data-tour="toolbar" className={`contents ${activeTab !== 'map' ? 'hidden' : ''}`}> <div data-tour="toolbar" className={`contents ${activeTab !== 'map' ? 'hidden' : ''}`}>
<LeftToolbar <LeftToolbar
drawMode={drawMode} drawMode={drawMode || 'select'}
onDrawModeChange={setDrawMode} onDrawModeChange={setDrawMode}
selectedColor={selectedColor} selectedColor={selectedColor}
onColorChange={setSelectedColor} onColorChange={setSelectedColor}
@@ -1619,7 +909,7 @@ export default function AppPage() {
<MapView <MapView
project={currentProject} project={currentProject}
features={features} features={features}
drawMode={drawMode} drawMode={drawMode || 'select'}
selectedColor={selectedColor} selectedColor={selectedColor}
selectedWidth={selectedWidth} selectedWidth={selectedWidth}
onFeaturesChange={handleFeaturesChange} onFeaturesChange={handleFeaturesChange}
@@ -1633,8 +923,8 @@ export default function AppPage() {
</main> </main>
</div> </div>
{/* Journal view — always mounted, hidden when map tab is active to preserve state */} {/* Journal view — always mounted, hidden via CSS */}
<main className={`flex-1 relative overflow-auto ${activeTab !== 'journal' ? 'hidden' : ''}`}> <main className={`flex-1 flex flex-col min-h-0 bg-background ${activeTab !== 'journal' ? 'hidden' : ''}`}>
<JournalView <JournalView
projectId={currentProject?.id || null} projectId={currentProject?.id || null}
projectTitle={currentProject?.title || ''} projectTitle={currentProject?.title || ''}
@@ -1707,7 +997,7 @@ export default function AppPage() {
))} ))}
<div className="font-semibold text-muted-foreground col-span-2 mt-3 mb-0.5">Aktionen</div> <div className="font-semibold text-muted-foreground col-span-2 mt-3 mb-0.5">Aktionen</div>
{[ {[
['Ctrl+Y', 'Rückgängig'], ['Ctrl+Z', 'Wiederholen'], ['Ctrl+Z', 'Rückgängig'], ['Ctrl+Y', 'Wiederholen'],
['Ctrl+S', 'Speichern'], ['Del', 'Auswahl löschen'], ['Ctrl+S', 'Speichern'], ['Del', 'Auswahl löschen'],
['Esc', 'Abbrechen'], ['?', 'Diese Hilfe'], ['Esc', 'Abbrechen'], ['?', 'Diese Hilfe'],
].map(([key, label]) => ( ].map(([key, label]) => (

View File

@@ -1,30 +1,16 @@
'use client'
import Link from 'next/link' import Link from 'next/link'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Logo } from '@/components/ui/logo' import { Logo } from '@/components/ui/logo'
import { useAuth } from '@/components/providers/auth-provider' import { NavAuthButtons } from '@/components/landing/nav-auth-buttons'
import { useRouter } from 'next/navigation' import { ContactForm } from '@/components/landing/contact-form'
import { useEffect, useState } from 'react'
import { import {
Flame, Map, Shield, Users, Smartphone, FileText, Ruler, Clock, Map, Shield, Users, Smartphone, FileText, Ruler, Clock,
Check, ArrowRight, Lock, ChevronRight, MessageSquare, Loader2, Send, Check, ArrowRight, Lock, ChevronRight, MessageSquare,
Heart, Coffee, Rocket, Sparkles, Lightbulb, HelpCircle, Heart, Coffee, Rocket, Sparkles, Lightbulb, HelpCircle,
MousePointer2, Minus, Pentagon, Square, Circle, Pencil, MoveRight, Type, Eraser, MousePointer2, Minus, Pentagon, Square, Circle, Pencil, MoveRight, Type, Eraser,
} from 'lucide-react' } from 'lucide-react'
export default function LandingPage() { export default function LandingPage() {
const { user, loading } = useAuth()
const router = useRouter()
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="animate-pulse text-muted-foreground">Laden...</div>
</div>
)
}
const jsonLd = { const jsonLd = {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'SoftwareApplication', '@type': 'SoftwareApplication',
@@ -40,11 +26,6 @@ export default function LandingPage() {
priceCurrency: 'CHF', priceCurrency: 'CHF',
description: 'Kostenlos für Schweizer Feuerwehren', description: 'Kostenlos für Schweizer Feuerwehren',
}, },
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: '4.8',
ratingCount: '12',
},
author: { author: {
'@type': 'Organization', '@type': 'Organization',
name: 'Lageplan.ch', name: 'Lageplan.ch',
@@ -62,12 +43,79 @@ export default function LandingPage() {
], ],
} }
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: [
{
'@type': 'Question',
name: 'Was kostet Lageplan?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Nichts. Lageplan ist kostenlos für alle Feuerwehren in der Schweiz. Die Entwicklung wird durch freiwillige Spenden finanziert.',
},
},
{
'@type': 'Question',
name: 'Brauche ich eine Installation?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Nein. Lageplan läuft komplett im Browser — auf Desktop, Tablet und Smartphone. Einfach registrieren und loslegen.',
},
},
{
'@type': 'Question',
name: 'Funktioniert es auf dem Tablet im Einsatz?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Ja. Die App ist für Touch-Bedienung optimiert und funktioniert auf allen modernen Tablets und Smartphones.',
},
},
{
'@type': 'Question',
name: 'Können mehrere Personen gleichzeitig arbeiten?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Ja. Über Echtzeit-Synchronisation (WebSocket) können mehrere Benutzer gleichzeitig am selben Lageplan zeichnen und das Journal führen.',
},
},
{
'@type': 'Question',
name: 'Wo werden meine Daten gespeichert?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Alle Daten werden auf Servern in der Schweiz gespeichert. Die Applikation ist DSG- und DSGVO-konform.',
},
},
{
'@type': 'Question',
name: 'Welche Symbole sind verfügbar?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Alle 117 offiziellen FKS/BABS-Signaturen sind integriert. Zusätzlich können eigene Symbole hochgeladen werden.',
},
},
{
'@type': 'Question',
name: 'Kann ich Lagepläne exportieren?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Ja. Lagepläne können als PNG oder PDF exportiert werden — inklusive Metadaten, Datum und Einsatzinformationen.',
},
},
],
}
return ( return (
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-white">
<script <script
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/> />
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
/>
<main> <main>
{/* Navigation */} {/* Navigation */}
<nav className="fixed top-0 w-full z-50 bg-white/80 backdrop-blur-md border-b border-gray-100"> <nav className="fixed top-0 w-full z-50 bg-white/80 backdrop-blur-md border-b border-gray-100">
@@ -84,24 +132,7 @@ export default function LandingPage() {
<a href="#roadmap" className="hover:text-gray-900 transition">Roadmap</a> <a href="#roadmap" className="hover:text-gray-900 transition">Roadmap</a>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{user ? ( <NavAuthButtons />
<Link href="/app">
<Button size="sm" className="bg-red-600 hover:bg-red-700">
Zur App
</Button>
</Link>
) : (
<>
<Link href="/login">
<Button variant="ghost" size="sm">Anmelden</Button>
</Link>
<Link href="/register">
<Button size="sm" className="bg-red-600 hover:bg-red-700">
Kostenlos starten
</Button>
</Link>
</>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -564,39 +595,6 @@ function SupportSection() {
} }
function ContactSection() { function ContactSection() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [message, setMessage] = useState('')
const [sending, setSending] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSending(true)
setError('')
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, message }),
})
if (res.ok) {
setSent(true)
setName('')
setEmail('')
setMessage('')
} else {
const data = await res.json()
setError(data.error || 'Senden fehlgeschlagen')
}
} catch {
setError('Verbindung fehlgeschlagen')
} finally {
setSending(false)
}
}
return ( return (
<section id="contact" className="py-20 px-4"> <section id="contact" className="py-20 px-4">
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
@@ -607,67 +605,7 @@ function ContactSection() {
Fragen, Feature-Wünsche oder Feedback? Schreib mir ich freue mich über jede Nachricht. Fragen, Feature-Wünsche oder Feedback? Schreib mir ich freue mich über jede Nachricht.
</p> </p>
</div> </div>
<ContactForm />
{sent ? (
<div className="text-center bg-green-50 border border-green-200 rounded-xl p-8">
<Check className="w-10 h-10 text-green-600 mx-auto mb-3" />
<h3 className="font-semibold text-green-900 text-lg">Nachricht gesendet!</h3>
<p className="text-green-700 mt-2">Vielen Dank! Ich melde mich so schnell wie möglich.</p>
<Button variant="outline" className="mt-4" onClick={() => setSent(false)}>
Weitere Nachricht senden
</Button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
placeholder="Dein Name"
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">E-Mail</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
placeholder="name@feuerwehr.ch"
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nachricht</label>
<textarea
value={message}
onChange={e => setMessage(e.target.value)}
required
rows={5}
placeholder="Deine Nachricht..."
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent resize-none"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<Button
type="submit"
className="bg-red-600 hover:bg-red-700"
disabled={sending || !name || !email || !message}
>
{sending ? (
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Wird gesendet...</>
) : (
<><Send className="w-4 h-4 mr-2" /> Nachricht senden</>
)}
</Button>
</form>
)}
</div> </div>
</section> </section>
) )

View File

@@ -210,9 +210,59 @@ export default function RapportViewerPage({ params }: { params: Promise<{ token:
</Section> </Section>
)} )}
{/* 5. Eingesetzte Mittel */} {/* 5. SOMA Checkliste */}
{Array.isArray(d.somaItems) && d.somaItems.length > 0 && (
<Section num="5" title="SOMA Checkliste">
<div className="border rounded">
<div className="grid grid-cols-[24px_24px_1fr_60px] gap-0 text-[7pt] font-semibold uppercase tracking-wider bg-gray-900 text-white p-1.5">
<span className="text-center">Best.</span>
<span className="text-center">OK</span>
<span>Punkt</span>
<span className="text-right">Zeit</span>
</div>
{d.somaItems.map((s: any, i: number) => (
<div key={i} className={`grid grid-cols-[24px_24px_1fr_60px] gap-0 p-1.5 border-b border-gray-100 text-[9pt] ${i % 2 === 1 ? 'bg-gray-50' : ''}`}>
<span className="text-center font-bold">{s.confirmed ? '✓' : '—'}</span>
<span className="text-center font-bold">{s.ok ? '✓' : '—'}</span>
<span className="font-medium">{s.label}</span>
<span className="text-right text-[8pt] text-gray-500 font-mono">{s.confirmedAt || ''}</span>
</div>
))}
</div>
</Section>
)}
{/* 6. Pendenzen */}
{Array.isArray(d.pendenzenItems) && d.pendenzenItems.length > 0 && (
<Section num="6" title="Pendenzen">
<table className="w-full border-collapse border rounded text-xs">
<thead>
<tr className="bg-gray-900 text-white">
<th className="p-1.5 text-center font-semibold uppercase tracking-wider text-[7pt] w-8"></th>
<th className="p-1.5 text-left font-semibold uppercase tracking-wider text-[7pt]">Aufgabe</th>
<th className="p-1.5 text-left font-semibold uppercase tracking-wider text-[7pt] w-24">Wer</th>
<th className="p-1.5 text-left font-semibold uppercase tracking-wider text-[7pt] w-32">Wann / Wie</th>
<th className="p-1.5 text-right font-semibold uppercase tracking-wider text-[7pt] w-16">Erledigt</th>
</tr>
</thead>
<tbody>
{d.pendenzenItems.map((p: any, i: number) => (
<tr key={i} className={`${i % 2 === 1 ? 'bg-gray-50' : ''} ${p.done ? 'text-gray-400' : ''}`}>
<td className="p-1.5 border-b border-gray-100 text-center font-bold">{p.done ? '✓' : '○'}</td>
<td className={`p-1.5 border-b border-gray-100 ${p.done ? 'line-through' : ''}`}>{p.what}</td>
<td className="p-1.5 border-b border-gray-100 text-gray-500">{p.who || '—'}</td>
<td className="p-1.5 border-b border-gray-100 text-gray-500">{p.whenHow || '—'}</td>
<td className="p-1.5 border-b border-gray-100 text-right font-mono text-[8pt]">{p.doneAt || ''}</td>
</tr>
))}
</tbody>
</table>
</Section>
)}
{/* 7. Eingesetzte Mittel */}
{d.fahrzeuge?.length > 0 && ( {d.fahrzeuge?.length > 0 && (
<Section num="5" title="Eingesetzte Mittel"> <Section num="7" title="Eingesetzte Mittel">
<table className="w-full border-collapse border rounded text-xs"> <table className="w-full border-collapse border rounded text-xs">
<thead> <thead>
<tr className="bg-gray-900 text-white"> <tr className="bg-gray-900 text-white">
@@ -238,8 +288,8 @@ export default function RapportViewerPage({ params }: { params: Promise<{ token:
</Section> </Section>
)} )}
{/* 6. Bemerkungen */} {/* 8. Bemerkungen */}
<Section num="6" title="Bemerkungen / Besondere Vorkommnisse"> <Section num="8" title="Bemerkungen / Besondere Vorkommnisse">
<div className="border rounded p-3 min-h-[50px] text-sm">{d.bemerkungen || '—'}</div> <div className="border rounded p-3 min-h-[50px] text-sm">{d.bemerkungen || '—'}</div>
</Section> </Section>

View File

@@ -23,10 +23,10 @@ export default function sitemap(): MetadataRoute.Sitemap {
priority: 0.8, priority: 0.8,
}, },
{ {
url: `${baseUrl}/impressum`, url: `${baseUrl}/demo`,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: 'yearly', changeFrequency: 'monthly',
priority: 0.3, priority: 0.7,
}, },
{ {
url: `${baseUrl}/spenden`, url: `${baseUrl}/spenden`,
@@ -34,5 +34,17 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: 'monthly', changeFrequency: 'monthly',
priority: 0.5, priority: 0.5,
}, },
{
url: `${baseUrl}/datenschutz`,
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 0.3,
},
{
url: `${baseUrl}/impressum`,
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 0.3,
},
] ]
} }

View File

@@ -0,0 +1,113 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useToast } from '@/components/ui/use-toast'
import { BookOpen, Plus, X } from 'lucide-react'
import { apiFetch, ApiError } from '@/lib/api'
interface DictWord {
id: string
word: string
scope: string
}
export function DictionaryTab() {
const { toast } = useToast()
const [globalDictWords, setGlobalDictWords] = useState<DictWord[]>([])
const [newGlobalWord, setNewGlobalWord] = useState('')
const [dictLoading, setDictLoading] = useState(false)
const fetchGlobalDict = async () => {
try {
const data = await apiFetch<{ words: DictWord[] }>('/api/dictionary?scope=GLOBAL', { silent: true })
if (data?.words) setGlobalDictWords(data.words)
} catch {}
}
useEffect(() => {
fetchGlobalDict()
}, [])
const handleAdd = async () => {
if (!newGlobalWord.trim()) return
setDictLoading(true)
try {
await apiFetch('/api/dictionary', {
method: 'POST',
body: JSON.stringify({ word: newGlobalWord.trim(), scope: 'GLOBAL' }),
})
setNewGlobalWord('')
fetchGlobalDict()
toast({ title: 'Begriff hinzugefügt' })
} catch (err) {
toast({ title: 'Fehler', description: err instanceof ApiError ? err.message : 'Fehler', variant: 'destructive' })
} finally { setDictLoading(false) }
}
return (
<div className="border rounded-lg p-6">
<h3 className="font-semibold text-lg mb-2 flex items-center gap-2">
<BookOpen className="w-5 h-5" />
Globales Wörterbuch
</h3>
<p className="text-sm text-muted-foreground mb-4">
Globale Begriffe, die allen Mandanten als Journal-Vorschläge zur Verfügung stehen.
Mandanten können zusätzlich eigene Begriffe über ihre Wörterliste hinzufügen.
</p>
{/* Add new word */}
<div className="flex gap-2 mb-4">
<Input
placeholder="Neuer globaler Begriff, z.B. 'Leitung aufbauen'..."
value={newGlobalWord}
onChange={(e) => setNewGlobalWord(e.target.value)}
onKeyDown={async (e) => {
if (e.key === 'Enter' && newGlobalWord.trim()) handleAdd()
}}
className="flex-1"
disabled={dictLoading}
/>
<Button
onClick={handleAdd}
disabled={!newGlobalWord.trim() || dictLoading}
>
<Plus className="w-4 h-4 mr-2" />
Hinzufügen
</Button>
</div>
{/* List of global words */}
{globalDictWords.length === 0 ? (
<div className="text-center text-muted-foreground py-8 text-sm border-2 border-dashed rounded-lg">
Noch keine globalen Begriffe hinterlegt.
</div>
) : (
<div className="flex flex-wrap gap-2">
{globalDictWords.map((w) => (
<span key={w.id} className="inline-flex items-center gap-1 px-3 py-1.5 bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-300 rounded-full text-sm border border-green-200 dark:border-green-800">
{w.word}
<button
onClick={async () => {
try {
await apiFetch(`/api/dictionary/${w.id}`, { method: 'DELETE' })
fetchGlobalDict()
toast({ title: 'Entfernt' })
} catch { toast({ title: 'Fehler', variant: 'destructive' }) }
}}
className="ml-1 hover:text-red-500 transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</span>
))}
</div>
)}
<p className="text-xs text-muted-foreground mt-4">
{globalDictWords.length} globale(r) Begriff(e). Diese erscheinen bei allen Mandanten als Vorschläge im Journal.
</p>
</div>
)
}

View File

@@ -0,0 +1,246 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import {
Building2, Upload, X, Loader2, Shield, Trash2, AlertTriangle,
} from 'lucide-react'
interface OrgTabProps {
tenantId?: string | null
}
export function OrgTab({ tenantId }: OrgTabProps) {
const { toast } = useToast()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [uploadingLogo, setUploadingLogo] = useState(false)
const [tenant, setTenant] = useState<any>(null)
// Editable fields
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [contactEmail, setContactEmail] = useState('')
const [contactPhone, setContactPhone] = useState('')
const [address, setAddress] = useState('')
const fetchTenant = async () => {
setLoading(true)
try {
const res = await fetch('/api/tenant/info')
if (res.ok) {
const data = await res.json()
const t = data.tenant
if (t) {
setTenant(t)
setName(t.name || '')
setDescription(t.description || '')
setContactEmail(t.contactEmail || '')
setContactPhone(t.contactPhone || '')
setAddress(t.address || '')
}
}
} catch (e) {
console.error('Failed to load tenant info:', e)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchTenant()
}, [tenantId])
const handleSave = async () => {
setSaving(true)
try {
const res = await fetch('/api/tenant/info', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.trim(),
description: description.trim() || null,
contactEmail: contactEmail.trim() || null,
contactPhone: contactPhone.trim() || null,
address: address.trim() || null,
}),
})
if (res.ok) {
toast({ title: 'Organisation aktualisiert' })
fetchTenant()
} else {
const err = await res.json()
toast({ title: 'Fehler', description: err.error || 'Speichern fehlgeschlagen', variant: 'destructive' })
}
} catch {
toast({ title: 'Fehler', description: 'Verbindungsfehler', variant: 'destructive' })
} finally {
setSaving(false)
}
}
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.[0]) return
setUploadingLogo(true)
try {
const formData = new FormData()
formData.append('logo', e.target.files[0])
const res = await fetch('/api/tenant/logo', { method: 'POST', body: formData })
if (res.ok) {
toast({ title: 'Logo hochgeladen' })
fetchTenant()
} else {
const data = await res.json()
toast({ title: 'Fehler', description: data.error || 'Upload fehlgeschlagen', variant: 'destructive' })
}
} catch {
toast({ title: 'Fehler', description: 'Upload fehlgeschlagen', variant: 'destructive' })
} finally {
setUploadingLogo(false)
e.target.value = ''
}
}
const handleLogoDelete = async () => {
try {
const res = await fetch('/api/tenant/logo', { method: 'DELETE' })
if (res.ok) {
toast({ title: 'Logo entfernt' })
fetchTenant()
}
} catch {
toast({ title: 'Fehler', variant: 'destructive' })
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
)
}
if (!tenant) {
return (
<div className="text-center text-muted-foreground py-12">
Keine Organisation zugeordnet.
</div>
)
}
return (
<div className="space-y-6 max-w-2xl">
{/* Logo */}
<div className="border rounded-lg p-5">
<h3 className="font-semibold text-base mb-3 flex items-center gap-2">
<Building2 className="w-4 h-4" />
Logo
</h3>
<div className="flex items-center gap-4">
<div className="w-20 h-20 rounded-lg border bg-muted flex items-center justify-center overflow-hidden shrink-0">
{tenant.logoUrl ? (
<img
src={tenant.logoUrl.startsWith('/') ? tenant.logoUrl : `/api/tenant/logo/serve`}
alt="Logo"
className="w-full h-full object-contain"
/>
) : (
<Building2 className="w-10 h-10 text-muted-foreground/40" />
)}
</div>
<div className="space-y-1.5">
<div className="flex gap-2">
<Button variant="outline" size="sm" className="relative" disabled={uploadingLogo}>
{uploadingLogo ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Upload className="w-3.5 h-3.5 mr-1" />}
{tenant.logoUrl ? 'Ändern' : 'Hochladen'}
<input
type="file"
accept="image/png,image/jpeg,image/svg+xml,image/webp"
onChange={handleLogoUpload}
className="absolute inset-0 opacity-0 cursor-pointer"
/>
</Button>
{tenant.logoUrl && (
<Button variant="ghost" size="sm" className="text-destructive" onClick={handleLogoDelete}>
<X className="w-3.5 h-3.5 mr-1" /> Entfernen
</Button>
)}
</div>
<p className="text-[11px] text-muted-foreground">PNG, JPEG, SVG oder WebP, max. 2 MB. Wird auch im Rapport angezeigt.</p>
</div>
</div>
</div>
{/* Organisation Details */}
<div className="border rounded-lg p-5 space-y-4">
<h3 className="font-semibold text-base mb-1 flex items-center gap-2">
<Shield className="w-4 h-4" />
Stammdaten
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs">Name</Label>
<Input value={name} onChange={e => setName(e.target.value)} />
</div>
<div>
<Label className="text-xs">Slug</Label>
<Input value={tenant.slug} disabled className="font-mono bg-muted" />
</div>
</div>
<div>
<Label className="text-xs">Beschreibung</Label>
<Input value={description} onChange={e => setDescription(e.target.value)} placeholder="z.B. Feuerwehr Musterstadt" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs">Kontakt E-Mail</Label>
<Input type="email" value={contactEmail} onChange={e => setContactEmail(e.target.value)} placeholder="kontakt@feuerwehr.ch" />
</div>
<div>
<Label className="text-xs">Kontakt Telefon</Label>
<Input value={contactPhone} onChange={e => setContactPhone(e.target.value)} placeholder="+41 ..." />
</div>
</div>
<div>
<Label className="text-xs">Adresse</Label>
<Input value={address} onChange={e => setAddress(e.target.value)} placeholder="Strasse, PLZ Ort" />
</div>
<Button onClick={handleSave} disabled={saving || !name.trim()}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : null}
Speichern
</Button>
</div>
{/* Info (read-only) */}
<div className="border rounded-lg p-5">
<h3 className="font-semibold text-base mb-3">Übersicht</h3>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-muted-foreground">Plan:</span>
<span className="ml-2">{tenant.plan}</span>
</div>
<div>
<span className="text-muted-foreground">Status:</span>
<span className="ml-2">{tenant.subscriptionStatus}</span>
</div>
{tenant._count && (
<>
<div>
<span className="text-muted-foreground">Benutzer:</span>
<span className="ml-2">{tenant._count.memberships}</span>
</div>
<div>
<span className="text-muted-foreground">Einsätze:</span>
<span className="ml-2">{tenant._count.projects}</span>
</div>
</>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,450 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import Link from 'next/link'
import {
Mail, Send, CheckCircle, Ban, CreditCard, Map, MapPin, Settings,
Shield, UserPlus, ArrowLeft, Loader2,
} from 'lucide-react'
interface SettingsTabProps {
usersCount: number
tenantsCount: number
iconsCount: number
onNavigateTab: (tab: string) => void
}
export function SettingsTab({ usersCount, tenantsCount, iconsCount, onNavigateTab }: SettingsTabProps) {
const { toast } = useToast()
// SMTP Settings
const [smtpHost, setSmtpHost] = useState('')
const [smtpPort, setSmtpPort] = useState('587')
const [smtpSecure, setSmtpSecure] = useState(false)
const [smtpUser, setSmtpUser] = useState('')
const [smtpPass, setSmtpPass] = useState('')
const [smtpFromName, setSmtpFromName] = useState('Lageplan')
const [smtpFromEmail, setSmtpFromEmail] = useState('')
const [smtpTestEmail, setSmtpTestEmail] = useState('')
const [smtpLoading, setSmtpLoading] = useState(false)
const [smtpStatus, setSmtpStatus] = useState<string | null>(null)
const [contactEmail, setContactEmail] = useState('app@lageplan.ch')
const [notifyRegistrationEmail, setNotifyRegistrationEmail] = useState('')
// Stripe Settings
const [stripePublicKey, setStripePublicKey] = useState('')
const [stripeSecretKey, setStripeSecretKey] = useState('')
const [stripeWebhookSecret, setStripeWebhookSecret] = useState('')
const [stripeLoading, setStripeLoading] = useState(false)
const [stripeStatus, setStripeStatus] = useState<string | null>(null)
// Demo Project
const [demoProjectId, setDemoProjectId] = useState('')
const [allProjects, setAllProjects] = useState<{ id: string; title: string; location?: string }[]>([])
const [demoLoading, setDemoLoading] = useState(false)
const [demoStatus, setDemoStatus] = useState<string | null>(null)
// Default Symbol Scale
const [defaultSymbolScale, setDefaultSymbolScale] = useState('1.5')
const [symbolScaleLoading, setSymbolScaleLoading] = useState(false)
const [symbolScaleStatus, setSymbolScaleStatus] = useState<string | null>(null)
// Load settings on mount
useEffect(() => {
fetch('/api/admin/settings').then(r => r.json()).then(data => {
if (data.smtp) {
setSmtpHost(data.smtp.host || '')
setSmtpPort(data.smtp.port?.toString() || '587')
setSmtpSecure(data.smtp.secure || false)
setSmtpUser(data.smtp.user || '')
setSmtpFromName(data.smtp.fromName || 'Lageplan')
setSmtpFromEmail(data.smtp.fromEmail || '')
}
if (data.stripe) {
setStripePublicKey(data.stripe.publicKey || '')
setStripeSecretKey(data.stripe.secretKey ? '••••••••' : '')
setStripeWebhookSecret(data.stripe.webhookSecret ? '••••••••' : '')
}
if (data.contactEmail) setContactEmail(data.contactEmail)
if (data.notifyRegistrationEmail) setNotifyRegistrationEmail(data.notifyRegistrationEmail)
if (data.demoProjectId) setDemoProjectId(data.demoProjectId)
if (data.defaultSymbolScale) setDefaultSymbolScale(data.defaultSymbolScale.toString())
}).catch(() => {})
// Load projects for demo selector
fetch('/api/projects').then(r => r.json()).then(data => {
if (data.projects) setAllProjects(data.projects)
}).catch(() => {})
}, [])
const handleSmtpSave = async () => {
setSmtpLoading(true)
setSmtpStatus(null)
try {
const res = await fetch('/api/admin/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'save_smtp',
smtp: { host: smtpHost, port: parseInt(smtpPort), secure: smtpSecure, user: smtpUser, pass: smtpPass, fromName: smtpFromName, fromEmail: smtpFromEmail },
}),
})
const data = await res.json()
if (data.success) {
toast({ title: 'SMTP gespeichert' })
setSmtpStatus('saved')
} else throw new Error(data.error)
} catch (error) {
toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
} finally { setSmtpLoading(false) }
}
const handleSmtpTest = async () => {
setSmtpLoading(true)
setSmtpStatus(null)
try {
const res = await fetch('/api/admin/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'test_smtp' }),
})
const data = await res.json()
if (data.success) {
setSmtpStatus('connected')
toast({ title: 'SMTP-Verbindung erfolgreich' })
} else {
setSmtpStatus('error')
toast({ title: 'Verbindung fehlgeschlagen', description: data.error, variant: 'destructive' })
}
} catch (error) {
setSmtpStatus('error')
toast({ title: 'Fehler', variant: 'destructive' })
} finally { setSmtpLoading(false) }
}
const handleSmtpSendTest = async () => {
if (!smtpTestEmail) return
setSmtpLoading(true)
try {
const res = await fetch('/api/admin/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'send_test_email', testEmail: smtpTestEmail }),
})
const data = await res.json()
if (data.success) toast({ title: data.message })
else toast({ title: 'Senden fehlgeschlagen', description: data.error, variant: 'destructive' })
} catch { toast({ title: 'Fehler', variant: 'destructive' }) }
finally { setSmtpLoading(false) }
}
const handleContactEmailSave = async () => {
setSmtpLoading(true)
try {
const res = await fetch('/api/admin/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'save_contact_email', contactEmail }),
})
const data = await res.json()
if (data.success) toast({ title: 'Kontakt-E-Mail gespeichert' })
else throw new Error(data.error)
} catch (error) {
toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
} finally { setSmtpLoading(false) }
}
const handleStripeSave = async () => {
setStripeLoading(true)
setStripeStatus(null)
try {
const res = await fetch('/api/admin/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'save_stripe',
stripe: {
publicKey: stripePublicKey,
secretKey: stripeSecretKey,
webhookSecret: stripeWebhookSecret,
},
}),
})
const data = await res.json()
if (data.success) {
setStripeStatus('saved')
toast({ title: 'Stripe-Einstellungen gespeichert' })
} else throw new Error(data.error)
} catch (error) {
setStripeStatus('error')
toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
} finally { setStripeLoading(false) }
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Contact Email */}
<div className="border rounded-lg p-6 md:col-span-2">
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
<Mail className="w-5 h-5 text-primary" />
Kontakt-E-Mail
</h3>
<p className="text-sm text-muted-foreground mb-3">E-Mail-Adresse für das Kontaktformular auf der Landing Page. Hierhin werden Anfragen gesendet.</p>
<div className="flex gap-2 items-end">
<div className="flex-1"><Label>Empfänger-Adresse</Label><Input value={contactEmail} onChange={e => setContactEmail(e.target.value)} placeholder="app@lageplan.ch" /></div>
<Button onClick={handleContactEmailSave} disabled={smtpLoading}>Speichern</Button>
</div>
</div>
{/* Registration Notification */}
<div className="border rounded-lg p-6">
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
<Mail className="w-5 h-5 text-primary" />
Registrierungs-Benachrichtigung
</h3>
<p className="text-sm text-muted-foreground mb-3">E-Mail-Adresse, an die bei neuen Registrierungen eine Benachrichtigung gesendet wird. Leer lassen = keine Benachrichtigung.</p>
<div className="flex gap-2 items-end">
<div className="flex-1"><Label>Admin-E-Mail</Label><Input value={notifyRegistrationEmail} onChange={e => setNotifyRegistrationEmail(e.target.value)} placeholder="admin@lageplan.ch" /></div>
<Button onClick={async () => {
try {
const res = await fetch('/api/admin/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'save_setting', key: 'notify_registration_email', value: notifyRegistrationEmail }),
})
if ((await res.json()).success) toast({ title: 'Gespeichert' })
} catch { toast({ title: 'Fehler', variant: 'destructive' }) }
}} disabled={smtpLoading}>Speichern</Button>
</div>
</div>
{/* SMTP Settings */}
<div className="border rounded-lg p-6 md:col-span-2">
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
<Mail className="w-5 h-5 text-primary" />
E-Mail / SMTP Konfiguration
</h3>
<p className="text-sm text-muted-foreground mb-4">SMTP-Server für den E-Mail-Versand konfigurieren. Empfohlen: TLS auf Port 587. Passwörter werden verschlüsselt in der Datenbank gespeichert.</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div><Label>SMTP Host</Label><Input value={smtpHost} onChange={e => setSmtpHost(e.target.value)} placeholder="smtp.gmail.com" /></div>
<div className="grid grid-cols-2 gap-2">
<div><Label>Port</Label><Input value={smtpPort} onChange={e => setSmtpPort(e.target.value)} placeholder="587" /></div>
<div className="flex items-end gap-2 pb-0.5">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={smtpSecure} onChange={e => setSmtpSecure(e.target.checked)} className="rounded" />
SSL/TLS
</label>
</div>
</div>
<div><Label>Benutzername</Label><Input value={smtpUser} onChange={e => setSmtpUser(e.target.value)} placeholder="user@example.com" /></div>
<div><Label>Passwort</Label><Input type="password" value={smtpPass} onChange={e => setSmtpPass(e.target.value)} placeholder="App-Passwort oder SMTP-Passwort" /></div>
<div><Label>Absender-Name</Label><Input value={smtpFromName} onChange={e => setSmtpFromName(e.target.value)} placeholder="Lageplan" /></div>
<div><Label>Absender-E-Mail</Label><Input value={smtpFromEmail} onChange={e => setSmtpFromEmail(e.target.value)} placeholder="noreply@lageplan.ch" /></div>
</div>
<div className="flex gap-2 mt-4">
<Button onClick={handleSmtpSave} disabled={smtpLoading || !smtpHost || !smtpUser}>
{smtpLoading ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : null}
Speichern
</Button>
<Button variant="outline" onClick={handleSmtpTest} disabled={smtpLoading || !smtpHost}>
Verbindung testen
</Button>
{smtpStatus === 'connected' && <span className="flex items-center text-sm text-green-600"><CheckCircle className="w-4 h-4 mr-1" /> Verbunden</span>}
{smtpStatus === 'error' && <span className="flex items-center text-sm text-red-600"><Ban className="w-4 h-4 mr-1" /> Fehlgeschlagen</span>}
{smtpStatus === 'saved' && <span className="flex items-center text-sm text-green-600"><CheckCircle className="w-4 h-4 mr-1" /> Gespeichert</span>}
</div>
<div className="border-t mt-4 pt-4">
<Label className="text-sm font-medium">Test-E-Mail senden</Label>
<div className="flex gap-2 mt-1.5">
<Input value={smtpTestEmail} onChange={e => setSmtpTestEmail(e.target.value)} placeholder="empfaenger@example.com" className="max-w-xs" />
<Button variant="outline" onClick={handleSmtpSendTest} disabled={smtpLoading || !smtpTestEmail}>
<Send className="w-4 h-4 mr-1.5" />
Senden
</Button>
</div>
</div>
</div>
{/* Stripe Settings */}
<div className="border rounded-lg p-6 md:col-span-2">
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
<CreditCard className="w-5 h-5 text-primary" />
Stripe / Spenden-Konfiguration
</h3>
<p className="text-sm text-muted-foreground mb-4">
Stripe API-Keys für die Spendenseite konfigurieren. Unterstützt Kreditkarte, Twint und weitere Zahlungsmethoden.
Keys findest du im <a href="https://dashboard.stripe.com/apikeys" target="_blank" rel="noopener noreferrer" className="text-primary underline">Stripe Dashboard</a>.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div><Label>Publishable Key (pk_...)</Label><Input value={stripePublicKey} onChange={e => setStripePublicKey(e.target.value)} placeholder="pk_live_..." /></div>
<div><Label>Secret Key (sk_...)</Label><Input type="password" value={stripeSecretKey} onChange={e => setStripeSecretKey(e.target.value)} placeholder="sk_live_..." /></div>
<div className="md:col-span-2"><Label>Webhook Secret (whsec_...) optional</Label><Input type="password" value={stripeWebhookSecret} onChange={e => setStripeWebhookSecret(e.target.value)} placeholder="whsec_..." /></div>
</div>
<p className="text-xs text-muted-foreground mt-2">
Webhook-Endpoint: <code className="bg-muted px-1.5 py-0.5 rounded text-xs">{typeof window !== 'undefined' ? window.location.origin : ''}/api/donate/webhook</code>
</p>
<div className="flex gap-2 mt-4">
<Button onClick={handleStripeSave} disabled={stripeLoading || !stripePublicKey || !stripeSecretKey}>
{stripeLoading ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : null}
Speichern
</Button>
{stripeStatus === 'saved' && <span className="flex items-center text-sm text-green-600"><CheckCircle className="w-4 h-4 mr-1" /> Gespeichert</span>}
{stripeStatus === 'error' && <span className="flex items-center text-sm text-red-600"><Ban className="w-4 h-4 mr-1" /> Fehlgeschlagen</span>}
</div>
</div>
{/* Demo Project */}
<div className="border rounded-lg p-6 md:col-span-2">
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
<Map className="w-5 h-5 text-primary" />
Live-Demo auf der Startseite
</h3>
<p className="text-sm text-muted-foreground mb-3">
Wähle ein Projekt als Demo-Karte für die Landing Page. Besucher können die Karte sehen und zoomen, aber nichts bearbeiten.
</p>
<div className="flex gap-2 items-end">
<div className="flex-1">
<Label>Demo-Projekt</Label>
<select
value={demoProjectId}
onChange={e => setDemoProjectId(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value=""> Keine Demo </option>
{allProjects.map(p => (
<option key={p.id} value={p.id}>{p.title}{p.location ? ` (${p.location})` : ''}</option>
))}
</select>
</div>
<Button
onClick={async () => {
setDemoLoading(true)
setDemoStatus(null)
try {
const res = await fetch('/api/admin/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'save_demo_project', demoProjectId }),
})
const data = await res.json()
if (data.success) {
toast({ title: 'Demo-Projekt gespeichert' })
setDemoStatus('saved')
} else throw new Error(data.error)
} catch (error) {
toast({ title: 'Fehler', description: error instanceof Error ? error.message : 'Fehler', variant: 'destructive' })
setDemoStatus('error')
} finally { setDemoLoading(false) }
}}
disabled={demoLoading}
>
{demoLoading ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : null}
Speichern
</Button>
{demoStatus === 'saved' && <span className="flex items-center text-sm text-green-600"><CheckCircle className="w-4 h-4 mr-1" /> Gespeichert</span>}
</div>
{demoProjectId && (
<p className="text-xs text-muted-foreground mt-2">
Vorschau: <a href="/demo" target="_blank" rel="noopener noreferrer" className="text-primary underline">/demo</a>
</p>
)}
</div>
{/* Symbol-Grösse */}
<div className="border rounded-lg p-6">
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
<Settings className="w-5 h-5 text-primary" />
Standard Symbol-Grösse
</h3>
<p className="text-sm text-muted-foreground mb-3">
Bestimmt die Standard-Grösse neuer Symbole auf der Karte. Kleinere Werte = kleinere Symbole.
</p>
<div className="flex items-center gap-4 mb-3">
<input
type="range"
min="0.5"
max="5"
step="0.1"
value={defaultSymbolScale}
onChange={e => setDefaultSymbolScale(e.target.value)}
className="flex-1"
/>
<span className="text-lg font-bold w-16 text-center">{defaultSymbolScale}x</span>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground mb-4">
<span>0.5x (klein)</span>
<span className="flex-1" />
<span>5x (gross)</span>
</div>
<div className="flex items-center gap-3">
<Button
variant="default"
size="sm"
disabled={symbolScaleLoading}
onClick={async () => {
setSymbolScaleLoading(true)
setSymbolScaleStatus(null)
try {
const res = await fetch('/api/admin/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'save_setting', key: 'default_symbol_scale', value: defaultSymbolScale }),
})
const data = await res.json()
if (data.success) setSymbolScaleStatus('saved')
} catch {} finally { setSymbolScaleLoading(false) }
}}
>
{symbolScaleLoading ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : null}
Speichern
</Button>
{symbolScaleStatus === 'saved' && <span className="flex items-center text-sm text-green-600"><CheckCircle className="w-4 h-4 mr-1" /> Gespeichert</span>}
</div>
</div>
{/* App Info */}
<div className="border rounded-lg p-6">
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
<MapPin className="w-5 h-5 text-primary" />
System-Info
</h3>
<div className="space-y-3 text-sm">
<div className="flex justify-between"><span className="text-muted-foreground">Version</span><span className="font-medium">1.0.0</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Framework</span><span className="font-medium">Next.js 14.1</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Datenbank</span><span className="font-medium">PostgreSQL 16</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Benutzer</span><span className="font-medium">{usersCount}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Mandanten</span><span className="font-medium">{tenantsCount}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Symbole</span><span className="font-medium">{iconsCount}</span></div>
</div>
</div>
{/* Quick Actions */}
<div className="border rounded-lg p-6">
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
<Settings className="w-5 h-5 text-primary" />
Schnellaktionen
</h3>
<div className="space-y-3">
<Button variant="outline" className="w-full justify-start" onClick={() => onNavigateTab('tenants')}>
<Shield className="w-4 h-4 mr-2" />
Mandanten verwalten
</Button>
<Button variant="outline" className="w-full justify-start" onClick={() => onNavigateTab('users')}>
<UserPlus className="w-4 h-4 mr-2" />
Benutzer anlegen
</Button>
<Button variant="outline" className="w-full justify-start" asChild>
<Link href="/app">
<ArrowLeft className="w-4 h-4 mr-2" />
Zur Krokier-App
</Link>
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,147 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useToast } from '@/components/ui/use-toast'
import { AlertTriangle, Eye, EyeOff, Trash2, Plus, Loader2, GripVertical } from 'lucide-react'
interface SomaTemplate {
id: string
label: string
sortOrder: number
isActive: boolean
}
export function SomaTab() {
const { toast } = useToast()
const [somaTemplates, setSomaTemplates] = useState<SomaTemplate[]>([])
const [newSomaLabel, setNewSomaLabel] = useState('')
const [somaLoading, setSomaLoading] = useState(false)
const fetchSomaTemplates = async () => {
setSomaLoading(true)
try {
const res = await fetch('/api/tenant/soma-templates')
if (res.ok) {
const data = await res.json()
setSomaTemplates(data.templates || [])
}
} catch {}
setSomaLoading(false)
}
useEffect(() => {
fetchSomaTemplates()
}, [])
const handleAdd = async () => {
if (!newSomaLabel.trim()) return
try {
await fetch('/api/tenant/soma-templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label: newSomaLabel.trim(), sortOrder: somaTemplates.length }),
})
setNewSomaLabel('')
fetchSomaTemplates()
toast({ title: 'SOMA-Vorlage hinzugefügt' })
} catch {}
}
return (
<div className="border rounded-lg p-6">
<h3 className="font-semibold text-lg mb-2 flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-red-600" />
SOMA-Checkliste verwalten
</h3>
<p className="text-sm text-muted-foreground mb-4">
Definiere die Sofortmassnahmen (SOMA), die bei jedem neuen Einsatz als Checkliste erscheinen.
Bestehende Einsätze werden nicht verändert.
</p>
{somaLoading ? (
<div className="flex items-center gap-2 py-4 text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" /> Laden...
</div>
) : (
<>
{/* Template list */}
<div className="border rounded-lg divide-y">
{somaTemplates.length === 0 ? (
<div className="px-4 py-6 text-center text-muted-foreground text-sm">
Keine SOMA-Vorlagen definiert. Neue Einsätze starten ohne Checkliste.
</div>
) : somaTemplates.map((tpl, idx) => (
<div key={tpl.id} className={`flex items-center gap-3 px-4 py-2.5 ${!tpl.isActive ? 'opacity-50' : ''}`}>
<GripVertical className="w-4 h-4 text-muted-foreground/40 shrink-0" />
<span className="text-sm font-medium flex-1">{tpl.label}</span>
<span className="text-xs text-muted-foreground">#{idx + 1}</span>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={async () => {
try {
await fetch('/api/tenant/soma-templates', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updates: [{ id: tpl.id, isActive: !tpl.isActive }] }),
})
fetchSomaTemplates()
} catch {}
}}
>
{tpl.isActive ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-destructive hover:text-destructive"
onClick={async () => {
if (!confirm(`"${tpl.label}" wirklich löschen?`)) return
try {
await fetch('/api/tenant/soma-templates', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: tpl.id }),
})
fetchSomaTemplates()
toast({ title: 'SOMA-Vorlage gelöscht' })
} catch {}
}}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
))}
</div>
{/* Add new */}
<div className="flex gap-2 mt-4">
<Input
placeholder="Neue Sofortmassnahme..."
value={newSomaLabel}
onChange={e => setNewSomaLabel(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && newSomaLabel.trim()) {
e.preventDefault()
handleAdd()
}
}}
className="flex-1"
/>
<Button disabled={!newSomaLabel.trim()} onClick={handleAdd}>
<Plus className="w-4 h-4 mr-1" /> Hinzufügen
</Button>
</div>
<p className="text-xs text-muted-foreground mt-3">
{somaTemplates.filter(t => t.isActive).length} aktiv / {somaTemplates.length} gesamt
Nur aktive Vorlagen erscheinen bei neuen Einsätzen.
</p>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,150 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useToast } from '@/components/ui/use-toast'
import { BookOpen, Plus, X, Download, Upload } from 'lucide-react'
interface SuggestionsTabProps {
tenantId: string | undefined
}
export function SuggestionsTab({ tenantId }: SuggestionsTabProps) {
const { toast } = useToast()
const [journalSuggestions, setJournalSuggestions] = useState<string[]>([])
const [newSuggestion, setNewSuggestion] = useState('')
useEffect(() => {
if (!tenantId) return
fetch(`/api/tenants/${tenantId}/suggestions`)
.then(r => r.ok ? r.json() : null)
.then(data => { if (data?.suggestions) setJournalSuggestions(data.suggestions) })
.catch(() => {})
}, [tenantId])
const saveSuggestions = (updated: string[]) => {
if (!tenantId) return
fetch(`/api/tenants/${tenantId}/suggestions`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ suggestions: updated }),
}).then(r => { if (r.ok) toast({ title: 'Gespeichert' }) })
}
const handleAdd = () => {
const trimmed = newSuggestion.trim()
if (!trimmed || journalSuggestions.includes(trimmed)) return
const updated = [...journalSuggestions, trimmed].sort((a, b) => a.localeCompare(b, 'de'))
setJournalSuggestions(updated)
setNewSuggestion('')
saveSuggestions(updated)
}
const handleRemove = (index: number) => {
const updated = journalSuggestions.filter((_, idx) => idx !== index)
setJournalSuggestions(updated)
saveSuggestions(updated)
toast({ title: 'Entfernt' })
}
return (
<div className="border rounded-lg p-6">
<h3 className="font-semibold text-lg mb-2 flex items-center gap-2">
<BookOpen className="w-5 h-5" />
Journal-Wörterliste
</h3>
<p className="text-sm text-muted-foreground mb-4">
Häufige Begriffe und Textbausteine, die beim Erfassen von Journal-Einträgen als Vorschläge erscheinen.
Wenn der Benutzer im &quot;Was...&quot;-Feld tippt, werden passende Begriffe vorgeschlagen.
</p>
{/* Add new suggestion */}
<div className="flex gap-2 mb-4">
<Input
placeholder="Neuer Begriff, z.B. 'Leitung aufbauen'..."
value={newSuggestion}
onChange={(e) => setNewSuggestion(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && newSuggestion.trim()) handleAdd()
}}
className="flex-1"
/>
<Button onClick={handleAdd} disabled={!newSuggestion.trim()}>
<Plus className="w-4 h-4 mr-2" />
Hinzufügen
</Button>
</div>
{/* List of suggestions */}
{journalSuggestions.length === 0 ? (
<div className="text-center text-muted-foreground py-8 text-sm border-2 border-dashed rounded-lg">
Noch keine Begriffe hinterlegt. Fügen Sie häufig verwendete Textbausteine hinzu,<br />
z.B. &quot;Leitung aufbauen&quot;, &quot;Leitung abbauen&quot;, &quot;Lüfter in Stellung&quot;, etc.
</div>
) : (
<div className="flex flex-wrap gap-2">
{journalSuggestions.map((s, i) => (
<span key={i} className="inline-flex items-center gap-1 px-3 py-1.5 bg-blue-50 dark:bg-blue-950/30 text-blue-700 dark:text-blue-300 rounded-full text-sm border border-blue-200 dark:border-blue-800">
{s}
<button
onClick={() => handleRemove(i)}
className="ml-1 hover:text-red-500 transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</span>
))}
</div>
)}
<div className="flex items-center gap-2 mt-4 pt-4 border-t">
<p className="text-xs text-muted-foreground flex-1">
{journalSuggestions.length} Begriff(e) hinterlegt. Änderungen werden automatisch gespeichert.
</p>
<Button
variant="outline"
size="sm"
onClick={() => {
const blob = new Blob([journalSuggestions.join('\n')], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'woerterliste.txt'
a.click()
URL.revokeObjectURL(url)
toast({ title: 'Exportiert', description: `${journalSuggestions.length} Begriffe exportiert` })
}}
disabled={journalSuggestions.length === 0}
>
<Download className="w-4 h-4 mr-1" />
Export
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.txt,.csv'
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
const text = await file.text()
const words = text.split(/[\n\r,;]+/).map(w => w.trim()).filter(Boolean)
if (words.length === 0) { toast({ title: 'Keine Begriffe gefunden', variant: 'destructive' }); return }
const merged = Array.from(new Set([...journalSuggestions, ...words])).sort((a, b) => a.localeCompare(b, 'de'))
setJournalSuggestions(merged)
saveSuggestions(merged)
toast({ title: 'Importiert', description: `${words.length} Begriffe importiert (${merged.length} total)` })
}
input.click()
}}
>
<Upload className="w-4 h-4 mr-1" />
Import
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,372 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useToast } from '@/components/ui/use-toast'
import {
ChevronDown,
ChevronRight,
Plus,
Pencil,
Trash2,
Search,
Upload,
Loader2,
X,
Check,
LayoutGrid,
ImageIcon,
Info,
} from 'lucide-react'
interface LibraryIcon {
id: string
name: string
mimeType: string
iconType: string
categoryId: string
categoryName: string
}
interface MySymbol {
id: string
iconId: string
name: string
customName: string | null
baseName: string
mimeType: string
iconType: string
categoryName: string
sortOrder: number
}
export function SymbolManager() {
const { toast } = useToast()
const [loading, setLoading] = useState(true)
const [library, setLibrary] = useState<LibraryIcon[]>([])
const [mySymbols, setMySymbols] = useState<MySymbol[]>([])
// Library UI state
const [librarySearch, setLibrarySearch] = useState('')
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
const [libraryCollapsed, setLibraryCollapsed] = useState(false)
// My Symbols UI state
const [editingId, setEditingId] = useState<string | null>(null)
const [editName, setEditName] = useState('')
const fetchData = useCallback(async () => {
setLoading(true)
try {
const res = await fetch('/api/tenant/symbols')
if (res.ok) {
const data = await res.json()
setLibrary(data.library || [])
setMySymbols(data.mySymbols || [])
// Auto-collapse library if tenant has own symbols
if ((data.mySymbols || []).length > 0) {
setLibraryCollapsed(true)
}
}
} catch {}
setLoading(false)
}, [])
useEffect(() => { fetchData() }, [fetchData])
// Add symbol from library to my collection
const addSymbol = async (iconId: string, customName?: string) => {
try {
const res = await fetch('/api/tenant/symbols', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ iconId, customName }),
})
if (res.ok) {
const symbol = await res.json()
setMySymbols(prev => [...prev, symbol])
toast({ title: 'Symbol hinzugefügt' })
}
} catch {
toast({ title: 'Fehler', variant: 'destructive' })
}
}
// Rename a symbol
const renameSymbol = async (id: string, customName: string) => {
try {
await fetch('/api/tenant/symbols', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, customName }),
})
setMySymbols(prev => prev.map(s =>
s.id === id ? { ...s, name: customName || s.baseName, customName: customName || null } : s
))
setEditingId(null)
setEditName('')
toast({ title: 'Umbenannt' })
} catch {
toast({ title: 'Fehler', variant: 'destructive' })
}
}
// Remove a symbol
const removeSymbol = async (id: string) => {
try {
await fetch('/api/tenant/symbols', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
})
setMySymbols(prev => prev.filter(s => s.id !== id))
toast({ title: 'Symbol entfernt' })
} catch {
toast({ title: 'Fehler', variant: 'destructive' })
}
}
// Toggle category expand/collapse
const toggleCategory = (cat: string) => {
setExpandedCategories(prev => {
const next = new Set(prev)
next.has(cat) ? next.delete(cat) : next.add(cat)
return next
})
}
// Group library icons by category
const filteredLibrary = library.filter(icon =>
!librarySearch || icon.name.toLowerCase().includes(librarySearch.toLowerCase())
)
const libraryGrouped = filteredLibrary.reduce<Record<string, LibraryIcon[]>>((acc, icon) => {
const key = icon.categoryName
if (!acc[key]) acc[key] = []
acc[key].push(icon)
return acc
}, {})
if (loading) {
return (
<div className="flex items-center gap-2 py-12 justify-center text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" /> Symbole laden...
</div>
)
}
return (
<div className="space-y-6">
{/* ===== MEINE SYMBOLE (always on top, prominent) ===== */}
<div className="border-2 border-primary/20 rounded-lg">
<div className="flex items-center justify-between px-4 py-3 bg-primary/5 border-b border-primary/20">
<h3 className="font-semibold text-sm flex items-center gap-2">
<LayoutGrid className="w-4 h-4 text-primary" />
Meine Symbole
<span className="text-xs text-muted-foreground font-normal">({mySymbols.length})</span>
</h3>
</div>
{mySymbols.length === 0 ? (
<div className="p-8 text-center">
<ImageIcon className="w-10 h-10 mx-auto text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground mb-1">Noch keine eigenen Symbole definiert.</p>
<p className="text-xs text-muted-foreground">
Füge Symbole aus der Bibliothek unten hinzu oder lade eigene SVGs hoch.
</p>
</div>
) : (
<div className="p-4">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-3">
{mySymbols.map(sym => (
<div
key={sym.id}
className="group relative border rounded-lg p-2 transition-all hover:shadow-md hover:border-primary/30"
>
<div className="aspect-square flex items-center justify-center mb-1.5 bg-muted/50 rounded">
<img
src={`/api/icons/${sym.iconId}/image`}
alt={sym.name}
className="w-12 h-12 object-contain"
draggable={false}
/>
</div>
{/* Name / Edit */}
{editingId === sym.id ? (
<div className="flex gap-0.5">
<Input
value={editName}
onChange={e => setEditName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') renameSymbol(sym.id, editName)
if (e.key === 'Escape') { setEditingId(null); setEditName('') }
}}
className="h-6 text-[10px] px-1"
autoFocus
/>
<button onClick={() => renameSymbol(sym.id, editName)} className="text-green-600 hover:text-green-700">
<Check className="w-3.5 h-3.5" />
</button>
<button onClick={() => { setEditingId(null); setEditName('') }} className="text-muted-foreground hover:text-foreground">
<X className="w-3.5 h-3.5" />
</button>
</div>
) : (
<p
className="text-[11px] text-center truncate cursor-pointer hover:text-primary"
title={`${sym.name}${sym.customName ? ` (Basis: ${sym.baseName})` : ''} — Klick zum Umbenennen`}
onClick={() => { setEditingId(sym.id); setEditName(sym.name) }}
>
{sym.name}
</p>
)}
{/* Hover actions */}
<div className="absolute top-1 right-1 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => { setEditingId(sym.id); setEditName(sym.name) }}
className="w-5 h-5 rounded bg-background/80 border flex items-center justify-center text-muted-foreground hover:text-primary"
title="Umbenennen"
>
<Pencil className="w-3 h-3" />
</button>
<button
onClick={() => removeSymbol(sym.id)}
className="w-5 h-5 rounded bg-background/80 border flex items-center justify-center text-muted-foreground hover:text-destructive"
title="Entfernen"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
{/* Custom name badge */}
{sym.customName && (
<div className="absolute top-1 left-1">
<div className="w-2 h-2 rounded-full bg-primary" title="Eigener Name" />
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
{/* ===== UPLOAD HINWEIS ===== */}
<div className="flex items-start gap-3 px-4 py-3 bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<Info className="w-4 h-4 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
<div className="text-xs text-blue-700 dark:text-blue-300">
<strong>Tipp:</strong> Eigene Symbole am besten als <strong>SVG</strong> hochladen diese werden in jeder Grösse scharf dargestellt.
PNG/JPEG sind auch möglich, können aber bei Vergrösserung unscharf werden.
</div>
</div>
{/* ===== STANDARD-BIBLIOTHEK (collapsible) ===== */}
<div className="border rounded-lg">
<button
className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/50 transition-colors"
onClick={() => setLibraryCollapsed(!libraryCollapsed)}
>
<h3 className="font-semibold text-sm flex items-center gap-2">
{libraryCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
Standard-Bibliothek
<span className="text-xs text-muted-foreground font-normal">({library.length} Symbole)</span>
</h3>
<span className="text-xs text-muted-foreground">
{libraryCollapsed ? 'Aufklappen' : 'Zuklappen'}
</span>
</button>
{!libraryCollapsed && (
<div className="border-t px-4 py-3 space-y-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Symbole suchen..."
value={librarySearch}
onChange={e => setLibrarySearch(e.target.value)}
className="pl-9"
/>
</div>
{/* Categories */}
{Object.entries(libraryGrouped).sort(([a], [b]) => a.localeCompare(b)).map(([catName, icons]) => (
<div key={catName} className="border rounded-lg">
<button
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/30 transition-colors text-sm"
onClick={() => toggleCategory(catName)}
>
<span className="font-medium flex items-center gap-2">
{expandedCategories.has(catName) ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
{catName}
<span className="text-xs text-muted-foreground font-normal">({icons.length})</span>
</span>
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-xs"
onClick={(e) => {
e.stopPropagation()
// Add all icons from this category
icons.forEach(icon => addSymbol(icon.id))
}}
>
<Plus className="w-3 h-3 mr-1" /> Alle hinzufügen
</Button>
</button>
{expandedCategories.has(catName) && (
<div className="border-t px-3 py-3">
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 gap-2">
{icons.map(icon => {
const alreadyAdded = mySymbols.some(s => s.iconId === icon.id)
return (
<button
key={icon.id}
onClick={() => addSymbol(icon.id)}
className={`group relative border rounded-lg p-2 transition-all hover:shadow-sm hover:border-primary/40 ${
alreadyAdded ? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800' : ''
}`}
title={`${icon.name} — Klick zum Hinzufügen`}
>
<div className="aspect-square flex items-center justify-center mb-1 bg-muted/30 rounded">
<img
src={`/api/icons/${icon.id}/image`}
alt={icon.name}
className="w-10 h-10 object-contain"
draggable={false}
/>
</div>
<p className="text-[10px] text-center truncate">{icon.name}</p>
{/* Add overlay */}
<div className="absolute inset-0 flex items-center justify-center bg-primary/10 opacity-0 group-hover:opacity-100 rounded-lg transition-opacity">
<Plus className="w-5 h-5 text-primary" />
</div>
{/* Already added indicator */}
{alreadyAdded && (
<div className="absolute top-1 right-1 w-3 h-3 rounded-full bg-green-500 flex items-center justify-center">
<Check className="w-2 h-2 text-white" />
</div>
)}
</button>
)
})}
</div>
</div>
)}
</div>
))}
{Object.keys(libraryGrouped).length === 0 && (
<div className="text-center text-muted-foreground py-6 text-sm">
{librarySearch ? 'Keine Symbole gefunden.' : 'Keine Standard-Symbole vorhanden.'}
</div>
)}
</div>
)}
</div>
</div>
)
}

View File

@@ -13,7 +13,7 @@ import {
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { MapPin, Loader2, X } from 'lucide-react' import { MapPin, Loader2, X } from 'lucide-react'
import type { Project } from '@/app/app/page' import type { Project } from '@/types'
interface NominatimResult { interface NominatimResult {
place_id: number place_id: number

View File

@@ -0,0 +1,58 @@
'use client'
import React from 'react'
import { AlertTriangle, RotateCcw } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface ErrorBoundaryProps {
children: React.ReactNode
fallback?: React.ReactNode
}
interface ErrorBoundaryState {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('[ErrorBoundary] Caught error:', error, errorInfo)
}
handleReset = () => {
this.setState({ hasError: false, error: null })
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback
}
return (
<div className="flex flex-col items-center justify-center min-h-[200px] p-8 text-center">
<AlertTriangle className="w-10 h-10 text-destructive mb-4" />
<h3 className="text-lg font-semibold mb-2">Etwas ist schiefgelaufen</h3>
<p className="text-sm text-muted-foreground mb-4 max-w-md">
{this.state.error?.message || 'Ein unerwarteter Fehler ist aufgetreten.'}
</p>
<Button variant="outline" onClick={this.handleReset}>
<RotateCcw className="w-4 h-4 mr-2" />
Erneut versuchen
</Button>
</div>
)
}
return this.props.children
}
}

View File

@@ -9,6 +9,7 @@ import {
AlertTriangle, ClipboardList, Loader2, Printer, Pencil, Send, FileText, AlertTriangle, ClipboardList, Loader2, Printer, Pencil, Send, FileText,
} from 'lucide-react' } from 'lucide-react'
import { getSocket } from '@/lib/socket' import { getSocket } from '@/lib/socket'
import { RapportDialog } from '@/components/journal/rapport-dialog'
interface JournalEntry { interface JournalEntry {
id: string id: string
@@ -86,7 +87,6 @@ export function JournalView({ projectId, projectTitle, projectLocation, einsatzl
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
// Rapport creation // Rapport creation
const [creatingRapport, setCreatingRapport] = useState(false)
const [lastRapportLink, setLastRapportLink] = useState<string | null>(null) const [lastRapportLink, setLastRapportLink] = useState<string | null>(null)
const [showRapportDialog, setShowRapportDialog] = useState(false) const [showRapportDialog, setShowRapportDialog] = useState(false)
const [rapportForm, setRapportForm] = useState<Record<string, any>>({}) const [rapportForm, setRapportForm] = useState<Record<string, any>>({})
@@ -461,8 +461,21 @@ export function JournalView({ projectId, projectTitle, projectLocation, einsatzl
zeitKontrolle: '', zeitAus: '', zeitEinruecken: '', zeitEnde: '', zeitKontrolle: '', zeitAus: '', zeitEinruecken: '', zeitEnde: '',
lageEintreffen: '', lageEintreffen: '',
massnahmen: entries.map(e => `${formatTime(e.time)} ${e.what}${e.who ? ` (${e.who})` : ''}`), massnahmen: entries.map(e => `${formatTime(e.time)} ${e.what}${e.who ? ` (${e.who})` : ''}`),
somaItems: checkItems.map(c => ({
label: c.label,
confirmed: c.confirmed,
ok: c.ok,
confirmedAt: c.confirmedAt ? formatTime(c.confirmedAt) : null,
})),
pendenzenItems: pendenzen.map(p => ({
what: p.what,
who: p.who || '',
whenHow: p.whenHow || '',
done: p.done,
doneAt: p.doneAt ? formatTime(p.doneAt) : null,
})),
fahrzeuge: [] as any[], fahrzeuge: [] as any[],
bemerkungen: pendenzen.filter(p => !p.done).map(p => `PENDENT: ${p.what}${p.who ? ` (${p.who})` : ''}`).join('\n'), bemerkungen: '',
einsatzleiter: einsatzleiter || '', einsatzleiter: einsatzleiter || '',
rapporteur: journalfuehrer || '', rapporteur: journalfuehrer || '',
reportNumber: '', reportNumber: '',
@@ -895,210 +908,16 @@ export function JournalView({ projectId, projectTitle, projectLocation, einsatzl
</div> </div>
{/* Rapport Dialog */} {/* Rapport Dialog */}
{showRapportDialog && ( {showRapportDialog && projectId && (
<div className="fixed inset-0 z-50 bg-black/50 flex items-start justify-center overflow-auto py-8 print:hidden"> <RapportDialog
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-2xl w-full max-w-2xl mx-4"> projectId={projectId}
<div className="flex items-center justify-between p-4 border-b"> rapportForm={rapportForm}
<h3 className="text-lg font-bold flex items-center gap-2"> setRapportForm={setRapportForm}
<FileText className="w-5 h-5" /> mapRef={mapRef}
Einsatzrapport erstellen mapScreenshot={preCapuredScreenshot}
</h3> onClose={() => setShowRapportDialog(false)}
<button onClick={() => setShowRapportDialog(false)} className="text-gray-400 hover:text-gray-600 text-xl leading-none">&times;</button> onRapportCreated={(link) => setLastRapportLink(link)}
</div> />
<div className="p-4 space-y-4 max-h-[70vh] overflow-auto">
{/* Organisation */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Organisation</label>
<Input value={rapportForm.organisation || ''} onChange={e => setRapportForm(f => ({ ...f, organisation: e.target.value }))} />
</div>
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Abteilung</label>
<Input value={rapportForm.abteilung || ''} onChange={e => setRapportForm(f => ({ ...f, abteilung: e.target.value }))} />
</div>
</div>
{/* Einsatzdaten */}
<div className="grid grid-cols-4 gap-3">
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Datum</label>
<Input value={rapportForm.datum || ''} onChange={e => setRapportForm(f => ({ ...f, datum: e.target.value }))} />
</div>
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Uhrzeit</label>
<Input value={rapportForm.uhrzeit || ''} onChange={e => setRapportForm(f => ({ ...f, uhrzeit: e.target.value }))} />
</div>
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Einsatz-Nr.</label>
<Input value={rapportForm.einsatzNr || ''} onChange={e => setRapportForm(f => ({ ...f, einsatzNr: e.target.value }))} />
</div>
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Priorität</label>
<Input value={rapportForm.prioritaet || ''} onChange={e => setRapportForm(f => ({ ...f, prioritaet: e.target.value }))} placeholder="z.B. Hoch" />
</div>
</div>
{/* Ort */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Einsatzort / Adresse</label>
<Input value={rapportForm.einsatzort || ''} onChange={e => setRapportForm(f => ({ ...f, einsatzort: e.target.value }))} />
</div>
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Objekt / Gebäude</label>
<Input value={rapportForm.objekt || ''} onChange={e => setRapportForm(f => ({ ...f, objekt: e.target.value }))} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Alarmierungsart</label>
<Input value={rapportForm.alarmierungsart || ''} onChange={e => setRapportForm(f => ({ ...f, alarmierungsart: e.target.value }))} />
</div>
</div>
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Stichwort / Meldebild</label>
<Input value={rapportForm.stichwort || ''} onChange={e => setRapportForm(f => ({ ...f, stichwort: e.target.value }))} />
</div>
{/* Zeitverlauf */}
<div>
<label className="text-xs font-semibold text-gray-500 uppercase mb-1 block">Zeitverlauf</label>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[10px] text-gray-400">Alarm</label>
<Input type="time" className="text-sm h-8" value={rapportForm.zeitAlarm || ''} onChange={e => setRapportForm(f => ({ ...f, zeitAlarm: e.target.value }))} />
</div>
<div>
<label className="text-[10px] text-gray-400">Eintreffen</label>
<Input type="time" className="text-sm h-8" value={rapportForm.zeitEintreffen || ''} onChange={e => setRapportForm(f => ({ ...f, zeitEintreffen: e.target.value }))} />
</div>
</div>
</div>
{/* Lagebild */}
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Lage bei Eintreffen</label>
<textarea className="w-full border rounded-md px-3 py-2 text-sm min-h-[60px] resize-y" value={rapportForm.lageEintreffen || ''} onChange={e => setRapportForm(f => ({ ...f, lageEintreffen: e.target.value }))} />
</div>
{/* Massnahmen (read-only, from journal) */}
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Massnahmen (aus Journal)</label>
<div className="border rounded-md p-2 bg-gray-50 dark:bg-gray-800 text-sm max-h-32 overflow-auto">
{Array.isArray(rapportForm.massnahmen) && rapportForm.massnahmen.length > 0 ? (
rapportForm.massnahmen.map((m: string, i: number) => <div key={i} className="py-0.5"> {m}</div>)
) : <span className="text-gray-400">Keine Einträge</span>}
</div>
</div>
{/* Bemerkungen */}
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Bemerkungen</label>
<textarea className="w-full border rounded-md px-3 py-2 text-sm min-h-[60px] resize-y" value={rapportForm.bemerkungen || ''} onChange={e => setRapportForm(f => ({ ...f, bemerkungen: e.target.value }))} />
</div>
{/* Unterschriften */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Einsatzleiter/in</label>
<Input value={rapportForm.einsatzleiter || ''} onChange={e => setRapportForm(f => ({ ...f, einsatzleiter: e.target.value }))} />
</div>
<div>
<label className="text-xs font-semibold text-gray-500 uppercase">Rapporteur</label>
<Input value={rapportForm.rapporteur || ''} onChange={e => setRapportForm(f => ({ ...f, rapporteur: e.target.value }))} />
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 p-4 border-t">
<Button variant="outline" size="sm" onClick={() => setShowRapportDialog(false)}>Abbrechen</Button>
<Button
size="sm"
disabled={creatingRapport}
onClick={async () => {
if (!projectId) return
setCreatingRapport(true)
try {
// Capture map screenshot — compress to JPEG and resize for smaller payload
let mapScreenshot = ''
const rawScreenshot = preCapuredScreenshot || ''
if (!rawScreenshot) {
try {
if (mapRef?.current) {
const canvas = mapRef.current.getCanvas()
if (canvas) {
// Resize to max 2400px wide and convert to JPEG
const maxW = 2400
const ratio = Math.min(1, maxW / canvas.width)
const offscreen = document.createElement('canvas')
offscreen.width = Math.round(canvas.width * ratio)
offscreen.height = Math.round(canvas.height * ratio)
const ctx = offscreen.getContext('2d')
if (ctx) {
ctx.drawImage(canvas, 0, 0, offscreen.width, offscreen.height)
mapScreenshot = offscreen.toDataURL('image/jpeg', 0.85)
}
}
}
} catch (e) { console.warn('Map screenshot failed:', e) }
} else if (rawScreenshot.length > 800000) {
// Compress pre-captured screenshot if too large
try {
const img = new Image()
img.src = rawScreenshot
await new Promise(r => { img.onload = r; img.onerror = r })
const maxW = 2400
const ratio = Math.min(1, maxW / img.naturalWidth)
const offscreen = document.createElement('canvas')
offscreen.width = Math.round(img.naturalWidth * ratio)
offscreen.height = Math.round(img.naturalHeight * ratio)
const ctx = offscreen.getContext('2d')
if (ctx) {
ctx.drawImage(img, 0, 0, offscreen.width, offscreen.height)
mapScreenshot = offscreen.toDataURL('image/jpeg', 0.85)
}
} catch { mapScreenshot = rawScreenshot }
} else {
mapScreenshot = rawScreenshot
}
// Convert logo URL to base64 for PDF rendering
let logoDataUri = ''
if (rapportForm.logoUrl) {
try {
const logoRes = await fetch(rapportForm.logoUrl)
if (logoRes.ok) {
const blob = await logoRes.blob()
logoDataUri = await new Promise<string>((resolve) => {
const reader = new FileReader()
reader.onloadend = () => resolve(reader.result as string)
reader.readAsDataURL(blob)
})
}
} catch (e) { console.warn('Logo fetch failed:', e) }
}
const rapportData = { ...rapportForm, mapScreenshot, logoUrl: logoDataUri || rapportForm.logoUrl }
console.log('[Rapport] Sending request, body size ~', JSON.stringify({ projectId, data: rapportData }).length, 'bytes')
const res = await fetch('/api/rapports', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId, data: rapportData }),
})
if (res.ok) {
const result = await res.json()
setLastRapportLink(`/rapport/${result.token}`)
setShowRapportDialog(false)
window.open(`/rapport/${result.token}`, '_blank')
} else {
const errData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
console.error('[Rapport] API error:', res.status, errData)
alert(`Rapport-Fehler: ${errData.error || 'Unbekannter Fehler (Status ' + res.status + ')'}`)
}
} catch (err: any) {
console.error('Error creating rapport:', err)
alert('Rapport-Fehler: ' + (err?.message || 'Netzwerkfehler'))
} finally {
setCreatingRapport(false)
}
}}
>
{creatingRapport ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <FileText className="w-4 h-4 mr-1.5" />}
Rapport generieren
</Button>
</div>
</div>
</div>
)} )}
</> </>
) )

View File

@@ -0,0 +1,269 @@
'use client'
import { useState, MutableRefObject } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { FileText, Loader2 } from 'lucide-react'
interface RapportDialogProps {
projectId: string
rapportForm: Record<string, any>
setRapportForm: React.Dispatch<React.SetStateAction<Record<string, any>>>
mapRef?: MutableRefObject<any>
mapScreenshot?: string
onClose: () => void
onRapportCreated: (link: string) => void
}
export function RapportDialog({
projectId,
rapportForm,
setRapportForm,
mapRef,
mapScreenshot: preCapuredScreenshot,
onClose,
onRapportCreated,
}: RapportDialogProps) {
const [creatingRapport, setCreatingRapport] = useState(false)
const handleCreate = async () => {
if (!projectId) return
setCreatingRapport(true)
try {
// Capture map screenshot — compress to JPEG and resize for smaller payload
let mapScreenshot = ''
const rawScreenshot = preCapuredScreenshot || ''
if (!rawScreenshot) {
try {
if (mapRef?.current) {
const canvas = mapRef.current.getCanvas()
if (canvas) {
// Resize to max 2400px wide and convert to JPEG
const maxW = 2400
const ratio = Math.min(1, maxW / canvas.width)
const offscreen = document.createElement('canvas')
offscreen.width = Math.round(canvas.width * ratio)
offscreen.height = Math.round(canvas.height * ratio)
const ctx = offscreen.getContext('2d')
if (ctx) {
ctx.drawImage(canvas, 0, 0, offscreen.width, offscreen.height)
mapScreenshot = offscreen.toDataURL('image/jpeg', 0.85)
}
}
}
} catch (e) { console.warn('Map screenshot failed:', e) }
} else if (rawScreenshot.length > 800000) {
// Compress pre-captured screenshot if too large
try {
const img = new Image()
img.src = rawScreenshot
await new Promise(r => { img.onload = r; img.onerror = r })
const maxW = 2400
const ratio = Math.min(1, maxW / img.naturalWidth)
const offscreen = document.createElement('canvas')
offscreen.width = Math.round(img.naturalWidth * ratio)
offscreen.height = Math.round(img.naturalHeight * ratio)
const ctx = offscreen.getContext('2d')
if (ctx) {
ctx.drawImage(img, 0, 0, offscreen.width, offscreen.height)
mapScreenshot = offscreen.toDataURL('image/jpeg', 0.85)
}
} catch { mapScreenshot = rawScreenshot }
} else {
mapScreenshot = rawScreenshot
}
// Convert logo URL to base64 for PDF rendering
let logoDataUri = ''
if (rapportForm.logoUrl) {
try {
const logoRes = await fetch(rapportForm.logoUrl)
if (logoRes.ok) {
const blob = await logoRes.blob()
logoDataUri = await new Promise<string>((resolve) => {
const reader = new FileReader()
reader.onloadend = () => resolve(reader.result as string)
reader.readAsDataURL(blob)
})
}
} catch (e) { console.warn('Logo fetch failed:', e) }
}
const rapportData = { ...rapportForm, mapScreenshot, logoUrl: logoDataUri || rapportForm.logoUrl }
console.log('[Rapport] Sending request, body size ~', JSON.stringify({ projectId, data: rapportData }).length, 'bytes')
const res = await fetch('/api/rapports', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId, data: rapportData }),
})
if (res.ok) {
const result = await res.json()
onRapportCreated(`/rapport/${result.token}`)
onClose()
window.open(`/rapport/${result.token}`, '_blank')
} else {
const errData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
console.error('[Rapport] API error:', res.status, errData)
alert(`Rapport-Fehler: ${errData.error || 'Unbekannter Fehler (Status ' + res.status + ')'}`)
}
} catch (err: any) {
console.error('Error creating rapport:', err)
alert('Rapport-Fehler: ' + (err?.message || 'Netzwerkfehler'))
} finally {
setCreatingRapport(false)
}
}
return (
<div className="fixed inset-0 z-50 bg-black/50 flex items-start justify-center overflow-auto py-8 print:hidden">
<div className="bg-card rounded-lg shadow-2xl w-full max-w-2xl mx-4">
<div className="flex items-center justify-between p-4 border-b">
<h3 className="text-lg font-bold flex items-center gap-2">
<FileText className="w-5 h-5" />
Einsatzrapport erstellen
</h3>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground text-xl leading-none">&times;</button>
</div>
<div className="p-4 space-y-4 max-h-[70vh] overflow-auto">
{/* Organisation */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Organisation</label>
<Input value={rapportForm.organisation || ''} onChange={e => setRapportForm(f => ({ ...f, organisation: e.target.value }))} />
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Abteilung</label>
<Input value={rapportForm.abteilung || ''} onChange={e => setRapportForm(f => ({ ...f, abteilung: e.target.value }))} />
</div>
</div>
{/* Einsatzdaten */}
<div className="grid grid-cols-4 gap-3">
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Datum</label>
<Input value={rapportForm.datum || ''} onChange={e => setRapportForm(f => ({ ...f, datum: e.target.value }))} />
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Uhrzeit</label>
<Input value={rapportForm.uhrzeit || ''} onChange={e => setRapportForm(f => ({ ...f, uhrzeit: e.target.value }))} />
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Einsatz-Nr.</label>
<Input value={rapportForm.einsatzNr || ''} onChange={e => setRapportForm(f => ({ ...f, einsatzNr: e.target.value }))} />
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Priorität</label>
<Input value={rapportForm.prioritaet || ''} onChange={e => setRapportForm(f => ({ ...f, prioritaet: e.target.value }))} placeholder="z.B. Hoch" />
</div>
</div>
{/* Ort */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Einsatzort / Adresse</label>
<Input value={rapportForm.einsatzort || ''} onChange={e => setRapportForm(f => ({ ...f, einsatzort: e.target.value }))} />
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Objekt / Gebäude</label>
<Input value={rapportForm.objekt || ''} onChange={e => setRapportForm(f => ({ ...f, objekt: e.target.value }))} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Alarmierungsart</label>
<Input value={rapportForm.alarmierungsart || ''} onChange={e => setRapportForm(f => ({ ...f, alarmierungsart: e.target.value }))} />
</div>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Stichwort / Meldebild</label>
<Input value={rapportForm.stichwort || ''} onChange={e => setRapportForm(f => ({ ...f, stichwort: e.target.value }))} />
</div>
{/* Zeitverlauf */}
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase mb-1 block">Zeitverlauf</label>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[10px] text-muted-foreground">Alarm</label>
<Input type="time" className="text-sm h-8" value={rapportForm.zeitAlarm || ''} onChange={e => setRapportForm(f => ({ ...f, zeitAlarm: e.target.value }))} />
</div>
<div>
<label className="text-[10px] text-muted-foreground">Eintreffen</label>
<Input type="time" className="text-sm h-8" value={rapportForm.zeitEintreffen || ''} onChange={e => setRapportForm(f => ({ ...f, zeitEintreffen: e.target.value }))} />
</div>
</div>
</div>
{/* Lagebild */}
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Lage bei Eintreffen</label>
<textarea className="w-full border rounded-md px-3 py-2 text-sm min-h-[60px] resize-y" value={rapportForm.lageEintreffen || ''} onChange={e => setRapportForm(f => ({ ...f, lageEintreffen: e.target.value }))} />
</div>
{/* Massnahmen (read-only, from journal) */}
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Massnahmen (aus Journal)</label>
<div className="border rounded-md p-2 bg-muted text-sm max-h-32 overflow-auto">
{Array.isArray(rapportForm.massnahmen) && rapportForm.massnahmen.length > 0 ? (
rapportForm.massnahmen.map((m: string, i: number) => <div key={i} className="py-0.5"> {m}</div>)
) : <span className="text-muted-foreground">Keine Einträge</span>}
</div>
</div>
{/* SOMA Checklist (read-only, from journal) */}
{Array.isArray(rapportForm.somaItems) && rapportForm.somaItems.length > 0 && (
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">SOMA Checkliste</label>
<div className="border rounded-md p-2 bg-muted text-sm max-h-32 overflow-auto">
{rapportForm.somaItems.map((s: any, i: number) => (
<div key={i} className="flex items-center gap-2 py-0.5">
<span className={`inline-block w-4 text-center ${s.confirmed ? 'text-blue-600 font-bold' : 'text-muted-foreground'}`}>{s.confirmed ? '✓' : '—'}</span>
<span className={`inline-block w-4 text-center ${s.ok ? 'text-green-600 font-bold' : 'text-muted-foreground'}`}>{s.ok ? '✓' : '—'}</span>
<span>{s.label}</span>
{s.confirmedAt && <span className="text-[10px] text-muted-foreground ml-auto">{s.confirmedAt}</span>}
</div>
))}
</div>
</div>
)}
{/* Pendenzen (read-only, from journal) */}
{Array.isArray(rapportForm.pendenzenItems) && rapportForm.pendenzenItems.length > 0 && (
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Pendenzen</label>
<div className="border rounded-md p-2 bg-muted text-sm max-h-32 overflow-auto">
{rapportForm.pendenzenItems.map((p: any, i: number) => (
<div key={i} className={`flex items-center gap-2 py-0.5 ${p.done ? 'line-through text-muted-foreground' : ''}`}>
<span className={`inline-block w-4 text-center ${p.done ? 'text-green-600 font-bold' : 'text-muted-foreground'}`}>{p.done ? '✓' : '○'}</span>
<span>{p.what}</span>
{p.who && <span className="text-muted-foreground text-xs">({p.who})</span>}
{p.whenHow && <span className="text-muted-foreground text-xs ml-1"> {p.whenHow}</span>}
{p.doneAt && <span className="text-[10px] text-green-600 ml-auto">{p.doneAt}</span>}
</div>
))}
</div>
</div>
)}
{/* Bemerkungen */}
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Bemerkungen</label>
<textarea className="w-full border rounded-md px-3 py-2 text-sm min-h-[60px] resize-y" value={rapportForm.bemerkungen || ''} onChange={e => setRapportForm(f => ({ ...f, bemerkungen: e.target.value }))} />
</div>
{/* Unterschriften */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Einsatzleiter/in</label>
<Input value={rapportForm.einsatzleiter || ''} onChange={e => setRapportForm(f => ({ ...f, einsatzleiter: e.target.value }))} />
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase">Rapporteur</label>
<Input value={rapportForm.rapporteur || ''} onChange={e => setRapportForm(f => ({ ...f, rapporteur: e.target.value }))} />
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 p-4 border-t">
<Button variant="outline" size="sm" onClick={onClose}>Abbrechen</Button>
<Button
size="sm"
disabled={creatingRapport}
onClick={handleCreate}
>
{creatingRapport ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <FileText className="w-4 h-4 mr-1.5" />}
Rapport generieren
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,105 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Loader2, Send, Check } from 'lucide-react'
export function ContactForm() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [message, setMessage] = useState('')
const [sending, setSending] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSending(true)
setError('')
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, message }),
})
if (res.ok) {
setSent(true)
setName('')
setEmail('')
setMessage('')
} else {
const data = await res.json()
setError(data.error || 'Senden fehlgeschlagen')
}
} catch {
setError('Verbindung fehlgeschlagen')
} finally {
setSending(false)
}
}
if (sent) {
return (
<div className="text-center bg-green-50 border border-green-200 rounded-xl p-8">
<Check className="w-10 h-10 text-green-600 mx-auto mb-3" />
<h3 className="font-semibold text-green-900 text-lg">Nachricht gesendet!</h3>
<p className="text-green-700 mt-2">Vielen Dank! Ich melde mich so schnell wie möglich.</p>
<Button variant="outline" className="mt-4" onClick={() => setSent(false)}>
Weitere Nachricht senden
</Button>
</div>
)
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
placeholder="Dein Name"
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">E-Mail</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
placeholder="name@feuerwehr.ch"
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nachricht</label>
<textarea
value={message}
onChange={e => setMessage(e.target.value)}
required
rows={5}
placeholder="Deine Nachricht..."
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent resize-none"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<Button
type="submit"
className="bg-red-600 hover:bg-red-700"
disabled={sending || !name || !email || !message}
>
{sending ? (
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Wird gesendet...</>
) : (
<><Send className="w-4 h-4 mr-2" /> Nachricht senden</>
)}
</Button>
</form>
)
}

View File

@@ -0,0 +1,36 @@
'use client'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { useAuth } from '@/components/providers/auth-provider'
export function NavAuthButtons() {
const { user, loading } = useAuth()
if (loading) {
return <div className="w-24 h-9" /> // placeholder to avoid layout shift
}
if (user) {
return (
<Link href="/app">
<Button size="sm" className="bg-red-600 hover:bg-red-700">
Zur App
</Button>
</Link>
)
}
return (
<>
<Link href="/login">
<Button variant="ghost" size="sm">Anmelden</Button>
</Link>
<Link href="/register">
<Button size="sm" className="bg-red-600 hover:bg-red-700">
Kostenlos starten
</Button>
</Link>
</>
)
}

View File

@@ -22,7 +22,7 @@ import {
Ruler, Ruler,
Eraser, Eraser,
} from 'lucide-react' } from 'lucide-react'
import type { DrawMode } from '@/app/app/page' import type { DrawMode } from '@/types'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
interface LeftToolbarProps { interface LeftToolbarProps {

View File

@@ -108,7 +108,7 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
async function fetchIcons() { async function fetchIcons() {
setIsLoading(true) setIsLoading(true)
try { try {
const res = await fetch('/api/icons') const res = await fetch('/api/icons', { cache: 'no-store' })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
const allCats: DisplayCategory[] = (data.categories || []) const allCats: DisplayCategory[] = (data.categories || [])
@@ -126,9 +126,26 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
// Separate tenant-specific icons ("Eigene" category) from global library // Separate tenant-specific icons ("Eigene" category) from global library
const eigene = allCats.find(c => c.name === 'Eigene') const eigene = allCats.find(c => c.name === 'Eigene')
const globalCats = allCats.filter(c => c.name !== 'Eigene') const globalCats = allCats.filter(c => c.name !== 'Eigene')
setTenantIcons(eigene?.symbols || [])
// Merge: mySymbols (custom collection) + legacy "Eigene" category uploads
const mySymbols: DisplaySymbol[] = (data.mySymbols || []).map((s: any) => ({
id: s.id,
name: s.name,
imageUrl: s.url || `/api/icons/${s.id}/image`,
}))
const legacyOwn = eigene?.symbols || []
// Deduplicate: mySymbols takes priority over legacy
const mySymbolIds = new Set(mySymbols.map(s => s.id))
const mergedTenant = [...mySymbols, ...legacyOwn.filter(s => !mySymbolIds.has(s.id))]
setTenantIcons(mergedTenant)
setCategories(globalCats) setCategories(globalCats)
if (globalCats.length > 0) setActiveCategory(globalCats[0].id) if (globalCats.length > 0) setActiveCategory(globalCats[0].id)
// Auto-collapse library if tenant has own symbols
if (mergedTenant.length > 0) {
setShowLibrarySection(false)
}
} }
} catch (err) { } catch (err) {
console.error('Failed to load icons:', err) console.error('Failed to load icons:', err)
@@ -137,7 +154,7 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
} }
} }
fetchIcons() fetchIcons()
}, []) }, [tenantId])
const filteredCategories = categories.map((cat) => ({ const filteredCategories = categories.map((cat) => ({
...cat, ...cat,

View File

@@ -37,12 +37,13 @@ import {
ImagePlus, ImagePlus,
Key, Key,
Shield, Shield,
Building2,
MapPin, MapPin,
HelpCircle, HelpCircle,
Lock,
Unlock,
} from 'lucide-react' } from 'lucide-react'
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog' import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
import type { Project, DrawFeature } from '@/app/app/page' import type { Project, DrawFeature } from '@/types'
import { formatDateTime } from '@/lib/utils' import { formatDateTime } from '@/lib/utils'
import { Logo } from '@/components/ui/logo' import { Logo } from '@/components/ui/logo'
@@ -67,6 +68,8 @@ interface TopbarProps {
userRole?: string userRole?: string
onLogout?: () => void onLogout?: () => void
onStartTour?: () => void onStartTour?: () => void
presentationLocked?: boolean
onTogglePresentationLock?: () => void
} }
export function Topbar({ export function Topbar({
@@ -90,6 +93,8 @@ export function Topbar({
userRole, userRole,
onLogout, onLogout,
onStartTour, onStartTour,
presentationLocked,
onTogglePresentationLock,
}: TopbarProps) { }: TopbarProps) {
const [isLoadDialogOpen, setIsLoadDialogOpen] = useState(false) const [isLoadDialogOpen, setIsLoadDialogOpen] = useState(false)
const [isHoseSettingsOpen, setIsHoseSettingsOpen] = useState(false) const [isHoseSettingsOpen, setIsHoseSettingsOpen] = useState(false)
@@ -173,6 +178,16 @@ export function Topbar({
<span className="hidden lg:inline">{isSaving ? 'Speichern...' : 'Speichern'}</span> <span className="hidden lg:inline">{isSaving ? 'Speichern...' : 'Speichern'}</span>
</Button> </Button>
<Button
variant={presentationLocked ? 'default' : 'outline'}
className={`h-9 md:h-10 px-2 md:px-3 text-sm ${presentationLocked ? 'bg-amber-600 hover:bg-amber-700 text-white border-amber-600' : ''}`}
onClick={onTogglePresentationLock}
title={presentationLocked ? 'Präsentationsmodus deaktivieren' : 'Präsentationsmodus aktivieren'}
>
{presentationLocked ? <Lock className="w-5 h-5 md:mr-1" /> : <Unlock className="w-5 h-5 md:mr-1" />}
<span className="hidden lg:inline">{presentationLocked ? 'Gesperrt' : 'Frei'}</span>
</Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" className="h-9 md:h-10 px-2 md:px-3 text-sm" title="Menü"> <Button variant="outline" className="h-9 md:h-10 px-2 md:px-3 text-sm" title="Menü">
@@ -286,12 +301,6 @@ export function Topbar({
<Key className="w-4 h-4 mr-2" /> <Key className="w-4 h-4 mr-2" />
Kennwort ändern Kennwort ändern
</DropdownMenuItem> </DropdownMenuItem>
{userRole === 'TENANT_ADMIN' && (
<DropdownMenuItem onClick={() => window.location.href = '/settings'}>
<Building2 className="w-4 h-4 mr-2" />
Organisation
</DropdownMenuItem>
)}
{(userRole === 'SERVER_ADMIN' || userRole === 'TENANT_ADMIN') && ( {(userRole === 'SERVER_ADMIN' || userRole === 'TENANT_ADMIN') && (
<DropdownMenuItem onClick={() => window.location.href = '/admin'}> <DropdownMenuItem onClick={() => window.location.href = '/admin'}>
<Shield className="w-4 h-4 mr-2" /> <Shield className="w-4 h-4 mr-2" />

View File

@@ -6,7 +6,7 @@ import 'maplibre-gl/dist/maplibre-gl.css'
import { useDrop } from 'react-dnd' import { useDrop } from 'react-dnd'
import Moveable from 'react-moveable' import Moveable from 'react-moveable'
import { getSymbolById, getSymbolDataUri } from '@/lib/fw-symbols' import { getSymbolById, getSymbolDataUri } from '@/lib/fw-symbols'
import type { Project, DrawFeature, DrawMode } from '@/app/app/page' import type { Project, DrawFeature, DrawMode } from '@/types'
// Haversine distance between two [lng, lat] points in meters // Haversine distance between two [lng, lat] points in meters
function haversineDistance(a: number[], b: number[]): number { function haversineDistance(a: number[], b: number[]): number {
@@ -728,6 +728,46 @@ export function MapView({
// Expose map instance to parent for export // Expose map instance to parent for export
if (externalMapRef) externalMapRef.current = map.current if (externalMapRef) externalMapRef.current = map.current
// --- WebGL context loss recovery ---
// When the browser reclaims GPU memory (background tab, memory pressure),
// the WebGL context is lost and tiles go black. This recovers automatically.
const canvas = map.current.getCanvas()
canvas.addEventListener('webglcontextlost', (e) => {
console.warn('[Map] WebGL context lost — will restore when possible')
e.preventDefault() // allows context to be restored
})
canvas.addEventListener('webglcontextrestored', () => {
console.info('[Map] WebGL context restored — reloading map style')
const m = map.current
if (m) {
// Force full tile reload by re-setting the style
const style = m.getStyle()
if (style) {
m.setStyle(style)
}
}
})
// --- Page visibility recovery ---
// When user switches back to this tab after a while, tiles may be stale/black.
// Force a resize + tile re-request on visibility change.
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible' && map.current) {
// Small delay to let browser finish tab switch
setTimeout(() => {
if (!map.current) return
map.current.resize()
// Nudge the map to force tile re-requests
const center = map.current.getCenter()
map.current.setCenter(center)
}, 100)
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
// Store cleanup reference
const cleanupVisibility = () => document.removeEventListener('visibilitychange', handleVisibilityChange)
map.current.addControl(new maplibregl.NavigationControl(), 'bottom-right') map.current.addControl(new maplibregl.NavigationControl(), 'bottom-right')
map.current.addControl(new maplibregl.ScaleControl(), 'bottom-left') map.current.addControl(new maplibregl.ScaleControl(), 'bottom-left')
@@ -1286,6 +1326,7 @@ export function MapView({
}) })
return () => { return () => {
cleanupVisibility()
map.current?.remove() map.current?.remove()
map.current = null map.current = null
} }
@@ -1421,26 +1462,27 @@ export function MapView({
const lineCoords = f.geometry.coordinates as number[][] const lineCoords = f.geometry.coordinates as number[][]
if (lineCoords.length < 2) return if (lineCoords.length < 2) return
// Get last two points to calculate arrow direction using screen-projected coords // Geographic bearing from p1 to p2 (works for short distances)
const p1 = lineCoords[lineCoords.length - 2] const p1 = lineCoords[lineCoords.length - 2]
const p2 = lineCoords[lineCoords.length - 1] const p2 = lineCoords[lineCoords.length - 1]
const px1 = map.current.project(p1 as [number, number]) const dLng = p2[0] - p1[0]
const px2 = map.current.project(p2 as [number, number]) const dLat = p2[1] - p1[1]
const screenAngle = Math.atan2(px2.y - px1.y, px2.x - px1.x) * (180 / Math.PI) + 90 // atan2(dLng, dLat) gives angle from north (up), clockwise — matches CSS triangle ▲ default
const geoBearing = Math.atan2(dLng, dLat) * (180 / Math.PI)
const color = (f.properties.color as string) || '#000000' const color = (f.properties.color as string) || '#000000'
const arrowEl = document.createElement('div') const arrowEl = document.createElement('div')
arrowEl.style.cssText = ` arrowEl.style.cssText = `
width: 0; height: 0; width: 0; height: 0;
border-left: 10px solid transparent; border-left: 12px solid transparent;
border-right: 10px solid transparent; border-right: 12px solid transparent;
border-bottom: 20px solid ${color}; border-bottom: 24px solid ${color};
transform: rotate(${screenAngle}deg); transform: rotate(${geoBearing}deg);
transform-origin: center center; transform-origin: center center;
pointer-events: none; pointer-events: none;
` `
const marker = new maplibregl.Marker({ element: arrowEl, anchor: 'center', rotationAlignment: 'viewport' }) const marker = new maplibregl.Marker({ element: arrowEl, anchor: 'center', rotationAlignment: 'map' })
.setLngLat(p2 as [number, number]) .setLngLat(p2 as [number, number])
.addTo(map.current) .addTo(map.current)
markersRef.current.push(marker) markersRef.current.push(marker)
@@ -1472,21 +1514,27 @@ export function MapView({
midpoint = [cx / len, cy / len] midpoint = [cx / len, cy / len]
} }
// Apply stored label offset if present
const labelOffset = f.properties.labelOffset as [number, number] | undefined
if (labelOffset) {
midpoint = [midpoint[0] + labelOffset[0], midpoint[1] + labelOffset[1]]
}
const el = document.createElement('div') const el = document.createElement('div')
const isDanger = f.type === 'dangerzone' const isDanger = f.type === 'dangerzone'
el.style.cssText = ` el.style.cssText = `
background: ${isDanger ? 'rgba(220,38,38,0.85)' : 'rgba(0,0,0,0.75)'}; background: ${isDanger ? 'rgba(220,38,38,0.85)' : 'rgba(0,0,0,0.82)'};
color: #fff; color: #fff;
padding: 1px 5px; padding: 3px 8px;
border-radius: 3px; border-radius: 4px;
font-size: 11px; font-size: 13px;
font-weight: 600; font-weight: 600;
white-space: nowrap; white-space: nowrap;
pointer-events: ${canEdit ? 'auto' : 'none'}; pointer-events: ${canEdit ? 'auto' : 'none'};
letter-spacing: 0.3px; letter-spacing: 0.3px;
border: 1px solid ${isDanger ? '#dc2626' : 'rgba(255,255,255,0.4)'}; border: 1px solid ${isDanger ? '#dc2626' : 'rgba(255,255,255,0.4)'};
box-shadow: 0 1px 3px rgba(0,0,0,0.25); box-shadow: 0 1px 4px rgba(0,0,0,0.3);
cursor: ${canEdit ? 'pointer' : 'default'}; cursor: ${canEdit ? 'grab' : 'default'};
transform: translate(0,0); transform: translate(0,0);
will-change: transform; will-change: transform;
` `
@@ -1503,11 +1551,11 @@ export function MapView({
const labelLine = document.createElement('div') const labelLine = document.createElement('div')
labelLine.textContent = label labelLine.textContent = label
labelLine.style.cssText = 'font-size:11px;font-weight:600;line-height:1.2;' labelLine.style.cssText = 'font-size:13px;font-weight:700;line-height:1.3;'
const infoLine = document.createElement('div') const infoLine = document.createElement('div')
infoLine.textContent = `${lenText} / ${hoseCount} Schl.` infoLine.textContent = `${lenText} / ${hoseCount} Schl.`
infoLine.style.cssText = 'font-size:8px;opacity:0.8;line-height:1.2;font-weight:400;' infoLine.style.cssText = 'font-size:10px;opacity:0.85;line-height:1.3;font-weight:500;'
el.appendChild(labelLine) el.appendChild(labelLine)
el.appendChild(infoLine) el.appendChild(infoLine)
@@ -1519,11 +1567,11 @@ export function MapView({
const labelLine = document.createElement('div') const labelLine = document.createElement('div')
labelLine.textContent = label labelLine.textContent = label
labelLine.style.cssText = 'font-size:11px;font-weight:600;line-height:1.2;' labelLine.style.cssText = 'font-size:13px;font-weight:700;line-height:1.3;'
const infoLine = document.createElement('div') const infoLine = document.createElement('div')
infoLine.textContent = areaText infoLine.textContent = areaText
infoLine.style.cssText = 'font-size:8px;opacity:0.8;line-height:1.2;font-weight:400;' infoLine.style.cssText = 'font-size:10px;opacity:0.85;line-height:1.3;font-weight:500;'
el.appendChild(labelLine) el.appendChild(labelLine)
el.appendChild(infoLine) el.appendChild(infoLine)
@@ -1539,9 +1587,41 @@ export function MapView({
}) })
} }
const marker = new maplibregl.Marker({ element: el, anchor: 'center', rotationAlignment: 'viewport' }) const marker = new maplibregl.Marker({ element: el, anchor: 'center', draggable: canEdit, rotationAlignment: 'map' })
.setLngLat(midpoint) .setLngLat(midpoint)
.addTo(map.current) .addTo(map.current)
// Save label position offset on drag end
if (canEdit) {
marker.on('dragend', () => {
const newPos = marker.getLngLat()
// Calculate midpoint without offset to get the base midpoint
let baseMid: [number, number]
const feat = featuresRef.current.find(feat => feat.id === f.id)
if (!feat) return
if (feat.geometry.type === 'LineString') {
const coords = feat.geometry.coordinates as number[][]
const midIdx = Math.floor(coords.length / 2)
if (coords.length === 2) {
baseMid = [(coords[0][0] + coords[1][0]) / 2, (coords[0][1] + coords[1][1]) / 2]
} else {
baseMid = coords[midIdx] as [number, number]
}
} else {
const ring = (feat.geometry.coordinates as number[][][])[0]
const len = ring.length - 1
let cx = 0, cy = 0
for (let i = 0; i < len; i++) { cx += ring[i][0]; cy += ring[i][1] }
baseMid = [cx / len, cy / len]
}
const offset: [number, number] = [newPos.lng - baseMid[0], newPos.lat - baseMid[1]]
const updated = featuresRef.current.map(pf =>
pf.id === f.id ? { ...pf, properties: { ...pf.properties, labelOffset: offset } } : pf
)
onFeaturesChangeRef.current(updated)
})
}
markersRef.current.push(marker) markersRef.current.push(marker)
}) })
@@ -1590,13 +1670,56 @@ export function MapView({
inner.style.transform = `rotate(${rotation}deg)` inner.style.transform = `rotate(${rotation}deg)`
inner.style.transition = 'transform 0.1s' inner.style.transition = 'transform 0.1s'
if (imgSrc) { // Try primary image source, with fallback chain for broken/deleted icons
inner.style.backgroundImage = `url("${imgSrc}")` const applyImage = (src: string) => {
inner.style.backgroundImage = `url("${src}")`
inner.style.backgroundSize = 'contain' inner.style.backgroundSize = 'contain'
inner.style.backgroundRepeat = 'no-repeat' inner.style.backgroundRepeat = 'no-repeat'
inner.style.backgroundPosition = 'center' inner.style.backgroundPosition = 'center'
} }
if (imgSrc) {
applyImage(imgSrc)
// If image fails to load (404 from deleted icon), try fallback
const testImg = new Image()
testImg.onload = () => {} // OK
testImg.onerror = () => {
// Fallback 1: Try API endpoint with iconId
if (iconId) {
const fallbackUrl = `/api/icons/${iconId}/image`
if (fallbackUrl !== imgSrc) {
const test2 = new Image()
test2.onload = () => applyImage(fallbackUrl)
test2.onerror = () => {
// Fallback 2: Show broken symbol indicator
inner.style.backgroundImage = 'none'
inner.style.display = 'flex'
inner.style.alignItems = 'center'
inner.style.justifyContent = 'center'
inner.style.border = '2px dashed #ef4444'
inner.style.borderRadius = '4px'
inner.style.backgroundColor = 'rgba(239,68,68,0.1)'
inner.innerHTML = '<span style="font-size:10px;color:#ef4444;text-align:center">⚠️</span>'
}
test2.src = fallbackUrl
} else {
inner.style.backgroundImage = 'none'
inner.style.display = 'flex'
inner.style.alignItems = 'center'
inner.style.justifyContent = 'center'
inner.style.border = '2px dashed #ef4444'
inner.style.borderRadius = '4px'
inner.style.backgroundColor = 'rgba(239,68,68,0.1)'
inner.innerHTML = '<span style="font-size:10px;color:#ef4444;text-align:center">⚠️</span>'
}
}
}
testImg.src = imgSrc
} else if (iconId) {
// No imageUrl at all — try loading via API
applyImage(`/api/icons/${iconId}/image`)
}
wrapper.appendChild(inner) wrapper.appendChild(inner)
// Click/tap to select symbol for Moveable editing — ONLY in 'select' mode // Click/tap to select symbol for Moveable editing — ONLY in 'select' mode
@@ -1626,7 +1749,7 @@ export function MapView({
} }
try { try {
const marker = new maplibregl.Marker({ element: wrapper, draggable: canEdit, anchor: 'center', rotationAlignment: 'viewport' }) const marker = new maplibregl.Marker({ element: wrapper, draggable: canEdit, anchor: 'center', rotationAlignment: 'map' })
.setLngLat(coords) .setLngLat(coords)
.addTo(map.current) .addTo(map.current)
@@ -1692,7 +1815,7 @@ export function MapView({
el.textContent = (f.properties.text as string) || '' el.textContent = (f.properties.text as string) || ''
wrapper.appendChild(el) wrapper.appendChild(el)
const marker = new maplibregl.Marker({ element: wrapper, draggable: canEdit, anchor: 'center', rotationAlignment: 'viewport' }) const marker = new maplibregl.Marker({ element: wrapper, draggable: canEdit, anchor: 'center', rotationAlignment: 'map' })
.setLngLat(coords) .setLngLat(coords)
.addTo(map.current) .addTo(map.current)
@@ -1854,9 +1977,28 @@ export function MapView({
} }
}, [drawMode, deselectSymbol]) }, [drawMode, deselectSymbol])
// ESC to cancel drawing / finalize measurement // ESC to cancel drawing, DEL to delete selected symbol/line/polygon
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
// DEL / Backspace → delete selected symbol or line/polygon
if (e.key === 'Delete' || e.key === 'Backspace') {
const tag = (e.target as HTMLElement)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (e.target as HTMLElement)?.isContentEditable) return
// Delete selected symbol/text
if (selectedSymbolRef.current) {
e.preventDefault()
deleteSelectedSymbol()
return
}
// Delete selected line/polygon/arrow (vertex-editing selection)
if (selectedLineIdRef.current) {
e.preventDefault()
const updated = featuresRef.current.filter(f => f.id !== selectedLineIdRef.current)
onFeaturesChangeRef.current(updated)
showVertexMarkersRef.current(null)
return
}
}
if (e.key === 'Escape') { if (e.key === 'Escape') {
// In measure mode: finalize (keep line + labels), just stop adding // In measure mode: finalize (keep line + labels), just stop adding
if (drawModeRef.current === 'measure' && drawingRef.current.isDrawing) { if (drawModeRef.current === 'measure' && drawingRef.current.isDrawing) {
@@ -1883,7 +2025,7 @@ export function MapView({
} }
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown)
}, []) }, [deleteSelectedSymbol])
// Drop zone for symbols — use stable ref connection (no inline ref callback) // Drop zone for symbols — use stable ref connection (no inline ref callback)
const [, drop] = useDrop(() => ({ const [, drop] = useDrop(() => ({

View File

@@ -5,6 +5,8 @@ import { Button } from '@/components/ui/button'
import { import {
X, ChevronRight, ChevronLeft, SkipForward, X, ChevronRight, ChevronLeft, SkipForward,
MapPin, Pencil, LayoutGrid, Save, Ruler, Users, Keyboard, Rocket, MapPin, Pencil, LayoutGrid, Save, Ruler, Users, Keyboard, Rocket,
MousePointer2, CircleDot, Minus, Pentagon, Square, Circle, MoveRight, Type, Eraser,
Lock, ClipboardList, Download, AlertTriangle,
} from 'lucide-react' } from 'lucide-react'
const TOUR_STORAGE_KEY = 'lageplan-onboarding-completed' const TOUR_STORAGE_KEY = 'lageplan-onboarding-completed'
@@ -15,61 +17,96 @@ interface TourStep {
icon?: React.ReactNode icon?: React.ReactNode
targetSelector?: string targetSelector?: string
position?: 'top' | 'bottom' | 'left' | 'right' position?: 'top' | 'bottom' | 'left' | 'right'
tools?: { icon: React.ReactNode; label: string; shortcut?: string }[]
} }
const TOUR_STEPS: TourStep[] = [ const TOUR_STEPS: TourStep[] = [
{ {
title: 'Willkommen bei Lageplan!', title: 'Willkommen bei Lageplan!',
icon: <Rocket className="w-5 h-5 text-red-500" />, icon: <Rocket className="w-5 h-5 text-red-500" />,
description: 'Lageplan ist deine taktische Lageskizzen-App für den Feuerwehr-Einsatz. Diese kurze Tour zeigt dir die wichtigsten Funktionen. Du kannst sie jederzeit überspringen oder später im Benutzermenü erneut starten.', description: 'Lageplan ist deine taktische Lageskizzen-App für den Feuerwehr-Einsatz. Diese kurze Tour zeigt dir die wichtigsten Funktionen.',
}, },
{ {
title: 'Einsatz erstellen', title: 'Einsatz erstellen',
icon: <MapPin className="w-5 h-5 text-red-500" />, icon: <MapPin className="w-5 h-5 text-red-500" />,
description: 'Erstelle über «Neuer Einsatz» ein neues Projekt. Gib eine Adresse ein — die Karte fliegt automatisch dorthin. Jeder Einsatz wird separat gespeichert und kann als PDF oder PNG exportiert werden.', description: 'Erstelle über «Neuer Einsatz» ein Projekt. Gib eine Adresse ein — die Karte fliegt automatisch dorthin.',
targetSelector: '[data-tour="new-project"]', targetSelector: '[data-tour="new-project"]',
position: 'bottom', position: 'bottom',
}, },
{
title: 'Bearbeitung starten',
icon: <Lock className="w-5 h-5 text-green-500" />,
description: 'Klicke auf «Bearbeitung starten» um die Karte zu bearbeiten. Nur ein Benutzer gleichzeitig kann bearbeiten — andere sehen deine Änderungen live.',
targetSelector: '[data-tour="edit-toggle"]',
position: 'bottom',
},
{ {
title: 'Zeichenwerkzeuge', title: 'Zeichenwerkzeuge',
icon: <Pencil className="w-5 h-5 text-blue-500" />, icon: <Pencil className="w-5 h-5 text-blue-500" />,
description: 'Die Werkzeugleiste links enthält alle Zeichentools: Punkte, Linien, Polygone, Freihand, Pfeile, Text, Radiergummi und mehr. Jedes Tool hat ein Tastenkürzel — drücke «?» für eine Übersicht.', description: 'Links findest du alle Werkzeuge. Jedes hat ein Tastenkürzel:',
targetSelector: '[data-tour="toolbar"]', targetSelector: '[data-tour="toolbar"]',
position: 'right', position: 'right',
tools: [
{ icon: <MousePointer2 className="w-3.5 h-3.5" />, label: 'Auswählen', shortcut: 'V' },
{ icon: <CircleDot className="w-3.5 h-3.5" />, label: 'Punkt', shortcut: 'P' },
{ icon: <Minus className="w-3.5 h-3.5" />, label: 'Linie / Schlauch', shortcut: 'L' },
{ icon: <Pentagon className="w-3.5 h-3.5" />, label: 'Polygon', shortcut: 'G' },
{ icon: <Square className="w-3.5 h-3.5" />, label: 'Rechteck', shortcut: 'R' },
{ icon: <Circle className="w-3.5 h-3.5" />, label: 'Kreis', shortcut: 'C' },
{ icon: <Pencil className="w-3.5 h-3.5" />, label: 'Freihand', shortcut: 'F' },
{ icon: <MoveRight className="w-3.5 h-3.5" />, label: 'Pfeil / Route', shortcut: 'A' },
{ icon: <Type className="w-3.5 h-3.5" />, label: 'Text', shortcut: 'T' },
{ icon: <Eraser className="w-3.5 h-3.5" />, label: 'Radiergummi', shortcut: 'E' },
{ icon: <Ruler className="w-3.5 h-3.5" />, label: 'Messen', shortcut: 'M' },
{ icon: <AlertTriangle className="w-3.5 h-3.5" />, label: 'Gefahrenzone', shortcut: 'D' },
],
}, },
{ {
title: 'Symbole & Sidebar', title: 'Symbole (Drag & Drop)',
icon: <LayoutGrid className="w-5 h-5 text-orange-500" />, icon: <LayoutGrid className="w-5 h-5 text-orange-500" />,
description: 'Rechts findest du über 100 taktische Feuerwehr-Symbole, sortiert nach Kategorien (Wasser, Feuer, Fahrzeuge usw.). Ziehe sie per Drag & Drop auf die Karte. Wechsle zwischen Symbolen und dem Einsatz-Journal.', description: 'Rechts findest du taktische Feuerwehr-Symbole. Deine eigenen Symbole stehen zuoberst. Ziehe Symbole per Drag & Drop auf die Karte. Klicke auf ein platziertes Symbol um es zu drehen, skalieren oder löschen.',
targetSelector: '[data-tour="sidebar"]',
position: 'left',
},
{
title: 'Journal & Rapport',
icon: <ClipboardList className="w-5 h-5 text-indigo-500" />,
description: 'Wechsle rechts zum Journal-Tab für das Einsatz-Journal. Erfasse Einträge, SOMA-Checkliste und Pendenzen. Erstelle einen druckfertigen Einsatzrapport als PDF.',
targetSelector: '[data-tour="sidebar"]', targetSelector: '[data-tour="sidebar"]',
position: 'left', position: 'left',
}, },
{ {
title: 'Speichern & Export', title: 'Speichern & Export',
icon: <Save className="w-5 h-5 text-green-500" />, icon: <Save className="w-5 h-5 text-green-500" />,
description: 'Speichere deinen Einsatz mit Ctrl+S oder dem Speichern-Button. Exportiere als PNG (Bild) oder als druckfertiges PDF. Die letzte Kartenansicht wird automatisch gespeichert.', description: 'Speichere mit dem Speichern-Button oder Ctrl+S. Exportiere die Karte als PNG oder PDF über das Menü.',
targetSelector: '[data-tour="save"]', targetSelector: '[data-tour="save"]',
position: 'bottom', position: 'bottom',
}, tools: [
{ { icon: <Save className="w-3.5 h-3.5" />, label: 'Speichern', shortcut: 'Ctrl+S' },
title: 'Messen & Schlauch-Rechner', { icon: <Download className="w-3.5 h-3.5" />, label: 'Export als PNG/PDF' },
icon: <Ruler className="w-5 h-5 text-purple-500" />, ],
description: 'Mit dem Messwerkzeug (Taste «M») misst du Distanzen direkt auf der Karte. Der Schlauch-Rechner im Admin-Bereich berechnet die benötigten Schlauchlängen und -typen für deinen Einsatz.',
}, },
{ {
title: 'Live-Zusammenarbeit', title: 'Live-Zusammenarbeit',
icon: <Users className="w-5 h-5 text-cyan-500" />, icon: <Users className="w-5 h-5 text-cyan-500" />,
description: 'Mehrere Benutzer können gleichzeitig am selben Einsatz arbeiten. Änderungen werden in Echtzeit synchronisiert — ideal für die Einsatzleitung mit mehreren Operateuren.', description: 'Mehrere Benutzer arbeiten gleichzeitig am selben Einsatz. Änderungen werden in Echtzeit synchronisiert.',
}, },
{ {
title: 'Tastenkürzel (CH)', title: 'Tastenkürzel',
icon: <Keyboard className="w-5 h-5 text-slate-500" />, icon: <Keyboard className="w-5 h-5 text-slate-500" />,
description: 'Optimiert für Schweizer Tastaturen: Ctrl+Y = Rückgängig, Ctrl+Z = Wiederholen, Del = Löschen, Ctrl+S = Speichern. Drücke «?» oder F1 für die komplette Übersicht aller Kürzel.', description: 'Wichtige Kürzel:',
tools: [
{ icon: <span className="text-[10px] font-mono font-bold">Ctrl+Z</span>, label: 'Rückgängig' },
{ icon: <span className="text-[10px] font-mono font-bold">Ctrl+Y</span>, label: 'Wiederholen' },
{ icon: <span className="text-[10px] font-mono font-bold">Del</span>, label: 'Symbol löschen' },
{ icon: <span className="text-[10px] font-mono font-bold">Ctrl+S</span>, label: 'Speichern' },
{ icon: <span className="text-[10px] font-mono font-bold">?</span>, label: 'Alle Kürzel anzeigen' },
],
}, },
{ {
title: 'Bereit für den Einsatz!', title: 'Bereit für den Einsatz!',
icon: <Rocket className="w-5 h-5 text-red-500" />, icon: <Rocket className="w-5 h-5 text-red-500" />,
description: 'Du bist startklar! Diese Tour kannst du jederzeit über dein Benutzermenü (oben rechts → «Tour starten») erneut aufrufen. Viel Erfolg im Einsatz — Feuer frei!', description: 'Du bist startklar! Diese Tour kannst du jederzeit über dein Benutzermenü (oben rechts) erneut starten. Viel Erfolg!',
}, },
] ]
@@ -224,9 +261,20 @@ export function OnboardingTour({ forceShow = false, onComplete }: OnboardingTour
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
</div> </div>
<p className="text-sm text-muted-foreground leading-relaxed mb-4"> <p className="text-sm text-muted-foreground leading-relaxed mb-2">
{step.description} {step.description}
</p> </p>
{step.tools && (
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 mb-3 text-xs">
{step.tools.map((t, i) => (
<div key={i} className="flex items-center gap-1.5 py-0.5">
<span className="text-muted-foreground shrink-0">{t.icon}</span>
<span className="truncate">{t.label}</span>
{t.shortcut && <kbd className="ml-auto shrink-0 px-1 py-0.5 bg-muted rounded border border-border font-mono text-[9px]">{t.shortcut}</kbd>}
</div>
))}
</div>
)}
{/* Progress dots */} {/* Progress dots */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

101
src/hooks/use-auto-save.ts Normal file
View File

@@ -0,0 +1,101 @@
import { useCallback, useEffect, useRef } from 'react'
import type { DrawFeature, Project } from '@/types'
import { addToSyncQueue, getSyncQueue } from '@/lib/offline-sync'
interface UseAutoSaveOptions {
currentProject: Project | null
features: DrawFeature[]
featuresRef: React.MutableRefObject<DrawFeature[]>
mapRef: React.MutableRefObject<any>
socketRef: React.MutableRefObject<any>
isEditingByMe: boolean
setSyncQueueCount: (count: number) => void
}
export function useAutoSave({
currentProject,
features,
featuresRef,
mapRef,
socketRef,
isEditingByMe,
setSyncQueueCount,
}: UseAutoSaveOptions) {
// Persist features to localStorage on change (including empty array to reflect deletions)
useEffect(() => {
localStorage.setItem('lageplan-features', JSON.stringify(features))
}, [features])
// Auto-save to API — debounced 2s after every feature change + fallback interval
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const saveFeaturesToApi = useCallback(async () => {
if (!currentProject?.id) return
const url = `/api/projects/${currentProject.id}/features`
const mapInstance = mapRef.current
const body: any = { features: featuresRef.current }
if (mapInstance) {
const c = mapInstance.getCenter()
body.mapCenter = { lng: c.lng, lat: c.lat }
body.mapZoom = mapInstance.getZoom()
}
// If offline, queue the save for later sync
if (!navigator.onLine) {
addToSyncQueue(url, 'PUT', body)
setSyncQueueCount(getSyncQueue().length)
console.log('[Auto-Save] Offline — in Sync-Queue gespeichert')
return
}
try {
const res = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (res.ok) {
console.log('[Auto-Save] Features gespeichert')
socketRef.current?.emit('features-updated', {
projectId: currentProject.id,
features: featuresRef.current,
})
} else if (res.status === 404) {
console.warn('[Auto-Save] Projekt nicht in DB')
}
} catch (e) {
// Network error — queue for later
addToSyncQueue(url, 'PUT', body)
setSyncQueueCount(getSyncQueue().length)
console.warn('[Auto-Save] Netzwerkfehler — in Sync-Queue:', e)
}
}, [currentProject, mapRef, featuresRef, socketRef, setSyncQueueCount])
// Debounced save on every feature change (2s delay)
useEffect(() => {
if (!currentProject || !isEditingByMe) return
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(() => saveFeaturesToApi(), 2000)
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }
}, [features, currentProject, isEditingByMe, saveFeaturesToApi])
// Also save on page unload / tab switch
useEffect(() => {
const handleBeforeUnload = () => {
if (currentProject?.id && featuresRef.current.length > 0) {
const payload = JSON.stringify({ features: featuresRef.current })
navigator.sendBeacon(`/api/projects/${currentProject.id}/features`, new Blob([payload], { type: 'application/json' }))
}
}
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden' && currentProject?.id && isEditingByMe) {
saveFeaturesToApi()
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload)
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [currentProject, isEditingByMe, saveFeaturesToApi, featuresRef])
}

View File

@@ -0,0 +1,74 @@
import { useEffect, useCallback } from 'react'
import type { DrawMode, DrawFeature } from '@/types'
interface UseKeyboardShortcutsOptions {
featuresRef: React.MutableRefObject<DrawFeature[]>
onUndo: () => void
onRedo: () => void
onSave: () => void
onDelete: (newFeatures: DrawFeature[]) => void
onToolChange: (mode: DrawMode) => void
onHelpOpen: () => void
}
const TOOL_SHORTCUTS: Record<string, DrawMode> = {
'v': 'select', 's': 'select',
'p': 'point',
'l': 'linestring',
'g': 'polygon',
'r': 'rectangle',
'c': 'circle',
'f': 'freehand',
'a': 'arrow',
't': 'text',
'e': 'eraser',
'm': 'measure',
'd': 'dangerzone',
}
export function useKeyboardShortcuts({
featuresRef,
onUndo,
onRedo,
onSave,
onDelete,
onToolChange,
onHelpOpen,
}: UseKeyboardShortcutsOptions) {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore when typing in inputs/textareas
const tag = (e.target as HTMLElement)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (e.target as HTMLElement)?.isContentEditable) return
// ? or F1 → help
if (e.key === '?' || e.key === 'F1') { e.preventDefault(); onHelpOpen(); return }
// DEL / Backspace → delete selected feature(s)
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault()
const current = featuresRef.current
const selected = current.filter(f => f.properties?._selected)
if (selected.length > 0) {
onDelete(current.filter(f => !f.properties?._selected))
}
return
}
// Ctrl/Cmd shortcuts
if (e.ctrlKey || e.metaKey) {
if (e.key === 'z' && e.shiftKey) { e.preventDefault(); onRedo(); return }
if (e.key === 'z') { e.preventDefault(); onUndo(); return }
if (e.key === 'y') { e.preventDefault(); onRedo(); return }
if (e.key === 's') { e.preventDefault(); onSave(); return }
return
}
// Tool shortcuts (single key, no modifier)
const mode = TOOL_SHORTCUTS[e.key.toLowerCase()]
if (mode) { e.preventDefault(); onToolChange(mode); return }
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [featuresRef, onUndo, onRedo, onSave, onDelete, onToolChange, onHelpOpen])
}

329
src/hooks/use-map-export.ts Normal file
View File

@@ -0,0 +1,329 @@
import { useCallback } from 'react'
import { jsPDF } from 'jspdf'
import type { DrawFeature, Project } from '@/types'
interface UseMapExportOptions {
mapRef: React.MutableRefObject<any>
featuresRef: React.MutableRefObject<DrawFeature[]>
currentProject: Project | null
tenant: { id: string; name: string } | null
addAudit: (action: string) => void
toast: (opts: { title: string; description?: string; variant?: string }) => void
}
export function useMapExport({
mapRef,
featuresRef,
currentProject,
tenant,
addAudit,
toast,
}: UseMapExportOptions) {
const handleExport = useCallback(async (format: 'png' | 'pdf') => {
const mapInstance = mapRef.current
if (!mapInstance) {
toast({ title: 'Fehler', description: 'Karte nicht bereit.', variant: 'destructive' })
return
}
try {
// 1. Get the MapLibre canvas (tiles + vector drawings)
const mapCanvas = mapInstance.getCanvas() as HTMLCanvasElement
const w = mapCanvas.width
const h = mapCanvas.height
// 2. Create composite canvas
const exportCanvas = document.createElement('canvas')
exportCanvas.width = w
exportCanvas.height = h
const ctx = exportCanvas.getContext('2d')!
ctx.drawImage(mapCanvas, 0, 0)
// 3. Draw symbols manually at correct size/rotation
const currentFeatures = featuresRef.current
// Derive actual pixel ratio from canvas vs container (more reliable than window.devicePixelRatio)
const container = mapInstance.getContainer()
const dpr = mapCanvas.width / container.offsetWidth
const zoom = mapInstance.getZoom()
// Symbol sizing: match the map rendering logic exactly
// In map-view.tsx: size = baseSize * scale * Math.pow(2, currentZoom - placementZoom)
const currentZoom = zoom
// Helper: load image as promise
const loadImage = (src: string): Promise<HTMLImageElement> => new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => resolve(img)
img.onerror = reject
img.src = src
})
// Draw symbol features
for (const f of currentFeatures.filter(f => f.type === 'symbol')) {
if (f.geometry.type !== 'Point') continue
const coords = f.geometry.coordinates as [number, number]
const pixel = mapInstance.project(coords)
const px = pixel.x * dpr
const py = pixel.y * dpr
const scale = (f.properties.scale as number) || 1
const rotation = (f.properties.rotation as number) || 0
const baseSize = 32
const placementZoom = (f.properties.placementZoom as number) || 17
const zoomFactor = Math.pow(2, currentZoom - placementZoom)
const size = Math.max(8, Math.min(400, baseSize * scale * zoomFactor)) * dpr
// Determine image source
const iconId = f.properties.iconId as string
const imageUrl = f.properties.imageUrl as string
let imgSrc = imageUrl || ''
if (!imgSrc && iconId) {
const { getSymbolById, getSymbolDataUri } = await import('@/lib/fw-symbols')
const sym = getSymbolById(iconId)
if (sym) imgSrc = getSymbolDataUri(sym)
}
if (imgSrc) {
try {
const img = await loadImage(imgSrc)
// Replicate CSS background-size: contain (preserve aspect ratio)
const imgAspect = img.naturalWidth / img.naturalHeight
let drawW = size
let drawH = size
if (imgAspect > 1) {
drawH = size / imgAspect
} else {
drawW = size * imgAspect
}
ctx.save()
ctx.translate(px, py)
ctx.rotate((rotation * Math.PI) / 180)
ctx.drawImage(img, -drawW / 2, -drawH / 2, drawW, drawH)
ctx.restore()
} catch (e) {
console.warn('[Export] Failed to load symbol image:', iconId, e)
}
}
}
// Draw arrowheads for arrow features
for (const f of currentFeatures.filter(f => f.type === 'arrow')) {
if (f.geometry.type !== 'LineString') continue
const lineCoords = f.geometry.coordinates as number[][]
if (lineCoords.length < 2) continue
const p1 = lineCoords[lineCoords.length - 2]
const p2 = lineCoords[lineCoords.length - 1]
const px1 = mapInstance.project(p1 as [number, number])
const px2 = mapInstance.project(p2 as [number, number])
const angle = Math.atan2(px2.y - px1.y, px2.x - px1.x)
const color = (f.properties.color as string) || '#000000'
const arrowSize = 14 * dpr
ctx.save()
ctx.translate(px2.x * dpr, px2.y * dpr)
ctx.rotate(angle + Math.PI / 2)
ctx.beginPath()
ctx.moveTo(0, -arrowSize)
ctx.lineTo(-arrowSize * 0.7, arrowSize * 0.3)
ctx.lineTo(arrowSize * 0.7, arrowSize * 0.3)
ctx.closePath()
ctx.fillStyle = color
ctx.fill()
ctx.restore()
}
// Draw line/polygon label markers at midpoints
for (const f of currentFeatures.filter(f => f.properties.label && (f.geometry.type === 'LineString' || f.geometry.type === 'Polygon'))) {
const label = f.properties.label as string
let midpoint: [number, number]
if (f.geometry.type === 'LineString') {
const coords = f.geometry.coordinates as number[][]
const midIdx = Math.floor(coords.length / 2)
if (coords.length === 2) {
midpoint = [(coords[0][0] + coords[1][0]) / 2, (coords[0][1] + coords[1][1]) / 2]
} else {
midpoint = coords[midIdx] as [number, number]
}
} else {
// Polygon: centroid of first ring
const ring = (f.geometry.coordinates as number[][][])[0]
const len = ring.length - 1
let cx = 0, cy = 0
for (let i = 0; i < len; i++) { cx += ring[i][0]; cy += ring[i][1] }
midpoint = [cx / len, cy / len]
}
const pixel = mapInstance.project(midpoint)
const px = pixel.x * dpr
const py = pixel.y * dpr
const fontSize = 13 * dpr
const isDanger = f.type === 'dangerzone'
const bgColor = isDanger ? 'rgba(220,38,38,0.85)' : 'rgba(0,0,0,0.75)'
const borderColor = isDanger ? '#dc2626' : 'rgba(255,255,255,0.5)'
ctx.save()
ctx.font = `bold ${fontSize}px system-ui, sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const metrics = ctx.measureText(label)
const padX = 7 * dpr
const padY = 3 * dpr
const boxW = metrics.width + padX * 2
const boxH = fontSize + padY * 2
const radius = 4 * dpr
// Background pill
ctx.fillStyle = bgColor
ctx.beginPath()
ctx.roundRect(px - boxW / 2, py - boxH / 2, boxW, boxH, radius)
ctx.fill()
// Border
ctx.strokeStyle = borderColor
ctx.lineWidth = 1.5 * dpr
ctx.beginPath()
ctx.roundRect(px - boxW / 2, py - boxH / 2, boxW, boxH, radius)
ctx.stroke()
// Text
ctx.fillStyle = '#ffffff'
ctx.fillText(label, px, py)
ctx.restore()
}
// Draw text features
for (const f of currentFeatures.filter(f => f.type === 'text')) {
if (f.geometry.type !== 'Point') continue
const coords = f.geometry.coordinates as [number, number]
const pixel = mapInstance.project(coords)
const px = pixel.x * dpr
const py = pixel.y * dpr
const text = (f.properties.text as string) || ''
const fontSize = ((f.properties.fontSize as number) || 14) * dpr
const color = (f.properties.color as string) || '#000000'
ctx.save()
ctx.font = `bold ${fontSize}px system-ui, sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
// White outline
ctx.strokeStyle = '#ffffff'
ctx.lineWidth = 3 * dpr
ctx.lineJoin = 'round'
ctx.strokeText(text, px, py)
// Fill
ctx.fillStyle = color
ctx.fillText(text, px, py)
ctx.restore()
}
const title = currentProject?.title || 'Lageplan'
const safeName = title.replace(/[^a-z0-9äöüÄÖÜß]/gi, '_')
if (format === 'png') {
const link = document.createElement('a')
link.download = `${safeName}.png`
link.href = exportCanvas.toDataURL('image/png')
link.click()
addAudit(`Export: ${safeName}.png`)
toast({ title: 'Exportiert', description: `${safeName}.png wurde heruntergeladen.` })
} else {
// PDF Export — rapport-style clean layout
const imgData = exportCanvas.toDataURL('image/png')
const now = new Date()
const dateStr = now.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
const timeStr = now.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
const locationStr = currentProject?.location || ''
const einsatzNr = (currentProject as any)?.einsatzNr || ''
const tenantLabel = tenant?.name || ''
// A4 landscape (mm)
const pdf = new jsPDF('l', 'mm', 'a4')
const pageW = pdf.internal.pageSize.getWidth() // 297
const pageH = pdf.internal.pageSize.getHeight() // 210
const m = 10 // margin
// ── Header section ──
const headerY = m
pdf.setFontSize(18)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(26, 26, 26)
pdf.text('Einsatz-Lageplan', m, headerY + 6)
pdf.setFontSize(9)
pdf.setFont('helvetica', 'normal')
pdf.setTextColor(107, 114, 128) // gray-500
pdf.text(`${tenantLabel}${tenantLabel ? ' · ' : ''}${title}`, m, headerY + 12)
// Right side: Einsatz-Nr + date
pdf.setFontSize(14)
pdf.setFont('helvetica', 'bold')
pdf.setTextColor(185, 28, 28) // red-700
if (einsatzNr) {
const nrW = pdf.getTextWidth(einsatzNr)
pdf.text(einsatzNr, pageW - m - nrW, headerY + 6)
}
pdf.setFontSize(9)
pdf.setFont('helvetica', 'normal')
pdf.setTextColor(107, 114, 128)
const dateLabel = `${dateStr} · ${timeStr}`
const dlW = pdf.getTextWidth(dateLabel)
pdf.text(dateLabel, pageW - m - dlW, headerY + 12)
// Divider line + red accent
const divY = headerY + 15
pdf.setDrawColor(26, 26, 26)
pdf.setLineWidth(0.8)
pdf.line(m, divY, pageW - m, divY)
pdf.setFillColor(185, 28, 28)
pdf.rect(m, divY, (pageW - 2 * m) * 0.3, 1, 'F')
// ── Map image ──
const mapTop = divY + 3
const mapBottom = pageH - m - 12 // leave space for footer
const mapAreaW = pageW - 2 * m
const mapAreaH = mapBottom - mapTop
// Fit map image into area while preserving aspect ratio
const imgAspect = w / h
const areaAspect = mapAreaW / mapAreaH
let drawW = mapAreaW
let drawH = mapAreaH
if (imgAspect > areaAspect) {
drawH = mapAreaW / imgAspect
} else {
drawW = mapAreaH * imgAspect
}
const mapX = m + (mapAreaW - drawW) / 2
const mapY = mapTop + (mapAreaH - drawH) / 2
// Light border around map
pdf.setDrawColor(229, 231, 235)
pdf.setLineWidth(0.3)
pdf.rect(mapX, mapY, drawW, drawH)
pdf.addImage(imgData, 'PNG', mapX, mapY, drawW, drawH)
// ── Footer ──
const footerY = pageH - m - 4
pdf.setFontSize(7)
pdf.setFont('helvetica', 'normal')
pdf.setTextColor(156, 163, 175) // gray-400
pdf.text(`Erstellt: ${dateStr} ${timeStr}${locationStr ? ' · Standort: ' + locationStr : ''} · Projekt: ${title}`, m, footerY)
const footerR = 'app.lageplan.ch'
const frW = pdf.getTextWidth(footerR)
pdf.text(footerR, pageW - m - frW, footerY)
pdf.save(`${safeName}.pdf`)
addAudit(`Export: ${safeName}.pdf`)
toast({ title: 'Exportiert', description: `${safeName}.pdf wurde heruntergeladen.` })
}
} catch (error) {
console.error('Export error:', error)
toast({ title: 'Fehler', description: 'Export fehlgeschlagen.', variant: 'destructive' })
}
}, [currentProject, tenant, toast, addAudit, mapRef, featuresRef])
return { handleExport }
}

View File

@@ -0,0 +1,57 @@
import { useState, useEffect } from 'react'
import { flushSyncQueue, getSyncQueue, isOnline as checkOnline } from '@/lib/offline-sync'
interface UseOfflineSyncOptions {
toast: (opts: { title: string; description?: string; variant?: string }) => void
}
export function useOfflineSync({ toast }: UseOfflineSyncOptions) {
const [isOffline, setIsOffline] = useState(false)
const [syncQueueCount, setSyncQueueCount] = useState(0)
useEffect(() => {
setIsOffline(!checkOnline())
setSyncQueueCount(getSyncQueue().length)
const goOffline = () => {
setIsOffline(true)
toast({ title: 'Offline-Modus', description: 'Änderungen werden lokal gespeichert und beim Reconnect synchronisiert.' })
}
const goOnline = async () => {
setIsOffline(false)
const queue = getSyncQueue()
if (queue.length > 0) {
toast({ title: 'Verbindung wiederhergestellt', description: `${queue.length} Änderung(en) werden synchronisiert...` })
const result = await flushSyncQueue()
setSyncQueueCount(getSyncQueue().length)
if (result.success > 0) {
toast({ title: 'Synchronisiert', description: `${result.success} Änderung(en) erfolgreich gespeichert.` })
}
if (result.failed > 0) {
toast({ title: 'Sync-Fehler', description: `${result.failed} Änderung(en) konnten nicht gespeichert werden.`, variant: 'destructive' })
}
} else {
toast({ title: 'Wieder online' })
}
}
window.addEventListener('offline', goOffline)
window.addEventListener('online', goOnline)
// Listen for SW sync messages
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data?.type === 'FLUSH_SYNC_QUEUE') {
flushSyncQueue().then(() => setSyncQueueCount(getSyncQueue().length))
}
})
}
return () => {
window.removeEventListener('offline', goOffline)
window.removeEventListener('online', goOnline)
}
}, [toast])
return { isOffline, syncQueueCount, setSyncQueueCount }
}

View File

@@ -0,0 +1,268 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { getSocket, setSocketRoom } from '@/lib/socket'
import type { DrawFeature, Project } from '@/types'
interface UseRealtimeSyncOptions {
currentProject: Project | null
user: { id: string; name: string; role: string } | null
featuresRef: React.MutableRefObject<DrawFeature[]>
setFeatures: (features: DrawFeature[] | ((prev: DrawFeature[]) => DrawFeature[])) => void
toast: (opts: { title: string; description?: string; variant?: string }) => void
}
export function useRealtimeSync({
currentProject,
user,
featuresRef,
setFeatures,
toast,
}: UseRealtimeSyncOptions) {
// Live editing lock state
const [editingBy, setEditingBy] = useState<{ id: string; name: string; since: string } | null>(null)
const [isEditingByMe, setIsEditingByMe] = useState(false)
const [editingLoading, setEditingLoading] = useState(false)
// Unique session ID per browser tab (survives re-renders, not page reload)
const sessionIdRef = useRef<string>('')
if (!sessionIdRef.current) {
sessionIdRef.current = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
// ─── Editing Lock: Check status + Heartbeat + Polling ─────────
const checkEditingStatus = useCallback(async (projectId: string) => {
try {
const res = await fetch(`/api/projects/${projectId}/editing?sessionId=${sessionIdRef.current}`)
if (!res.ok) return
const data = await res.json()
if (data.editing) {
setEditingBy(data.editingBy)
setIsEditingByMe(data.isMe)
} else {
setEditingBy(null)
setIsEditingByMe(false)
}
} catch (e) {
console.warn('[Editing] Status check failed:', e)
}
}, [])
// Check editing status when project changes
useEffect(() => {
if (!currentProject?.id) {
setEditingBy(null)
setIsEditingByMe(false)
return
}
checkEditingStatus(currentProject.id)
}, [currentProject?.id, checkEditingStatus])
// Heartbeat: keep lock alive every 30s while I'm editing
useEffect(() => {
if (!currentProject?.id || !isEditingByMe) return
const interval = setInterval(async () => {
try {
await fetch(`/api/projects/${currentProject.id}/editing`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'heartbeat', sessionId: sessionIdRef.current }),
})
} catch (e) {
console.warn('[Heartbeat] Failed:', e)
}
}, 30000)
return () => clearInterval(interval)
}, [currentProject?.id, isEditingByMe])
// Socket.io: real-time sync for features, editing status, journal
const socketRef = useRef<any>(null)
const prevProjectIdRef = useRef<string | null>(null)
// Throttled socket broadcast for near-real-time sync
const lastEmitRef = useRef(0)
const emitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const currentProjectRef = useRef(currentProject)
useEffect(() => { currentProjectRef.current = currentProject }, [currentProject])
const isEditingByMeRef = useRef(false)
useEffect(() => { isEditingByMeRef.current = isEditingByMe }, [isEditingByMe])
const broadcastFeatures = useCallback((feats: DrawFeature[]) => {
const proj = currentProjectRef.current
if (!socketRef.current || !proj?.id || !isEditingByMeRef.current) return
const now = Date.now()
const emit = () => {
socketRef.current?.emit('features-updated', {
projectId: proj!.id,
features: feats,
})
lastEmitRef.current = Date.now()
}
// Throttle: emit at most every 800ms for snappier sync
if (now - lastEmitRef.current > 800) {
emit()
} else {
if (emitTimerRef.current) clearTimeout(emitTimerRef.current)
emitTimerRef.current = setTimeout(emit, 800 - (now - lastEmitRef.current))
}
}, [])
useEffect(() => {
if (!currentProject?.id) return
const socket = getSocket()
socketRef.current = socket
// Leave old room, join new room
if (prevProjectIdRef.current && prevProjectIdRef.current !== currentProject.id) {
socket.emit('leave-project', prevProjectIdRef.current)
}
socket.emit('join-project', currentProject.id)
setSocketRoom(currentProject.id)
prevProjectIdRef.current = currentProject.id
// Listen for features changes from other clients (only apply if NOT the editor)
const onFeaturesChanged = (data: { features: any[] }) => {
// Skip if I'm the one editing — my local state is the source of truth
if (isEditingByMeRef.current) {
console.log('[Socket.io] Ignoring features-changed (I am the editor)')
return
}
if (data.features && Array.isArray(data.features)) {
console.log('[Socket.io] Features updated from another client')
setFeatures(data.features)
}
}
// Listen for editing status changes from other clients
const onEditingStatus = (data: { editing: boolean; editingBy: any; sessionId: string }) => {
if (data.sessionId === sessionIdRef.current) return // ignore own events
if (data.editing && data.editingBy) {
setEditingBy(data.editingBy)
setIsEditingByMe(false)
} else {
setEditingBy(null)
setIsEditingByMe(false)
}
}
// Listen for journal changes — trigger a re-fetch in JournalView
const onJournalChanged = () => {
console.log('[Socket.io] Journal updated from another client')
window.dispatchEvent(new CustomEvent('journal-refresh'))
}
socket.on('features-changed', onFeaturesChanged)
socket.on('editing-status', onEditingStatus)
socket.on('journal-changed', onJournalChanged)
return () => {
socket.off('features-changed', onFeaturesChanged)
socket.off('editing-status', onEditingStatus)
socket.off('journal-changed', onJournalChanged)
}
}, [currentProject?.id, setFeatures])
// Fallback: check editing status on initial load and every 30s
useEffect(() => {
if (!currentProject?.id) return
checkEditingStatus(currentProject.id)
const interval = setInterval(() => checkEditingStatus(currentProject.id), 30000)
return () => clearInterval(interval)
}, [currentProject?.id, checkEditingStatus])
// Release lock on unmount / page close
useEffect(() => {
const release = () => {
if (currentProject?.id && isEditingByMe) {
const blob = new Blob([JSON.stringify({ action: 'stop', sessionId: sessionIdRef.current })], { type: 'application/json' })
navigator.sendBeacon(`/api/projects/${currentProject.id}/editing`, blob)
}
}
window.addEventListener('beforeunload', release)
return () => {
window.removeEventListener('beforeunload', release)
release()
}
}, [currentProject?.id, isEditingByMe])
const handleStartEditing = useCallback(async () => {
if (!currentProject?.id) return
setEditingLoading(true)
try {
const res = await fetch(`/api/projects/${currentProject.id}/editing`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'start', sessionId: sessionIdRef.current }),
})
if (!res.ok) {
const data = await res.json()
toast({ title: 'Gesperrt', description: data.error || 'Bearbeitung nicht möglich', variant: 'destructive' })
return
}
setIsEditingByMe(true)
const editingInfo = { id: user!.id, name: user!.name, since: new Date().toISOString() }
setEditingBy(editingInfo)
// Notify other clients
socketRef.current?.emit('editing-changed', {
projectId: currentProject.id,
editing: true,
editingBy: editingInfo,
sessionId: sessionIdRef.current,
})
toast({ title: 'Bearbeitung gestartet', description: 'Sie können jetzt zeichnen und Einträge erstellen.' })
} catch (e) {
toast({ title: 'Fehler', description: 'Konnte Bearbeitung nicht starten.', variant: 'destructive' })
} finally {
setEditingLoading(false)
}
}, [currentProject?.id, user, toast])
const handleStopEditing = useCallback(async () => {
if (!currentProject?.id) return
setEditingLoading(true)
try {
// Save features before releasing lock
const currentFeatures = featuresRef.current
await fetch(`/api/projects/${currentProject.id}/features`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ features: currentFeatures }),
})
// Release lock
await fetch(`/api/projects/${currentProject.id}/editing`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'stop', sessionId: sessionIdRef.current }),
})
setIsEditingByMe(false)
setEditingBy(null)
// Notify other clients: editing stopped + send final features
socketRef.current?.emit('editing-changed', {
projectId: currentProject.id,
editing: false,
editingBy: null,
sessionId: sessionIdRef.current,
})
socketRef.current?.emit('features-updated', {
projectId: currentProject.id,
features: currentFeatures,
})
toast({ title: 'Bearbeitung beendet', description: 'Änderungen gespeichert. Andere können jetzt bearbeiten.' })
} catch (e) {
toast({ title: 'Fehler', description: 'Konnte Bearbeitung nicht beenden.', variant: 'destructive' })
} finally {
setEditingLoading(false)
}
}, [currentProject?.id, toast, featuresRef])
return {
editingBy,
isEditingByMe,
editingLoading,
socketRef,
broadcastFeatures,
handleStartEditing,
handleStopEditing,
}
}

83
src/lib/api.ts Normal file
View File

@@ -0,0 +1,83 @@
/**
* Standardized API fetch wrapper with consistent error handling.
*
* Usage:
* const data = await apiFetch<{ projects: Project[] }>('/api/projects')
* const result = await apiFetch('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ ... }) })
*/
export class ApiError extends Error {
status: number
data: any
constructor(message: string, status: number, data?: any) {
super(message)
this.name = 'ApiError'
this.status = status
this.data = data
}
}
interface ApiFetchOptions extends RequestInit {
/** If true, don't throw on non-2xx responses — return null instead */
silent?: boolean
}
/**
* Typed fetch wrapper that:
* - Automatically sets Content-Type for JSON bodies
* - Parses JSON responses
* - Throws ApiError with status code and server error message on failure
* - Supports silent mode for optional/non-critical requests
*/
export async function apiFetch<T = any>(
url: string,
options: ApiFetchOptions = {}
): Promise<T> {
const { silent, ...fetchOptions } = options
// Auto-set Content-Type for JSON string bodies
if (
fetchOptions.body &&
typeof fetchOptions.body === 'string' &&
!fetchOptions.headers
) {
fetchOptions.headers = { 'Content-Type': 'application/json' }
} else if (
fetchOptions.body &&
typeof fetchOptions.body === 'string' &&
fetchOptions.headers &&
!(fetchOptions.headers as Record<string, string>)['Content-Type']
) {
fetchOptions.headers = {
...fetchOptions.headers,
'Content-Type': 'application/json',
}
}
const res = await fetch(url, fetchOptions)
if (!res.ok) {
if (silent) return null as T
let errorData: any = null
let errorMessage = `HTTP ${res.status}`
try {
errorData = await res.json()
errorMessage = errorData?.error || errorData?.message || errorMessage
} catch {
// Response not JSON, use status text
errorMessage = res.statusText || errorMessage
}
throw new ApiError(errorMessage, res.status, errorData)
}
// Handle 204 No Content
if (res.status === 204) return null as T
try {
return await res.json()
} catch {
return null as T
}
}

View File

@@ -1,6 +1,6 @@
import html2canvas from 'html2canvas' import html2canvas from 'html2canvas'
import jsPDF from 'jspdf' import jsPDF from 'jspdf'
import type { Project, DrawFeature } from '@/app/app/page' import type { Project, DrawFeature } from '@/types'
import { formatDateTime } from './utils' import { formatDateTime } from './utils'
export interface ExportOptions { export interface ExportOptions {

View File

@@ -5,6 +5,31 @@ import { io, Socket } from 'socket.io-client'
let socket: Socket | null = null let socket: Socket | null = null
let currentRoom: string | null = null let currentRoom: string | null = null
export type SocketStatus = 'connected' | 'disconnected' | 'reconnecting'
type StatusListener = (status: SocketStatus) => void
let currentStatus: SocketStatus = 'disconnected'
const statusListeners = new Set<StatusListener>()
function setStatus(status: SocketStatus) {
if (status === currentStatus) return
currentStatus = status
statusListeners.forEach(fn => fn(status))
}
/** Subscribe to socket connection status changes. Returns an unsubscribe function. */
export function onSocketStatus(listener: StatusListener): () => void {
statusListeners.add(listener)
// Immediately notify current status
listener(currentStatus)
return () => { statusListeners.delete(listener) }
}
/** Get current socket connection status */
export function getSocketStatus(): SocketStatus {
return currentStatus
}
export function getSocket(): Socket { export function getSocket(): Socket {
if (!socket) { if (!socket) {
socket = io({ socket = io({
@@ -20,6 +45,7 @@ export function getSocket(): Socket {
}) })
socket.on('connect', () => { socket.on('connect', () => {
console.log('[Socket.io] Connected:', socket?.id) console.log('[Socket.io] Connected:', socket?.id)
setStatus('connected')
// Re-join project room after reconnect // Re-join project room after reconnect
if (currentRoom) { if (currentRoom) {
console.log('[Socket.io] Re-joining room:', currentRoom) console.log('[Socket.io] Re-joining room:', currentRoom)
@@ -28,6 +54,7 @@ export function getSocket(): Socket {
}) })
socket.on('disconnect', (reason) => { socket.on('disconnect', (reason) => {
console.warn('[Socket.io] Disconnected:', reason) console.warn('[Socket.io] Disconnected:', reason)
setStatus('disconnected')
if (reason === 'io server disconnect') { if (reason === 'io server disconnect') {
// Server disconnected us, need to manually reconnect // Server disconnected us, need to manually reconnect
socket?.connect() socket?.connect()
@@ -38,9 +65,11 @@ export function getSocket(): Socket {
}) })
socket.io.on('reconnect', (attempt) => { socket.io.on('reconnect', (attempt) => {
console.log('[Socket.io] Reconnected after', attempt, 'attempts') console.log('[Socket.io] Reconnected after', attempt, 'attempts')
setStatus('connected')
}) })
socket.io.on('reconnect_attempt', (attempt) => { socket.io.on('reconnect_attempt', (attempt) => {
console.log('[Socket.io] Reconnect attempt', attempt) console.log('[Socket.io] Reconnect attempt', attempt)
setStatus('reconnecting')
}) })
} }
return socket return socket

View File

@@ -0,0 +1,69 @@
import { create } from 'zustand'
import type { Project, Feature, JournalEntry } from '@/types'
interface ProjectStore {
// Projekt-Daten
project: Project | null
features: Feature[] // Karten-Elemente (Symbole, Linien, Polygone)
journalEntries: JournalEntry[]
// Actions
setProject: (project: Project | null) => void
setFeatures: (features: Feature[]) => void
addFeature: (feature: Feature) => void
updateFeature: (id: string, updates: Partial<Feature>) => void
removeFeature: (id: string) => void
setJournalEntries: (entries: JournalEntry[]) => void
addJournalEntry: (entry: JournalEntry) => void
// Realtime-Sync Actions (werden von Socket.io getriggert)
syncFeatures: (features: Feature[]) => void
syncJournalEntry: (entry: JournalEntry) => void
}
export const useProjectStore = create<ProjectStore>((set) => ({
project: null,
features: [],
journalEntries: [],
setProject: (project) => set({ project }),
setFeatures: (features) => set({ features }),
addFeature: (feature) => set((state) => ({
features: [...state.features, feature]
})),
updateFeature: (id, updates) => set((state) => ({
features: state.features.map(f =>
f.id === id || f.properties?.id === id
? { ...f, properties: { ...f.properties, ...updates } }
: f
)
})),
removeFeature: (id) => set((state) => ({
features: state.features.filter(f => f.id !== id && f.properties?.id !== id)
})),
setJournalEntries: (entries) => set({ journalEntries: entries }),
addJournalEntry: (entry) => set((state) => ({
journalEntries: [...state.journalEntries, entry]
})),
syncFeatures: (features) => set({ features }),
syncJournalEntry: (entry) => set((state) => {
// Avoid duplicates
if (state.journalEntries.some(e => e.id === entry.id)) {
return {
journalEntries: state.journalEntries.map(e => e.id === entry.id ? entry : e)
}
}
return {
journalEntries: [...state.journalEntries, entry]
}
}),
}))

39
src/stores/tool-store.ts Normal file
View File

@@ -0,0 +1,39 @@
import { create } from 'zustand'
import type { DrawMode } from '@/types'
export type LineType = 'solid' | 'dashed' | 'dotted'
interface ToolStore {
activeTool: DrawMode | null
activeColor: string
lineType: LineType
lineWidth: number
selectedFeatureId: string | null
// Actions
setActiveTool: (tool: ToolStore['activeTool']) => void
setActiveColor: (color: string) => void
setLineType: (type: ToolStore['lineType']) => void
setLineWidth: (width: number) => void
selectFeature: (id: string | null) => void
resetTool: () => void
}
export const useToolStore = create<ToolStore>((set) => ({
activeTool: 'select',
activeColor: '#000000', // Default Schwarz
lineType: 'solid',
lineWidth: 3,
selectedFeatureId: null,
setActiveTool: (tool) => set({ activeTool: tool }),
setActiveColor: (color) => set({ activeColor: color }),
setLineType: (type) => set({ lineType: type }),
setLineWidth: (width) => set({ lineWidth: width }),
selectFeature: (id) => set({ selectedFeatureId: id }),
resetTool: () => set({
activeTool: 'select',
selectedFeatureId: null
}),
}))

39
src/stores/ui-store.ts Normal file
View File

@@ -0,0 +1,39 @@
import { create } from 'zustand'
export type SidebarTab = 'map' | 'journal'
export type ConnectionStatus = 'connected' | 'reconnecting' | 'offline'
interface UIStore {
sidebarOpen: boolean
sidebarTab: SidebarTab
activeModal: string | null
isEditing: boolean
connectionStatus: ConnectionStatus
// Actions
toggleSidebar: () => void
setSidebarOpen: (open: boolean) => void
setSidebarTab: (tab: SidebarTab) => void
openModal: (modal: string) => void
closeModal: () => void
setIsEditing: (editing: boolean) => void
setConnectionStatus: (status: ConnectionStatus) => void
}
export const useUIStore = create<UIStore>((set) => ({
sidebarOpen: true,
sidebarTab: 'map',
activeModal: null,
isEditing: false,
connectionStatus: 'offline',
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setSidebarOpen: (open) => set({ sidebarOpen: open }),
setSidebarTab: (tab) => set({ sidebarTab: tab }),
openModal: (modal) => set({ activeModal: modal }),
closeModal: () => set({ activeModal: null }),
setIsEditing: (editing) => set({ isEditing: editing }),
setConnectionStatus: (status) => set({ connectionStatus: status }),
}))

70
src/types/index.ts Normal file
View File

@@ -0,0 +1,70 @@
export interface Project {
id: string
title: string
location?: string
description?: string
einsatzleiter?: string
journalfuehrer?: string
mapCenter: { lng: number; lat: number }
mapZoom: number
isLocked: boolean
editingById?: string | null
editingUserName?: string | null
editingStartedAt?: string | null
planImageKey?: string | null
planBounds?: { north: number; south: number; east: number; west: number } | null
createdAt: string
updatedAt: string
}
export interface DrawFeature {
id: string
type: string
geometry: {
type: string
coordinates: number[] | number[][] | number[][][]
}
properties: Record<string, any>
}
// Mapbox Feature Types
export type Feature = {
id?: string | number
type: 'Feature'
geometry: {
type: string
coordinates: any
}
properties: Record<string, any>
}
export type DrawMode =
| 'select'
| 'point'
| 'linestring'
| 'polygon'
| 'rectangle'
| 'circle'
| 'freehand'
| 'text'
| 'arrow'
| 'measure'
| 'dangerzone'
| 'eraser'
export interface JournalEntry {
id: string
type: 'TEXT' | 'IMAGE' | 'AUDIO' | 'SOMA' | 'DANGER'
content?: string
timestamp: string
userId?: string
userName?: string
userRole?: string
isDone?: boolean
fileUrl?: string
fileKey?: string
somaTemplateId?: string
somaChecked?: boolean
isCorrected?: boolean
correctionOfId?: string
}