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_PORT=3000
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)
*.tar
*.zip
*.mp4
# Allow MP4s in public folder (used by the app)
!public/*.mp4
!public/**/*.mp4
lageplan-web.tar
# Reference materials (keep locally, not in git)
@@ -58,4 +60,3 @@ Reglement_*/
# Stack env (contains secrets)
stack.env
.env.docker

View File

@@ -26,34 +26,41 @@ RUN npm run build
# Stage 3: Runner
FROM node:20-alpine AS runner
RUN apk add --no-cache openssl
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
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
WORKDIR /app
# Fast: only chown the /app directory itself, not recursively
RUN chown nextjs:nodejs /app
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
ENV PORT 3000
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,102 +1,168 @@
# 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 |
|-------|-------------|
| `lageplan-web-v1.0.0.tar` | Docker Image (~92 MB) |
| `portainer-stack.yml` | Stack YAML für Portainer |
| `.env.example` | Environment Variables Vorlage |
- Gitea läuft mit Container Registry aktiviert
- Gitea Actions Runner ist registriert (`deploy/docker-compose.runner.yml`)
- Portainer Stack ist deployed mit korrekten Environment-Variablen
---
## Schritt 1: Image auf Server laden
## Schritt 1: Gitea Container Registry aktivieren
```bash
# Kopieren
scp lageplan-web-v1.0.0.tar user@server:/tmp/
In Gitea:
1. **Admin-Konsole****Konfiguration****Pakete**
2. **Container Registry** auf `Aktiviert` setzen
3. Speichern
# Auf dem Server laden
docker load -i /tmp/lageplan-web-v1.0.0.tar
Oder direkt in der `app.ini`:
```ini
[packages]
ENABLED = true
# Prüfen
docker images | grep lageplan
[package.container_registry]
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`
2. **Name**: `lageplan`
3. **Web editor**: Inhalt von `portainer-stack.yml` einfügen
4. **Environment variables** setzen:
3. **Build method**: `Repository`
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 |
|----------|------|
| `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)* |
### Environment Variables setzen:
> **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:
```bash
npx prisma db push
npx prisma db seed
```
Oder per SSH:
```bash
docker exec -it lageplan-web-1 npx prisma db push
docker exec -it lageplan-web-1 npx prisma db seed
```
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 4: Zugriff
## Schritt 6: Erstes Deployment testen
- **Web App**: `http://SERVER_IP:3000`
- **Login**: `admin@lageplan.local` / `admin123`
1. Lokal einen Push auf `main` machen:
```bash
git add .
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
---
## Update
## 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
docker exec -it lageplan-web-1 npx prisma db push
docker exec -it lageplan-web-1 npx prisma db seed
```
---
## Manuelles Update (falls nötig)
Wenn Watchtower mal nicht greift:
```bash
# Lokal: neues Image bauen + exportieren
docker compose build web
docker tag lageplan-web:latest lageplan-web:v1.1.0
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
# Auf dem Portainer-Host
docker pull git.purepixel.ch/adminpepe/lageplan:latest
docker compose -f docker-compose.portainer.yml up -d web
```
---

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
NEXTAUTH_URL=http://SERVER_IP:3000
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:
# 1. Stacks → Add Stack → "Gitea"
@@ -12,6 +12,10 @@
# 3. Repository "lageplan" erstellen
# 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.
##############################################
@@ -27,6 +31,9 @@ services:
- GITEA__server__ROOT_URL=https://git.purepixel.ch
- GITEA__server__HTTP_PORT=3000
- GITEA__server__LFS_START_SERVER=true
# Container Registry aktivieren
- GITEA__packages__ENABLED=true
- GITEA__package__container_registry__ENABLED=true
volumes:
- gitea_data:/data
- /etc/timezone:/etc/timezone:ro
@@ -43,4 +50,3 @@ volumes:
networks:
lageplan_lageplan-net:
external: true

View File

@@ -1,21 +1,28 @@
##############################################
# Lageplan — Portainer Stack Configuration
#
# Verwendung in Portainer:
# 1. Stacks → Add Stack
# 2. "Upload" oder diesen Inhalt einfügen
# 3. Environment-Variablen setzen (siehe unten)
# 4. Deploy
# Lageplan — Portainer Stack (Watchtower Auto-Update)
#
# Setup in Portainer:
# 1. Stacks → Add Stack → "Repository"
# 2. Git-URL: https://git.purepixel.ch/adminpepe/Lageplan.git
# 3. Compose-Pfad: docker-compose.portainer.yml
# 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:
# POSTGRES_USER (default: lageplan)
# POSTGRES_PASSWORD (ÄNDERN!)
# POSTGRES_DB (default: lageplan)
# 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_PASSWORD (ÄNDERN!)
# 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:
@@ -76,8 +83,9 @@ services:
- lageplan
# ─── Lageplan Web App ──────────────────────
# Image kommt aus Gitea Container Registry (gebaut via Gitea Actions)
web:
image: 192.168.1.183:3100/adminpepe/lageplan:latest
image: git.purepixel.ch/adminpepe/lageplan:latest
restart: unless-stopped
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-lageplan}:${POSTGRES_PASSWORD:-lageplan_secret}@db:5432/${POSTGRES_DB:-lageplan}
@@ -99,6 +107,25 @@ services:
condition: service_healthy
networks:
- 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:
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'",
"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:",
"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'",
"base-uri 'self'",
"form-action 'self'",

36
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "lageplan",
"version": "1.0.1",
"version": "1.3.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lageplan",
"version": "1.0.1",
"version": "1.3.2",
"hasInstallScript": true,
"dependencies": {
"@dnd-kit/core": "^6.1.0",
@@ -53,7 +53,8 @@
"tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7",
"uuid": "^9.0.1",
"zod": "^3.22.4"
"zod": "^3.22.4",
"zustand": "^5.0.11"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
@@ -10566,6 +10567,35 @@
"funding": {
"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",
"version": "1.2.0",
"version": "1.3.5",
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
"private": true,
"scripts": {
@@ -14,7 +14,10 @@
"db:migrate:prod": "prisma migrate deploy",
"db:seed": "npx tsx prisma/seed.ts",
"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": {
"@dnd-kit/core": "^6.1.0",
@@ -61,7 +64,8 @@
"tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7",
"uuid": "^9.0.1",
"zod": "^3.22.4"
"zod": "^3.22.4",
"zustand": "^5.0.11"
},
"prisma": {
"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.
* Does NOT require the Prisma CLI (npx prisma) — only the runtime client.
* 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')
@@ -121,10 +127,9 @@ async function migrate() {
await prisma.$executeRawUnsafe(`UPDATE tenants SET "subscriptionStatus" = 'ACTIVE' WHERE "subscriptionStatus" = 'TRIAL'`)
} catch (e) { /* ignore */ }
// ─── Step 6: Clean up orphan users ───
console.log(' [6/7] Cleaning up orphan users...')
// ─── Step 6: Detect orphan users (log only, no deletion) ───
console.log(' [6/7] Checking for orphan users...')
try {
// Find users who are NOT SERVER_ADMIN and have NO tenant membership
const orphans = await prisma.user.findMany({
where: {
role: { not: 'SERVER_ADMIN' },
@@ -133,22 +138,15 @@ async function migrate() {
select: { id: true, email: true, name: true },
})
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) {
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 {
console.log(' No orphan users found')
}
} catch (e) {
console.log(' Orphan cleanup skipped:', e.message)
console.log(' Orphan check skipped:', e.message)
}
// ─── Step 7: Backfill logoFileKey from logoUrl ───
@@ -222,7 +220,7 @@ async function migrate() {
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
"isActive" BOOLEAN NOT NULL DEFAULT true,
"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")
)
`)
@@ -231,6 +229,98 @@ async function migrate() {
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')
}

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[]
iconAssets IconAsset[]
tenantSymbols TenantSymbol[]
tenantCategories TenantCategory[]
upgradeRequests UpgradeRequest[]
dictionaryEntries DictionaryEntry[]
rapports Rapport[]
@@ -378,22 +379,67 @@ model UpgradeRequest {
@@map("upgrade_requests")
}
// ─── Tenant Symbol Visibility ─────────────────────────────
// ─── Tenant Symbol Collection ─────────────────────────────
model TenantSymbol {
id String @id @default(uuid())
isActive Boolean @default(true)
id String @id @default(uuid())
customName String?
sortOrder Int @default(0)
createdAt DateTime @default(now())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
iconId String
icon IconAsset @relation(fields: [iconId], references: [id], onDelete: Cascade)
iconId String?
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")
}
// ─── 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) ────────────
model DictionaryEntry {

View File

@@ -44,11 +44,10 @@ async function main() {
patterns: ['Massstab', 'Nordrichtung', 'Windrichtung'] },
]
// Delete ALL old system icons (regardless of fileKey pattern)
const deleted = await prisma.iconAsset.deleteMany({
where: { isSystem: true },
})
console.log(`🗑️ ${deleted.count} old system icons removed`)
// NOTE: We intentionally do NOT delete old system icons here.
// TenantSymbol rows reference IconAsset.id via foreign key.
// Deleting would either break references (tenant symbols become 404s)
// or cascade-delete tenant symbols. Instead we upsert by fileKey.
// Upsert global categories (preserves tenant categories)
const catMap = {}
@@ -90,6 +89,7 @@ async function main() {
}
let created = 0
let updated = 0
for (const file of svgFiles) {
let name = file.replace('.svg', '')
name = name.replace(/_de$/i, '').replace(/_DE$/i, '').replace(/-de$/i, '')
@@ -99,7 +99,21 @@ async function main() {
const category = findCategory(file)
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({
data: {
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()

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'] },
]
// Delete ALL old system icons (regardless of fileKey pattern)
const deletedIcons = await prisma.iconAsset.deleteMany({
where: { isSystem: true },
})
console.log(`🗑️ ${deletedIcons.count} old system icons removed`)
// NOTE: We intentionally do NOT delete old system icons here.
// TenantSymbol rows reference IconAsset.id via foreign key.
// Deleting would either break references (tenant symbols become 404s)
// or cascade-delete tenant symbols. Instead we upsert by fileKey.
// Clean up empty global categories
const oldGlobalCats = await prisma.iconCategory.findMany({ where: { tenantId: null } })
for (const oldCat of oldGlobalCats) {
const remaining = await prisma.iconAsset.count({ where: { categoryId: oldCat.id } })
if (remaining === 0) {
await prisma.iconCategory.delete({ where: { id: oldCat.id } }).catch(() => {})
}
}
// Create new global categories
// NOTE: We intentionally do NOT delete any icon categories here.
// Tenant-specific categories may reference them, and deleting could
// orphan user data. Empty categories are harmless.
// Create or update global categories
const catMap = {}
for (const def of catDefs) {
const cat = await prisma.iconCategory.upsert({
@@ -163,6 +157,7 @@ async function main() {
}
let created = 0
let updated = 0
for (const file of svgFiles) {
// Clean name: remove .svg, remove _de/_DE suffix
let name = file.replace('.svg', '')
@@ -173,7 +168,21 @@ async function main() {
const category = findCategory(file)
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({
data: {
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
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) {
try {
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 })
}
@@ -55,27 +55,25 @@ export async function POST(req: NextRequest) {
// Generate safe filename
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 fileKey = `icons/${safeFileName}`
const fileKey = `${prefix}/${safeFileName}`
// Upload to MinIO
const buffer = Buffer.from(await file.arrayBuffer())
await uploadFile(fileKey, buffer, file.type)
// TENANT_ADMIN: icons get tenantId. SERVER_ADMIN: global icons (tenantId=null)
const tenantId = user.role === 'SERVER_ADMIN' ? null : user.tenantId || null
// Create database entry
// Save to DB
const icon = await (prisma as any).iconAsset.create({
data: {
name: name.trim(),
fileKey,
mimeType: file.type,
categoryId,
iconType: iconType as any,
isSystem: false,
isActive: true,
tenantId,
fileKey,
mimeType: file.type,
isSystem: !isTenantAdmin, // true für Server Admin, false für Tenant Admin
tenantId: isTenantAdmin ? user.tenantId : null,
ownerId: user.id,
},
include: {

View File

@@ -6,20 +6,6 @@ export async function GET() {
try {
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
const categoryWhere: any = user?.tenantId
? { OR: [{ tenantId: null }, { tenantId: user.tenantId }] }
@@ -32,41 +18,52 @@ export async function GET() {
icons: {
where: user?.tenantId
? { isActive: true, OR: [{ tenantId: null }, { tenantId: user.tenantId }] }
: { isActive: true },
: { isActive: true, tenantId: null },
orderBy: { name: 'asc' },
},
},
})
// Get tenant's hidden icon IDs (legacy) + TenantSymbol overrides
// Get tenant's hidden icon IDs (legacy)
let hiddenIconIds: string[] = []
let deactivatedIconIds = new Set<string>()
if (user?.tenantId) {
const [tenant, tenantSymbols] = await Promise.all([
(prisma as any).tenant.findUnique({
where: { id: user.tenantId },
select: { hiddenIconIds: true },
}),
(prisma as any).tenantSymbol.findMany({
where: { tenantId: user.tenantId, isActive: false },
select: { iconId: true },
}),
])
const tenant = await (prisma as any).tenant.findUnique({
where: { id: user.tenantId },
select: { hiddenIconIds: true },
})
hiddenIconIds = tenant?.hiddenIconIds || []
deactivatedIconIds = new Set(tenantSymbols.map((ts: any) => ts.iconId))
}
const categoriesWithUrls = categories.map((cat: any) => ({
...cat,
icons: cat.icons
.filter((icon: any) => !hiddenIconIds.includes(icon.id) && !deactivatedIconIds.has(icon.id))
.filter((icon: any) => !hiddenIconIds.includes(icon.id))
.map((icon: any) => ({
...icon,
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) {
console.error('Error fetching icons:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })

View File

@@ -17,9 +17,13 @@ export async function GET(req: NextRequest) {
id: true,
name: true,
slug: true,
description: true,
contactEmail: true,
contactPhone: true,
address: true,
logoUrl: true,
plan: true,
subscriptionStatus: true,
contactEmail: true,
privacyAccepted: true,
privacyAcceptedAt: true,
adminAccessAccepted: true,
@@ -39,3 +43,35 @@ export async function GET(req: NextRequest) {
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 { getSession } from '@/lib/auth'
// GET: List all icons with their tenant-specific active status
async function getTenantId() {
const user = await getSession()
if (!user) return { error: 'Nicht autorisiert', status: 401 }
if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') {
return { error: 'Keine Berechtigung', status: 403 }
}
if (!user.tenantId) return { error: 'Kein Mandant zugeordnet', status: 400 }
return { tenantId: user.tenantId }
}
// GET: Returns library (all system icons) + tenant's own symbol collection
export async function GET() {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const auth = await getTenantId()
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const { tenantId } = auth
const tenantId = user.tenantId
if (!tenantId) return NextResponse.json({ error: 'Kein Mandant zugeordnet' }, { status: 400 })
// Get all system icons (active ones)
// All system icons grouped by category (the library)
const icons = await (prisma as any).iconAsset.findMany({
where: { isActive: true },
include: { category: { select: { id: true, name: true } } },
orderBy: [{ category: { sortOrder: 'asc' } }, { name: 'asc' }],
})
// Get tenant-specific overrides
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) => ({
const library = icons.map((icon: any) => ({
id: icon.id,
name: icon.name,
fileKey: icon.fileKey,
mimeType: icon.mimeType,
iconType: icon.iconType,
categoryId: icon.categoryId,
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) {
console.error('Error fetching tenant symbols:', error)
return NextResponse.json({ error: 'Interner Fehler' }, { status: 500 })
}
}
// PATCH: Update symbol visibility for the tenant (bulk)
export async function PATCH(req: NextRequest) {
// POST: Add a symbol from the library to "my symbols"
export async function POST(req: NextRequest) {
try {
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role !== 'TENANT_ADMIN' && user.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const auth = await getTenantId()
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const { tenantId } = auth
const tenantId = user.tenantId
if (!tenantId) return NextResponse.json({ error: 'Kein Mandant zugeordnet' }, { status: 400 })
const { iconId, customName } = await req.json()
if (!iconId) return NextResponse.json({ error: 'iconId erforderlich' }, { status: 400 })
const { updates } = await req.json()
if (!Array.isArray(updates)) {
return NextResponse.json({ error: 'updates Array erforderlich' }, { status: 400 })
}
// Get max sortOrder for this tenant
const maxSort = await (prisma as any).tenantSymbol.aggregate({
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 },
})
)
)
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({ success: 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) {
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 })
}
}

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 { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { JournalView } from '@/components/journal/journal-view'
import { jsPDF } from 'jspdf'
import { Lock, Unlock, Eye, AlertTriangle, WifiOff } from 'lucide-react'
import { getSocket, setSocketRoom } from '@/lib/socket'
import { CustomDragLayer } from '@/components/map/custom-drag-layer'
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 {
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 type { DrawMode }
export default function AppPage() {
const router = useRouter()
const { toast } = useToast()
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 [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 [isSaving, setIsSaving] = useState(false)
const [isDeleteAllConfirmOpen, setIsDeleteAllConfirmOpen] = useState(false)
const [isFullscreen, setIsFullscreen] = useState(false)
const [auditLog, setAuditLog] = useState<{ time: string; action: string }[]>([])
const [isAuditOpen, setIsAuditOpen] = useState(false)
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
const [presentationLocked, setPresentationLocked] = useState(false)
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
const [activeTab, setActiveTab] = useState<'map' | 'journal'>('map')
const [lastMapScreenshot, setLastMapScreenshot] = useState<string>('')
const [defaultSymbolScale, setDefaultSymbolScale] = useState(1.5)
// Onboarding tour
const [showTour, setShowTour] = useState(false)
// 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)
// Ref to access the map for export
const mapRef = useRef<any>(null)
// 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)}`
}
// 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)
// 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)
const handleTabChange = useCallback(async (tab: 'map' | 'journal') => {
@@ -368,67 +347,8 @@ export default function AppPage() {
const [isLineLabelDialogOpen, setIsLineLabelDialogOpen] = useState(false)
const [pendingLineFeature, setPendingLineFeature] = useState<DrawFeature | null>(null)
// Ref to access the map for export
const mapRef = useRef<any>(null)
// 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)
// Offline detection + sync queue management
const { isOffline, syncQueueCount, setSyncQueueCount } = useOfflineSync({ toast: toast as any })
// Audit trail helper
const addAudit = useCallback((action: string) => {
@@ -447,8 +367,6 @@ export default function AppPage() {
}).catch(() => {})
}, [])
const router = useRouter()
// Redirect to login if not authenticated
useEffect(() => {
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
// 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
// ─── 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 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])
// Auto-save: localStorage persistence + debounced API save + beacon on unload
useAutoSave({
currentProject,
features,
featuresRef,
mapRef,
socketRef,
isEditingByMe,
setSyncQueueCount,
})
// Fullscreen toggle
const toggleFullscreen = useCallback(() => {
@@ -1066,56 +686,15 @@ export default function AppPage() {
// Keyboard shortcuts for tools
const [isShortcutHelpOpen, setIsShortcutHelpOpen] = useState(false)
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(); setIsShortcutHelpOpen(true); return }
// 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])
useKeyboardShortcuts({
featuresRef,
onUndo: handleUndo,
onRedo: handleRedo,
onSave: handleSaveProject,
onDelete: handleFeaturesChange,
onToolChange: setDrawMode,
onHelpOpen: useCallback(() => setIsShortcutHelpOpen(true), []),
})
const handlePlanUpload = useCallback(() => {
if (!currentProject) return
@@ -1180,311 +759,14 @@ export default function AppPage() {
setIsDeleteAllConfirmOpen(false)
}, [toast, addAudit])
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, toast])
const { handleExport } = useMapExport({
mapRef,
featuresRef,
currentProject,
tenant: tenant ? { id: tenant.id, name: tenant.name } : null,
addAudit,
toast: toast as any,
})
// Show loading state while checking auth
if (authLoading || !user) {
@@ -1525,6 +807,14 @@ export default function AppPage() {
userRole={user?.role}
onLogout={logout}
onStartTour={() => { resetOnboardingTour(); setShowTour(true) }}
presentationLocked={presentationLocked}
onTogglePresentationLock={() => {
const next = !presentationLocked
setPresentationLocked(next)
if (next && isEditingByMe) {
handleStopEditing()
}
}}
/>
{/* Offline banner */}
@@ -1581,7 +871,7 @@ export default function AppPage() {
<span>Niemand bearbeitet gerade</span>
</div>
)}
<div className="flex items-center gap-2">
<div data-tour="edit-toggle" className="flex items-center gap-2">
{roleCanEdit && !isEditingByMe && !isReadOnly && (
<Button size="sm" variant="default" onClick={handleStartEditing} disabled={editingLoading}>
<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 */}
<div data-tour="toolbar" className={`contents ${activeTab !== 'map' ? 'hidden' : ''}`}>
<LeftToolbar
drawMode={drawMode}
drawMode={drawMode || 'select'}
onDrawModeChange={setDrawMode}
selectedColor={selectedColor}
onColorChange={setSelectedColor}
@@ -1619,7 +909,7 @@ export default function AppPage() {
<MapView
project={currentProject}
features={features}
drawMode={drawMode}
drawMode={drawMode || 'select'}
selectedColor={selectedColor}
selectedWidth={selectedWidth}
onFeaturesChange={handleFeaturesChange}
@@ -1633,8 +923,8 @@ export default function AppPage() {
</main>
</div>
{/* Journal view — always mounted, hidden when map tab is active to preserve state */}
<main className={`flex-1 relative overflow-auto ${activeTab !== 'journal' ? 'hidden' : ''}`}>
{/* Journal view — always mounted, hidden via CSS */}
<main className={`flex-1 flex flex-col min-h-0 bg-background ${activeTab !== 'journal' ? 'hidden' : ''}`}>
<JournalView
projectId={currentProject?.id || null}
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>
{[
['Ctrl+Y', 'Rückgängig'], ['Ctrl+Z', 'Wiederholen'],
['Ctrl+Z', 'Rückgängig'], ['Ctrl+Y', 'Wiederholen'],
['Ctrl+S', 'Speichern'], ['Del', 'Auswahl löschen'],
['Esc', 'Abbrechen'], ['?', 'Diese Hilfe'],
].map(([key, label]) => (

View File

@@ -1,30 +1,16 @@
'use client'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Logo } from '@/components/ui/logo'
import { useAuth } from '@/components/providers/auth-provider'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { NavAuthButtons } from '@/components/landing/nav-auth-buttons'
import { ContactForm } from '@/components/landing/contact-form'
import {
Flame, Map, Shield, Users, Smartphone, FileText, Ruler, Clock,
Check, ArrowRight, Lock, ChevronRight, MessageSquare, Loader2, Send,
Map, Shield, Users, Smartphone, FileText, Ruler, Clock,
Check, ArrowRight, Lock, ChevronRight, MessageSquare,
Heart, Coffee, Rocket, Sparkles, Lightbulb, HelpCircle,
MousePointer2, Minus, Pentagon, Square, Circle, Pencil, MoveRight, Type, Eraser,
} from 'lucide-react'
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 = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
@@ -40,11 +26,6 @@ export default function LandingPage() {
priceCurrency: 'CHF',
description: 'Kostenlos für Schweizer Feuerwehren',
},
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: '4.8',
ratingCount: '12',
},
author: {
'@type': 'Organization',
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 (
<div className="min-h-screen bg-white">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
/>
<main>
{/* Navigation */}
<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>
</div>
<div className="flex items-center gap-3">
{user ? (
<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>
</>
)}
<NavAuthButtons />
</div>
</div>
</div>
@@ -564,39 +595,6 @@ function SupportSection() {
}
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 (
<section id="contact" className="py-20 px-4">
<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.
</p>
</div>
{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>
)}
<ContactForm />
</div>
</section>
)

View File

@@ -210,9 +210,59 @@ export default function RapportViewerPage({ params }: { params: Promise<{ token:
</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 && (
<Section num="5" title="Eingesetzte Mittel">
<Section num="7" title="Eingesetzte Mittel">
<table className="w-full border-collapse border rounded text-xs">
<thead>
<tr className="bg-gray-900 text-white">
@@ -238,8 +288,8 @@ export default function RapportViewerPage({ params }: { params: Promise<{ token:
</Section>
)}
{/* 6. Bemerkungen */}
<Section num="6" title="Bemerkungen / Besondere Vorkommnisse">
{/* 8. Bemerkungen */}
<Section num="8" title="Bemerkungen / Besondere Vorkommnisse">
<div className="border rounded p-3 min-h-[50px] text-sm">{d.bemerkungen || '—'}</div>
</Section>

View File

@@ -23,10 +23,10 @@ export default function sitemap(): MetadataRoute.Sitemap {
priority: 0.8,
},
{
url: `${baseUrl}/impressum`,
url: `${baseUrl}/demo`,
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 0.3,
changeFrequency: 'monthly',
priority: 0.7,
},
{
url: `${baseUrl}/spenden`,
@@ -34,5 +34,17 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: 'monthly',
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'
import { useToast } from '@/components/ui/use-toast'
import { MapPin, Loader2, X } from 'lucide-react'
import type { Project } from '@/app/app/page'
import type { Project } from '@/types'
interface NominatimResult {
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,
} from 'lucide-react'
import { getSocket } from '@/lib/socket'
import { RapportDialog } from '@/components/journal/rapport-dialog'
interface JournalEntry {
id: string
@@ -86,7 +87,6 @@ export function JournalView({ projectId, projectTitle, projectLocation, einsatzl
const scrollRef = useRef<HTMLDivElement>(null)
// Rapport creation
const [creatingRapport, setCreatingRapport] = useState(false)
const [lastRapportLink, setLastRapportLink] = useState<string | null>(null)
const [showRapportDialog, setShowRapportDialog] = useState(false)
const [rapportForm, setRapportForm] = useState<Record<string, any>>({})
@@ -461,8 +461,21 @@ export function JournalView({ projectId, projectTitle, projectLocation, einsatzl
zeitKontrolle: '', zeitAus: '', zeitEinruecken: '', zeitEnde: '',
lageEintreffen: '',
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[],
bemerkungen: pendenzen.filter(p => !p.done).map(p => `PENDENT: ${p.what}${p.who ? ` (${p.who})` : ''}`).join('\n'),
bemerkungen: '',
einsatzleiter: einsatzleiter || '',
rapporteur: journalfuehrer || '',
reportNumber: '',
@@ -895,210 +908,16 @@ export function JournalView({ projectId, projectTitle, projectLocation, einsatzl
</div>
{/* Rapport Dialog */}
{showRapportDialog && (
<div className="fixed inset-0 z-50 bg-black/50 flex items-start justify-center overflow-auto py-8 print:hidden">
<div className="bg-white dark:bg-gray-900 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={() => setShowRapportDialog(false)} className="text-gray-400 hover:text-gray-600 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-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>
{showRapportDialog && projectId && (
<RapportDialog
projectId={projectId}
rapportForm={rapportForm}
setRapportForm={setRapportForm}
mapRef={mapRef}
mapScreenshot={preCapuredScreenshot}
onClose={() => setShowRapportDialog(false)}
onRapportCreated={(link) => setLastRapportLink(link)}
/>
)}
</>
)

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,
Eraser,
} from 'lucide-react'
import type { DrawMode } from '@/app/app/page'
import type { DrawMode } from '@/types'
import { cn } from '@/lib/utils'
interface LeftToolbarProps {

View File

@@ -108,7 +108,7 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
async function fetchIcons() {
setIsLoading(true)
try {
const res = await fetch('/api/icons')
const res = await fetch('/api/icons', { cache: 'no-store' })
if (res.ok) {
const data = await res.json()
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
const eigene = allCats.find(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)
if (globalCats.length > 0) setActiveCategory(globalCats[0].id)
// Auto-collapse library if tenant has own symbols
if (mergedTenant.length > 0) {
setShowLibrarySection(false)
}
}
} catch (err) {
console.error('Failed to load icons:', err)
@@ -137,7 +154,7 @@ export function RightSidebar({ onSymbolDrop, canEdit, isOpen, onToggle, activeTa
}
}
fetchIcons()
}, [])
}, [tenantId])
const filteredCategories = categories.map((cat) => ({
...cat,

View File

@@ -37,12 +37,13 @@ import {
ImagePlus,
Key,
Shield,
Building2,
MapPin,
HelpCircle,
Lock,
Unlock,
} from 'lucide-react'
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 { Logo } from '@/components/ui/logo'
@@ -67,6 +68,8 @@ interface TopbarProps {
userRole?: string
onLogout?: () => void
onStartTour?: () => void
presentationLocked?: boolean
onTogglePresentationLock?: () => void
}
export function Topbar({
@@ -90,6 +93,8 @@ export function Topbar({
userRole,
onLogout,
onStartTour,
presentationLocked,
onTogglePresentationLock,
}: TopbarProps) {
const [isLoadDialogOpen, setIsLoadDialogOpen] = useState(false)
const [isHoseSettingsOpen, setIsHoseSettingsOpen] = useState(false)
@@ -173,6 +178,16 @@ export function Topbar({
<span className="hidden lg:inline">{isSaving ? 'Speichern...' : 'Speichern'}</span>
</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>
<DropdownMenuTrigger asChild>
<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" />
Kennwort ändern
</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') && (
<DropdownMenuItem onClick={() => window.location.href = '/admin'}>
<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 Moveable from 'react-moveable'
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
function haversineDistance(a: number[], b: number[]): number {
@@ -728,6 +728,46 @@ export function MapView({
// Expose map instance to parent for export
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.ScaleControl(), 'bottom-left')
@@ -1286,6 +1326,7 @@ export function MapView({
})
return () => {
cleanupVisibility()
map.current?.remove()
map.current = null
}
@@ -1421,26 +1462,27 @@ export function MapView({
const lineCoords = f.geometry.coordinates as number[][]
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 p2 = lineCoords[lineCoords.length - 1]
const px1 = map.current.project(p1 as [number, number])
const px2 = map.current.project(p2 as [number, number])
const screenAngle = Math.atan2(px2.y - px1.y, px2.x - px1.x) * (180 / Math.PI) + 90
const dLng = p2[0] - p1[0]
const dLat = p2[1] - p1[1]
// 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 arrowEl = document.createElement('div')
arrowEl.style.cssText = `
width: 0; height: 0;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-bottom: 20px solid ${color};
transform: rotate(${screenAngle}deg);
border-left: 12px solid transparent;
border-right: 12px solid transparent;
border-bottom: 24px solid ${color};
transform: rotate(${geoBearing}deg);
transform-origin: center center;
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])
.addTo(map.current)
markersRef.current.push(marker)
@@ -1472,21 +1514,27 @@ export function MapView({
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 isDanger = f.type === 'dangerzone'
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;
padding: 1px 5px;
border-radius: 3px;
font-size: 11px;
padding: 3px 8px;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
pointer-events: ${canEdit ? 'auto' : 'none'};
letter-spacing: 0.3px;
border: 1px solid ${isDanger ? '#dc2626' : 'rgba(255,255,255,0.4)'};
box-shadow: 0 1px 3px rgba(0,0,0,0.25);
cursor: ${canEdit ? 'pointer' : 'default'};
box-shadow: 0 1px 4px rgba(0,0,0,0.3);
cursor: ${canEdit ? 'grab' : 'default'};
transform: translate(0,0);
will-change: transform;
`
@@ -1503,11 +1551,11 @@ export function MapView({
const labelLine = document.createElement('div')
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')
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(infoLine)
@@ -1519,11 +1567,11 @@ export function MapView({
const labelLine = document.createElement('div')
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')
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(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)
.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)
})
@@ -1590,13 +1670,56 @@ export function MapView({
inner.style.transform = `rotate(${rotation}deg)`
inner.style.transition = 'transform 0.1s'
if (imgSrc) {
inner.style.backgroundImage = `url("${imgSrc}")`
// Try primary image source, with fallback chain for broken/deleted icons
const applyImage = (src: string) => {
inner.style.backgroundImage = `url("${src}")`
inner.style.backgroundSize = 'contain'
inner.style.backgroundRepeat = 'no-repeat'
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)
// Click/tap to select symbol for Moveable editing — ONLY in 'select' mode
@@ -1626,7 +1749,7 @@ export function MapView({
}
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)
.addTo(map.current)
@@ -1692,7 +1815,7 @@ export function MapView({
el.textContent = (f.properties.text as string) || ''
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)
.addTo(map.current)
@@ -1854,9 +1977,28 @@ export function MapView({
}
}, [drawMode, deselectSymbol])
// ESC to cancel drawing / finalize measurement
// ESC to cancel drawing, DEL to delete selected symbol/line/polygon
useEffect(() => {
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') {
// In measure mode: finalize (keep line + labels), just stop adding
if (drawModeRef.current === 'measure' && drawingRef.current.isDrawing) {
@@ -1883,7 +2025,7 @@ export function MapView({
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
}, [deleteSelectedSymbol])
// Drop zone for symbols — use stable ref connection (no inline ref callback)
const [, drop] = useDrop(() => ({

View File

@@ -5,6 +5,8 @@ import { Button } from '@/components/ui/button'
import {
X, ChevronRight, ChevronLeft, SkipForward,
MapPin, Pencil, LayoutGrid, Save, Ruler, Users, Keyboard, Rocket,
MousePointer2, CircleDot, Minus, Pentagon, Square, Circle, MoveRight, Type, Eraser,
Lock, ClipboardList, Download, AlertTriangle,
} from 'lucide-react'
const TOUR_STORAGE_KEY = 'lageplan-onboarding-completed'
@@ -15,61 +17,96 @@ interface TourStep {
icon?: React.ReactNode
targetSelector?: string
position?: 'top' | 'bottom' | 'left' | 'right'
tools?: { icon: React.ReactNode; label: string; shortcut?: string }[]
}
const TOUR_STEPS: TourStep[] = [
{
title: 'Willkommen bei Lageplan!',
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',
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"]',
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',
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"]',
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" />,
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"]',
position: 'left',
},
{
title: 'Speichern & Export',
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"]',
position: 'bottom',
},
{
title: 'Messen & Schlauch-Rechner',
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.',
tools: [
{ icon: <Save className="w-3.5 h-3.5" />, label: 'Speichern', shortcut: 'Ctrl+S' },
{ icon: <Download className="w-3.5 h-3.5" />, label: 'Export als PNG/PDF' },
],
},
{
title: 'Live-Zusammenarbeit',
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" />,
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!',
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" />
</button>
</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}
</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 */}
<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 jsPDF from 'jspdf'
import type { Project, DrawFeature } from '@/app/app/page'
import type { Project, DrawFeature } from '@/types'
import { formatDateTime } from './utils'
export interface ExportOptions {

View File

@@ -5,6 +5,31 @@ import { io, Socket } from 'socket.io-client'
let socket: Socket | 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 {
if (!socket) {
socket = io({
@@ -20,6 +45,7 @@ export function getSocket(): Socket {
})
socket.on('connect', () => {
console.log('[Socket.io] Connected:', socket?.id)
setStatus('connected')
// Re-join project room after reconnect
if (currentRoom) {
console.log('[Socket.io] Re-joining room:', currentRoom)
@@ -28,6 +54,7 @@ export function getSocket(): Socket {
})
socket.on('disconnect', (reason) => {
console.warn('[Socket.io] Disconnected:', reason)
setStatus('disconnected')
if (reason === 'io server disconnect') {
// Server disconnected us, need to manually reconnect
socket?.connect()
@@ -38,9 +65,11 @@ export function getSocket(): Socket {
})
socket.io.on('reconnect', (attempt) => {
console.log('[Socket.io] Reconnected after', attempt, 'attempts')
setStatus('connected')
})
socket.io.on('reconnect_attempt', (attempt) => {
console.log('[Socket.io] Reconnect attempt', attempt)
setStatus('reconnecting')
})
}
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
}