Initial release v1.0.0
Schneespur — Open-source winter service documentation software (PWA + Admin). GPS tracking via OwnTracks, weather data, photo evidence, and legally compliant service records for winter maintenance operators. License: AGPL-3.0-or-later
This commit is contained in:
commit
ee3dbba6cc
8077 changed files with 1083731 additions and 0 deletions
18
.editorconfig
Normal file
18
.editorconfig
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[compose.yaml]
|
||||
indent_size = 4
|
||||
54
.env.example
Normal file
54
.env.example
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
APP_NAME=Schneespur
|
||||
APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
|
||||
APP_LOCALE=de
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=de_DE
|
||||
APP_TIMEZONE=UTC
|
||||
APP_DISPLAY_TIMEZONE=Europe/Berlin
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
# PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=schneespur
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=file
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=file
|
||||
# CACHE_PREFIX=
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
295
INSTALL.de.md
Normal file
295
INSTALL.de.md
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
# Schneespur — Installationsanleitung
|
||||
|
||||
Diese Anleitung beschreibt die Installation von Schneespur auf einem klassischen Shared-Webhosting (Strato, IONOS, All-Inkl o. ä.) mit PHP und MySQL. SSH oder Docker sind **nicht** erforderlich.
|
||||
|
||||
---
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
|
||||
1. [Systemvoraussetzungen](#1-systemvoraussetzungen)
|
||||
2. [Dateien hochladen](#2-dateien-hochladen)
|
||||
3. [Document-Root konfigurieren](#3-document-root-konfigurieren)
|
||||
4. [Datenbank anlegen](#4-datenbank-anlegen)
|
||||
5. [Installations-Assistent](#5-installations-assistent)
|
||||
6. [Cron-Job einrichten](#6-cron-job-einrichten)
|
||||
7. [OwnTracks einrichten](#7-owntracks-einrichten)
|
||||
8. [Update-Anleitung](#8-update-anleitung)
|
||||
9. [Backup](#9-backup)
|
||||
10. [Troubleshooting](#10-troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## 1. Systemvoraussetzungen
|
||||
|
||||
| Anforderung | Minimum | Empfohlen |
|
||||
|-------------|---------|-----------|
|
||||
| PHP | 8.2 | 8.3 oder 8.4 |
|
||||
| MySQL | 5.7 | 8.0+ |
|
||||
| MariaDB (alternativ) | 10.3 | 10.6+ |
|
||||
|
||||
### Benoetigte PHP-Erweiterungen
|
||||
|
||||
**Pflicht** (Installation schlaegt ohne diese fehl):
|
||||
|
||||
- `pdo_mysql`
|
||||
- `gd`
|
||||
|
||||
**Empfohlen** (Warnungen im Assistenten, wenn fehlend):
|
||||
|
||||
- `mbstring`
|
||||
- `openssl`
|
||||
- `tokenizer`
|
||||
- `xml`
|
||||
- `ctype`
|
||||
- `json`
|
||||
- `bcmath`
|
||||
- `fileinfo`
|
||||
|
||||
> Die meisten Shared-Hosting-Anbieter haben alle genannten Erweiterungen bereits aktiviert.
|
||||
|
||||
### Weitere Voraussetzungen
|
||||
|
||||
- FTP- oder Dateimanager-Zugang zum Webspace
|
||||
- Eine MySQL/MariaDB-Datenbank (wird vom Hoster bereitgestellt)
|
||||
- Das Document-Root muss auf einen Unterordner (`/public`) zeigbar sein
|
||||
|
||||
---
|
||||
|
||||
## 2. Dateien hochladen
|
||||
|
||||
1. Laden Sie das aktuelle Schneespur-Release herunter (ZIP-Archiv).
|
||||
2. Entpacken Sie das Archiv auf Ihrem Computer.
|
||||
3. Laden Sie den gesamten Inhalt per FTP oder Dateimanager in Ihr Webverzeichnis hoch, z. B. `/schneespur/` oder direkt ins Hauptverzeichnis.
|
||||
|
||||
**Ordnerstruktur nach dem Upload:**
|
||||
|
||||
```
|
||||
/schneespur/
|
||||
app/
|
||||
bootstrap/
|
||||
config/
|
||||
database/
|
||||
lang/
|
||||
public/ <-- hierhin muss das Document-Root zeigen
|
||||
resources/
|
||||
routes/
|
||||
storage/
|
||||
vendor/
|
||||
.env.example
|
||||
artisan
|
||||
composer.json
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Document-Root konfigurieren
|
||||
|
||||
Das Document-Root (manchmal auch „Webroot" oder „Stammverzeichnis" genannt) Ihrer Domain muss auf den Unterordner `public/` zeigen.
|
||||
|
||||
**Beispiel:** Wenn Sie die Dateien nach `/schneespur/` hochgeladen haben, setzen Sie das Document-Root auf `/schneespur/public/`.
|
||||
|
||||
So geht das bei gaengigen Hostern:
|
||||
|
||||
- **Strato:** Paket-Verwaltung → Domain-Verwaltung → Umleitung/Ziel → Pfad aendern
|
||||
- **IONOS:** Hosting → Domains → Document-Root bearbeiten
|
||||
- **All-Inkl:** Domain-Einstellungen → Ordnerzuordnung
|
||||
|
||||
> **Wichtig:** Wenn Ihr Hoster keinen Unterordner als Document-Root erlaubt, verschieben Sie den Inhalt von `public/` ins Hauptverzeichnis und passen Sie die Pfade in `index.php` entsprechend an. Der Installations-Assistent hilft dabei nicht — kontaktieren Sie im Zweifel Ihren Hoster.
|
||||
|
||||
---
|
||||
|
||||
## 4. Datenbank anlegen
|
||||
|
||||
Erstellen Sie ueber das Verwaltungspanel Ihres Hosters eine neue MySQL-Datenbank. Notieren Sie sich:
|
||||
|
||||
- **Host** (z. B. `localhost` oder `rdbms.strato.de`)
|
||||
- **Port** (Standard: `3306`)
|
||||
- **Datenbankname**
|
||||
- **Benutzername**
|
||||
- **Passwort**
|
||||
|
||||
Diese Daten benoetigen Sie im naechsten Schritt.
|
||||
|
||||
---
|
||||
|
||||
## 5. Installations-Assistent
|
||||
|
||||
Oeffnen Sie Ihre Domain im Browser. Schneespur erkennt automatisch, dass noch keine Installation vorliegt, und startet den Assistenten.
|
||||
|
||||
### Schritt 1: Willkommen
|
||||
|
||||
Der Assistent prueft die Grundvoraussetzungen und erzeugt die Konfigurationsdatei (`.env`) sowie den Anwendungsschluessel (`APP_KEY`).
|
||||
|
||||
### Schritt 2: Datenbank
|
||||
|
||||
Geben Sie die Zugangsdaten aus Schritt 4 ein. Der Assistent testet die Verbindung, bevor er fortfaehrt.
|
||||
|
||||
> Falls die `.env`-Datei nicht beschreibbar ist (selten bei Shared-Hosting), zeigt der Assistent eine Anleitung zum manuellen Bearbeiten per FTP an.
|
||||
|
||||
### Schritt 3: Systemcheck
|
||||
|
||||
Der Assistent prueft PHP-Version, Erweiterungen und Schreibrechte auf wichtige Verzeichnisse (`storage/`, `bootstrap/cache/`). Fehlende Erweiterungen werden als Pflicht oder Empfehlung markiert.
|
||||
|
||||
### Schritt 4: Datenbank-Migration
|
||||
|
||||
Die Datenbanktabellen werden automatisch angelegt. Dieser Schritt kann bei Fehlern beliebig oft wiederholt werden, ohne Datenverlust.
|
||||
|
||||
### Schritt 5: Anwendungskonfiguration
|
||||
|
||||
Legen Sie fest:
|
||||
|
||||
- **App-URL** (Ihre Domain, z. B. `https://schneespur.meinefirma.de`)
|
||||
- **Zeitzone** (z. B. `Europe/Berlin`)
|
||||
- **Sprache** (`de` oder `en`)
|
||||
|
||||
### Schritt 6: Speicher & Caches
|
||||
|
||||
Der Assistent erstellt die Verknuepfung zum oeffentlichen Speicher (`storage:link`) und baut Caches auf. Falls die Verknuepfung auf Ihrem Hoster nicht funktioniert, wird eine Anleitung zum manuellen Anlegen per FTP angezeigt.
|
||||
|
||||
### Schritt 7: Admin-Konto
|
||||
|
||||
Erstellen Sie Ihr Administrator-Konto (Name, E-Mail, Passwort mit mindestens 8 Zeichen).
|
||||
|
||||
### Schritt 8: E-Mail-Konfiguration (optional)
|
||||
|
||||
Richten Sie SMTP-Versand ein, damit Schneespur Benachrichtigungen senden kann. Dieser Schritt kann uebersprungen und spaeter in den Einstellungen nachgeholt werden.
|
||||
|
||||
### Fertig
|
||||
|
||||
Nach Abschluss sehen Sie eine Zusammenfassung. Sie koennen sich jetzt mit Ihren Admin-Zugangsdaten anmelden.
|
||||
|
||||
---
|
||||
|
||||
## 6. Cron-Job einrichten
|
||||
|
||||
Schneespur benoetigt einen Cron-Job, der einmal pro Minute den Laravel-Scheduler ausfuehrt. Dieser verarbeitet die Auftragswarteschlange (z. B. Wetterdaten abrufen, Benachrichtigungen senden).
|
||||
|
||||
### Cron-Befehl
|
||||
|
||||
```
|
||||
* * * * * /usr/local/bin/php /pfad/zu/schneespur/artisan schedule:run >> /dev/null 2>&1
|
||||
```
|
||||
|
||||
> **Wichtig:** Ersetzen Sie `/pfad/zu/schneespur/` durch den tatsaechlichen Pfad auf Ihrem Webspace und `/usr/local/bin/php` durch den PHP-Pfad Ihres Hosters (haeufig auch `/usr/bin/php` oder `/usr/bin/php8.3`).
|
||||
|
||||
### So richten Sie den Cron-Job ein
|
||||
|
||||
- **Strato:** Paket-Verwaltung → Cron-Jobs → Neuer Cronjob
|
||||
- **IONOS:** Hosting → Cron-Jobs → Cronjob anlegen
|
||||
- **All-Inkl:** Tools → Cronjobs → Neuer Cronjob
|
||||
|
||||
Stellen Sie die Ausfuehrung auf **jede Minute** oder das kuerzeste verfuegbare Intervall.
|
||||
|
||||
### Warum ist der Cron-Job noetig?
|
||||
|
||||
Ohne Cron-Job werden keine Hintergrundaufgaben verarbeitet:
|
||||
|
||||
- Wetterdaten werden nicht automatisch zu Einsaetzen hinzugefuegt
|
||||
- E-Mail-Benachrichtigungen werden nicht versendet
|
||||
- Geplante Aufgaben laufen nicht
|
||||
|
||||
---
|
||||
|
||||
## 7. OwnTracks einrichten
|
||||
|
||||
OwnTracks ist die GPS-Tracking-App, mit der Ihre Fahrer die Einsaetze aufzeichnen. Jeder Fahrer benoetigt die App auf seinem Smartphone.
|
||||
|
||||
### Kurzanleitung
|
||||
|
||||
1. **App installieren:** OwnTracks aus dem App Store (iOS) oder Google Play Store (Android) herunterladen.
|
||||
2. **Zugangsdaten erzeugen:** Melden Sie sich als Admin in Schneespur an, oeffnen Sie die Fahrer-Uebersicht und klicken Sie beim jeweiligen Fahrer auf „Zugangsdaten". Schneespur erzeugt automatisch Benutzername und Passwort.
|
||||
3. **QR-Code scannen:** Auf der Zugangsdaten-Seite wird ein QR-Code angezeigt. Der Fahrer scannt diesen mit der OwnTracks-App, und die Verbindung wird automatisch konfiguriert.
|
||||
4. **Manuell konfigurieren** (falls QR-Code nicht funktioniert):
|
||||
- Modus: **HTTP**
|
||||
- URL: `https://ihre-domain.de/api/owntracks/report`
|
||||
- Benutzername und Passwort: wie in Schneespur angezeigt
|
||||
5. **Testen:** Oeffnen Sie in Schneespur unter „OwnTracks" die Uebersicht. Sobald der Fahrer die App startet, sollte dort ein gruener Status erscheinen.
|
||||
|
||||
---
|
||||
|
||||
## 8. Update-Anleitung
|
||||
|
||||
### Vor dem Update
|
||||
|
||||
1. Erstellen Sie ein Backup (siehe [Backup](#9-backup)).
|
||||
2. Aktivieren Sie den Wartungsmodus: Oeffnen Sie `https://ihre-domain.de/down` im Browser oder fuehren Sie `php artisan down` per SSH/Cron aus.
|
||||
|
||||
### Update durchfuehren
|
||||
|
||||
1. Laden Sie das neue Release herunter.
|
||||
2. Ueberschreiben Sie alle Dateien per FTP. Ueberspringen Sie dabei **nicht** die `.env`-Datei — diese wird beim Upload ohnehin nicht ueberschrieben, solange Sie nur die Release-Dateien hochladen.
|
||||
3. Fuehren Sie die Datenbank-Migration aus. Dafuer gibt es zwei Wege:
|
||||
- **Ueber den Browser:** Oeffnen Sie `https://ihre-domain.de/admin/settings` und pruefen Sie, ob eine Update-Migration angeboten wird.
|
||||
- **Per Cron/SSH:** `php artisan migrate --force`
|
||||
4. Leeren Sie die Caches: `php artisan config:cache && php artisan view:cache`
|
||||
5. Deaktivieren Sie den Wartungsmodus: Oeffnen Sie `https://ihre-domain.de/up` oder fuehren Sie `php artisan up` aus.
|
||||
|
||||
---
|
||||
|
||||
## 9. Backup
|
||||
|
||||
### Was sichern?
|
||||
|
||||
| Was | Wo | Wie |
|
||||
|-----|----|----|
|
||||
| Datenbank | MySQL-Datenbank | phpMyAdmin → Export (SQL-Format) |
|
||||
| Hochgeladene Dateien | `storage/app/` | Per FTP herunterladen |
|
||||
| Konfiguration | `.env`-Datei im Hauptverzeichnis | Per FTP herunterladen |
|
||||
|
||||
### Empfohlener Rhythmus
|
||||
|
||||
- **Datenbank:** woechentlich oder vor jedem Update
|
||||
- **Dateien:** vor jedem Update
|
||||
- **Konfiguration:** nach jeder Aenderung und vor Updates
|
||||
|
||||
---
|
||||
|
||||
## 10. Troubleshooting
|
||||
|
||||
### Installations-Assistent erscheint nicht
|
||||
|
||||
- Pruefen Sie, ob das Document-Root korrekt auf `/public` zeigt.
|
||||
- Pruefen Sie, ob die `.htaccess`-Datei im `public/`-Ordner vorhanden ist.
|
||||
- Stellen Sie sicher, dass `mod_rewrite` (Apache) aktiviert ist.
|
||||
|
||||
### Datenbankverbindung schlaegt fehl
|
||||
|
||||
- Pruefen Sie Host, Port, Datenbankname, Benutzername und Passwort.
|
||||
- Bei Strato lautet der Host oft `rdbms.strato.de`, nicht `localhost`.
|
||||
- Stellen Sie sicher, dass der Datenbankbenutzer Zugriff auf die angegebene Datenbank hat.
|
||||
|
||||
### Seite zeigt „500 Internal Server Error"
|
||||
|
||||
- Pruefen Sie die Schreibrechte: `storage/` und `bootstrap/cache/` muessen beschreibbar sein (Rechte 755 oder 775).
|
||||
- Schauen Sie in `storage/logs/laravel.log` nach der Fehlermeldung.
|
||||
|
||||
### GPS-Daten kommen nicht an
|
||||
|
||||
- Pruefen Sie in OwnTracks, ob der Modus auf „HTTP" steht (nicht MQTT).
|
||||
- Pruefen Sie die URL: `https://ihre-domain.de/api/owntracks/report`
|
||||
- Pruefen Sie Benutzername und Passwort in der OwnTracks-App.
|
||||
- Oeffnen Sie die OwnTracks-Uebersicht in Schneespur — dort wird der letzte Verbindungsstatus angezeigt.
|
||||
|
||||
### Wetterdaten fehlen bei Einsaetzen
|
||||
|
||||
- Stellen Sie sicher, dass der Cron-Job laeuft (siehe [Cron-Job einrichten](#6-cron-job-einrichten)).
|
||||
- Wetterdaten werden ueber Open-Meteo abgerufen. Pruefen Sie, ob Ihr Server ausgehende HTTPS-Verbindungen erlaubt.
|
||||
|
||||
### E-Mails werden nicht versendet
|
||||
|
||||
- Pruefen Sie die SMTP-Einstellungen unter Einstellungen → E-Mail.
|
||||
- Nutzen Sie die Test-E-Mail-Funktion in den Einstellungen.
|
||||
- Schauen Sie in `storage/logs/laravel.log` nach Fehlermeldungen.
|
||||
|
||||
### Cron-Job funktioniert nicht
|
||||
|
||||
- Pruefen Sie den PHP-Pfad: Fuehren Sie `which php` aus oder fragen Sie Ihren Hoster.
|
||||
- Pruefen Sie den Pfad zur `artisan`-Datei.
|
||||
- Testen Sie den Befehl manuell: `php /pfad/zu/schneespur/artisan schedule:run`
|
||||
|
||||
---
|
||||
|
||||
## Hilfe
|
||||
|
||||
Bei Fragen nutzen Sie die integrierte Hilfe im Admin-Bereich (Menue → Hilfe) oder erstellen Sie ein Issue im GitHub-Repository.
|
||||
295
INSTALL.en.md
Normal file
295
INSTALL.en.md
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
# Schneespur — Installation Guide
|
||||
|
||||
This guide describes how to install Schneespur on a standard shared web hosting plan (Strato, IONOS, All-Inkl, or similar) with PHP and MySQL. SSH and Docker are **not** required.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [System Requirements](#1-system-requirements)
|
||||
2. [Upload Files](#2-upload-files)
|
||||
3. [Configure Document Root](#3-configure-document-root)
|
||||
4. [Create Database](#4-create-database)
|
||||
5. [Installation Wizard](#5-installation-wizard)
|
||||
6. [Set Up Cron Job](#6-set-up-cron-job)
|
||||
7. [Set Up OwnTracks](#7-set-up-owntracks)
|
||||
8. [Update Instructions](#8-update-instructions)
|
||||
9. [Backup](#9-backup)
|
||||
10. [Troubleshooting](#10-troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## 1. System Requirements
|
||||
|
||||
| Requirement | Minimum | Recommended |
|
||||
|-------------|---------|-------------|
|
||||
| PHP | 8.2 | 8.3 or 8.4 |
|
||||
| MySQL | 5.7 | 8.0+ |
|
||||
| MariaDB (alternative) | 10.3 | 10.6+ |
|
||||
|
||||
### Required PHP Extensions
|
||||
|
||||
**Mandatory** (installation will fail without these):
|
||||
|
||||
- `pdo_mysql`
|
||||
- `gd`
|
||||
|
||||
**Recommended** (warnings in the wizard if missing):
|
||||
|
||||
- `mbstring`
|
||||
- `openssl`
|
||||
- `tokenizer`
|
||||
- `xml`
|
||||
- `ctype`
|
||||
- `json`
|
||||
- `bcmath`
|
||||
- `fileinfo`
|
||||
|
||||
> Most shared hosting providers have all of the above extensions enabled by default.
|
||||
|
||||
### Additional Requirements
|
||||
|
||||
- FTP or file manager access to your web space
|
||||
- A MySQL/MariaDB database (provided by your hosting plan)
|
||||
- The document root must be configurable to point to a subdirectory (`/public`)
|
||||
|
||||
---
|
||||
|
||||
## 2. Upload Files
|
||||
|
||||
1. Download the latest Schneespur release (ZIP archive).
|
||||
2. Extract the archive on your computer.
|
||||
3. Upload the entire contents via FTP or your hosting provider's file manager to your web directory, e.g. `/schneespur/` or directly into the root directory.
|
||||
|
||||
**Folder structure after upload:**
|
||||
|
||||
```
|
||||
/schneespur/
|
||||
app/
|
||||
bootstrap/
|
||||
config/
|
||||
database/
|
||||
lang/
|
||||
public/ <-- document root must point here
|
||||
resources/
|
||||
routes/
|
||||
storage/
|
||||
vendor/
|
||||
.env.example
|
||||
artisan
|
||||
composer.json
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Configure Document Root
|
||||
|
||||
Your domain's document root (sometimes called "web root" or "home directory") must point to the `public/` subdirectory.
|
||||
|
||||
**Example:** If you uploaded the files to `/schneespur/`, set the document root to `/schneespur/public/`.
|
||||
|
||||
How to do this on common hosts:
|
||||
|
||||
- **Strato:** Package management → Domain management → Redirect/Target → Change path
|
||||
- **IONOS:** Hosting → Domains → Edit document root
|
||||
- **All-Inkl:** Domain settings → Folder assignment
|
||||
|
||||
> **Important:** If your host does not allow setting a subdirectory as the document root, move the contents of `public/` into the main directory and adjust the paths in `index.php` accordingly. The installation wizard does not help with this — contact your host's support if unsure.
|
||||
|
||||
---
|
||||
|
||||
## 4. Create Database
|
||||
|
||||
Create a new MySQL database through your hosting provider's control panel. Make note of:
|
||||
|
||||
- **Host** (e.g. `localhost` or `rdbms.strato.de`)
|
||||
- **Port** (default: `3306`)
|
||||
- **Database name**
|
||||
- **Username**
|
||||
- **Password**
|
||||
|
||||
You will need these in the next step.
|
||||
|
||||
---
|
||||
|
||||
## 5. Installation Wizard
|
||||
|
||||
Open your domain in a browser. Schneespur automatically detects that no installation exists and starts the wizard.
|
||||
|
||||
### Step 1: Welcome
|
||||
|
||||
The wizard checks basic requirements and creates the configuration file (`.env`) along with the application key (`APP_KEY`).
|
||||
|
||||
### Step 2: Database
|
||||
|
||||
Enter the database credentials from Step 4. The wizard tests the connection before proceeding.
|
||||
|
||||
> If the `.env` file is not writable (rare on shared hosting), the wizard displays instructions for manual editing via FTP.
|
||||
|
||||
### Step 3: System Check
|
||||
|
||||
The wizard verifies the PHP version, extensions, and write permissions on key directories (`storage/`, `bootstrap/cache/`). Missing extensions are flagged as mandatory or recommended.
|
||||
|
||||
### Step 4: Database Migration
|
||||
|
||||
Database tables are created automatically. This step can be retried as many times as needed without data loss.
|
||||
|
||||
### Step 5: Application Configuration
|
||||
|
||||
Configure the following:
|
||||
|
||||
- **App URL** (your domain, e.g. `https://schneespur.mycompany.com`)
|
||||
- **Timezone** (e.g. `Europe/Berlin`)
|
||||
- **Language** (`de` or `en`)
|
||||
|
||||
### Step 6: Storage & Caches
|
||||
|
||||
The wizard creates the public storage symlink (`storage:link`) and builds caches. If the symlink fails on your host, instructions for manual FTP setup are displayed.
|
||||
|
||||
### Step 7: Admin Account
|
||||
|
||||
Create your administrator account (name, email, password with at least 8 characters).
|
||||
|
||||
### Step 8: Email Configuration (optional)
|
||||
|
||||
Set up SMTP so Schneespur can send notifications. This step can be skipped and completed later in the settings.
|
||||
|
||||
### Done
|
||||
|
||||
After completion you will see a summary. You can now log in with your admin credentials.
|
||||
|
||||
---
|
||||
|
||||
## 6. Set Up Cron Job
|
||||
|
||||
Schneespur requires a cron job that runs the Laravel scheduler once per minute. This processes the job queue (e.g. fetching weather data, sending notifications).
|
||||
|
||||
### Cron Command
|
||||
|
||||
```
|
||||
* * * * * /usr/local/bin/php /path/to/schneespur/artisan schedule:run >> /dev/null 2>&1
|
||||
```
|
||||
|
||||
> **Important:** Replace `/path/to/schneespur/` with the actual path on your web space and `/usr/local/bin/php` with your host's PHP path (often `/usr/bin/php` or `/usr/bin/php8.3`).
|
||||
|
||||
### How to Set Up the Cron Job
|
||||
|
||||
- **Strato:** Package management → Cron jobs → New cron job
|
||||
- **IONOS:** Hosting → Cron jobs → Create cron job
|
||||
- **All-Inkl:** Tools → Cron jobs → New cron job
|
||||
|
||||
Set the execution interval to **every minute** or the shortest interval available.
|
||||
|
||||
### Why Is the Cron Job Needed?
|
||||
|
||||
Without the cron job, no background tasks are processed:
|
||||
|
||||
- Weather data is not automatically added to jobs
|
||||
- Email notifications are not sent
|
||||
- Scheduled tasks do not run
|
||||
|
||||
---
|
||||
|
||||
## 7. Set Up OwnTracks
|
||||
|
||||
OwnTracks is the GPS tracking app your drivers use to record their operations. Each driver needs the app on their smartphone.
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. **Install the app:** Download OwnTracks from the App Store (iOS) or Google Play Store (Android).
|
||||
2. **Generate credentials:** Log in to Schneespur as admin, open the driver list, and click "Credentials" for the respective driver. Schneespur automatically generates a username and password.
|
||||
3. **Scan QR code:** The credentials page displays a QR code. The driver scans it with the OwnTracks app, and the connection is configured automatically.
|
||||
4. **Manual configuration** (if the QR code does not work):
|
||||
- Mode: **HTTP**
|
||||
- URL: `https://your-domain.com/api/owntracks/report`
|
||||
- Username and password: as shown in Schneespur
|
||||
5. **Test:** Open the OwnTracks overview in Schneespur. Once the driver starts the app, a green status indicator should appear.
|
||||
|
||||
---
|
||||
|
||||
## 8. Update Instructions
|
||||
|
||||
### Before Updating
|
||||
|
||||
1. Create a backup (see [Backup](#9-backup)).
|
||||
2. Enable maintenance mode: Open `https://your-domain.com/down` in a browser, or run `php artisan down` via SSH/cron.
|
||||
|
||||
### Perform the Update
|
||||
|
||||
1. Download the new release.
|
||||
2. Overwrite all files via FTP. Do **not** skip the `.env` file — it will not be overwritten as long as you only upload the release files.
|
||||
3. Run the database migration. There are two ways:
|
||||
- **Via browser:** Open `https://your-domain.com/admin/settings` and check if an update migration is offered.
|
||||
- **Via cron/SSH:** `php artisan migrate --force`
|
||||
4. Clear caches: `php artisan config:cache && php artisan view:cache`
|
||||
5. Disable maintenance mode: Open `https://your-domain.com/up` or run `php artisan up`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Backup
|
||||
|
||||
### What to Back Up
|
||||
|
||||
| What | Where | How |
|
||||
|------|-------|----|
|
||||
| Database | MySQL database | phpMyAdmin → Export (SQL format) |
|
||||
| Uploaded files | `storage/app/` | Download via FTP |
|
||||
| Configuration | `.env` file in the root directory | Download via FTP |
|
||||
|
||||
### Recommended Schedule
|
||||
|
||||
- **Database:** weekly or before each update
|
||||
- **Files:** before each update
|
||||
- **Configuration:** after each change and before updates
|
||||
|
||||
---
|
||||
|
||||
## 10. Troubleshooting
|
||||
|
||||
### Installation Wizard Does Not Appear
|
||||
|
||||
- Verify that the document root points to `/public`.
|
||||
- Check that the `.htaccess` file exists in the `public/` directory.
|
||||
- Make sure `mod_rewrite` (Apache) is enabled.
|
||||
|
||||
### Database Connection Fails
|
||||
|
||||
- Double-check the host, port, database name, username, and password.
|
||||
- On Strato, the host is often `rdbms.strato.de`, not `localhost`.
|
||||
- Ensure the database user has access to the specified database.
|
||||
|
||||
### Page Shows "500 Internal Server Error"
|
||||
|
||||
- Check write permissions: `storage/` and `bootstrap/cache/` must be writable (permissions 755 or 775).
|
||||
- Check `storage/logs/laravel.log` for the error message.
|
||||
|
||||
### GPS Data Is Not Arriving
|
||||
|
||||
- In OwnTracks, make sure the mode is set to "HTTP" (not MQTT).
|
||||
- Verify the URL: `https://your-domain.com/api/owntracks/report`
|
||||
- Check the username and password in the OwnTracks app.
|
||||
- Open the OwnTracks overview in Schneespur — it shows the last connection status.
|
||||
|
||||
### Weather Data Is Missing from Jobs
|
||||
|
||||
- Make sure the cron job is running (see [Set Up Cron Job](#6-set-up-cron-job)).
|
||||
- Weather data is fetched from Open-Meteo. Check that your server allows outgoing HTTPS connections.
|
||||
|
||||
### Emails Are Not Being Sent
|
||||
|
||||
- Check the SMTP settings under Settings → Email.
|
||||
- Use the test email function in the settings.
|
||||
- Check `storage/logs/laravel.log` for error messages.
|
||||
|
||||
### Cron Job Is Not Working
|
||||
|
||||
- Verify the PHP path: run `which php` or ask your hosting provider.
|
||||
- Verify the path to the `artisan` file.
|
||||
- Test the command manually: `php /path/to/schneespur/artisan schedule:run`
|
||||
|
||||
---
|
||||
|
||||
## Help
|
||||
|
||||
For questions, use the built-in help in the admin area (Menu → Help) or open an issue in the GitHub repository.
|
||||
661
LICENSE
Normal file
661
LICENSE
Normal file
|
|
@ -0,0 +1,661 @@
|
|||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
133
README.md
Normal file
133
README.md
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<p align="center">
|
||||
<img src="public/pwa-icon-512x512.png" alt="Schneespur" width="120">
|
||||
</p>
|
||||
|
||||
<h1 align="center">Schneespur</h1>
|
||||
|
||||
<p align="center">
|
||||
Quelloffene, selbst gehostete Winterdienst-Dokumentation.<br>
|
||||
GPS-Tracks · Wetterdaten · Fotos · rechtsfester Einsatznachweis
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#english">English</a> ·
|
||||
<a href="INSTALL.de.md">Installation (DE)</a> ·
|
||||
<a href="INSTALL.en.md">Installation (EN)</a> ·
|
||||
<a href="https://jenni.noschmarrn.dev">Download</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Was ist Schneespur?
|
||||
|
||||
Schneespur dokumentiert Raeum- und Streueinsaetze fuer kleine Winterdienst-Betriebe — vollstaendig, automatisch und rechtssicher. Die Software laeuft auf jedem guenstigen Shared-Webhosting (Strato, IONOS, All-Inkl, ...) und braucht weder SSH noch Docker.
|
||||
|
||||
**Kernversprechen:** Wenn ein Passant auf einer gestreuten Flaeche ausrutscht und der Betreiber nachweisen muss, dass er seiner Verkehrssicherungspflicht nachgekommen ist, liefert Schneespur den Beleg — mit GPS-Track, Wetterlage, Fotos und Zeitstempeln.
|
||||
|
||||
### Funktionen
|
||||
|
||||
- **GPS-Tracking** via [OwnTracks](https://owntracks.org)-App (iOS/Android) — kein eigener Tracking-Client noetig
|
||||
- **Automatische Wetterdokumentation** — Temperatur, Niederschlag, Wind, Schneelage zum Einsatzzeitpunkt (Open-Meteo, BrightSky, Met.no)
|
||||
- **Foto-Dokumentation** — Bilder direkt aus der Fahrer-App hochladen
|
||||
- **PDF-Einsatznachweise** — einzeln oder als Sammelreport pro Kunde und Zeitraum
|
||||
- **Kundenportal** — Kunden koennen ihre Einsaetze selbst einsehen
|
||||
- **Fahrer-App (PWA)** — funktioniert offline, synchronisiert automatisch bei Verbindung
|
||||
- **Kunden- und Objektverwaltung** — mehrere Objekte pro Kunde, Zuordnung zu Einsaetzen
|
||||
- **Fahrzeugverwaltung** — Fuhrpark mit Kennzeichen und Fahrzeugtyp
|
||||
- **DSGVO-konform** — Fahrer-Anonymisierung, Datenexport, konfigurierbare Aufbewahrungsfristen
|
||||
- **Automatische Updates** — kryptographisch signiert (Ed25519), ein Klick im Admin-Panel
|
||||
- **Modulsystem** — Erweiterbar ueber Module aus dem Schneespur-Modulkatalog
|
||||
|
||||
### Systemanforderungen
|
||||
|
||||
| Komponente | Minimum |
|
||||
|------------|---------|
|
||||
| PHP | 8.2 |
|
||||
| MySQL | 5.7 / MariaDB 10.3 |
|
||||
| Webserver | Apache mit `mod_rewrite` |
|
||||
| PHP-Extensions | `pdo_mysql`, `mbstring`, `openssl`, `gd`, `sodium`, `fileinfo` |
|
||||
| Speicherplatz | ca. 50 MB + Fotos |
|
||||
|
||||
### Schnellstart
|
||||
|
||||
1. [Download](https://jenni.noschmarrn.dev) der aktuellen Version (ZIP)
|
||||
2. ZIP entpacken und per FTP auf den Webserver laden
|
||||
3. Document Root auf den `public/`-Ordner setzen
|
||||
4. Im Browser die Domain aufrufen — der Installations-Assistent fuehrt durch die Einrichtung
|
||||
|
||||
Detaillierte Anleitung: **[INSTALL.de.md](INSTALL.de.md)**
|
||||
|
||||
### Tech-Stack
|
||||
|
||||
| Bereich | Technologie |
|
||||
|---------|-------------|
|
||||
| Backend | PHP 8.2+ / Laravel 12 |
|
||||
| Frontend | Blade + Alpine.js + Tailwind CSS v4 |
|
||||
| Karten | Leaflet + OpenStreetMap |
|
||||
| PDF | DomPDF (rein PHP, kein Chrome/Puppeteer) |
|
||||
| PWA | Workbox via vite-plugin-pwa |
|
||||
| Wetter | Open-Meteo / BrightSky / Met.no |
|
||||
|
||||
### Lizenz
|
||||
|
||||
Schneespur ist lizenziert unter der [GNU Affero General Public License v3.0](LICENSE).
|
||||
|
||||
---
|
||||
|
||||
<h2 id="english">English</h2>
|
||||
|
||||
> The international edition of this software is called **Wintertrace**. The branding is set during installation based on the chosen language.
|
||||
|
||||
### What is Schneespur?
|
||||
|
||||
Schneespur (German) / Wintertrace (international) is an open-source, self-hosted winter service documentation platform for small snow removal and gritting operators. It runs on any standard shared web hosting (no SSH or Docker required).
|
||||
|
||||
**Core promise:** When a pedestrian slips on a cleared surface and the operator needs to prove they fulfilled their duty of care, Schneespur provides the evidence — GPS track, weather conditions, photos, and timestamps.
|
||||
|
||||
### Features
|
||||
|
||||
- **GPS tracking** via [OwnTracks](https://owntracks.org) app (iOS/Android) — no custom tracking client needed
|
||||
- **Automatic weather documentation** — temperature, precipitation, wind, snow depth at the time of service (Open-Meteo, BrightSky, Met.no)
|
||||
- **Photo documentation** — upload images directly from the driver app
|
||||
- **PDF proof-of-service reports** — individual or batch reports per customer and time period
|
||||
- **Customer portal** — customers can review their service records
|
||||
- **Driver app (PWA)** — works offline, syncs automatically when connected
|
||||
- **Customer & site management** — multiple sites per customer, assigned to jobs
|
||||
- **Vehicle management** — fleet with license plates and vehicle types
|
||||
- **GDPR-compliant** — driver anonymization, data export, configurable retention periods
|
||||
- **Automatic updates** — cryptographically signed (Ed25519), one click in the admin panel
|
||||
- **Module system** — extensible via modules from the Schneespur module catalog
|
||||
|
||||
### System Requirements
|
||||
|
||||
| Component | Minimum |
|
||||
|-----------|---------|
|
||||
| PHP | 8.2 |
|
||||
| MySQL | 5.7 / MariaDB 10.3 |
|
||||
| Web server | Apache with `mod_rewrite` |
|
||||
| PHP extensions | `pdo_mysql`, `mbstring`, `openssl`, `gd`, `sodium`, `fileinfo` |
|
||||
| Disk space | approx. 50 MB + photos |
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. [Download](https://jenni.noschmarrn.dev) the latest release (ZIP)
|
||||
2. Extract and upload via FTP to your web server
|
||||
3. Set the document root to the `public/` directory
|
||||
4. Open the domain in your browser — the installation wizard guides you through setup
|
||||
|
||||
Detailed guide: **[INSTALL.en.md](INSTALL.en.md)**
|
||||
|
||||
### Tech Stack
|
||||
|
||||
| Area | Technology |
|
||||
|------|------------|
|
||||
| Backend | PHP 8.2+ / Laravel 12 |
|
||||
| Frontend | Blade + Alpine.js + Tailwind CSS v4 |
|
||||
| Maps | Leaflet + OpenStreetMap |
|
||||
| PDF | DomPDF (pure PHP, no Chrome/Puppeteer) |
|
||||
| PWA | Workbox via vite-plugin-pwa |
|
||||
| Weather | Open-Meteo / BrightSky / Met.no |
|
||||
|
||||
### License
|
||||
|
||||
Schneespur is licensed under the [GNU Affero General Public License v3.0](LICENSE).
|
||||
1
VERSION
Normal file
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
1.0.0
|
||||
52
app/Console/Commands/ModulesList.php
Normal file
52
app/Console/Commands/ModulesList.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Module;
|
||||
use App\Services\SchneespurModuleClient;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class ModulesList extends Command
|
||||
{
|
||||
protected $signature = 'schneespur:modules-list';
|
||||
|
||||
protected $description = 'List all installed modules with their state.';
|
||||
|
||||
public function handle(SchneespurModuleClient $client): int
|
||||
{
|
||||
if (! Schema::hasTable('modules')) {
|
||||
$this->info('Keine Module installiert.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$modules = Module::orderBy('slug')->get();
|
||||
|
||||
if ($modules->isEmpty()) {
|
||||
$this->info('Keine Module installiert.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$rows = $modules->map(fn (Module $m) => [
|
||||
$m->slug,
|
||||
$m->version ?? '—',
|
||||
$m->enabled ? '✓' : '✗',
|
||||
$m->installed_at?->format('Y-m-d H:i') ?? '—',
|
||||
])->toArray();
|
||||
|
||||
$this->table(['Slug', 'Version', 'Aktiv', 'Installiert am'], $rows);
|
||||
|
||||
$state = $client->loadState();
|
||||
$orphans = $state['orphans'] ?? [];
|
||||
|
||||
if (! empty($orphans)) {
|
||||
$this->newLine();
|
||||
$this->warn('Verwaiste Module (nicht mehr im Katalog):');
|
||||
foreach ($orphans as $slug) {
|
||||
$this->warn(" • {$slug}");
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
63
app/Console/Commands/ModulesRemove.php
Normal file
63
app/Console/Commands/ModulesRemove.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Module;
|
||||
use App\Services\SchneespurModuleClient;
|
||||
use App\Services\SchneespurModuleInstaller;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class ModulesRemove extends Command
|
||||
{
|
||||
protected $signature = 'schneespur:modules-remove
|
||||
{slug : The module slug to remove}
|
||||
{--force : Skip confirmation prompt}';
|
||||
|
||||
protected $description = 'Remove an installed module completely.';
|
||||
|
||||
public function handle(
|
||||
SchneespurModuleInstaller $installer,
|
||||
SchneespurModuleClient $client,
|
||||
): int {
|
||||
if (! Schema::hasTable('modules')) {
|
||||
$this->error('Modules-Tabelle nicht vorhanden. Bitte zuerst "php artisan migrate" ausführen.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$slug = $this->argument('slug');
|
||||
|
||||
$module = Module::bySlug($slug)->first();
|
||||
|
||||
if (! $module) {
|
||||
$this->error("Modul \"{$slug}\" nicht gefunden.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (! $this->option('force')) {
|
||||
if (! $this->confirm("Modul \"{$slug}\" (v{$module->version}) wirklich entfernen?")) {
|
||||
$this->info('Abgebrochen.');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
$module->update(['enabled' => false]);
|
||||
|
||||
$removed = $installer->remove($slug);
|
||||
|
||||
if (! $removed) {
|
||||
$this->warn("Modul-Dateien für \"{$slug}\" konnten nicht gelöscht werden (evtl. bereits entfernt).");
|
||||
}
|
||||
|
||||
$module->delete();
|
||||
|
||||
$state = $client->loadState();
|
||||
$state['installed'] = Module::pluck('slug')->toArray();
|
||||
$state['orphans'] = array_values(array_diff($state['orphans'] ?? [], [$slug]));
|
||||
$client->writeState($state);
|
||||
|
||||
$this->info("Modul \"{$slug}\" wurde entfernt.");
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
184
app/Console/Commands/ModulesSync.php
Normal file
184
app/Console/Commands/ModulesSync.php
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Module;
|
||||
use App\Services\SchneespurModuleClient;
|
||||
use App\Services\SchneespurModuleInstaller;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class ModulesSync extends Command
|
||||
{
|
||||
protected $signature = 'schneespur:modules-sync
|
||||
{--dry-run : Show what would happen without making changes}';
|
||||
|
||||
protected $description = 'Sync modules from the catalog server (install/update/skip).';
|
||||
|
||||
public function handle(
|
||||
SchneespurModuleClient $client,
|
||||
SchneespurModuleInstaller $installer,
|
||||
): int {
|
||||
if (! Schema::hasTable('modules')) {
|
||||
$this->error('Modules-Tabelle nicht vorhanden. Bitte zuerst "php artisan migrate" ausführen.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$dryRun = $this->option('dry-run');
|
||||
$appVersion = config('app.version', '0.0.0');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info('[DRY-RUN] Keine Änderungen werden vorgenommen.');
|
||||
}
|
||||
|
||||
$this->info('Katalog wird abgerufen…');
|
||||
|
||||
try {
|
||||
$catalog = $client->fetchCatalog();
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Katalog-Fetch fehlgeschlagen: ' . $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($catalog === null) {
|
||||
$this->info('Katalog nicht geändert (304). Nichts zu tun.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$modules = $catalog['modules'] ?? [];
|
||||
$catalogSlugs = [];
|
||||
$installed = 0;
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($modules as $entry) {
|
||||
$slug = $entry['slug'] ?? null;
|
||||
if (! $slug) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$catalogSlugs[] = $slug;
|
||||
$version = $entry['version'] ?? 'unknown';
|
||||
$sha256 = $entry['sha256'] ?? null;
|
||||
$size = $entry['size'] ?? null;
|
||||
$downloadUrl = $entry['download_url'] ?? null;
|
||||
$minAppVersion = $entry['minimum_app_version'] ?? null;
|
||||
|
||||
if ($minAppVersion && version_compare($appVersion, $minAppVersion, '<')) {
|
||||
$this->warn("Modul {$slug} benötigt Schneespur >= {$minAppVersion}, aktuell {$appVersion} — übersprungen.");
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $sha256 || ! $downloadUrl || ! $size) {
|
||||
$this->warn("Modul {$slug}: Fehlende Metadaten (sha256/download_url/size) — übersprungen.");
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing = Module::bySlug($slug)->first();
|
||||
|
||||
if ($existing) {
|
||||
$existingManifest = $existing->manifest_json ?? [];
|
||||
$existingSha = $existingManifest['sha256'] ?? null;
|
||||
|
||||
if ($existingSha === $sha256) {
|
||||
$this->line(" {$slug} v{$version} — aktuell, übersprungen.");
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info("[DRY-RUN] Würde aktualisieren: {$slug} → v{$version}");
|
||||
$updated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->info("Aktualisiere {$slug} → v{$version}…");
|
||||
|
||||
try {
|
||||
$zipPath = $client->downloadModule($slug, $downloadUrl, $sha256, $size);
|
||||
$success = $installer->update($zipPath, $slug);
|
||||
@unlink($zipPath);
|
||||
|
||||
if (! $success) {
|
||||
$this->error("Update fehlgeschlagen für {$slug}.");
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing->update([
|
||||
'version' => $version,
|
||||
'manifest_json' => $entry,
|
||||
]);
|
||||
$updated++;
|
||||
$this->info(" ✓ {$slug} aktualisiert auf v{$version}.");
|
||||
} catch (\Throwable $e) {
|
||||
$this->error("Fehler bei {$slug}: " . $e->getMessage());
|
||||
}
|
||||
} else {
|
||||
if ($dryRun) {
|
||||
$this->info("[DRY-RUN] Würde installieren: {$slug} v{$version}");
|
||||
$installed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->info("Installiere {$slug} v{$version}…");
|
||||
|
||||
try {
|
||||
$zipPath = $client->downloadModule($slug, $downloadUrl, $sha256, $size);
|
||||
$success = $installer->install($zipPath, $slug);
|
||||
@unlink($zipPath);
|
||||
|
||||
if (! $success) {
|
||||
$this->error("Installation fehlgeschlagen für {$slug}.");
|
||||
continue;
|
||||
}
|
||||
|
||||
Module::create([
|
||||
'slug' => $slug,
|
||||
'version' => $version,
|
||||
'enabled' => true,
|
||||
'manifest_json' => $entry,
|
||||
'installed_at' => now(),
|
||||
]);
|
||||
$installed++;
|
||||
$this->info(" ✓ {$slug} v{$version} installiert.");
|
||||
} catch (\Throwable $e) {
|
||||
$this->error("Fehler bei {$slug}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->detectOrphans($catalogSlugs, $client);
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Sync abgeschlossen: {$installed} installiert, {$updated} aktualisiert, {$skipped} übersprungen.");
|
||||
|
||||
if (! $dryRun) {
|
||||
$state = $client->loadState();
|
||||
$state['installed'] = Module::pluck('slug')->toArray();
|
||||
$client->writeState($state);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function detectOrphans(array $catalogSlugs, SchneespurModuleClient $client): void
|
||||
{
|
||||
$localSlugs = Module::pluck('slug')->toArray();
|
||||
$orphans = array_diff($localSlugs, $catalogSlugs);
|
||||
|
||||
if (empty($orphans)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->warn('Verwaiste Module (lokal installiert, nicht mehr im Katalog):');
|
||||
foreach ($orphans as $slug) {
|
||||
$this->warn(" • {$slug}");
|
||||
}
|
||||
|
||||
$state = $client->loadState();
|
||||
$state['orphans'] = array_values($orphans);
|
||||
$client->writeState($state);
|
||||
}
|
||||
}
|
||||
61
app/Console/Commands/PurgeExpiredJobs.php
Normal file
61
app/Console/Commands/PurgeExpiredJobs.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Services\RetentionService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class PurgeExpiredJobs extends Command
|
||||
{
|
||||
protected $signature = 'jobs:retention-delete
|
||||
{--dry-run : Show what would be deleted without deleting}
|
||||
{--limit=50 : Maximum jobs to delete per run}';
|
||||
|
||||
protected $description = 'Delete expired jobs after the configured retention period, preserving monthly aggregates.';
|
||||
|
||||
public function handle(RetentionService $retentionService): int
|
||||
{
|
||||
$isDryRun = $this->option('dry-run');
|
||||
$limit = (int) $this->option('limit');
|
||||
|
||||
if (! $isDryRun && ! Setting::get('retention_auto_delete', false)) {
|
||||
$this->info('Auto-Löschung ist deaktiviert.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$jobs = $retentionService->getExpiredJobs($limit);
|
||||
|
||||
if ($jobs->isEmpty()) {
|
||||
$this->info('Keine abgelaufenen Einsätze.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->info("Folgende Einsätze würden gelöscht ({$jobs->count()}):");
|
||||
$this->table(
|
||||
['ID', 'Kunde', 'Beendet am'],
|
||||
$jobs->map(fn ($job) => [
|
||||
$job->id,
|
||||
$job->customer?->name ?? '–',
|
||||
$job->ended_at->format('d.m.Y H:i'),
|
||||
]),
|
||||
);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$deleted = $retentionService->purge($limit);
|
||||
|
||||
$this->info("{$deleted} Einsätze gelöscht und in Monatsstatistik aggregiert.");
|
||||
|
||||
if ($deleted < $jobs->count()) {
|
||||
$failed = $jobs->count() - $deleted;
|
||||
$this->warn("{$failed} Einsätze konnten nicht gelöscht werden. Siehe Laravel-Log.");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
67
app/Console/Commands/UpdateCheck.php
Normal file
67
app/Console/Commands/UpdateCheck.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Services\SchneespurUpdater;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class UpdateCheck extends Command
|
||||
{
|
||||
protected $signature = 'schneespur:update-check
|
||||
{--apply : Download and verify the ZIP after finding an update}';
|
||||
|
||||
protected $description = 'Check the update server for a new Schneespur version.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! $this->option('apply') && ! Setting::get('auto_update_check', true)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (! function_exists('sodium_crypto_sign_verify_detached')) {
|
||||
$this->error(__('update.sodium_missing'));
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
$updater = new SchneespurUpdater;
|
||||
$manifest = $updater->checkForUpdate();
|
||||
} catch (\Throwable $e) {
|
||||
$this->error(__('update.artisan_check_failed', ['error' => $e->getMessage()]));
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($manifest === null) {
|
||||
$this->info(__('update.artisan_up_to_date'));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info(__('update.artisan_update_available', [
|
||||
'version' => $manifest['version'],
|
||||
'counter' => $manifest['counter'],
|
||||
'signed_at' => $manifest['signed_at'],
|
||||
]));
|
||||
|
||||
if (! $this->option('apply')) {
|
||||
$this->line(__('update.artisan_apply_hint'));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$zipPath = $updater->downloadAndVerifyZip($manifest);
|
||||
} catch (\Throwable $e) {
|
||||
$this->error(__('update.artisan_zip_failed', ['error' => $e->getMessage()]));
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info(__('update.artisan_zip_verified', ['path' => $zipPath]));
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
83
app/Console/Commands/UpdateRecover.php
Normal file
83
app/Console/Commands/UpdateRecover.php
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\SchneespurUpdater;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class UpdateRecover extends Command
|
||||
{
|
||||
protected $signature = 'schneespur:update-recover
|
||||
{--force : Skip confirmation prompt}';
|
||||
|
||||
protected $description = 'Recover from a failed Schneespur update (restore backup + exit maintenance mode).';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$updater = new SchneespurUpdater;
|
||||
$recovery = $updater->getRecoveryInfo();
|
||||
|
||||
if ($recovery === null) {
|
||||
$this->info(__('update.recovery_no_info'));
|
||||
|
||||
if (app()->isDownForMaintenance()) {
|
||||
$this->warn(__('update.recovery_still_maintenance'));
|
||||
if ($this->option('force') || $this->confirm(__('update.recovery_confirm_up'))) {
|
||||
\Illuminate\Support\Facades\Artisan::call('up');
|
||||
$this->info(__('update.recovery_maintenance_disabled'));
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->error(__('update.recovery_found'));
|
||||
$this->table(
|
||||
['Key', 'Value'],
|
||||
[
|
||||
['Failed At', $recovery['failed_at'] ?? '?'],
|
||||
['Target Version', $recovery['target_version'] ?? '?'],
|
||||
['Error', $recovery['error'] ?? '?'],
|
||||
['Backup Dir', $recovery['backup_dir'] ?? '?'],
|
||||
]
|
||||
);
|
||||
|
||||
if (! empty($recovery['recovery_steps'])) {
|
||||
$this->line('');
|
||||
$this->info(__('update.recovery_steps_title'));
|
||||
foreach ($recovery['recovery_steps'] as $step) {
|
||||
$this->line(" {$step}");
|
||||
}
|
||||
}
|
||||
|
||||
$backupDir = $recovery['backup_dir'] ?? null;
|
||||
|
||||
if ($backupDir && is_dir($backupDir)) {
|
||||
$this->line('');
|
||||
if ($this->option('force') || $this->confirm(__('update.recovery_confirm_restore'))) {
|
||||
$ok = $updater->restoreFromBackup($backupDir);
|
||||
if ($ok) {
|
||||
$this->info(__('update.recovery_restore_success'));
|
||||
} else {
|
||||
$this->error(__('update.recovery_restore_failed'));
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->warn(__('update.recovery_no_backup'));
|
||||
}
|
||||
|
||||
if (app()->isDownForMaintenance()) {
|
||||
if ($this->option('force') || $this->confirm(__('update.recovery_confirm_up'))) {
|
||||
\Illuminate\Support\Facades\Artisan::call('up');
|
||||
$this->info(__('update.recovery_maintenance_disabled'));
|
||||
}
|
||||
}
|
||||
|
||||
$updater->clearRecoveryInfo();
|
||||
$this->info(__('update.recovery_cleared'));
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
16
app/Enums/JobType.php
Normal file
16
app/Enums/JobType.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum JobType: string
|
||||
{
|
||||
case Raumen = 'raumen';
|
||||
case Streuen = 'streuen';
|
||||
case Kontrolle = 'kontrolle';
|
||||
case RaumenStreuen = 'raumen_streuen';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return __('job.type_' . $this->value);
|
||||
}
|
||||
}
|
||||
18
app/Enums/UserRole.php
Normal file
18
app/Enums/UserRole.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum UserRole: string
|
||||
{
|
||||
case Admin = 'admin';
|
||||
case Driver = 'driver';
|
||||
|
||||
/**
|
||||
* Return the human-readable label for this role.
|
||||
* Uses the lang/de/admin.php (or current locale equivalent) keys.
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return __('admin.role_' . $this->value);
|
||||
}
|
||||
}
|
||||
14
app/Enums/WeatherMoment.php
Normal file
14
app/Enums/WeatherMoment.php
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum WeatherMoment: string
|
||||
{
|
||||
case Start = 'start';
|
||||
case End = 'end';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return __('weather.moment_' . $this->value);
|
||||
}
|
||||
}
|
||||
16
app/Events/CustomerCreated.php
Normal file
16
app/Events/CustomerCreated.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Customer;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class CustomerCreated
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Customer $customer,
|
||||
) {}
|
||||
}
|
||||
18
app/Events/JobCompleted.php
Normal file
18
app/Events/JobCompleted.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Job;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class JobCompleted
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Job $job,
|
||||
public bool $weatherAvailable,
|
||||
public bool $isWeatherUpdate = false,
|
||||
) {}
|
||||
}
|
||||
16
app/Events/JobStarted.php
Normal file
16
app/Events/JobStarted.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Job;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class JobStarted
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Job $job,
|
||||
) {}
|
||||
}
|
||||
16
app/Events/WeatherSnapshotCreated.php
Normal file
16
app/Events/WeatherSnapshotCreated.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\WeatherSnapshot;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class WeatherSnapshotCreated
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public WeatherSnapshot $snapshot,
|
||||
) {}
|
||||
}
|
||||
33
app/Exceptions/JobLifecycleException.php
Normal file
33
app/Exceptions/JobLifecycleException.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class JobLifecycleException extends RuntimeException
|
||||
{
|
||||
public static function shiftAlreadyActive(): self
|
||||
{
|
||||
return new self('Eine Schicht ist bereits aktiv. Beende die aktuelle Schicht, bevor du eine neue startest.');
|
||||
}
|
||||
|
||||
public static function noActiveShift(): self
|
||||
{
|
||||
return new self('Keine aktive Schicht vorhanden. Starte zuerst eine Schicht.');
|
||||
}
|
||||
|
||||
public static function jobAlreadyActive(): self
|
||||
{
|
||||
return new self('Ein Einsatz ist bereits aktiv. Beende den aktuellen Einsatz, bevor du einen neuen startest.');
|
||||
}
|
||||
|
||||
public static function noActiveJob(): self
|
||||
{
|
||||
return new self('Kein aktiver Einsatz vorhanden.');
|
||||
}
|
||||
|
||||
public static function activeJobMustEndFirst(): self
|
||||
{
|
||||
return new self('Ein aktiver Einsatz muss zuerst beendet werden, bevor die Schicht beendet werden kann.');
|
||||
}
|
||||
}
|
||||
121
app/Http/Controllers/Admin/AdminJobController.php
Normal file
121
app/Http/Controllers/Admin/AdminJobController.php
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Enums\JobType;
|
||||
use App\Models\Customer;
|
||||
use App\Models\CustomerObject;
|
||||
use App\Models\Job;
|
||||
use App\Models\User;
|
||||
use App\Services\GpsSmoothingService;
|
||||
use App\Services\JobAuditService;
|
||||
use App\Services\PdfReportService;
|
||||
use App\Services\RetentionService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AdminJobController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$jobs = Job::query()
|
||||
->with(['customer', 'customerObject.customer', 'user'])
|
||||
->withCount('gpsPoints')
|
||||
->when($request->started_after, fn ($q, $date) => $q->where('started_at', '>=', $date))
|
||||
->when($request->started_before, fn ($q, $date) => $q->where('started_at', '<=', $date))
|
||||
->when($request->user_id, fn ($q, $id) => $q->where('user_id', $id))
|
||||
->when($request->customer_id, fn ($q, $id) => $q->where('customer_id', $id))
|
||||
->when($request->customer_object_id, fn ($q, $id) => $q->where('customer_object_id', $id))
|
||||
->when($request->type, fn ($q, $type) => $q->where('type', $type))
|
||||
->orderByDesc('started_at')
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
$drivers = User::drivers()->orderBy('name')->get();
|
||||
$customers = Customer::orderBy('name')->get();
|
||||
$jobTypes = JobType::cases();
|
||||
$objects = $request->customer_id
|
||||
? CustomerObject::where('customer_id', $request->customer_id)->orderBy('name')->get()
|
||||
: collect();
|
||||
|
||||
return view('admin.jobs.index', compact('jobs', 'drivers', 'customers', 'jobTypes', 'objects'));
|
||||
}
|
||||
|
||||
public function show(Job $serviceJob, GpsSmoothingService $gpsSmoother): View
|
||||
{
|
||||
$serviceJob->load([
|
||||
'customer',
|
||||
'customerObject.customer',
|
||||
'user',
|
||||
'vehicle',
|
||||
'gpsPoints' => fn ($q) => $q->orderBy('timestamp'),
|
||||
'weatherSnapshots',
|
||||
'jobPhotos' => fn ($q) => $q->orderBy('sort_order')->orderBy('created_at'),
|
||||
'audits.user',
|
||||
]);
|
||||
|
||||
$smoothedGps = $gpsSmoother->smooth($serviceJob->gpsPoints)
|
||||
->map(fn ($p) => ['lat' => $p->lat, 'lon' => $p->lon]);
|
||||
|
||||
return view('admin.jobs.show', ['job' => $serviceJob, 'smoothedGps' => $smoothedGps]);
|
||||
}
|
||||
|
||||
public function edit(Job $serviceJob): View
|
||||
{
|
||||
$this->authorize('update', $serviceJob);
|
||||
|
||||
$serviceJob->load(['customer', 'customerObject.customer', 'user']);
|
||||
|
||||
return view('admin.jobs.edit', ['job' => $serviceJob]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Job $serviceJob, JobAuditService $auditService): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $serviceJob);
|
||||
|
||||
$validated = $request->validate([
|
||||
'notes' => ['nullable', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
$oldValues = ['notes' => $serviceJob->notes];
|
||||
$serviceJob->update($validated);
|
||||
$newValues = ['notes' => $serviceJob->notes];
|
||||
|
||||
$auditService->logChange($serviceJob, 'updated', $oldValues, $newValues);
|
||||
|
||||
return redirect()->route('admin.jobs.show', $serviceJob)
|
||||
->with('success', __('job.edit_success'));
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Job $serviceJob, JobAuditService $auditService, RetentionService $retentionService): RedirectResponse
|
||||
{
|
||||
$this->authorize('delete', $serviceJob);
|
||||
|
||||
$request->validate([
|
||||
'confirmation' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
if ($request->input('confirmation') !== __('job.delete_confirmation_word')) {
|
||||
return back()->withErrors(['confirmation' => __('job.delete_confirm_mismatch')]);
|
||||
}
|
||||
|
||||
$auditService->logDeletion($serviceJob);
|
||||
$retentionService->deleteJob($serviceJob);
|
||||
|
||||
return redirect()->route('admin.jobs.index')
|
||||
->with('success', __('job.delete_success'));
|
||||
}
|
||||
|
||||
public function pdf(Job $serviceJob, PdfReportService $pdfService): Response
|
||||
{
|
||||
abort_if(is_null($serviceJob->ended_at), 422, __('job.pdf_active_blocked'));
|
||||
|
||||
$pdf = $pdfService->generateJobReport($serviceJob);
|
||||
$filename = $pdfService->jobReportFilename($serviceJob);
|
||||
|
||||
return $pdf->download($filename);
|
||||
}
|
||||
}
|
||||
251
app/Http/Controllers/Admin/AdminModuleController.php
Normal file
251
app/Http/Controllers/Admin/AdminModuleController.php
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Module;
|
||||
use App\Services\SchneespurModuleClient;
|
||||
use App\Services\SchneespurModuleInstaller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AdminModuleController extends Controller
|
||||
{
|
||||
public function index(SchneespurModuleClient $client): View
|
||||
{
|
||||
$installed = Module::all()->keyBy('slug');
|
||||
|
||||
$catalogModules = [];
|
||||
$catalogError = null;
|
||||
|
||||
try {
|
||||
$catalog = $client->fetchCatalog();
|
||||
if ($catalog !== null) {
|
||||
$catalogModules = $catalog['modules'] ?? [];
|
||||
} else {
|
||||
$state = $client->loadState();
|
||||
$catalogModules = $state['installed'] ?? [];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('schneespur-modules: catalog fetch failed in admin UI', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$catalogError = $e->getMessage();
|
||||
}
|
||||
|
||||
$modules = [];
|
||||
foreach ($catalogModules as $catModule) {
|
||||
$slug = $catModule['slug'] ?? null;
|
||||
if (! $slug) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$local = $installed->get($slug);
|
||||
$modules[$slug] = [
|
||||
'slug' => $slug,
|
||||
'name' => SchneespurModuleClient::i18nPick($catModule['name'] ?? [], app()->getLocale()),
|
||||
'description' => SchneespurModuleClient::i18nPick($catModule['description'] ?? [], app()->getLocale()),
|
||||
'catalog_version' => $catModule['version'] ?? null,
|
||||
'category' => $catModule['category'] ?? null,
|
||||
'image' => $catModule['image'] ?? null,
|
||||
'installed' => $local !== null,
|
||||
'enabled' => $local?->enabled ?? false,
|
||||
'installed_version' => $local?->version,
|
||||
'has_update' => $local !== null && isset($catModule['version']) && version_compare($catModule['version'], $local->version, '>'),
|
||||
'is_orphan' => false,
|
||||
'download_url' => $catModule['download_url'] ?? null,
|
||||
'sha256' => $catModule['sha256'] ?? null,
|
||||
'size_bytes' => $catModule['size_bytes'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($installed as $slug => $local) {
|
||||
if (isset($modules[$slug])) {
|
||||
continue;
|
||||
}
|
||||
$modules[$slug] = [
|
||||
'slug' => $slug,
|
||||
'name' => $local->name ?? $slug,
|
||||
'description' => $local->description ?? '',
|
||||
'catalog_version' => null,
|
||||
'category' => $local->manifest_json['category'] ?? null,
|
||||
'image' => $local->manifest_json['image'] ?? null,
|
||||
'installed' => true,
|
||||
'enabled' => $local->enabled,
|
||||
'installed_version' => $local->version,
|
||||
'has_update' => false,
|
||||
'is_orphan' => true,
|
||||
'download_url' => null,
|
||||
'sha256' => null,
|
||||
'size_bytes' => null,
|
||||
];
|
||||
}
|
||||
|
||||
return view('admin.settings.modules.index', [
|
||||
'modules' => $modules,
|
||||
'catalogError' => $catalogError,
|
||||
]);
|
||||
}
|
||||
|
||||
public function install(Request $request, string $slug, SchneespurModuleClient $client, SchneespurModuleInstaller $installer): RedirectResponse
|
||||
{
|
||||
$catalog = null;
|
||||
try {
|
||||
$catalog = $client->fetchCatalog();
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->route('admin.settings.modules.index')
|
||||
->with('error', __('modules.catalog_fetch_failed', ['error' => $e->getMessage()]));
|
||||
}
|
||||
|
||||
if ($catalog === null) {
|
||||
return redirect()->route('admin.settings.modules.index')
|
||||
->with('error', __('modules.catalog_unavailable'));
|
||||
}
|
||||
|
||||
$moduleData = collect($catalog['modules'] ?? [])->firstWhere('slug', $slug);
|
||||
if (! $moduleData) {
|
||||
return redirect()->route('admin.settings.modules.index')
|
||||
->with('error', __('modules.not_found_in_catalog', ['slug' => $slug]));
|
||||
}
|
||||
|
||||
try {
|
||||
$zipPath = $client->downloadModule(
|
||||
$slug,
|
||||
$moduleData['download_url'],
|
||||
$moduleData['sha256'],
|
||||
$moduleData['size_bytes'],
|
||||
);
|
||||
|
||||
$success = $installer->install($zipPath, $slug);
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->route('admin.settings.modules.index')
|
||||
->with('error', __('modules.install_failed', ['slug' => $slug, 'error' => $e->getMessage()]));
|
||||
} finally {
|
||||
if (isset($zipPath)) {
|
||||
@unlink($zipPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (! $success) {
|
||||
return redirect()->route('admin.settings.modules.index')
|
||||
->with('error', __('modules.install_failed', ['slug' => $slug, 'error' => __('modules.directory_exists')]));
|
||||
}
|
||||
|
||||
Module::updateOrCreate(
|
||||
['slug' => $slug],
|
||||
[
|
||||
'version' => $moduleData['version'] ?? '0.0.0',
|
||||
'enabled' => true,
|
||||
'manifest_json' => $moduleData,
|
||||
'installed_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
return redirect()->route('admin.settings.modules.index')
|
||||
->with('success', __('modules.installed', ['slug' => $slug]));
|
||||
}
|
||||
|
||||
public function update(Request $request, string $slug, SchneespurModuleClient $client, SchneespurModuleInstaller $installer): RedirectResponse
|
||||
{
|
||||
$catalog = null;
|
||||
try {
|
||||
$catalog = $client->fetchCatalog();
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->route('admin.settings.modules.index')
|
||||
->with('error', __('modules.catalog_fetch_failed', ['error' => $e->getMessage()]));
|
||||
}
|
||||
|
||||
if ($catalog === null) {
|
||||
return redirect()->route('admin.settings.modules.index')
|
||||
->with('error', __('modules.catalog_unavailable'));
|
||||
}
|
||||
|
||||
$moduleData = collect($catalog['modules'] ?? [])->firstWhere('slug', $slug);
|
||||
if (! $moduleData) {
|
||||
return redirect()->route('admin.settings.modules.index')
|
||||
->with('error', __('modules.not_found_in_catalog', ['slug' => $slug]));
|
||||
}
|
||||
|
||||
try {
|
||||
$zipPath = $client->downloadModule(
|
||||
$slug,
|
||||
$moduleData['download_url'],
|
||||
$moduleData['sha256'],
|
||||
$moduleData['size_bytes'],
|
||||
);
|
||||
|
||||
$success = $installer->update($zipPath, $slug);
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->route('admin.settings.modules.index')
|
||||
->with('error', __('modules.update_failed', ['slug' => $slug, 'error' => $e->getMessage()]));
|
||||
} finally {
|
||||
if (isset($zipPath)) {
|
||||
@unlink($zipPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (! $success) {
|
||||
return redirect()->route('admin.settings.modules.index')
|
||||
->with('error', __('modules.update_failed', ['slug' => $slug, 'error' => __('modules.extraction_failed')]));
|
||||
}
|
||||
|
||||
Module::where('slug', $slug)->update([
|
||||
'version' => $moduleData['version'] ?? '0.0.0',
|
||||
'manifest_json' => $moduleData,
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.settings.modules.index')
|
||||
->with('success', __('modules.updated', ['slug' => $slug]));
|
||||
}
|
||||
|
||||
public function enable(string $slug): RedirectResponse
|
||||
{
|
||||
$module = Module::where('slug', $slug)->first();
|
||||
|
||||
if (! $module) {
|
||||
return redirect()->route('admin.settings.modules.index')
|
||||
->with('error', __('modules.not_installed', ['slug' => $slug]));
|
||||
}
|
||||
|
||||
$module->update(['enabled' => true]);
|
||||
|
||||
return redirect()->route('admin.settings.modules.index')
|
||||
->with('success', __('modules.enabled', ['slug' => $slug]));
|
||||
}
|
||||
|
||||
public function disable(string $slug): RedirectResponse
|
||||
{
|
||||
$module = Module::where('slug', $slug)->first();
|
||||
|
||||
if (! $module) {
|
||||
return redirect()->route('admin.settings.modules.index')
|
||||
->with('error', __('modules.not_installed', ['slug' => $slug]));
|
||||
}
|
||||
|
||||
$module->update(['enabled' => false]);
|
||||
|
||||
return redirect()->route('admin.settings.modules.index')
|
||||
->with('success', __('modules.disabled', ['slug' => $slug]));
|
||||
}
|
||||
|
||||
public function remove(string $slug, SchneespurModuleInstaller $installer): RedirectResponse
|
||||
{
|
||||
$module = Module::where('slug', $slug)->first();
|
||||
|
||||
if (! $module) {
|
||||
return redirect()->route('admin.settings.modules.index')
|
||||
->with('error', __('modules.not_installed', ['slug' => $slug]));
|
||||
}
|
||||
|
||||
$module->update(['enabled' => false]);
|
||||
|
||||
$installer->remove($slug);
|
||||
|
||||
$module->delete();
|
||||
|
||||
return redirect()->route('admin.settings.modules.index')
|
||||
->with('success', __('modules.removed', ['slug' => $slug]));
|
||||
}
|
||||
}
|
||||
41
app/Http/Controllers/Admin/AdminWorkShiftController.php
Normal file
41
app/Http/Controllers/Admin/AdminWorkShiftController.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkShift;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AdminWorkShiftController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$shifts = WorkShift::query()
|
||||
->with('user')
|
||||
->withCount('jobs')
|
||||
->when($request->user_id, fn ($q, $id) => $q->where('user_id', $id))
|
||||
->when($request->started_after, fn ($q, $date) => $q->where('started_at', '>=', $date))
|
||||
->when($request->started_before, fn ($q, $date) => $q->where('started_at', '<=', $date))
|
||||
->orderByDesc('started_at')
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
$drivers = User::drivers()->orderBy('name')->get();
|
||||
|
||||
return view('admin.workshifts.index', compact('shifts', 'drivers'));
|
||||
}
|
||||
|
||||
public function show(WorkShift $workShift): View
|
||||
{
|
||||
$workShift->load(['user', 'jobs.customer']);
|
||||
|
||||
$duration = null;
|
||||
if ($workShift->started_at && $workShift->ended_at) {
|
||||
$duration = $workShift->started_at->diffForHumans($workShift->ended_at, true);
|
||||
}
|
||||
|
||||
return view('admin.workshifts.show', compact('workShift', 'duration'));
|
||||
}
|
||||
}
|
||||
72
app/Http/Controllers/Admin/AlertController.php
Normal file
72
app/Http/Controllers/Admin/AlertController.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Job;
|
||||
use App\Services\AlertService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AlertController extends Controller
|
||||
{
|
||||
public function __construct(private AlertService $alertService) {}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$filters = $request->only(['type', 'date_from', 'date_to', 'status']);
|
||||
$counts = $this->alertService->counts();
|
||||
|
||||
$type = $filters['type'] ?? null;
|
||||
$alerts = null;
|
||||
|
||||
if ($type && in_array($type, ['missing_gps', 'missing_weather', 'overdue'])) {
|
||||
$query = $this->alertService->forType($type, $filters);
|
||||
$isResolved = ($filters['status'] ?? null) === 'resolved';
|
||||
|
||||
if ($isResolved) {
|
||||
$query->with(['job.customer', 'job.user', 'resolvedBy']);
|
||||
} else {
|
||||
$query->with(['customer', 'user']);
|
||||
}
|
||||
|
||||
$alerts = $query->paginate(15)->withQueryString();
|
||||
}
|
||||
|
||||
return view('admin.alerts.index', [
|
||||
'alerts' => $alerts,
|
||||
'counts' => $counts,
|
||||
'filters' => $filters,
|
||||
]);
|
||||
}
|
||||
|
||||
public function resolve(Request $request, Job $serviceJob)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'alert_type' => 'required|in:missing_gps,missing_weather,overdue',
|
||||
'note' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
$this->alertService->resolve(
|
||||
$serviceJob->id,
|
||||
$validated['alert_type'],
|
||||
$validated['note'] ?? null,
|
||||
$request->user()->id,
|
||||
);
|
||||
|
||||
return redirect()->back()->with('success', __('alerts.resolved'));
|
||||
}
|
||||
|
||||
public function bulkResolve(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'type' => 'required|in:missing_gps,missing_weather,overdue',
|
||||
]);
|
||||
|
||||
$count = $this->alertService->bulkResolve(
|
||||
$validated['type'],
|
||||
$request->user()->id,
|
||||
);
|
||||
|
||||
return redirect()->back()->with('success', __('alerts.bulk_resolved', ['count' => $count]));
|
||||
}
|
||||
}
|
||||
19
app/Http/Controllers/Admin/ArchivedDriverController.php
Normal file
19
app/Http/Controllers/Admin/ArchivedDriverController.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ArchivedDriverController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$drivers = User::onlyAnonymized()
|
||||
->orderByDesc('anonymized_at')
|
||||
->paginate(25);
|
||||
|
||||
return view('admin.drivers.archived', compact('drivers'));
|
||||
}
|
||||
}
|
||||
54
app/Http/Controllers/Admin/BrandingController.php
Normal file
54
app/Http/Controllers/Admin/BrandingController.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class BrandingController extends Controller
|
||||
{
|
||||
public function edit(): View
|
||||
{
|
||||
return view('admin.settings.branding', [
|
||||
'logoPath' => Setting::get('company_logo_path'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'company_logo' => ['nullable', 'image', 'mimes:png,jpg,jpeg,svg', 'max:2048'],
|
||||
]);
|
||||
|
||||
if ($request->hasFile('company_logo')) {
|
||||
$oldPath = Setting::get('company_logo_path');
|
||||
if ($oldPath && Storage::disk('public')->exists($oldPath)) {
|
||||
Storage::disk('public')->delete($oldPath);
|
||||
}
|
||||
|
||||
$path = $request->file('company_logo')->store('branding', 'public');
|
||||
Setting::set('company_logo_path', $path);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.settings.branding')
|
||||
->with('success', __('ui.saved'));
|
||||
}
|
||||
|
||||
public function deleteLogo(): RedirectResponse
|
||||
{
|
||||
$path = Setting::get('company_logo_path');
|
||||
|
||||
if ($path && Storage::disk('public')->exists($path)) {
|
||||
Storage::disk('public')->delete($path);
|
||||
}
|
||||
|
||||
Setting::set('company_logo_path', '');
|
||||
|
||||
return redirect()->route('admin.settings.branding')
|
||||
->with('success', __('ui.saved'));
|
||||
}
|
||||
}
|
||||
96
app/Http/Controllers/Admin/CompanyController.php
Normal file
96
app/Http/Controllers/Admin/CompanyController.php
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Setting;
|
||||
use App\Services\GeocodingService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class CompanyController extends Controller
|
||||
{
|
||||
private const LOCALES = [
|
||||
'de' => 'Deutsch',
|
||||
'en' => 'English',
|
||||
];
|
||||
|
||||
public function edit(): View
|
||||
{
|
||||
return view('admin.settings.company', [
|
||||
'company_name' => Setting::get('company_name', ''),
|
||||
'company_street' => Setting::get('company_street', ''),
|
||||
'company_zip' => Setting::get('company_zip', ''),
|
||||
'company_city' => Setting::get('company_city', ''),
|
||||
'company_phone' => Setting::get('company_phone', ''),
|
||||
'company_email' => Setting::get('company_email', ''),
|
||||
'company_lat' => Setting::get('company_lat'),
|
||||
'company_lon' => Setting::get('company_lon'),
|
||||
'dpo_contact' => Setting::get('dpo_contact', ''),
|
||||
'dpo_email' => Setting::get('dpo_email', ''),
|
||||
'season_from' => Setting::get('season_from', '11-01'),
|
||||
'season_to' => Setting::get('season_to', '03-31'),
|
||||
'alert_overdue_hours' => Setting::get('alert_overdue_hours', 4),
|
||||
'default_locale' => Setting::get('default_locale', 'de'),
|
||||
'locales' => self::LOCALES,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, GeocodingService $geocoding): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'company_name' => ['required', 'string', 'max:255'],
|
||||
'company_street' => ['nullable', 'string', 'max:255'],
|
||||
'company_zip' => ['nullable', 'string', 'max:10'],
|
||||
'company_city' => ['nullable', 'string', 'max:255'],
|
||||
'company_phone' => ['nullable', 'string', 'max:50'],
|
||||
'company_email' => ['nullable', 'email', 'max:255'],
|
||||
'dpo_contact' => ['nullable', 'string', 'max:255'],
|
||||
'dpo_email' => ['nullable', 'email', 'max:255'],
|
||||
'season_from' => ['required', 'string', 'regex:/^\d{2}-\d{2}$/'],
|
||||
'season_to' => ['required', 'string', 'regex:/^\d{2}-\d{2}$/'],
|
||||
'alert_overdue_hours' => ['required', 'integer', 'min:1'],
|
||||
'default_locale' => ['required', 'string', 'in:'.implode(',', array_keys(self::LOCALES))],
|
||||
]);
|
||||
|
||||
Setting::set('company_name', $validated['company_name']);
|
||||
Setting::set('company_street', $validated['company_street'] ?? '');
|
||||
Setting::set('company_zip', $validated['company_zip'] ?? '');
|
||||
Setting::set('company_city', $validated['company_city'] ?? '');
|
||||
Setting::set('company_phone', $validated['company_phone'] ?? '');
|
||||
Setting::set('company_email', $validated['company_email'] ?? '');
|
||||
Setting::set('dpo_contact', $validated['dpo_contact'] ?? '');
|
||||
Setting::set('dpo_email', $validated['dpo_email'] ?? '');
|
||||
Setting::set('season_from', $validated['season_from']);
|
||||
Setting::set('season_to', $validated['season_to']);
|
||||
Setting::set('alert_overdue_hours', $validated['alert_overdue_hours'], 'int');
|
||||
Setting::set('default_locale', $validated['default_locale']);
|
||||
|
||||
$street = $validated['company_street'] ?? '';
|
||||
$zip = $validated['company_zip'] ?? '';
|
||||
$city = $validated['company_city'] ?? '';
|
||||
|
||||
if ($street !== '' && $zip !== '' && $city !== '') {
|
||||
$oldStreet = Setting::get('company_street', '');
|
||||
$oldZip = Setting::get('company_zip', '');
|
||||
$oldCity = Setting::get('company_city', '');
|
||||
|
||||
$result = $geocoding->resolve($street, $zip, $city);
|
||||
|
||||
if ($result) {
|
||||
Setting::set('company_lat', (string) $result['lat']);
|
||||
Setting::set('company_lon', (string) $result['lon']);
|
||||
|
||||
return redirect()->route('admin.settings.company')
|
||||
->with('success', __('settings.company_geocode_success').' ('.$result['lat'].', '.$result['lon'].')');
|
||||
}
|
||||
|
||||
return redirect()->route('admin.settings.company')
|
||||
->with('warning', __('settings.company_geocode_fail'));
|
||||
}
|
||||
|
||||
return redirect()->route('admin.settings.company')
|
||||
->with('success', __('ui.saved'));
|
||||
}
|
||||
}
|
||||
62
app/Http/Controllers/Admin/CsvExportController.php
Normal file
62
app/Http/Controllers/Admin/CsvExportController.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Models\User;
|
||||
use App\Services\CsvExportService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class CsvExportController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CsvExportService $csvExportService,
|
||||
) {}
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
$drivers = User::withAnonymized()->drivers()->orderBy('name')->get();
|
||||
$customers = Customer::orderBy('name')->get();
|
||||
|
||||
return view('admin.exports.csv', [
|
||||
'drivers' => $drivers,
|
||||
'customers' => $customers,
|
||||
'defaultFrom' => Carbon::now()->startOfMonth()->format('Y-m-d'),
|
||||
'defaultTo' => Carbon::now()->format('Y-m-d'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function download(Request $request): Response
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'from' => ['required', 'date'],
|
||||
'to' => ['required', 'date', 'after_or_equal:from'],
|
||||
'variant' => ['required', 'in:all,driver,customer'],
|
||||
'user_id' => ['required_if:variant,driver', 'nullable', 'exists:users,id'],
|
||||
'customer_id' => ['required_if:variant,customer', 'nullable', 'exists:customers,id'],
|
||||
]);
|
||||
|
||||
$csv = $this->csvExportService->buildCsv(
|
||||
variant: $validated['variant'],
|
||||
from: $validated['from'],
|
||||
to: $validated['to'],
|
||||
userId: $validated['user_id'] ?? null,
|
||||
customerId: $validated['customer_id'] ?? null,
|
||||
);
|
||||
|
||||
$filename = $this->csvExportService->generateFilename(
|
||||
variant: $validated['variant'],
|
||||
from: $validated['from'],
|
||||
to: $validated['to'],
|
||||
);
|
||||
|
||||
return new Response($csv, 200, [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||
]);
|
||||
}
|
||||
}
|
||||
93
app/Http/Controllers/Admin/CustomerController.php
Normal file
93
app/Http/Controllers/Admin/CustomerController.php
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Events\CustomerCreated as CustomerCreatedEvent;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\StoreCustomerRequest;
|
||||
use App\Http\Requests\Admin\UpdateCustomerRequest;
|
||||
use App\Models\Customer;
|
||||
use App\Services\GeocodingService;
|
||||
use App\Services\NotificationLogService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class CustomerController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$customers = Customer::query()
|
||||
->with('objects')
|
||||
->when($request->search, function ($query, $search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhereHas('objects', fn ($obj) => $obj->where('city', 'like', "%{$search}%"));
|
||||
});
|
||||
})
|
||||
->orderBy('name')
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.customers.index', compact('customers'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.customers.create');
|
||||
}
|
||||
|
||||
public function store(StoreCustomerRequest $request): RedirectResponse
|
||||
{
|
||||
$customer = Customer::create($request->validated());
|
||||
|
||||
CustomerCreatedEvent::dispatch($customer);
|
||||
|
||||
return redirect()
|
||||
->route('admin.customers.index')
|
||||
->with('success', __('customer.flash_created', ['name' => $customer->name]));
|
||||
}
|
||||
|
||||
public function edit(Customer $customer): View
|
||||
{
|
||||
return view('admin.customers.edit', compact('customer'));
|
||||
}
|
||||
|
||||
public function update(UpdateCustomerRequest $request, Customer $customer): RedirectResponse
|
||||
{
|
||||
$customer->update($request->validated());
|
||||
|
||||
return redirect()
|
||||
->route('admin.customers.index')
|
||||
->with('success', __('customer.flash_updated', ['name' => $customer->name]));
|
||||
}
|
||||
|
||||
public function geocode(Request $request, GeocodingService $geocoding): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'street' => ['required', 'string'],
|
||||
'zip' => ['required', 'string'],
|
||||
'city' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$result = $geocoding->resolve($request->street, $request->zip, $request->city);
|
||||
|
||||
if ($result) {
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
return response()->json(['error' => __('customer.geocode_failed')], 422);
|
||||
}
|
||||
|
||||
public function destroy(Customer $customer, NotificationLogService $notificationLogService): RedirectResponse
|
||||
{
|
||||
$name = $customer->name;
|
||||
$notificationLogService->anonymizeForCustomer($customer);
|
||||
$customer->delete();
|
||||
|
||||
return redirect()
|
||||
->route('admin.customers.index')
|
||||
->with('success', __('customer.flash_deleted', ['name' => $name]));
|
||||
}
|
||||
}
|
||||
65
app/Http/Controllers/Admin/CustomerObjectController.php
Normal file
65
app/Http/Controllers/Admin/CustomerObjectController.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\StoreCustomerObjectRequest;
|
||||
use App\Http\Requests\Admin\UpdateCustomerObjectRequest;
|
||||
use App\Models\Customer;
|
||||
use App\Models\CustomerObject;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class CustomerObjectController extends Controller
|
||||
{
|
||||
public function index(Customer $customer): View
|
||||
{
|
||||
$objects = $customer->objects()->orderBy('name')->get();
|
||||
|
||||
return view('admin.customer_objects.index', compact('customer', 'objects'));
|
||||
}
|
||||
|
||||
public function create(Customer $customer): View
|
||||
{
|
||||
return view('admin.customer_objects.create', compact('customer'));
|
||||
}
|
||||
|
||||
public function store(StoreCustomerObjectRequest $request, Customer $customer): RedirectResponse
|
||||
{
|
||||
$object = $customer->objects()->create($request->validated());
|
||||
|
||||
return redirect()
|
||||
->route('admin.customers.objects.index', $customer)
|
||||
->with('success', __('customer_object.flash_created', ['name' => $object->name]));
|
||||
}
|
||||
|
||||
public function edit(Customer $customer, CustomerObject $object): View
|
||||
{
|
||||
return view('admin.customer_objects.edit', compact('customer', 'object'));
|
||||
}
|
||||
|
||||
public function update(UpdateCustomerObjectRequest $request, Customer $customer, CustomerObject $object): RedirectResponse
|
||||
{
|
||||
$object->update($request->validated());
|
||||
|
||||
return redirect()
|
||||
->route('admin.customers.objects.index', $customer)
|
||||
->with('success', __('customer_object.flash_updated', ['name' => $object->name]));
|
||||
}
|
||||
|
||||
public function destroy(Customer $customer, CustomerObject $object): RedirectResponse
|
||||
{
|
||||
if ($object->serviceJobs()->exists()) {
|
||||
return redirect()
|
||||
->route('admin.customers.objects.index', $customer)
|
||||
->with('error', __('customer_object.flash_delete_has_jobs', ['name' => $object->name]));
|
||||
}
|
||||
|
||||
$name = $object->name;
|
||||
$object->delete();
|
||||
|
||||
return redirect()
|
||||
->route('admin.customers.objects.index', $customer)
|
||||
->with('success', __('customer_object.flash_deleted', ['name' => $name]));
|
||||
}
|
||||
}
|
||||
78
app/Http/Controllers/Admin/CustomerPdfController.php
Normal file
78
app/Http/Controllers/Admin/CustomerPdfController.php
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Job;
|
||||
use App\Services\PdfReportService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class CustomerPdfController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PdfReportService $pdfReportService,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$customers = Customer::orderBy('name')->get();
|
||||
|
||||
return view('admin.exports.customer-pdf', [
|
||||
'customers' => $customers,
|
||||
'selectedCustomer' => $request->query('customer'),
|
||||
'defaultFrom' => Carbon::now()->startOfMonth()->format('Y-m-d'),
|
||||
'defaultTo' => Carbon::now()->format('Y-m-d'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function generate(Request $request): Response
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'customer_id' => ['required', 'exists:customers,id'],
|
||||
'from' => ['required', 'date'],
|
||||
'to' => ['required', 'date', 'after_or_equal:from'],
|
||||
'include_active' => ['sometimes', 'boolean'],
|
||||
'confirmed' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
|
||||
$customer = Customer::findOrFail($validated['customer_id']);
|
||||
$from = Carbon::parse($validated['from']);
|
||||
$to = Carbon::parse($validated['to']);
|
||||
$includeActive = (bool) ($validated['include_active'] ?? false);
|
||||
|
||||
$jobCount = Job::where('customer_id', $customer->id)
|
||||
->where('started_at', '>=', $from)
|
||||
->where('started_at', '<=', $to->copy()->endOfDay())
|
||||
->when(! $includeActive, fn ($q) => $q->whereNotNull('ended_at'))
|
||||
->count();
|
||||
|
||||
if ($jobCount === 0) {
|
||||
return redirect()->back()
|
||||
->withInput()
|
||||
->with('error', __('export.pdf_no_jobs'));
|
||||
}
|
||||
|
||||
if ($jobCount > 50 && ! ($validated['confirmed'] ?? false)) {
|
||||
return redirect()->back()
|
||||
->withInput()
|
||||
->with('warning', __('export.pdf_warning_many_jobs', ['count' => $jobCount]));
|
||||
}
|
||||
|
||||
try {
|
||||
$pdf = $this->pdfReportService->generateCustomerReport($customer, $from, $to, $includeActive);
|
||||
$filename = $this->pdfReportService->customerReportFilename($customer, $from, $to);
|
||||
|
||||
return $pdf->download($filename);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return redirect()->back()
|
||||
->withInput()
|
||||
->with('error', __('export.pdf_no_jobs'));
|
||||
}
|
||||
}
|
||||
}
|
||||
69
app/Http/Controllers/Admin/CustomerPortalController.php
Normal file
69
app/Http/Controllers/Admin/CustomerPortalController.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\PortalCredentialsMail;
|
||||
use App\Models\Customer;
|
||||
use App\Services\NotificationLogService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CustomerPortalController extends Controller
|
||||
{
|
||||
public function setupAccess(Request $request, Customer $customer, NotificationLogService $logService): RedirectResponse
|
||||
{
|
||||
if (! $customer->email) {
|
||||
return redirect()
|
||||
->route('admin.customers.edit', $customer)
|
||||
->with('error', __('customer.portal_no_email'));
|
||||
}
|
||||
|
||||
$isReset = $customer->getOriginal('password') !== null;
|
||||
$plainPassword = Str::random(12);
|
||||
|
||||
$customer->password = $plainPassword;
|
||||
$customer->portal_enabled = true;
|
||||
$customer->save();
|
||||
|
||||
try {
|
||||
Mail::to($customer->email)->send(new PortalCredentialsMail($customer, $plainPassword, $isReset));
|
||||
|
||||
$logService->logSentForCustomer($customer, 'portal_credentials', $customer->email, [
|
||||
'action' => $isReset ? 'reset' : 'setup',
|
||||
]);
|
||||
|
||||
$flashKey = $isReset ? 'customer.portal_flash_reset' : 'customer.portal_flash_setup';
|
||||
|
||||
return redirect()
|
||||
->route('admin.customers.edit', $customer)
|
||||
->with('success', __($flashKey, ['name' => $customer->name]));
|
||||
} catch (\Throwable $e) {
|
||||
$logService->logFailedForCustomer($customer, 'portal_credentials', $customer->email, $e->getMessage(), [
|
||||
'action' => $isReset ? 'reset' : 'setup',
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('admin.customers.edit', $customer)
|
||||
->with('error', __('customer.portal_flash_email_failed', ['name' => $customer->name]));
|
||||
}
|
||||
}
|
||||
|
||||
public function updateSettings(Request $request, Customer $customer): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'portal_enabled' => ['required', 'boolean'],
|
||||
'portal_show_gps' => ['required', 'boolean'],
|
||||
'portal_show_photos' => ['required', 'boolean'],
|
||||
'portal_show_driver_name' => ['required', 'boolean'],
|
||||
]);
|
||||
|
||||
$customer->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('admin.customers.edit', $customer)
|
||||
->with('success', __('customer.portal_flash_settings_updated', ['name' => $customer->name]));
|
||||
}
|
||||
}
|
||||
124
app/Http/Controllers/Admin/CustomerReportController.php
Normal file
124
app/Http/Controllers/Admin/CustomerReportController.php
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Job;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class CustomerReportController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$customers = Customer::orderBy('name')->get();
|
||||
|
||||
$from = $this->parseDate($request->input('from'), Carbon::now()->startOfMonth());
|
||||
$to = $this->parseDate($request->input('to'), Carbon::now()->startOfDay());
|
||||
|
||||
$selectedCustomer = null;
|
||||
$jobs = null;
|
||||
$totalJobs = 0;
|
||||
$totalMinutes = 0;
|
||||
$driverCount = 0;
|
||||
$jobTypeBreakdown = collect();
|
||||
$avgDurationMinutes = 0;
|
||||
$frequencyPerWeek = null;
|
||||
$sammelPdfUrl = null;
|
||||
|
||||
$customerId = $request->input('customer');
|
||||
|
||||
if ($customerId) {
|
||||
$selectedCustomer = Customer::find($customerId);
|
||||
|
||||
if ($selectedCustomer) {
|
||||
$jobs = Job::with(['customerObject', 'user' => fn ($q) => $q->withAnonymized()])
|
||||
->where('customer_id', $customerId)
|
||||
->where('started_at', '>=', $from)
|
||||
->where('started_at', '<', $to->copy()->addDay())
|
||||
->orderBy('started_at')
|
||||
->get();
|
||||
|
||||
$totalJobs = $jobs->count();
|
||||
$totalMinutes = $jobs->sum(fn ($job) => $job->started_at->diffInMinutes($job->ended_at ?? $job->started_at));
|
||||
$driverCount = $jobs->pluck('user_id')->unique()->count();
|
||||
$jobTypeBreakdown = $jobs->groupBy(fn ($j) => $j->type->value)->map->count();
|
||||
$avgDurationMinutes = $totalJobs > 0 ? intdiv($totalMinutes, $totalJobs) : 0;
|
||||
|
||||
$days = max(1, $from->diffInDays($to));
|
||||
if ($days >= 7) {
|
||||
$weeks = max(1, ceil($days / 7));
|
||||
$frequencyPerWeek = round($totalJobs / $weeks, 1);
|
||||
}
|
||||
|
||||
$sammelPdfUrl = route('admin.exports.customer-pdf', [
|
||||
'customer' => $customerId,
|
||||
'from' => $from->format('Y-m-d'),
|
||||
'to' => $to->format('Y-m-d'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return view('admin.overview.customer-report', [
|
||||
'customers' => $customers,
|
||||
'selectedCustomer' => $selectedCustomer,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'quickFilters' => $this->buildQuickFilters(),
|
||||
'jobs' => $jobs,
|
||||
'totalJobs' => $totalJobs,
|
||||
'totalMinutes' => $totalMinutes,
|
||||
'driverCount' => $driverCount,
|
||||
'jobTypeBreakdown' => $jobTypeBreakdown,
|
||||
'avgDurationMinutes' => $avgDurationMinutes,
|
||||
'frequencyPerWeek' => $frequencyPerWeek,
|
||||
'sammelPdfUrl' => $sammelPdfUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
private function parseDate(?string $input, Carbon $default): Carbon
|
||||
{
|
||||
try {
|
||||
return $input ? Carbon::parse($input)->startOfDay() : $default;
|
||||
} catch (\Exception) {
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
private function buildQuickFilters(): array
|
||||
{
|
||||
$now = Carbon::now();
|
||||
|
||||
if ($now->month >= 11) {
|
||||
$seasonFrom = Carbon::create($now->year, 11, 1);
|
||||
$seasonTo = Carbon::create($now->year + 1, 3, 31);
|
||||
} elseif ($now->month <= 3) {
|
||||
$seasonFrom = Carbon::create($now->year - 1, 11, 1);
|
||||
$seasonTo = Carbon::create($now->year, 3, 31);
|
||||
} else {
|
||||
$seasonFrom = Carbon::create($now->year - 1, 11, 1);
|
||||
$seasonTo = Carbon::create($now->year, 3, 31);
|
||||
}
|
||||
|
||||
return [
|
||||
'week' => [
|
||||
'from' => $now->copy()->startOfWeek()->format('Y-m-d'),
|
||||
'to' => $now->format('Y-m-d'),
|
||||
],
|
||||
'month' => [
|
||||
'from' => $now->copy()->startOfMonth()->format('Y-m-d'),
|
||||
'to' => $now->format('Y-m-d'),
|
||||
],
|
||||
'30days' => [
|
||||
'from' => $now->copy()->subDays(30)->format('Y-m-d'),
|
||||
'to' => $now->format('Y-m-d'),
|
||||
],
|
||||
'season' => [
|
||||
'from' => $seasonFrom->format('Y-m-d'),
|
||||
'to' => $seasonTo->format('Y-m-d'),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
49
app/Http/Controllers/Admin/CustomerReportEmailController.php
Normal file
49
app/Http/Controllers/Admin/CustomerReportEmailController.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\SendCustomerReportEmail;
|
||||
use App\Models\Customer;
|
||||
use App\Models\CustomerObject;
|
||||
use App\Services\NotificationLogService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CustomerReportEmailController extends Controller
|
||||
{
|
||||
public function send(Request $request, NotificationLogService $notificationLogService): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'customer_id' => ['required', 'exists:customers,id'],
|
||||
'customer_object_id' => ['nullable', 'exists:customer_objects,id'],
|
||||
'from' => ['required', 'date'],
|
||||
'to' => ['required', 'date', 'after_or_equal:from'],
|
||||
]);
|
||||
|
||||
$customer = Customer::findOrFail($validated['customer_id']);
|
||||
$object = isset($validated['customer_object_id'])
|
||||
? CustomerObject::findOrFail($validated['customer_object_id'])
|
||||
: null;
|
||||
|
||||
$recipient = $object
|
||||
? ($object->contact_email ?? $customer->notification_email ?? $customer->email)
|
||||
: ($customer->notification_email ?? $customer->email);
|
||||
|
||||
if (empty($recipient)) {
|
||||
return redirect()->back()->with('error', __('notification.customer_report_email_no_email'));
|
||||
}
|
||||
|
||||
$from = Carbon::parse($validated['from']);
|
||||
$to = Carbon::parse($validated['to']);
|
||||
|
||||
if ($notificationLogService->wasRecentlySentToCustomer($customer, 'customer_report_email', $from, $to)) {
|
||||
return redirect()->back()->with('error', __('notification.customer_report_email_duplicate'));
|
||||
}
|
||||
|
||||
SendCustomerReportEmail::dispatch($customer, $from, $to, $object);
|
||||
|
||||
return redirect()->back()->with('success', __('notification.customer_report_email_sent'));
|
||||
}
|
||||
}
|
||||
26
app/Http/Controllers/Admin/DashboardController.php
Normal file
26
app/Http/Controllers/Admin/DashboardController.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Setting;
|
||||
use App\Services\Extension\DashboardWidgetRegistry;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function dismissOnboarding(): RedirectResponse
|
||||
{
|
||||
Setting::set('onboarding_dismissed', '1');
|
||||
|
||||
return redirect()->route('admin.dashboard');
|
||||
}
|
||||
|
||||
public function index(DashboardWidgetRegistry $widgetRegistry): View
|
||||
{
|
||||
$widgets = $widgetRegistry->getWidgets();
|
||||
|
||||
return view('admin.dashboard', compact('widgets'));
|
||||
}
|
||||
}
|
||||
24
app/Http/Controllers/Admin/DriverAnonymizationController.php
Normal file
24
app/Http/Controllers/Admin/DriverAnonymizationController.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\AnonymizeDriverRequest;
|
||||
use App\Models\User;
|
||||
use App\Services\DriverAnonymizationService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class DriverAnonymizationController extends Controller
|
||||
{
|
||||
public function __invoke(
|
||||
AnonymizeDriverRequest $request,
|
||||
User $driver,
|
||||
DriverAnonymizationService $service
|
||||
): RedirectResponse {
|
||||
$service->anonymize($driver, $request->validated('reason'));
|
||||
|
||||
return redirect()
|
||||
->route('admin.drivers.index')
|
||||
->with('success', __('driver.flash_anonymized'));
|
||||
}
|
||||
}
|
||||
77
app/Http/Controllers/Admin/DriverController.php
Normal file
77
app/Http/Controllers/Admin/DriverController.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\StoreDriverRequest;
|
||||
use App\Http\Requests\Admin\UpdateDriverRequest;
|
||||
use App\Models\User;
|
||||
use App\Models\Vehicle;
|
||||
use App\Services\OwntracksCredentialService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DriverController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$drivers = User::drivers()
|
||||
->when($request->search, function ($query, $search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
})
|
||||
->orderBy('name')
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.drivers.index', compact('drivers'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.drivers.create', [
|
||||
'vehicles' => Vehicle::orderBy('name')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreDriverRequest $request, OwntracksCredentialService $credentialService): RedirectResponse
|
||||
{
|
||||
$driver = User::create($request->safe()->only(['name', 'email', 'password', 'phone', 'notes', 'default_vehicle_id']));
|
||||
$driver->role = UserRole::Driver;
|
||||
$driver->save();
|
||||
|
||||
$credentials = $credentialService->generateCredentials($driver, $request->user());
|
||||
|
||||
session()->flash('owntracks_credentials', $credentials);
|
||||
|
||||
return redirect()
|
||||
->route('admin.drivers.credentials', $driver)
|
||||
->with('success', __('driver.flash_created', ['name' => $driver->name]));
|
||||
}
|
||||
|
||||
public function edit(User $driver): View
|
||||
{
|
||||
return view('admin.drivers.edit', [
|
||||
'driver' => $driver,
|
||||
'vehicles' => Vehicle::orderBy('name')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateDriverRequest $request, User $driver): RedirectResponse
|
||||
{
|
||||
$driver->update($request->safe()->only(['name', 'email', 'phone', 'notes', 'default_vehicle_id']));
|
||||
|
||||
if ($request->validated('password')) {
|
||||
$driver->password = $request->validated('password');
|
||||
$driver->save();
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('admin.drivers.index')
|
||||
->with('success', __('driver.flash_updated', ['name' => $driver->name]));
|
||||
}
|
||||
}
|
||||
38
app/Http/Controllers/Admin/DriverCredentialController.php
Normal file
38
app/Http/Controllers/Admin/DriverCredentialController.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\OwntracksCredentialService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DriverCredentialController extends Controller
|
||||
{
|
||||
public function show(Request $request, User $driver): View|RedirectResponse
|
||||
{
|
||||
$credentials = session('owntracks_credentials');
|
||||
|
||||
if (! $credentials) {
|
||||
return redirect()->route('admin.drivers.edit', $driver);
|
||||
}
|
||||
|
||||
$driver->owntracks_password_revealed_at = now();
|
||||
$driver->save();
|
||||
|
||||
return view('admin.drivers.credentials', compact('driver', 'credentials'));
|
||||
}
|
||||
|
||||
public function rotate(Request $request, User $driver, OwntracksCredentialService $credentialService): RedirectResponse
|
||||
{
|
||||
$credentials = $credentialService->generateCredentials($driver, $request->user());
|
||||
|
||||
session()->flash('owntracks_credentials', $credentials);
|
||||
|
||||
return redirect()
|
||||
->route('admin.drivers.credentials', $driver)
|
||||
->with('success', __('driver.flash_rotated'));
|
||||
}
|
||||
}
|
||||
28
app/Http/Controllers/Admin/DriverExportController.php
Normal file
28
app/Http/Controllers/Admin/DriverExportController.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\DriverExportService;
|
||||
|
||||
class DriverExportController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DriverExportService $exportService,
|
||||
) {}
|
||||
|
||||
public function exportSingle(User $driver)
|
||||
{
|
||||
$path = $this->exportService->exportSingle($driver);
|
||||
|
||||
return response()->download($path, "fahrer-{$driver->id}-export.zip")->deleteFileAfterSend(true);
|
||||
}
|
||||
|
||||
public function exportAll()
|
||||
{
|
||||
$path = $this->exportService->exportAll();
|
||||
|
||||
return response()->download($path, 'alle-fahrer-export.zip')->deleteFileAfterSend(true);
|
||||
}
|
||||
}
|
||||
121
app/Http/Controllers/Admin/DriverReportController.php
Normal file
121
app/Http/Controllers/Admin/DriverReportController.php
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Job;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkShift;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DriverReportController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$drivers = User::withAnonymized()->drivers()->orderBy('name')->get();
|
||||
|
||||
$from = $this->parseDate($request->input('from'), Carbon::now()->startOfMonth());
|
||||
$to = $this->parseDate($request->input('to'), Carbon::now()->startOfDay());
|
||||
|
||||
$selectedDriver = null;
|
||||
$jobs = null;
|
||||
$totalJobs = 0;
|
||||
$totalMinutes = 0;
|
||||
$customerCount = 0;
|
||||
$jobTypeBreakdown = collect();
|
||||
$shiftCount = 0;
|
||||
$totalShiftMinutes = 0;
|
||||
$avgShiftMinutes = 0;
|
||||
|
||||
$driverId = $request->input('driver');
|
||||
|
||||
if ($driverId) {
|
||||
$selectedDriver = User::withAnonymized()->find($driverId);
|
||||
|
||||
if ($selectedDriver) {
|
||||
$jobs = Job::with(['customer', 'customerObject.customer'])
|
||||
->where('user_id', $driverId)
|
||||
->where('started_at', '>=', $from)
|
||||
->where('started_at', '<', $to->copy()->addDay())
|
||||
->orderBy('started_at')
|
||||
->get();
|
||||
|
||||
$totalJobs = $jobs->count();
|
||||
$totalMinutes = $jobs->sum(fn ($job) => $job->started_at->diffInMinutes($job->ended_at ?? $job->started_at));
|
||||
$customerCount = $jobs->pluck('customer_id')->unique()->count();
|
||||
$jobTypeBreakdown = $jobs->groupBy(fn ($j) => $j->type->value)->map->count();
|
||||
|
||||
$shifts = WorkShift::where('user_id', $driverId)
|
||||
->where('started_at', '>=', $from)
|
||||
->where('started_at', '<', $to->copy()->addDay())
|
||||
->get();
|
||||
|
||||
$shiftCount = $shifts->count();
|
||||
$totalShiftMinutes = $shifts->sum(fn ($s) => $s->started_at->diffInMinutes($s->ended_at ?? $s->started_at));
|
||||
$avgShiftMinutes = $shiftCount > 0 ? intdiv($totalShiftMinutes, $shiftCount) : 0;
|
||||
}
|
||||
}
|
||||
|
||||
return view('admin.overview.driver-report', [
|
||||
'drivers' => $drivers,
|
||||
'selectedDriver' => $selectedDriver,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'quickFilters' => $this->buildQuickFilters(),
|
||||
'jobs' => $jobs,
|
||||
'totalJobs' => $totalJobs,
|
||||
'totalMinutes' => $totalMinutes,
|
||||
'customerCount' => $customerCount,
|
||||
'jobTypeBreakdown' => $jobTypeBreakdown,
|
||||
'shiftCount' => $shiftCount,
|
||||
'totalShiftMinutes' => $totalShiftMinutes,
|
||||
'avgShiftMinutes' => $avgShiftMinutes,
|
||||
]);
|
||||
}
|
||||
|
||||
private function parseDate(?string $input, Carbon $default): Carbon
|
||||
{
|
||||
try {
|
||||
return $input ? Carbon::parse($input)->startOfDay() : $default;
|
||||
} catch (\Exception) {
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
private function buildQuickFilters(): array
|
||||
{
|
||||
$now = Carbon::now();
|
||||
|
||||
if ($now->month >= 11) {
|
||||
$seasonFrom = Carbon::create($now->year, 11, 1);
|
||||
$seasonTo = Carbon::create($now->year + 1, 3, 31);
|
||||
} elseif ($now->month <= 3) {
|
||||
$seasonFrom = Carbon::create($now->year - 1, 11, 1);
|
||||
$seasonTo = Carbon::create($now->year, 3, 31);
|
||||
} else {
|
||||
$seasonFrom = Carbon::create($now->year - 1, 11, 1);
|
||||
$seasonTo = Carbon::create($now->year, 3, 31);
|
||||
}
|
||||
|
||||
return [
|
||||
'week' => [
|
||||
'from' => $now->copy()->startOfWeek()->format('Y-m-d'),
|
||||
'to' => $now->format('Y-m-d'),
|
||||
],
|
||||
'month' => [
|
||||
'from' => $now->copy()->startOfMonth()->format('Y-m-d'),
|
||||
'to' => $now->format('Y-m-d'),
|
||||
],
|
||||
'30days' => [
|
||||
'from' => $now->copy()->subDays(30)->format('Y-m-d'),
|
||||
'to' => $now->format('Y-m-d'),
|
||||
],
|
||||
'season' => [
|
||||
'from' => $seasonFrom->format('Y-m-d'),
|
||||
'to' => $seasonTo->format('Y-m-d'),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
118
app/Http/Controllers/Admin/DsgvoAdminController.php
Normal file
118
app/Http/Controllers/Admin/DsgvoAdminController.php
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DsgvoConfirmation;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DsgvoAdminController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$markdown = Setting::get('dsgvo_template_markdown');
|
||||
$version = (int) Setting::get('dsgvo_template_version', 1);
|
||||
|
||||
if ($markdown === null) {
|
||||
$markdown = view('dsgvo.default-template')->render();
|
||||
}
|
||||
|
||||
$previewHtml = Str::markdown($this->replacePlaceholders($markdown), ['html_input' => 'strip']);
|
||||
$confirmationCount = DsgvoConfirmation::where('template_version', $version)->count();
|
||||
|
||||
return view('admin.dsgvo.index', [
|
||||
'markdown' => $markdown,
|
||||
'previewHtml' => $previewHtml,
|
||||
'version' => $version,
|
||||
'confirmationCount' => $confirmationCount,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'markdown' => 'required|string|min:50|max:200000',
|
||||
'substantial_change' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
Setting::set('dsgvo_template_markdown', $validated['markdown']);
|
||||
|
||||
if ($request->boolean('substantial_change')) {
|
||||
$currentVersion = (int) Setting::get('dsgvo_template_version', 1);
|
||||
Setting::set('dsgvo_template_version', $currentVersion + 1, 'int');
|
||||
|
||||
return redirect()->back()
|
||||
->with('success', __('dsgvo.flash_template_updated_substantial'));
|
||||
}
|
||||
|
||||
return redirect()->back()
|
||||
->with('success', __('dsgvo.flash_template_updated'));
|
||||
}
|
||||
|
||||
public function preview(Request $request): Response
|
||||
{
|
||||
$request->validate([
|
||||
'markdown' => 'required|string',
|
||||
]);
|
||||
|
||||
$html = Str::markdown($this->replacePlaceholders($request->markdown), ['html_input' => 'strip']);
|
||||
|
||||
return new Response($html, 200, ['Content-Type' => 'text/html']);
|
||||
}
|
||||
|
||||
public function confirmations(Request $request): View
|
||||
{
|
||||
$query = DsgvoConfirmation::with('driver')
|
||||
->orderByDesc('confirmed_at');
|
||||
|
||||
if ($search = $request->input('search')) {
|
||||
$query->whereHas('driver', fn ($q) => $q->where('name', 'like', "%{$search}%"));
|
||||
}
|
||||
|
||||
$confirmations = $query->paginate(25)->withQueryString();
|
||||
|
||||
return view('admin.dsgvo.confirmations', [
|
||||
'confirmations' => $confirmations,
|
||||
]);
|
||||
}
|
||||
|
||||
private function replacePlaceholders(string $text): string
|
||||
{
|
||||
$companyName = Setting::get('company_name', '');
|
||||
$street = Setting::get('company_street', '');
|
||||
$zip = Setting::get('company_zip', '');
|
||||
$city = Setting::get('company_city', '');
|
||||
$email = Setting::get('company_email', '');
|
||||
$dpo = Setting::get('dpo_contact', '');
|
||||
$dpoEmail = Setting::get('dpo_email', '');
|
||||
|
||||
$address = trim("$street, $zip $city", ', ');
|
||||
|
||||
$replacements = [
|
||||
'[Firmenname eintragen]' => $companyName ?: '[Firmenname eintragen]',
|
||||
'[Adresse eintragen]' => $address ?: '[Adresse eintragen]',
|
||||
'[E-Mail-Adresse eintragen]' => $email ?: '[E-Mail-Adresse eintragen]',
|
||||
'[DPO-E-Mail-Adresse eintragen]' => $dpoEmail ?: '[DPO-E-Mail-Adresse eintragen]',
|
||||
'[Datenschutzbeauftragter / Ansprechpartner eintragen]' => $dpo ?: '[Datenschutzbeauftragter / Ansprechpartner eintragen]',
|
||||
];
|
||||
|
||||
return str_replace(array_keys($replacements), array_values($replacements), $text);
|
||||
}
|
||||
|
||||
public function showConfirmation(int $id): View
|
||||
{
|
||||
$confirmation = DsgvoConfirmation::findOrFail($id);
|
||||
$confirmation->load('driver');
|
||||
|
||||
$snapshotHtml = Str::markdown($confirmation->notice_text_snapshot, ['html_input' => 'strip']);
|
||||
|
||||
return view('admin.dsgvo.confirmation-show', [
|
||||
'confirmation' => $confirmation,
|
||||
'snapshotHtml' => $snapshotHtml,
|
||||
]);
|
||||
}
|
||||
}
|
||||
128
app/Http/Controllers/Admin/EmailSettingsController.php
Normal file
128
app/Http/Controllers/Admin/EmailSettingsController.php
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Installer\EnvFileWriter;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class EmailSettingsController extends Controller
|
||||
{
|
||||
private const PASSWORD_SENTINEL = '••••••••';
|
||||
|
||||
private const MAIL_KEYS = [
|
||||
'MAIL_MAILER',
|
||||
'MAIL_HOST',
|
||||
'MAIL_PORT',
|
||||
'MAIL_SCHEME',
|
||||
'MAIL_USERNAME',
|
||||
'MAIL_FROM_ADDRESS',
|
||||
'MAIL_FROM_NAME',
|
||||
];
|
||||
|
||||
public function edit(EnvFileWriter $envWriter): View
|
||||
{
|
||||
$config = [];
|
||||
foreach (self::MAIL_KEYS as $key) {
|
||||
$value = $envWriter->get($key) ?? '';
|
||||
if ($value === 'null') {
|
||||
$value = '';
|
||||
}
|
||||
if (preg_match('/\$\{(.+?)\}/', $value, $m)) {
|
||||
$value = env($m[1], $value);
|
||||
}
|
||||
$config[$key] = $value;
|
||||
}
|
||||
$config['MAIL_MAILER'] = $config['MAIL_MAILER'] ?: 'smtp';
|
||||
|
||||
$envWritable = $envWriter->isWritable();
|
||||
|
||||
$envContent = '';
|
||||
if (! $envWritable) {
|
||||
$lines = [];
|
||||
foreach (self::MAIL_KEYS as $key) {
|
||||
$lines[] = $key . '=' . $config[$key];
|
||||
}
|
||||
$lines[] = 'MAIL_PASSWORD=your-password-here';
|
||||
$envContent = implode("\n", $lines);
|
||||
}
|
||||
|
||||
return view('admin.settings.email', [
|
||||
'config' => $config,
|
||||
'envWritable' => $envWritable,
|
||||
'envContent' => $envContent,
|
||||
'passwordSentinel' => self::PASSWORD_SENTINEL,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, EnvFileWriter $envWriter): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'mail_mailer' => 'required|string',
|
||||
'mail_host' => 'required|string|max:255',
|
||||
'mail_port' => 'required|integer|min:1|max:65535',
|
||||
'mail_scheme' => 'required|in:null,tls,ssl',
|
||||
'mail_username' => 'nullable|string|max:255',
|
||||
'mail_password' => 'nullable|string|max:255',
|
||||
'mail_from_address' => 'required|email|max:255',
|
||||
'mail_from_name' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
if (! $envWriter->isWritable()) {
|
||||
return redirect()->back()->with('error', __('notification.env_not_writable'));
|
||||
}
|
||||
|
||||
$schemeInput = $request->input('mail_scheme');
|
||||
$schemeMap = ['tls' => '', 'ssl' => 'smtps', 'null' => ''];
|
||||
$mailScheme = $schemeMap[$schemeInput] ?? '';
|
||||
|
||||
$values = [
|
||||
'MAIL_MAILER' => $request->input('mail_mailer'),
|
||||
'MAIL_HOST' => $request->input('mail_host'),
|
||||
'MAIL_PORT' => $request->input('mail_port'),
|
||||
'MAIL_SCHEME' => $mailScheme,
|
||||
'MAIL_USERNAME' => $request->input('mail_username', ''),
|
||||
'MAIL_FROM_ADDRESS' => $request->input('mail_from_address'),
|
||||
'MAIL_FROM_NAME' => $request->input('mail_from_name'),
|
||||
];
|
||||
|
||||
$password = $request->input('mail_password');
|
||||
if ($password !== null && $password !== '' && $password !== self::PASSWORD_SENTINEL) {
|
||||
$values['MAIL_PASSWORD'] = $password;
|
||||
}
|
||||
|
||||
try {
|
||||
$envWriter->setMany($values);
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->back()->with('error', __('notification.env_not_writable'));
|
||||
}
|
||||
|
||||
Artisan::call('config:clear');
|
||||
|
||||
return redirect()->back()->with('success', __('notification.email_saved'));
|
||||
}
|
||||
|
||||
public function sendTest(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'test_recipient' => 'required|email|max:255',
|
||||
]);
|
||||
|
||||
$recipient = $request->input('test_recipient');
|
||||
|
||||
try {
|
||||
Mail::raw(__('notification.test_email_body', ['app_name' => brand()]), function ($message) use ($recipient) {
|
||||
$message->to($recipient)
|
||||
->subject(__('notification.test_email_subject', ['app_name' => brand()]));
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->back()->withInput()->with('error', __('notification.test_email_failed') . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('notification.test_email_sent_to', ['email' => $recipient]));
|
||||
}
|
||||
}
|
||||
43
app/Http/Controllers/Admin/HelpController.php
Normal file
43
app/Http/Controllers/Admin/HelpController.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
class HelpController extends Controller
|
||||
{
|
||||
private const TOPICS = [
|
||||
'installation' => 'help.topic_installation',
|
||||
'first-steps' => 'help.topic_first_steps',
|
||||
'customers' => 'help.topic_customers',
|
||||
'drivers' => 'help.topic_drivers',
|
||||
'owntracks' => 'help.topic_owntracks',
|
||||
'jobs' => 'help.topic_jobs',
|
||||
'overview' => 'help.topic_overview',
|
||||
'exports' => 'help.topic_exports',
|
||||
'dsgvo' => 'help.topic_dsgvo',
|
||||
'settings' => 'help.topic_settings',
|
||||
'updates' => 'help.topic_updates',
|
||||
'modules' => 'help.topic_modules',
|
||||
];
|
||||
|
||||
public function index()
|
||||
{
|
||||
return view('admin.help.index', [
|
||||
'topics' => self::TOPICS,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(string $topic)
|
||||
{
|
||||
if (! array_key_exists($topic, self::TOPICS)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return view('admin.help.show', [
|
||||
'topic' => $topic,
|
||||
'topics' => self::TOPICS,
|
||||
'langKey' => self::TOPICS[$topic],
|
||||
]);
|
||||
}
|
||||
}
|
||||
54
app/Http/Controllers/Admin/ManualJobController.php
Normal file
54
app/Http/Controllers/Admin/ManualJobController.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\JobType;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\StoreManualJobRequest;
|
||||
use App\Models\Customer;
|
||||
use App\Models\CustomerObject;
|
||||
use App\Models\User;
|
||||
use App\Models\Vehicle;
|
||||
use App\Services\JobLifecycleService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ManualJobController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly JobLifecycleService $service,
|
||||
) {}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.jobs.manual.create', [
|
||||
'customers' => Customer::with('objects')->orderBy('name')->get(),
|
||||
'drivers' => User::drivers()->get(),
|
||||
'vehicles' => Vehicle::all(),
|
||||
'jobTypes' => JobType::cases(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreManualJobRequest $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
$driver = User::findOrFail($validated['user_id']);
|
||||
$customerObject = CustomerObject::findOrFail($validated['customer_object_id']);
|
||||
$vehicle = isset($validated['vehicle_id']) ? Vehicle::find($validated['vehicle_id']) : null;
|
||||
|
||||
$this->service->createManualJob(
|
||||
driver: $driver,
|
||||
customerObject: $customerObject,
|
||||
type: JobType::from($validated['type']),
|
||||
startedAt: Carbon::parse($validated['started_at']),
|
||||
endedAt: Carbon::parse($validated['ended_at']),
|
||||
notes: $validated['notes'] ?? null,
|
||||
vehicle: $vehicle,
|
||||
);
|
||||
|
||||
return redirect()->route('admin.jobs.manual.create')
|
||||
->with('success', __('job.manual_created'));
|
||||
}
|
||||
}
|
||||
33
app/Http/Controllers/Admin/NotificationLogController.php
Normal file
33
app/Http/Controllers/Admin/NotificationLogController.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Job;
|
||||
use App\Models\NotificationLog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NotificationLogController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$logs = NotificationLog::query()
|
||||
->select('notification_logs.*')
|
||||
->leftJoin('service_jobs', function ($join) {
|
||||
$join->on('notification_logs.notifiable_id', '=', 'service_jobs.id')
|
||||
->where('notification_logs.notifiable_type', '=', Job::class);
|
||||
})
|
||||
->leftJoin('customers', 'service_jobs.customer_id', '=', 'customers.id')
|
||||
->addSelect('customers.name as customer_name')
|
||||
->when($request->status, fn ($q, $status) => $q->where('notification_logs.status', $status))
|
||||
->when($request->type, fn ($q, $type) => $q->where('notification_logs.type', $type))
|
||||
->when($request->date_from, fn ($q, $date) => $q->where('notification_logs.created_at', '>=', $date))
|
||||
->when($request->date_to, fn ($q, $date) => $q->where('notification_logs.created_at', '<=', $date . ' 23:59:59'))
|
||||
->orderByDesc('notification_logs.created_at')
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.settings.notification-log', compact('logs'));
|
||||
}
|
||||
}
|
||||
207
app/Http/Controllers/Admin/OverviewController.php
Normal file
207
app/Http/Controllers/Admin/OverviewController.php
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Job;
|
||||
use App\Models\User;
|
||||
use App\Models\WeatherSnapshot;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class OverviewController extends Controller
|
||||
{
|
||||
public function daily(Request $request): View
|
||||
{
|
||||
$date = $this->parseDate($request->input('date'));
|
||||
$dayStart = $date->copy()->startOfDay();
|
||||
$dayEnd = $date->copy()->addDay()->startOfDay();
|
||||
|
||||
$jobs = Job::with(['customer', 'customerObject.customer', 'user' => fn ($q) => $q->withAnonymized()])
|
||||
->where('started_at', '>=', $dayStart)
|
||||
->where('started_at', '<', $dayEnd)
|
||||
->orderBy('started_at')
|
||||
->get();
|
||||
|
||||
$driverSummaries = $jobs->groupBy('user_id')->map(function ($driverJobs) {
|
||||
$user = $driverJobs->first()->user;
|
||||
$totalMinutes = $driverJobs->sum(function ($job) {
|
||||
if (!$job->ended_at) {
|
||||
return 0;
|
||||
}
|
||||
return $job->started_at->diffInMinutes($job->ended_at);
|
||||
});
|
||||
$typeCounts = $driverJobs->groupBy(fn ($j) => $j->type->value)->map->count();
|
||||
|
||||
return (object) [
|
||||
'user' => $user,
|
||||
'jobs' => $driverJobs,
|
||||
'job_count' => $driverJobs->count(),
|
||||
'total_minutes' => $totalMinutes,
|
||||
'type_counts' => $typeCounts,
|
||||
];
|
||||
});
|
||||
|
||||
$totalJobs = $jobs->count();
|
||||
$totalMinutes = $driverSummaries->sum('total_minutes');
|
||||
$jobTypeBreakdown = $jobs->groupBy(fn ($j) => $j->type->value)->map->count();
|
||||
|
||||
$weatherSummary = $this->buildWeatherSummary($jobs);
|
||||
|
||||
$lastJobDate = null;
|
||||
if ($totalJobs === 0) {
|
||||
$lastStarted = Job::orderByDesc('started_at')->value('started_at');
|
||||
$lastJobDate = $lastStarted ? Carbon::parse($lastStarted)->startOfDay() : null;
|
||||
}
|
||||
|
||||
return view('admin.overview.daily', [
|
||||
'date' => $date,
|
||||
'driverSummaries' => $driverSummaries,
|
||||
'totalJobs' => $totalJobs,
|
||||
'totalMinutes' => $totalMinutes,
|
||||
'jobTypeBreakdown' => $jobTypeBreakdown,
|
||||
'weatherSummary' => $weatherSummary,
|
||||
'lastJobDate' => $lastJobDate,
|
||||
'prevDate' => $date->copy()->subDay(),
|
||||
'nextDate' => $date->copy()->addDay(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function monthly(Request $request): View
|
||||
{
|
||||
$month = $this->parseMonth($request->input('month'));
|
||||
$monthStart = $month->copy()->startOfMonth();
|
||||
$monthEnd = $month->copy()->endOfMonth()->addDay()->startOfDay();
|
||||
|
||||
$isSqlite = DB::getDriverName() === 'sqlite';
|
||||
$durationExpr = $isSqlite
|
||||
? "SUM((JULIANDAY(COALESCE(ended_at, started_at)) - JULIANDAY(started_at)) * 1440)"
|
||||
: "SUM(TIMESTAMPDIFF(MINUTE, started_at, COALESCE(ended_at, started_at)))";
|
||||
|
||||
$dailyCounts = Job::select(
|
||||
DB::raw('DATE(started_at) as job_date'),
|
||||
DB::raw('COUNT(*) as job_count'),
|
||||
DB::raw("{$durationExpr} as total_minutes")
|
||||
)
|
||||
->where('started_at', '>=', $monthStart)
|
||||
->where('started_at', '<', $monthEnd)
|
||||
->groupBy(DB::raw('DATE(started_at)'))
|
||||
->get()
|
||||
->keyBy('job_date');
|
||||
|
||||
$monthTotal = $dailyCounts->sum('job_count');
|
||||
$totalMinutes = (int) $dailyCounts->sum('total_minutes');
|
||||
|
||||
$activeDriverCount = Job::where('started_at', '>=', $monthStart)
|
||||
->where('started_at', '<', $monthEnd)
|
||||
->distinct('user_id')
|
||||
->count('user_id');
|
||||
|
||||
$monthKeyExpr = $isSqlite
|
||||
? "strftime('%Y-%m', started_at)"
|
||||
: "DATE_FORMAT(started_at, '%Y-%m')";
|
||||
|
||||
$activeMonths = Job::select(
|
||||
DB::raw("{$monthKeyExpr} as month_key"),
|
||||
DB::raw('COUNT(*) as job_count')
|
||||
)
|
||||
->groupBy('month_key')
|
||||
->orderByDesc('month_key')
|
||||
->get()
|
||||
->keyBy('month_key');
|
||||
|
||||
return view('admin.overview.monthly', [
|
||||
'month' => $month,
|
||||
'dailyCounts' => $dailyCounts,
|
||||
'monthTotal' => $monthTotal,
|
||||
'totalMinutes' => $totalMinutes,
|
||||
'activeDriverCount' => $activeDriverCount,
|
||||
'activeMonths' => $activeMonths,
|
||||
'prevMonth' => $month->copy()->subMonth(),
|
||||
'nextMonth' => $month->copy()->addMonth(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function dayDetail(Request $request): View
|
||||
{
|
||||
$date = $this->parseDate($request->input('date'));
|
||||
$dayStart = $date->copy()->startOfDay();
|
||||
$dayEnd = $date->copy()->addDay()->startOfDay();
|
||||
|
||||
$jobs = Job::with(['customer', 'customerObject.customer', 'user' => fn ($q) => $q->withAnonymized()])
|
||||
->where('started_at', '>=', $dayStart)
|
||||
->where('started_at', '<', $dayEnd)
|
||||
->orderBy('started_at')
|
||||
->get();
|
||||
|
||||
$driverSummaries = $jobs->groupBy('user_id')->map(function ($driverJobs) {
|
||||
$user = $driverJobs->first()->user;
|
||||
$totalMinutes = $driverJobs->sum(function ($job) {
|
||||
if (!$job->ended_at) {
|
||||
return 0;
|
||||
}
|
||||
return $job->started_at->diffInMinutes($job->ended_at);
|
||||
});
|
||||
|
||||
return (object) [
|
||||
'user' => $user,
|
||||
'jobs' => $driverJobs,
|
||||
'job_count' => $driverJobs->count(),
|
||||
'total_minutes' => $totalMinutes,
|
||||
];
|
||||
});
|
||||
|
||||
$totalJobs = $jobs->count();
|
||||
$totalMinutes = $driverSummaries->sum('total_minutes');
|
||||
|
||||
return view('admin.overview.partials.day-detail', [
|
||||
'date' => $date,
|
||||
'driverSummaries' => $driverSummaries,
|
||||
'totalJobs' => $totalJobs,
|
||||
'totalMinutes' => $totalMinutes,
|
||||
'isInline' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
private function parseDate(?string $input): Carbon
|
||||
{
|
||||
try {
|
||||
return $input ? Carbon::parse($input)->startOfDay() : Carbon::today();
|
||||
} catch (\Exception) {
|
||||
return Carbon::today();
|
||||
}
|
||||
}
|
||||
|
||||
private function parseMonth(?string $input): Carbon
|
||||
{
|
||||
try {
|
||||
return $input ? Carbon::parse($input)->startOfMonth() : Carbon::today()->startOfMonth();
|
||||
} catch (\Exception) {
|
||||
return Carbon::today()->startOfMonth();
|
||||
}
|
||||
}
|
||||
|
||||
private function buildWeatherSummary($jobs): ?object
|
||||
{
|
||||
$jobIds = $jobs->pluck('id');
|
||||
if ($jobIds->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$snapshots = WeatherSnapshot::whereIn('job_id', $jobIds)->get();
|
||||
if ($snapshots->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$temps = $snapshots->pluck('temperature')->filter()->values();
|
||||
$hasPrecipitation = $snapshots->contains(fn ($s) => $s->precipitation > 0);
|
||||
|
||||
return (object) [
|
||||
'temp_min' => $temps->isNotEmpty() ? $temps->min() : null,
|
||||
'temp_max' => $temps->isNotEmpty() ? $temps->max() : null,
|
||||
'has_precipitation' => $hasPrecipitation,
|
||||
];
|
||||
}
|
||||
}
|
||||
51
app/Http/Controllers/Admin/OwntracksOverviewController.php
Normal file
51
app/Http/Controllers/Admin/OwntracksOverviewController.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\GpsPoint;
|
||||
use App\Models\Job;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class OwntracksOverviewController extends Controller
|
||||
{
|
||||
public function __invoke(): View
|
||||
{
|
||||
$drivers = User::drivers()->orderBy('name')->get();
|
||||
$driverIds = $drivers->pluck('id');
|
||||
|
||||
$latestGps = collect();
|
||||
$activeJobs = collect();
|
||||
|
||||
if ($driverIds->isNotEmpty()) {
|
||||
$latestGps = GpsPoint::select('gps_points.*')
|
||||
->whereIn('gps_points.user_id', $driverIds)
|
||||
->joinSub(
|
||||
GpsPoint::select('user_id', DB::raw('MAX(timestamp) as max_ts'))
|
||||
->whereIn('user_id', $driverIds)
|
||||
->groupBy('user_id'),
|
||||
'latest',
|
||||
fn ($join) => $join->on('gps_points.user_id', '=', 'latest.user_id')
|
||||
->on('gps_points.timestamp', '=', 'latest.max_ts')
|
||||
)
|
||||
->get()
|
||||
->keyBy('user_id');
|
||||
|
||||
$activeJobs = Job::with('customer')
|
||||
->whereIn('user_id', $driverIds)
|
||||
->whereNull('ended_at')
|
||||
->whereHas('workShift', fn ($q) => $q->whereNull('ended_at'))
|
||||
->get()
|
||||
->keyBy('user_id');
|
||||
}
|
||||
|
||||
return view('admin.owntracks.overview', [
|
||||
'drivers' => $drivers,
|
||||
'latestGps' => $latestGps,
|
||||
'activeJobs' => $activeJobs,
|
||||
'now' => now()->timestamp,
|
||||
]);
|
||||
}
|
||||
}
|
||||
34
app/Http/Controllers/Admin/RetentionController.php
Normal file
34
app/Http/Controllers/Admin/RetentionController.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RetentionController extends Controller
|
||||
{
|
||||
public function edit(): View
|
||||
{
|
||||
return view('admin.settings.retention', [
|
||||
'retention_years' => Setting::get('retention_years', 3),
|
||||
'retention_auto_delete' => Setting::get('retention_auto_delete', false),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'retention_years' => ['required', 'integer', 'min:3'],
|
||||
'retention_auto_delete' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
Setting::set('retention_years', $validated['retention_years'], 'int');
|
||||
Setting::set('retention_auto_delete', $request->boolean('retention_auto_delete'), 'bool');
|
||||
|
||||
return redirect()->route('admin.settings.retention')
|
||||
->with('success', __('ui.saved'));
|
||||
}
|
||||
}
|
||||
150
app/Http/Controllers/Admin/UpdateSettingsController.php
Normal file
150
app/Http/Controllers/Admin/UpdateSettingsController.php
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Setting;
|
||||
use App\Services\SchneespurUpdater;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class UpdateSettingsController extends Controller
|
||||
{
|
||||
public function edit(): View
|
||||
{
|
||||
$hasSodium = function_exists('sodium_crypto_sign_verify_detached');
|
||||
$state = null;
|
||||
|
||||
if ($hasSodium) {
|
||||
try {
|
||||
$state = (new SchneespurUpdater)->getState();
|
||||
} catch (\Throwable) {
|
||||
// Config missing or corrupt — show page anyway
|
||||
}
|
||||
}
|
||||
|
||||
$preflight = null;
|
||||
if ($hasSodium) {
|
||||
try {
|
||||
$preflight = (new SchneespurUpdater)->canInstall();
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
return view('admin.settings.update', [
|
||||
'hasSodium' => $hasSodium,
|
||||
'autoCheck' => Setting::get('auto_update_check', true),
|
||||
'currentVersion' => config('app.version', '1.0.0'),
|
||||
'state' => $state,
|
||||
'preflight' => $preflight,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
Setting::set('auto_update_check', $request->boolean('auto_update_check'), 'bool');
|
||||
|
||||
return redirect()->route('admin.settings.update')
|
||||
->with('success', __('ui.saved'));
|
||||
}
|
||||
|
||||
public function checkNow(): JsonResponse
|
||||
{
|
||||
if (! function_exists('sodium_crypto_sign_verify_detached')) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => __('update.sodium_missing'),
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$updater = new SchneespurUpdater;
|
||||
$manifest = $updater->checkForUpdate();
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => __('update.check_result_error', ['error' => $e->getMessage()]),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($manifest === null) {
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'update' => false,
|
||||
'message' => __('update.check_result_up_to_date', ['app_name' => config('app.name')]),
|
||||
]);
|
||||
}
|
||||
|
||||
$locale = app()->getLocale();
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'update' => true,
|
||||
'message' => __('update.check_result_update', ['version' => $manifest['version']]),
|
||||
'version' => $manifest['version'],
|
||||
'changelog' => $manifest['changelog'][$locale] ?? $manifest['changelog']['de'] ?? '',
|
||||
'name' => $manifest['name'][$locale] ?? $manifest['name']['de'] ?? '',
|
||||
'description' => $manifest['description'][$locale] ?? $manifest['description']['de'] ?? '',
|
||||
'size_bytes' => $manifest['size_bytes'] ?? null,
|
||||
'signed_at' => $manifest['signed_at'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function install(): JsonResponse
|
||||
{
|
||||
if (! function_exists('sodium_crypto_sign_verify_detached')) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => __('update.sodium_missing'),
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$updater = new SchneespurUpdater;
|
||||
$manifest = $updater->checkForUpdate();
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => __('update.check_result_error', ['error' => $e->getMessage()]),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($manifest === null) {
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'update' => false,
|
||||
'message' => __('update.check_result_up_to_date', ['app_name' => config('app.name')]),
|
||||
]);
|
||||
}
|
||||
|
||||
$preflight = $updater->canInstall();
|
||||
if (in_array(false, $preflight, true)) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => __('update.preflight_fail'),
|
||||
'checks' => $preflight,
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$zipPath = $updater->downloadAndVerifyZip($manifest);
|
||||
$updater->install($zipPath, $manifest);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => __('update.install_failed', ['error' => $e->getMessage()]),
|
||||
]);
|
||||
} finally {
|
||||
if (isset($zipPath)) {
|
||||
@unlink($zipPath);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => __('update.install_success', ['version' => $manifest['version']]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
65
app/Http/Controllers/Admin/VehicleController.php
Normal file
65
app/Http/Controllers/Admin/VehicleController.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\StoreVehicleRequest;
|
||||
use App\Http\Requests\Admin\UpdateVehicleRequest;
|
||||
use App\Models\Vehicle;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class VehicleController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$vehicles = Vehicle::query()
|
||||
->when($request->search, function ($query, $search) {
|
||||
$query->where('name', 'like', "%{$search}%");
|
||||
})
|
||||
->orderBy('name')
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.vehicles.index', compact('vehicles'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.vehicles.create');
|
||||
}
|
||||
|
||||
public function store(StoreVehicleRequest $request): RedirectResponse
|
||||
{
|
||||
$vehicle = Vehicle::create($request->validated());
|
||||
|
||||
return redirect()
|
||||
->route('admin.vehicles.index')
|
||||
->with('success', __('vehicle.flash_created', ['name' => $vehicle->name]));
|
||||
}
|
||||
|
||||
public function edit(Vehicle $vehicle): View
|
||||
{
|
||||
return view('admin.vehicles.edit', compact('vehicle'));
|
||||
}
|
||||
|
||||
public function update(UpdateVehicleRequest $request, Vehicle $vehicle): RedirectResponse
|
||||
{
|
||||
$vehicle->update($request->validated());
|
||||
|
||||
return redirect()
|
||||
->route('admin.vehicles.index')
|
||||
->with('success', __('vehicle.flash_updated', ['name' => $vehicle->name]));
|
||||
}
|
||||
|
||||
public function destroy(Vehicle $vehicle): RedirectResponse
|
||||
{
|
||||
$name = $vehicle->name;
|
||||
$vehicle->delete();
|
||||
|
||||
return redirect()
|
||||
->route('admin.vehicles.index')
|
||||
->with('success', __('vehicle.flash_deleted', ['name' => $name]));
|
||||
}
|
||||
}
|
||||
40
app/Http/Controllers/Admin/WeatherRetryController.php
Normal file
40
app/Http/Controllers/Admin/WeatherRetryController.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\WeatherMoment;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\FetchWeather;
|
||||
use App\Models\Job;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class WeatherRetryController extends Controller
|
||||
{
|
||||
public function __invoke(Job $serviceJob, string $moment): RedirectResponse
|
||||
{
|
||||
$weatherMoment = WeatherMoment::from($moment);
|
||||
|
||||
$object = $serviceJob->customerObject;
|
||||
$lat = null;
|
||||
$lon = null;
|
||||
|
||||
if ($object !== null && $object->lat !== null && $object->lon !== null) {
|
||||
$lat = (float) $object->lat;
|
||||
$lon = (float) $object->lon;
|
||||
} else {
|
||||
$gpsPoint = $serviceJob->gpsPoints()->latest('timestamp')->first();
|
||||
if ($gpsPoint !== null && $gpsPoint->lat !== null && $gpsPoint->lon !== null) {
|
||||
$lat = (float) $gpsPoint->lat;
|
||||
$lon = (float) $gpsPoint->lon;
|
||||
}
|
||||
}
|
||||
|
||||
if ($lat === null || $lon === null) {
|
||||
return redirect()->back()->with('error', __('weather.retry_no_coordinates'));
|
||||
}
|
||||
|
||||
FetchWeather::dispatch($serviceJob->id, $weatherMoment, $lat, $lon);
|
||||
|
||||
return redirect()->back()->with('success', __('weather.retry_dispatched'));
|
||||
}
|
||||
}
|
||||
71
app/Http/Controllers/Admin/WeatherSettingsController.php
Normal file
71
app/Http/Controllers/Admin/WeatherSettingsController.php
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Setting;
|
||||
use App\Services\Weather\WeatherProviderRegistry;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class WeatherSettingsController extends Controller
|
||||
{
|
||||
public function edit(WeatherProviderRegistry $registry): View
|
||||
{
|
||||
return view('admin.settings.weather', [
|
||||
'providers' => $registry->availableProviders(),
|
||||
'activeProvider' => $registry->activeSlug(),
|
||||
'apiKey' => Setting::get('weather_api_key', ''),
|
||||
'userAgentEmail' => Setting::get('weather_user_agent_email', ''),
|
||||
'cacheTtlMinutes' => (int) (Setting::get('weather_cache_ttl', 300) / 60),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, WeatherProviderRegistry $registry): RedirectResponse
|
||||
{
|
||||
$providerSlugs = array_keys($registry->availableProviders());
|
||||
|
||||
$validated = $request->validate([
|
||||
'weather_provider' => ['required', 'string', 'in:'.implode(',', $providerSlugs)],
|
||||
'weather_api_key' => ['nullable', 'string', 'max:255'],
|
||||
'weather_user_agent_email' => ['nullable', 'email', 'max:255'],
|
||||
'weather_cache_ttl' => ['required', 'integer', 'min:1'],
|
||||
]);
|
||||
|
||||
Setting::set('weather_provider', $validated['weather_provider']);
|
||||
Setting::set('weather_api_key', $validated['weather_api_key'] ?? '');
|
||||
Setting::set('weather_user_agent_email', $validated['weather_user_agent_email'] ?? '');
|
||||
Setting::set('weather_cache_ttl', $validated['weather_cache_ttl'] * 60, 'int');
|
||||
|
||||
return redirect()->route('admin.settings.weather')
|
||||
->with('success', __('weather.settings_saved'));
|
||||
}
|
||||
|
||||
public function testConnection(Request $request, WeatherProviderRegistry $registry): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'provider' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$slug = $request->input('provider');
|
||||
|
||||
if (! $registry->has($slug)) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'Unknown provider',
|
||||
'latency_ms' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
$provider = $registry->resolve($slug);
|
||||
|
||||
$lat = (float) Setting::get('company_lat', 48.1351);
|
||||
$lon = (float) Setting::get('company_lon', 11.5820);
|
||||
|
||||
$result = $provider->testConnection($lat, $lon);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
}
|
||||
49
app/Http/Controllers/Api/OwnTracksController.php
Normal file
49
app/Http/Controllers/Api/OwnTracksController.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\GpsPoint;
|
||||
use App\Services\JobLifecycleService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class OwnTracksController extends Controller
|
||||
{
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
if ($request->input('_type') !== 'location') {
|
||||
return response()->json([], 200);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'lat' => 'required|numeric',
|
||||
'lon' => 'required|numeric',
|
||||
'tst' => 'required|integer',
|
||||
'alt' => 'nullable|numeric',
|
||||
'batt' => 'nullable|integer',
|
||||
'vel' => 'nullable|integer',
|
||||
'acc' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
$activeJob = app(JobLifecycleService::class)->findActiveJob($request->user());
|
||||
|
||||
if ($activeJob === null) {
|
||||
return response()->json([], 200);
|
||||
}
|
||||
|
||||
GpsPoint::create([
|
||||
'user_id' => $request->user()->id,
|
||||
'job_id' => $activeJob->id,
|
||||
'lat' => $validated['lat'],
|
||||
'lon' => $validated['lon'],
|
||||
'timestamp' => $validated['tst'],
|
||||
'altitude' => $validated['alt'] ?? null,
|
||||
'battery' => $validated['batt'] ?? null,
|
||||
'velocity' => $validated['vel'] ?? null,
|
||||
'accuracy' => $validated['acc'] ?? null,
|
||||
]);
|
||||
|
||||
return response()->json([], 200);
|
||||
}
|
||||
}
|
||||
47
app/Http/Controllers/Auth/AuthenticatedSessionController.php
Normal file
47
app/Http/Controllers/Auth/AuthenticatedSessionController.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AuthenticatedSessionController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the login view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming authentication request.
|
||||
*/
|
||||
public function store(LoginRequest $request): RedirectResponse
|
||||
{
|
||||
$request->authenticate();
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy an authenticated session.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
40
app/Http/Controllers/Auth/ConfirmablePasswordController.php
Normal file
40
app/Http/Controllers/Auth/ConfirmablePasswordController.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ConfirmablePasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the confirm password view.
|
||||
*/
|
||||
public function show(): View
|
||||
{
|
||||
return view('auth.confirm-password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the user's password.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if (! Auth::guard('web')->validate([
|
||||
'email' => $request->user()->email,
|
||||
'password' => $request->password,
|
||||
])) {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => __('auth.password'),
|
||||
]);
|
||||
}
|
||||
|
||||
$request->session()->put('auth.password_confirmed_at', time());
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EmailVerificationNotificationController extends Controller
|
||||
{
|
||||
/**
|
||||
* Send a new email verification notification.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
$request->user()->sendEmailVerificationNotification();
|
||||
|
||||
return back()->with('status', 'verification-link-sent');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class EmailVerificationPromptController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the email verification prompt.
|
||||
*/
|
||||
public function __invoke(Request $request): RedirectResponse|View
|
||||
{
|
||||
return $request->user()->hasVerifiedEmail()
|
||||
? redirect()->intended(route('dashboard', absolute: false))
|
||||
: view('auth.verify-email');
|
||||
}
|
||||
}
|
||||
63
app/Http/Controllers/Auth/NewPasswordController.php
Normal file
63
app/Http/Controllers/Auth/NewPasswordController.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NewPasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the password reset view.
|
||||
*/
|
||||
public function create(Request $request): View
|
||||
{
|
||||
return view('auth.reset-password', ['request' => $request]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming new password request.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'token' => ['required'],
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
// Here we will attempt to reset the user's password. If it is successful we
|
||||
// will update the password on an actual user model and persist it to the
|
||||
// database. Otherwise we will parse the error and return the response.
|
||||
$status = Password::reset(
|
||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||
function (User $user) use ($request) {
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($request->password),
|
||||
'remember_token' => Str::random(60),
|
||||
])->save();
|
||||
|
||||
event(new PasswordReset($user));
|
||||
}
|
||||
);
|
||||
|
||||
// If the password was successfully reset, we will redirect the user back to
|
||||
// the application's home authenticated view. If there is an error we can
|
||||
// redirect them back to where they came from with their error message.
|
||||
return $status == Password::PASSWORD_RESET
|
||||
? redirect()->route('login')->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
29
app/Http/Controllers/Auth/PasswordController.php
Normal file
29
app/Http/Controllers/Auth/PasswordController.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class PasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Update the user's password.
|
||||
*/
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validateWithBag('updatePassword', [
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'password' => ['required', Password::defaults(), 'confirmed'],
|
||||
]);
|
||||
|
||||
$request->user()->update([
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
return back()->with('status', 'password-updated');
|
||||
}
|
||||
}
|
||||
45
app/Http/Controllers/Auth/PasswordResetLinkController.php
Normal file
45
app/Http/Controllers/Auth/PasswordResetLinkController.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PasswordResetLinkController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the password reset link request view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.forgot-password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming password reset link request.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
]);
|
||||
|
||||
// We will send the password reset link to this user. Once we have attempted
|
||||
// to send the link, we will examine the response then see the message we
|
||||
// need to show to the user. Finally, we'll send out a proper response.
|
||||
$status = Password::sendResetLink(
|
||||
$request->only('email')
|
||||
);
|
||||
|
||||
return $status == Password::RESET_LINK_SENT
|
||||
? back()->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
51
app/Http/Controllers/Auth/RegisteredUserController.php
Normal file
51
app/Http/Controllers/Auth/RegisteredUserController.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RegisteredUserController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the registration view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.register');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming registration request.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($request->password),
|
||||
]);
|
||||
|
||||
event(new Registered($user));
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
return redirect(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
||||
27
app/Http/Controllers/Auth/VerifyEmailController.php
Normal file
27
app/Http/Controllers/Auth/VerifyEmailController.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class VerifyEmailController extends Controller
|
||||
{
|
||||
/**
|
||||
* Mark the authenticated user's email address as verified.
|
||||
*/
|
||||
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
|
||||
if ($request->user()->markEmailAsVerified()) {
|
||||
event(new Verified($request->user()));
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
}
|
||||
10
app/Http/Controllers/Controller.php
Normal file
10
app/Http/Controllers/Controller.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
}
|
||||
98
app/Http/Controllers/Driver/DriverJobController.php
Normal file
98
app/Http/Controllers/Driver/DriverJobController.php
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Driver;
|
||||
|
||||
use App\Enums\JobType;
|
||||
use App\Exceptions\JobLifecycleException;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CustomerObject;
|
||||
use App\Models\Vehicle;
|
||||
use App\Services\JobLifecycleService;
|
||||
use App\Services\PhotoService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class DriverJobController extends Controller
|
||||
{
|
||||
public function start(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'customer_object_id' => ['required', 'exists:customer_objects,id'],
|
||||
'type' => ['required', Rule::enum(JobType::class)],
|
||||
'vehicle_id' => ['nullable', 'exists:vehicles,id'],
|
||||
]);
|
||||
|
||||
$customerObject = CustomerObject::findOrFail($validated['customer_object_id']);
|
||||
$vehicle = isset($validated['vehicle_id']) ? Vehicle::find($validated['vehicle_id']) : null;
|
||||
|
||||
try {
|
||||
app(JobLifecycleService::class)->startJob(
|
||||
$request->user(),
|
||||
$customerObject,
|
||||
JobType::from($validated['type']),
|
||||
$vehicle,
|
||||
);
|
||||
} catch (JobLifecycleException $e) {
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('job.started'));
|
||||
}
|
||||
|
||||
public function end(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'notes' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
try {
|
||||
app(JobLifecycleService::class)->endJob(
|
||||
$request->user(),
|
||||
$validated['notes'] ?? null,
|
||||
);
|
||||
} catch (JobLifecycleException $e) {
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('job.ended'));
|
||||
}
|
||||
|
||||
public function active(Request $request): JsonResponse
|
||||
{
|
||||
$service = app(JobLifecycleService::class);
|
||||
$user = $request->user();
|
||||
|
||||
$shift = $service->findActiveShift($user);
|
||||
$job = $shift ? $service->findActiveJob($user) : null;
|
||||
|
||||
if ($job) {
|
||||
$job->loadCount('gpsPoints')->load(['customerObject.customer', 'vehicle', 'jobPhotos']);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'shift' => $shift ? [
|
||||
'id' => $shift->id,
|
||||
'started_at' => $shift->started_at->toIso8601String(),
|
||||
] : null,
|
||||
'job' => $job ? [
|
||||
'id' => $job->id,
|
||||
'customer_name' => $job->customerObject?->customer?->name ?? '–',
|
||||
'object_name' => $job->customerObject?->name,
|
||||
'type_label' => $job->type->label(),
|
||||
'vehicle_label' => $job->vehicle?->displayLabel(),
|
||||
'started_at' => $job->started_at->toIso8601String(),
|
||||
'gps_points_count' => $job->gps_points_count,
|
||||
'photos_remaining' => PhotoService::MAX_PHOTOS_PER_JOB - $job->jobPhotos->count(),
|
||||
'photos' => $job->jobPhotos->map(fn ($p) => [
|
||||
'id' => $p->id,
|
||||
'thumbnail_url' => Storage::disk('public')->url($p->thumbnail_path),
|
||||
'full_url' => Storage::disk('public')->url($p->file_path),
|
||||
'caption' => $p->caption,
|
||||
]),
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
32
app/Http/Controllers/Driver/DriverJobHistoryController.php
Normal file
32
app/Http/Controllers/Driver/DriverJobHistoryController.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Driver;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Job;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DriverJobHistoryController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$jobs = Job::where('user_id', $request->user()->id)
|
||||
->with(['customer', 'customerObject.customer'])
|
||||
->withCount('jobPhotos')
|
||||
->orderByDesc('started_at')
|
||||
->paginate(20);
|
||||
|
||||
return view('driver.jobs.index', compact('jobs'));
|
||||
}
|
||||
|
||||
public function show(Request $request, Job $job): View
|
||||
{
|
||||
abort_unless($job->user_id === $request->user()->id, 403);
|
||||
|
||||
$job->load(['customer', 'customerObject.customer', 'vehicle', 'weatherSnapshots', 'jobPhotos'])
|
||||
->loadCount('gpsPoints');
|
||||
|
||||
return view('driver.jobs.show', compact('job'));
|
||||
}
|
||||
}
|
||||
45
app/Http/Controllers/Driver/DriverPhotoController.php
Normal file
45
app/Http/Controllers/Driver/DriverPhotoController.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Driver;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\JobLifecycleService;
|
||||
use App\Services\PhotoService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class DriverPhotoController extends Controller
|
||||
{
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'photo' => ['required', 'image', 'mimes:jpeg,png,heic,webp', 'max:10240'],
|
||||
'caption' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$job = app(JobLifecycleService::class)->findActiveJob($request->user());
|
||||
|
||||
if (! $job) {
|
||||
return response()->json(['message' => __('job.no_active_job')], 422);
|
||||
}
|
||||
|
||||
if (! PhotoService::canAddPhoto($job)) {
|
||||
return response()->json([
|
||||
'message' => __('job.photo_limit_reached', ['max' => PhotoService::MAX_PHOTOS_PER_JOB]),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$photo = app(PhotoService::class)->store($validated['photo'], $job);
|
||||
|
||||
if (! empty($validated['caption'])) {
|
||||
$photo->update(['caption' => $validated['caption']]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'id' => $photo->id,
|
||||
'thumbnail_url' => Storage::disk('public')->url($photo->thumbnail_path),
|
||||
'photos_remaining' => PhotoService::MAX_PHOTOS_PER_JOB - $job->jobPhotos()->count(),
|
||||
], 201);
|
||||
}
|
||||
}
|
||||
34
app/Http/Controllers/Driver/DriverShiftController.php
Normal file
34
app/Http/Controllers/Driver/DriverShiftController.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Driver;
|
||||
|
||||
use App\Exceptions\JobLifecycleException;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\JobLifecycleService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DriverShiftController extends Controller
|
||||
{
|
||||
public function start(Request $request): RedirectResponse
|
||||
{
|
||||
try {
|
||||
app(JobLifecycleService::class)->startShift($request->user());
|
||||
} catch (JobLifecycleException $e) {
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('workshift.started'));
|
||||
}
|
||||
|
||||
public function end(Request $request): RedirectResponse
|
||||
{
|
||||
try {
|
||||
app(JobLifecycleService::class)->endShift($request->user());
|
||||
} catch (JobLifecycleException $e) {
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('workshift.ended'));
|
||||
}
|
||||
}
|
||||
51
app/Http/Controllers/Driver/ManualJobController.php
Normal file
51
app/Http/Controllers/Driver/ManualJobController.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Driver;
|
||||
|
||||
use App\Enums\JobType;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Driver\StoreManualJobRequest;
|
||||
use App\Models\Customer;
|
||||
use App\Models\CustomerObject;
|
||||
use App\Models\Vehicle;
|
||||
use App\Services\JobLifecycleService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ManualJobController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly JobLifecycleService $service,
|
||||
) {}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('driver.jobs.manual.create', [
|
||||
'customers' => Customer::with('objects')->orderBy('name')->get(),
|
||||
'vehicles' => Vehicle::all(),
|
||||
'jobTypes' => JobType::cases(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreManualJobRequest $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
$customerObject = CustomerObject::findOrFail($validated['customer_object_id']);
|
||||
$vehicle = isset($validated['vehicle_id']) ? Vehicle::find($validated['vehicle_id']) : null;
|
||||
|
||||
$this->service->createManualJob(
|
||||
driver: $request->user(),
|
||||
customerObject: $customerObject,
|
||||
type: JobType::from($validated['type']),
|
||||
startedAt: Carbon::parse($validated['started_at']),
|
||||
endedAt: Carbon::parse($validated['ended_at']),
|
||||
notes: $validated['notes'] ?? null,
|
||||
vehicle: $vehicle,
|
||||
);
|
||||
|
||||
return redirect()->route('driver.job.manual.create')
|
||||
->with('success', __('job.manual_created'));
|
||||
}
|
||||
}
|
||||
97
app/Http/Controllers/DsgvoOnboardingController.php
Normal file
97
app/Http/Controllers/DsgvoOnboardingController.php
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\ConfirmDsgvoRequest;
|
||||
use App\Models\DsgvoConfirmation;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class DsgvoOnboardingController extends Controller
|
||||
{
|
||||
public function show(): View
|
||||
{
|
||||
[$text, $version] = $this->currentTemplate();
|
||||
|
||||
$text = $this->replacePlaceholders($text);
|
||||
|
||||
$dsgvoHtml = Str::markdown($text, ['html_input' => 'strip']);
|
||||
|
||||
$companyDataMissing = empty(Setting::get('company_name'));
|
||||
|
||||
return view('onboarding.dsgvo', [
|
||||
'dsgvoHtml' => $dsgvoHtml,
|
||||
'templateVersion' => $version,
|
||||
'companyDataMissing' => $companyDataMissing,
|
||||
]);
|
||||
}
|
||||
|
||||
public function confirm(ConfirmDsgvoRequest $request): Response
|
||||
{
|
||||
if (empty(Setting::get('company_name'))) {
|
||||
return redirect()->route('onboarding.dsgvo')
|
||||
->with('error', __('dsgvo.company_data_missing_title'));
|
||||
}
|
||||
|
||||
[$text, $version] = $this->currentTemplate();
|
||||
|
||||
DsgvoConfirmation::create([
|
||||
'driver_id' => $request->user()->id,
|
||||
'confirmed_at' => now(),
|
||||
'signed_by' => $request->validated('signed_by'),
|
||||
'notice_text_snapshot' => $text,
|
||||
'notice_language' => 'de',
|
||||
'template_version' => $version,
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
$user->dsgvo_informed_at = now();
|
||||
$user->confirmed_version = $version;
|
||||
$user->save();
|
||||
|
||||
return redirect()->route('dashboard')
|
||||
->with('success', __('dsgvo.flash_confirmed'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: int}
|
||||
*/
|
||||
private function currentTemplate(): array
|
||||
{
|
||||
$text = Setting::get('dsgvo_template_markdown');
|
||||
$version = (int) Setting::get('dsgvo_template_version', 1);
|
||||
|
||||
if ($text === null) {
|
||||
$text = view('dsgvo.default-template')->render();
|
||||
}
|
||||
|
||||
return [$text, $version];
|
||||
}
|
||||
|
||||
private function replacePlaceholders(string $text): string
|
||||
{
|
||||
$companyName = Setting::get('company_name', '');
|
||||
$street = Setting::get('company_street', '');
|
||||
$zip = Setting::get('company_zip', '');
|
||||
$city = Setting::get('company_city', '');
|
||||
$email = Setting::get('company_email', '');
|
||||
$dpo = Setting::get('dpo_contact', '');
|
||||
$dpoEmail = Setting::get('dpo_email', '');
|
||||
|
||||
$address = trim("$street, $zip $city", ', ');
|
||||
|
||||
$replacements = [
|
||||
'[Firmenname eintragen]' => $companyName ?: '[Firmenname eintragen]',
|
||||
'[Adresse eintragen]' => $address ?: '[Adresse eintragen]',
|
||||
'[E-Mail-Adresse eintragen]' => $email ?: '[E-Mail-Adresse eintragen]',
|
||||
'[DPO-E-Mail-Adresse eintragen]' => $dpoEmail ?: '[DPO-E-Mail-Adresse eintragen]',
|
||||
'[Datenschutzbeauftragter / Ansprechpartner eintragen]' => $dpo ?: '[Datenschutzbeauftragter / Ansprechpartner eintragen]',
|
||||
];
|
||||
|
||||
return str_replace(array_keys($replacements), array_values($replacements), $text);
|
||||
}
|
||||
}
|
||||
400
app/Http/Controllers/InstallerController.php
Normal file
400
app/Http/Controllers/InstallerController.php
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Services\Installer\EnvFileWriter;
|
||||
use App\Services\Installer\InstallLockManager;
|
||||
use App\Services\Installer\MigrationRunner;
|
||||
use App\Services\Installer\PreflightChecker;
|
||||
use App\Services\Installer\StorageConfigurator;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\View\View;
|
||||
use PDO;
|
||||
|
||||
class InstallerController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private EnvFileWriter $envWriter,
|
||||
private PreflightChecker $preflightChecker,
|
||||
private MigrationRunner $migrationRunner,
|
||||
private StorageConfigurator $storageConfigurator,
|
||||
private InstallLockManager $lockManager,
|
||||
) {}
|
||||
|
||||
// --- Step 1: Welcome ---
|
||||
|
||||
public function showWelcome(Request $request): View
|
||||
{
|
||||
$this->autoDetectAppUrl($request);
|
||||
|
||||
return view('installer.step1-welcome', ['currentStep' => 1]);
|
||||
}
|
||||
|
||||
public function processWelcome(): RedirectResponse
|
||||
{
|
||||
return redirect()->route('install.preflight');
|
||||
}
|
||||
|
||||
// --- Step 2: Preflight (was Step 3) ---
|
||||
|
||||
// --- Step 3: Database (was Step 2) ---
|
||||
|
||||
public function showDatabase(): View
|
||||
{
|
||||
return view('installer.step2-database', [
|
||||
'currentStep' => 3,
|
||||
'env_content' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeDatabase(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'db_host' => 'required|string',
|
||||
'db_port' => 'required|integer|min:1|max:65535',
|
||||
'db_database' => 'required|string',
|
||||
'db_username' => 'required|string',
|
||||
'db_password' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$password = $validated['db_password'] ?? '';
|
||||
|
||||
try {
|
||||
new PDO(
|
||||
"mysql:host={$validated['db_host']};port={$validated['db_port']};dbname={$validated['db_database']}",
|
||||
$validated['db_username'],
|
||||
$password,
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 5]
|
||||
);
|
||||
} catch (\PDOException $e) {
|
||||
return redirect()->route('install.database')
|
||||
->withInput()
|
||||
->withErrors(['db_connection' => __('install.error_db_connection') . ' (' . $e->getMessage() . ')']);
|
||||
}
|
||||
|
||||
$envValues = [
|
||||
'DB_CONNECTION' => 'mysql',
|
||||
'DB_HOST' => $validated['db_host'],
|
||||
'DB_PORT' => (string) $validated['db_port'],
|
||||
'DB_DATABASE' => $validated['db_database'],
|
||||
'DB_USERNAME' => $validated['db_username'],
|
||||
'DB_PASSWORD' => $password,
|
||||
];
|
||||
|
||||
if (! $this->envWriter->isWritable()) {
|
||||
$this->envWriter->setMany($envValues);
|
||||
Artisan::call('config:clear');
|
||||
|
||||
if (! $this->envWriter->isWritable()) {
|
||||
return redirect()->route('install.database')
|
||||
->withInput()
|
||||
->with('env_content', $this->envWriter->getFullContent())
|
||||
->withErrors(['env_write' => __('install.error_env_write')]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->envWriter->setMany($envValues);
|
||||
Artisan::call('config:clear');
|
||||
|
||||
return redirect()->route('install.migrations');
|
||||
}
|
||||
|
||||
public function showPreflight(): View
|
||||
{
|
||||
return view('installer.step3-preflight', [
|
||||
'currentStep' => 2,
|
||||
'checks' => $this->preflightChecker->check(),
|
||||
'hasCritical' => $this->preflightChecker->hasCriticalFailures(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function processPreflight(): RedirectResponse
|
||||
{
|
||||
if ($this->preflightChecker->hasCriticalFailures()) {
|
||||
return redirect()->route('install.preflight')
|
||||
->withErrors(['preflight' => 'Kritische Voraussetzungen nicht erfüllt.']);
|
||||
}
|
||||
|
||||
return redirect()->route('install.database');
|
||||
}
|
||||
|
||||
// --- Step 4: Migrations ---
|
||||
|
||||
public function showMigrations(): View
|
||||
{
|
||||
return view('installer.step4-migrations', ['currentStep' => 4]);
|
||||
}
|
||||
|
||||
public function runMigrations(): RedirectResponse
|
||||
{
|
||||
$result = $this->migrationRunner->run();
|
||||
|
||||
if (! $result['success']) {
|
||||
return redirect()->route('install.migrations')
|
||||
->withErrors(['migration' => __('install.error_migration_main')])
|
||||
->with('migration_output', $result['error'] ?? $result['output']);
|
||||
}
|
||||
|
||||
$this->envWriter->setMany([
|
||||
'SESSION_DRIVER' => 'database',
|
||||
'CACHE_STORE' => 'database',
|
||||
]);
|
||||
Artisan::call('config:clear');
|
||||
|
||||
return redirect()->route('install.config');
|
||||
}
|
||||
|
||||
// --- Step 5: Config ---
|
||||
|
||||
public function showConfig(Request $request): View
|
||||
{
|
||||
$detectedUrl = $request->schemeAndHttpHost();
|
||||
|
||||
return view('installer.step5-config', [
|
||||
'currentStep' => 5,
|
||||
'app_url' => $this->envWriter->get('APP_URL') ?: $detectedUrl,
|
||||
'timezone' => $this->envWriter->get('APP_DISPLAY_TIMEZONE') ?: 'Europe/Berlin',
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeConfig(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'app_url' => 'required|url',
|
||||
'timezone' => 'required|string|timezone:all',
|
||||
'locale' => 'required|string|in:de,en',
|
||||
]);
|
||||
|
||||
$this->envWriter->setMany([
|
||||
'APP_URL' => $validated['app_url'],
|
||||
'APP_DISPLAY_TIMEZONE' => $validated['timezone'],
|
||||
'APP_LOCALE' => $validated['locale'],
|
||||
]);
|
||||
|
||||
Artisan::call('config:clear');
|
||||
|
||||
$brand = $validated['locale'] === 'de' ? 'schneespur' : 'wintertrace';
|
||||
|
||||
try {
|
||||
Setting::set('app_url', $validated['app_url']);
|
||||
Setting::set('display_timezone', $validated['timezone']);
|
||||
Setting::set('locale', $validated['locale']);
|
||||
Setting::set('app_brand', $brand);
|
||||
} catch (\Exception) {
|
||||
// Settings table may not exist yet in edge cases — .env is the primary store
|
||||
}
|
||||
|
||||
return redirect()->route('install.storage');
|
||||
}
|
||||
|
||||
// --- Step 6: Storage ---
|
||||
|
||||
public function showStorage(): View
|
||||
{
|
||||
return view('installer.step6-storage', [
|
||||
'currentStep' => 6,
|
||||
'results' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function runStorage(): View
|
||||
{
|
||||
$results = $this->storageConfigurator->runAll();
|
||||
|
||||
return view('installer.step6-storage', [
|
||||
'currentStep' => 6,
|
||||
'results' => $results,
|
||||
]);
|
||||
}
|
||||
|
||||
// --- Step 7: Admin ---
|
||||
|
||||
public function showAdmin(): View
|
||||
{
|
||||
return view('installer.step7-admin', ['currentStep' => 7]);
|
||||
}
|
||||
|
||||
public function storeAdmin(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|email|max:255',
|
||||
'password' => 'required|string|min:8|confirmed',
|
||||
]);
|
||||
|
||||
User::create([
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'password' => $validated['password'],
|
||||
])->forceFill(['role' => UserRole::Admin])->save();
|
||||
|
||||
$this->lockManager->lock();
|
||||
|
||||
try {
|
||||
Setting::set('installed_at', now()->toIso8601String());
|
||||
} catch (\Exception) {
|
||||
// Fallback: lock file is the authoritative indicator
|
||||
}
|
||||
|
||||
Artisan::call('config:clear');
|
||||
|
||||
return redirect()->route('install.mail');
|
||||
}
|
||||
|
||||
// --- Step 8: Mail ---
|
||||
|
||||
public function showMail(): View
|
||||
{
|
||||
return view('installer.step8-mail', ['currentStep' => 8]);
|
||||
}
|
||||
|
||||
public function sendTestMail(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'mail_host' => 'required|string',
|
||||
'mail_port' => 'required|integer',
|
||||
'mail_username' => 'nullable|string',
|
||||
'mail_password' => 'nullable|string',
|
||||
'mail_encryption' => 'nullable|string|in:tls,ssl,null',
|
||||
'mail_from_address' => 'required|email',
|
||||
'mail_from_name' => 'required|string',
|
||||
'test_recipient' => 'required|email',
|
||||
]);
|
||||
|
||||
$schemeMap = ['tls' => '', 'ssl' => 'smtps', 'null' => ''];
|
||||
$mailScheme = $schemeMap[$validated['mail_encryption'] ?? 'null'] ?? '';
|
||||
|
||||
$this->envWriter->setMany([
|
||||
'MAIL_MAILER' => 'smtp',
|
||||
'MAIL_HOST' => $validated['mail_host'],
|
||||
'MAIL_PORT' => (string) $validated['mail_port'],
|
||||
'MAIL_SCHEME' => $mailScheme,
|
||||
'MAIL_USERNAME' => $validated['mail_username'] ?? '',
|
||||
'MAIL_PASSWORD' => $validated['mail_password'] ?? '',
|
||||
'MAIL_FROM_ADDRESS' => $validated['mail_from_address'],
|
||||
'MAIL_FROM_NAME' => $validated['mail_from_name'],
|
||||
]);
|
||||
|
||||
Artisan::call('config:clear');
|
||||
|
||||
config([
|
||||
'mail.default' => 'smtp',
|
||||
'mail.mailers.smtp.host' => $validated['mail_host'],
|
||||
'mail.mailers.smtp.port' => $validated['mail_port'],
|
||||
'mail.mailers.smtp.username' => $validated['mail_username'],
|
||||
'mail.mailers.smtp.password' => $validated['mail_password'],
|
||||
'mail.mailers.smtp.scheme' => $mailScheme ?: null,
|
||||
'mail.from.address' => $validated['mail_from_address'],
|
||||
'mail.from.name' => $validated['mail_from_name'],
|
||||
]);
|
||||
|
||||
try {
|
||||
Mail::raw(__('install.mail_test_body', ['brand' => brand()]), function ($message) use ($validated) {
|
||||
$message->to($validated['test_recipient'])
|
||||
->subject(brand() . ' — ' . __('install.mail_test_subject'));
|
||||
});
|
||||
|
||||
return redirect()->route('install.cron')
|
||||
->with('flash_test_mail', __('install.flash_test_mail', ['email' => $validated['test_recipient']]));
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->route('install.mail')
|
||||
->withInput()
|
||||
->withErrors(['mail' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function skipMail(): RedirectResponse
|
||||
{
|
||||
return redirect()->route('install.cron');
|
||||
}
|
||||
|
||||
// --- Step 9: Cron ---
|
||||
|
||||
public function showCron(): View
|
||||
{
|
||||
$cronLine = '* * * * * ' . $this->detectPhpCli() . ' ' . base_path('artisan') . ' schedule:run >> /dev/null 2>&1';
|
||||
$cronActive = cache()->has('cron.last_run');
|
||||
|
||||
return view('installer.step9-cron', [
|
||||
'currentStep' => 9,
|
||||
'cronLine' => $cronLine,
|
||||
'cronActive' => $cronActive,
|
||||
]);
|
||||
}
|
||||
|
||||
public function testCron(): RedirectResponse
|
||||
{
|
||||
try {
|
||||
Artisan::call('schedule:run');
|
||||
} catch (\Exception) {
|
||||
// Not critical
|
||||
}
|
||||
|
||||
cache()->put('cron.last_run', now());
|
||||
|
||||
return redirect()->route('install.cron')
|
||||
->with('cron_test_success', true);
|
||||
}
|
||||
|
||||
public function skipCron(): RedirectResponse
|
||||
{
|
||||
return redirect()->route('install.done');
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
private function detectPhpCli(): string
|
||||
{
|
||||
$binary = PHP_BINARY;
|
||||
|
||||
if (str_contains($binary, 'fpm') || str_contains($binary, 'cgi')) {
|
||||
$version = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION;
|
||||
foreach (["/usr/bin/php{$version}", "/usr/bin/php", "/usr/local/bin/php"] as $candidate) {
|
||||
if (is_executable($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $binary;
|
||||
}
|
||||
|
||||
private function autoDetectAppUrl(Request $request): void
|
||||
{
|
||||
$detected = $request->getSchemeAndHttpHost();
|
||||
$current = $this->envWriter->get('APP_URL');
|
||||
|
||||
if (! $current || $current === 'http://localhost') {
|
||||
$this->envWriter->set('APP_URL', $detected);
|
||||
config(['app.url' => $detected]);
|
||||
url()->forceRootUrl($detected);
|
||||
if ($request->isSecure()) {
|
||||
url()->forceScheme('https');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Done ---
|
||||
|
||||
public function showDone(): View
|
||||
{
|
||||
if (! $this->lockManager->isLocked()) {
|
||||
return view('installer.step1-welcome', ['currentStep' => 1]);
|
||||
}
|
||||
|
||||
$admin = User::where('role', UserRole::Admin)->first();
|
||||
|
||||
return view('installer.done', [
|
||||
'currentStep' => 10,
|
||||
'appUrl' => $this->envWriter->get('APP_URL') ?: url('/'),
|
||||
'adminEmail' => $admin?->email ?? '—',
|
||||
'mailConfigured' => ! empty($this->envWriter->get('MAIL_HOST')),
|
||||
]);
|
||||
}
|
||||
}
|
||||
77
app/Http/Controllers/Portal/PortalAuthController.php
Normal file
77
app/Http/Controllers/Portal/PortalAuthController.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Portal;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PortalAuthController extends Controller
|
||||
{
|
||||
public function showLogin(): View
|
||||
{
|
||||
return view('portal.auth.login');
|
||||
}
|
||||
|
||||
public function login(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$throttleKey = Str::transliterate(Str::lower($request->input('email')).'|'.$request->ip());
|
||||
|
||||
if (RateLimiter::tooManyAttempts($throttleKey, 5)) {
|
||||
$seconds = RateLimiter::availableIn($throttleKey);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('auth.throttle', ['seconds' => $seconds]),
|
||||
]);
|
||||
}
|
||||
|
||||
if (! Auth::guard('customer')->attempt(
|
||||
$request->only('email', 'password'),
|
||||
$request->boolean('remember')
|
||||
)) {
|
||||
RateLimiter::hit($throttleKey);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('portal.invalid_credentials'),
|
||||
]);
|
||||
}
|
||||
|
||||
$customer = Auth::guard('customer')->user();
|
||||
|
||||
if (! $customer->portal_enabled) {
|
||||
Auth::guard('customer')->logout();
|
||||
|
||||
RateLimiter::hit($throttleKey);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('portal.account_disabled'),
|
||||
]);
|
||||
}
|
||||
|
||||
RateLimiter::clear($throttleKey);
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('portal.home'));
|
||||
}
|
||||
|
||||
public function logout(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::guard('customer')->logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('portal.login');
|
||||
}
|
||||
}
|
||||
48
app/Http/Controllers/Portal/PortalDashboardController.php
Normal file
48
app/Http/Controllers/Portal/PortalDashboardController.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Portal;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Job;
|
||||
use App\Services\SeasonService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PortalDashboardController extends Controller
|
||||
{
|
||||
public function __invoke(SeasonService $seasonService): View
|
||||
{
|
||||
$customer = auth('customer')->user();
|
||||
$season = $seasonService->currentOrLastSeason();
|
||||
|
||||
$totalJobs = Job::where('customer_id', $customer->id)
|
||||
->whereNotNull('ended_at')
|
||||
->whereBetween('started_at', [$season->start, $season->end])
|
||||
->count();
|
||||
|
||||
$isSqlite = DB::getDriverName() === 'sqlite';
|
||||
$durationExpr = $isSqlite
|
||||
? "SUM((JULIANDAY(COALESCE(ended_at, started_at)) - JULIANDAY(started_at)) * 1440)"
|
||||
: "SUM(TIMESTAMPDIFF(MINUTE, started_at, COALESCE(ended_at, started_at)))";
|
||||
|
||||
$totalMinutes = (int) Job::selectRaw("{$durationExpr} as total_minutes")
|
||||
->where('customer_id', $customer->id)
|
||||
->whereNotNull('ended_at')
|
||||
->whereBetween('started_at', [$season->start, $season->end])
|
||||
->value('total_minutes');
|
||||
|
||||
$totalHours = number_format($totalMinutes / 60, 1);
|
||||
|
||||
$lastJob = Job::where('customer_id', $customer->id)
|
||||
->whereNotNull('ended_at')
|
||||
->latest('started_at')
|
||||
->first();
|
||||
|
||||
$objects = $customer->objects()
|
||||
->withMax(['serviceJobs as last_job_at' => fn ($q) => $q->whereNotNull('ended_at')], 'started_at')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('portal.home', compact('season', 'totalJobs', 'totalHours', 'lastJob', 'objects'));
|
||||
}
|
||||
}
|
||||
74
app/Http/Controllers/Portal/PortalJobController.php
Normal file
74
app/Http/Controllers/Portal/PortalJobController.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Portal;
|
||||
|
||||
use App\Enums\JobType;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Job;
|
||||
use App\Services\GpsSmoothingService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PortalJobController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$customer = auth('customer')->user();
|
||||
|
||||
$jobs = Job::where('customer_id', $customer->id)
|
||||
->whereNotNull('ended_at')
|
||||
->with(['customerObject'])
|
||||
->when($customer->portal_show_driver_name, fn ($q) => $q->with('user'))
|
||||
->when($request->customer_object_id, fn ($q, $id) => $q->where('customer_object_id', $id))
|
||||
->when($request->started_after, fn ($q, $date) => $q->where('started_at', '>=', $date))
|
||||
->when($request->started_before, fn ($q, $date) => $q->where('started_at', '<=', $date))
|
||||
->when($request->type, fn ($q, $type) => $q->where('type', $type))
|
||||
->orderByDesc('started_at')
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
$objects = $customer->objects()->orderBy('name')->get();
|
||||
$jobTypes = JobType::cases();
|
||||
|
||||
return view('portal.jobs.index', compact('jobs', 'objects', 'jobTypes', 'customer'));
|
||||
}
|
||||
|
||||
public function show(Job $serviceJob, GpsSmoothingService $gpsSmoother): View
|
||||
{
|
||||
$customer = auth('customer')->user();
|
||||
abort_unless($serviceJob->customer_id === $customer->id, 404);
|
||||
|
||||
$relations = ['customerObject', 'weatherSnapshots'];
|
||||
|
||||
if ($customer->portal_show_photos) {
|
||||
$relations['jobPhotos'] = fn ($q) => $q->orderBy('sort_order')->orderBy('created_at');
|
||||
}
|
||||
if ($customer->portal_show_gps) {
|
||||
$relations['gpsPoints'] = fn ($q) => $q->orderBy('timestamp');
|
||||
}
|
||||
if ($customer->portal_show_driver_name) {
|
||||
$relations[] = 'user';
|
||||
}
|
||||
|
||||
$serviceJob->load($relations);
|
||||
|
||||
$smoothedGps = collect();
|
||||
if ($customer->portal_show_gps && $serviceJob->gpsPoints->isNotEmpty()) {
|
||||
$smoothedGps = $gpsSmoother->smooth($serviceJob->gpsPoints)
|
||||
->map(fn ($p) => ['lat' => $p->lat, 'lon' => $p->lon]);
|
||||
}
|
||||
|
||||
$driverLastName = null;
|
||||
if ($customer->portal_show_driver_name && $serviceJob->user) {
|
||||
$parts = explode(' ', trim($serviceJob->user->name));
|
||||
$driverLastName = end($parts);
|
||||
}
|
||||
|
||||
return view('portal.jobs.show', [
|
||||
'job' => $serviceJob,
|
||||
'customer' => $customer,
|
||||
'smoothedGps' => $smoothedGps,
|
||||
'driverLastName' => $driverLastName,
|
||||
]);
|
||||
}
|
||||
}
|
||||
33
app/Http/Controllers/Portal/PortalNotificationController.php
Normal file
33
app/Http/Controllers/Portal/PortalNotificationController.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Portal;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Job;
|
||||
use App\Models\NotificationLog;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PortalNotificationController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
/** @var Customer $customer */
|
||||
$customer = auth('customer')->user();
|
||||
|
||||
$logs = NotificationLog::query()
|
||||
->where(function ($query) use ($customer) {
|
||||
$query->where(function ($q) use ($customer) {
|
||||
$q->where('notifiable_type', Job::class)
|
||||
->whereIn('notifiable_id', $customer->serviceJobs()->select('id'));
|
||||
})->orWhere(function ($q) use ($customer) {
|
||||
$q->where('notifiable_type', Customer::class)
|
||||
->where('notifiable_id', $customer->id);
|
||||
});
|
||||
})
|
||||
->orderByDesc('created_at')
|
||||
->paginate(20);
|
||||
|
||||
return view('portal.notifications.index', compact('logs'));
|
||||
}
|
||||
}
|
||||
97
app/Http/Controllers/Portal/PortalPdfController.php
Normal file
97
app/Http/Controllers/Portal/PortalPdfController.php
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Portal;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CustomerObject;
|
||||
use App\Models\Job;
|
||||
use App\Services\PdfReportService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PortalPdfController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PdfReportService $pdfReportService,
|
||||
) {}
|
||||
|
||||
public function jobPdf(Job $serviceJob): Response
|
||||
{
|
||||
$customer = auth('customer')->user();
|
||||
abort_unless($serviceJob->customer_id === $customer->id, 404);
|
||||
abort_unless($serviceJob->ended_at !== null, 422, __('portal.reports_job_not_completed'));
|
||||
|
||||
$pdf = $this->pdfReportService->generateJobReport($serviceJob);
|
||||
$filename = $this->pdfReportService->jobReportFilename($serviceJob);
|
||||
|
||||
return $pdf->download($filename);
|
||||
}
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
$customer = auth('customer')->user();
|
||||
$objects = $customer->objects()->orderBy('name')->get();
|
||||
|
||||
return view('portal.reports.index', [
|
||||
'objects' => $objects,
|
||||
'defaultFrom' => Carbon::now()->startOfMonth()->format('Y-m-d'),
|
||||
'defaultTo' => Carbon::now()->format('Y-m-d'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function generate(Request $request): Response
|
||||
{
|
||||
$customer = auth('customer')->user();
|
||||
|
||||
$validated = $request->validate([
|
||||
'from' => ['required', 'date'],
|
||||
'to' => ['required', 'date', 'after_or_equal:from'],
|
||||
'customer_object_id' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
$from = Carbon::parse($validated['from']);
|
||||
$to = Carbon::parse($validated['to']);
|
||||
$objectId = $validated['customer_object_id'] ?? null;
|
||||
|
||||
if ($objectId) {
|
||||
$object = CustomerObject::where('id', $objectId)
|
||||
->where('customer_id', $customer->id)
|
||||
->first();
|
||||
abort_unless($object !== null, 404);
|
||||
|
||||
$jobCount = Job::where('customer_object_id', $object->id)
|
||||
->whereNotNull('ended_at')
|
||||
->where('started_at', '>=', $from)
|
||||
->where('started_at', '<=', $to->copy()->endOfDay())
|
||||
->count();
|
||||
|
||||
if ($jobCount === 0) {
|
||||
return redirect()->back()
|
||||
->withInput()
|
||||
->with('error', __('portal.reports_no_jobs'));
|
||||
}
|
||||
|
||||
$pdf = $this->pdfReportService->generateObjectReport($object, $from, $to);
|
||||
$filename = $this->pdfReportService->objectReportFilename($object, $from, $to);
|
||||
} else {
|
||||
$jobCount = Job::where('customer_id', $customer->id)
|
||||
->whereNotNull('ended_at')
|
||||
->where('started_at', '>=', $from)
|
||||
->where('started_at', '<=', $to->copy()->endOfDay())
|
||||
->count();
|
||||
|
||||
if ($jobCount === 0) {
|
||||
return redirect()->back()
|
||||
->withInput()
|
||||
->with('error', __('portal.reports_no_jobs'));
|
||||
}
|
||||
|
||||
$pdf = $this->pdfReportService->generateCustomerReport($customer, $from, $to);
|
||||
$filename = $this->pdfReportService->customerReportFilename($customer, $from, $to);
|
||||
}
|
||||
|
||||
return $pdf->download($filename);
|
||||
}
|
||||
}
|
||||
76
app/Http/Controllers/Portal/PortalProfileController.php
Normal file
76
app/Http/Controllers/Portal/PortalProfileController.php
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Portal;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\CustomerEmailChangedMail;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PortalProfileController extends Controller
|
||||
{
|
||||
public function edit(Request $request): View
|
||||
{
|
||||
return view('portal.profile.edit', [
|
||||
'customer' => auth('customer')->user(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$customer = auth('customer')->user();
|
||||
|
||||
$throttleKey = 'portal-profile|' . $customer->id;
|
||||
|
||||
if (RateLimiter::tooManyAttempts($throttleKey, 10)) {
|
||||
$seconds = RateLimiter::availableIn($throttleKey);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('auth.throttle', ['seconds' => $seconds]),
|
||||
]);
|
||||
}
|
||||
|
||||
RateLimiter::hit($throttleKey, 3600);
|
||||
|
||||
$validated = $request->validate([
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', Rule::unique('customers')->ignore($customer->id)],
|
||||
'locale' => ['required', 'in:de,en'],
|
||||
'current_password' => ['nullable', 'required_with:password', 'current_password:customer'],
|
||||
'password' => ['nullable', 'confirmed', Password::defaults()],
|
||||
]);
|
||||
|
||||
$customer->email = $validated['email'];
|
||||
$customer->locale = $validated['locale'];
|
||||
|
||||
$emailChanged = $customer->isDirty('email');
|
||||
$oldEmail = $customer->getOriginal('email');
|
||||
|
||||
if ($request->filled('password')) {
|
||||
$customer->password = $validated['password'];
|
||||
}
|
||||
|
||||
$customer->save();
|
||||
|
||||
RateLimiter::clear($throttleKey);
|
||||
|
||||
if ($emailChanged) {
|
||||
$admin = User::first();
|
||||
if ($admin) {
|
||||
Mail::to($admin)->send(new CustomerEmailChangedMail($customer, $oldEmail, $validated['email']));
|
||||
}
|
||||
}
|
||||
|
||||
App::setLocale($customer->locale);
|
||||
session(['locale' => $customer->locale]);
|
||||
|
||||
return redirect()->back()->with('success', __('portal.profile_saved'));
|
||||
}
|
||||
}
|
||||
60
app/Http/Controllers/ProfileController.php
Normal file
60
app/Http/Controllers/ProfileController.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\ProfileUpdateRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Redirect;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the user's profile form.
|
||||
*/
|
||||
public function edit(Request $request): View
|
||||
{
|
||||
return view('profile.edit', [
|
||||
'user' => $request->user(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's profile information.
|
||||
*/
|
||||
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||
{
|
||||
$request->user()->fill($request->validated());
|
||||
|
||||
if ($request->user()->isDirty('email')) {
|
||||
$request->user()->email_verified_at = null;
|
||||
}
|
||||
|
||||
$request->user()->save();
|
||||
|
||||
return Redirect::route('profile.edit')->with('status', 'profile-updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the user's account.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validateWithBag('userDeletion', [
|
||||
'password' => ['required', 'current_password'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
Auth::logout();
|
||||
|
||||
$user->delete();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return Redirect::to('/');
|
||||
}
|
||||
}
|
||||
27
app/Http/Controllers/StorageFallbackController.php
Normal file
27
app/Http/Controllers/StorageFallbackController.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class StorageFallbackController extends Controller
|
||||
{
|
||||
public function __invoke(string $path): Response
|
||||
{
|
||||
$disk = Storage::disk('public');
|
||||
|
||||
if (! $disk->exists($path)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$mimeType = $disk->mimeType($path) ?: 'application/octet-stream';
|
||||
$lastModified = $disk->lastModified($path);
|
||||
|
||||
return response($disk->get($path), 200, [
|
||||
'Content-Type' => $mimeType,
|
||||
'Cache-Control' => 'public, max-age=604800',
|
||||
'Last-Modified' => gmdate('D, d M Y H:i:s', $lastModified) . ' GMT',
|
||||
]);
|
||||
}
|
||||
}
|
||||
32
app/Http/Middleware/AuthenticateOwntracks.php
Normal file
32
app/Http/Middleware/AuthenticateOwntracks.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AuthenticateOwntracks
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$username = $request->getUser();
|
||||
$password = $request->getPassword();
|
||||
|
||||
if ($username === null || $password === null) {
|
||||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$user = User::where('owntracks_username', $username)->first();
|
||||
|
||||
if (! $user || ! Hash::check($password, $user->owntracks_password_hash)) {
|
||||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$request->setUserResolver(fn () => $user);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
19
app/Http/Middleware/EnsureAdmin.php
Normal file
19
app/Http/Middleware/EnsureAdmin.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureAdmin
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->user()?->isAdmin()) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
34
app/Http/Middleware/EnsureCustomer.php
Normal file
34
app/Http/Middleware/EnsureCustomer.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureCustomer
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$customer = auth('customer')->user();
|
||||
|
||||
if (! $customer) {
|
||||
return redirect()->route('portal.login');
|
||||
}
|
||||
|
||||
if (! $customer->portal_enabled) {
|
||||
auth('customer')->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('portal.login')
|
||||
->with('error', __('portal.account_disabled'));
|
||||
}
|
||||
|
||||
if ($customer->locale && in_array($customer->locale, ['de', 'en'], true)) {
|
||||
\Illuminate\Support\Facades\App::setLocale($customer->locale);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
19
app/Http/Middleware/EnsureDriver.php
Normal file
19
app/Http/Middleware/EnsureDriver.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureDriver
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->user()?->isDriver()) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
28
app/Http/Middleware/EnsureDsgvoInformed.php
Normal file
28
app/Http/Middleware/EnsureDsgvoInformed.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureDsgvoInformed
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user || $user->isAdmin()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$requiredVersion = (int) Setting::get('dsgvo_template_version', 1);
|
||||
|
||||
if ($user->dsgvo_informed_at === null || $user->confirmed_version < $requiredVersion) {
|
||||
return redirect('/onboarding/dsgvo');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
98
app/Http/Middleware/InstallerGuard.php
Normal file
98
app/Http/Middleware/InstallerGuard.php
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\Installer\EnvFileWriter;
|
||||
use Closure;
|
||||
use Illuminate\Encryption\Encrypter;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class InstallerGuard
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$this->ensureEnv();
|
||||
|
||||
config([
|
||||
'session.driver' => 'file',
|
||||
'cache.default' => 'file',
|
||||
'session.cookie' => Str::slug((string) config('app.name', 'laravel')) . '-session',
|
||||
]);
|
||||
|
||||
$this->ensureAppUrl($request);
|
||||
|
||||
if ($request->routeIs('install.done') || $request->routeIs('install.mail') || $request->routeIs('install.mail.send') || $request->routeIs('install.mail.skip') || $request->routeIs('install.cron') || $request->routeIs('install.cron.test') || $request->routeIs('install.cron.skip')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (file_exists(storage_path('app/installed.lock'))) {
|
||||
abort(410, __('install.already_installed', ['app_name' => brand()]));
|
||||
}
|
||||
|
||||
try {
|
||||
$userCount = DB::table('users')->count();
|
||||
if ($userCount > 0) {
|
||||
abort(410, __('install.already_installed', ['app_name' => brand()]));
|
||||
}
|
||||
} catch (\PDOException) {
|
||||
// DB not configured yet — installer should proceed
|
||||
} catch (\Illuminate\Database\QueryException) {
|
||||
// DB configured but tables don't exist yet — installer should proceed
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function ensureEnv(): void
|
||||
{
|
||||
$env = app(EnvFileWriter::class);
|
||||
$env->ensureEnvExists();
|
||||
|
||||
$key = $env->get('APP_KEY');
|
||||
|
||||
if (empty($key)) {
|
||||
$key = 'base64:' . base64_encode(random_bytes(32));
|
||||
$env->set('APP_KEY', $key);
|
||||
}
|
||||
|
||||
config(['app.key' => $key]);
|
||||
|
||||
$rawKey = base64_decode(substr($key, 7));
|
||||
app()->forgetInstance('encrypter');
|
||||
app()->singleton('encrypter', fn () => new Encrypter(
|
||||
$rawKey,
|
||||
config('app.cipher'),
|
||||
));
|
||||
}
|
||||
|
||||
private function ensureAppUrl(Request $request): void
|
||||
{
|
||||
$isSecure = $request->isSecure()
|
||||
|| $request->server('HTTP_X_FORWARDED_PORT') === '443'
|
||||
|| $request->server('SERVER_PORT') === '443';
|
||||
|
||||
if ($isSecure) {
|
||||
url()->forceScheme('https');
|
||||
}
|
||||
|
||||
$currentUrl = config('app.url');
|
||||
|
||||
if (! $currentUrl || $currentUrl === 'http://localhost') {
|
||||
$scheme = $isSecure ? 'https' : 'http';
|
||||
$detected = $scheme . '://' . $request->getHost();
|
||||
config(['app.url' => $detected]);
|
||||
url()->forceRootUrl($detected);
|
||||
|
||||
try {
|
||||
$env = app(EnvFileWriter::class);
|
||||
$env->ensureEnvExists();
|
||||
$env->set('APP_URL', $detected);
|
||||
} catch (\Throwable) {
|
||||
// .env not writable yet — runtime override is enough for rendering
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
30
app/Http/Middleware/RedirectToInstaller.php
Normal file
30
app/Http/Middleware/RedirectToInstaller.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RedirectToInstaller
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (file_exists(storage_path('app/installed.lock'))) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
config([
|
||||
'session.driver' => 'file',
|
||||
'cache.default' => 'file',
|
||||
'database.default' => 'sqlite',
|
||||
]);
|
||||
|
||||
if (! $request->is('install', 'install/*')) {
|
||||
return redirect('/install');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
51
app/Http/Requests/Admin/AnonymizeDriverRequest.php
Normal file
51
app/Http/Requests/Admin/AnonymizeDriverRequest.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class AnonymizeDriverRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'confirmation_name' => ['required', 'string'],
|
||||
'reason' => ['required', 'string', 'max:500'],
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator) {
|
||||
$driver = $this->route('driver');
|
||||
|
||||
if ($driver && $this->input('confirmation_name') !== $driver->name) {
|
||||
$validator->errors()->add(
|
||||
'confirmation_name',
|
||||
__('validation.same', ['attribute' => 'confirmation_name', 'other' => __('driver.field_name')])
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'confirmation_name' => __('driver.modal_anonymize_confirm_label'),
|
||||
'reason' => __('driver.modal_anonymize_reason_label'),
|
||||
];
|
||||
}
|
||||
}
|
||||
96
app/Http/Requests/Admin/StoreCustomerObjectRequest.php
Normal file
96
app/Http/Requests/Admin/StoreCustomerObjectRequest.php
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreCustomerObjectRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
if ($this->has('price_amount') && is_string($this->price_amount)) {
|
||||
$this->merge([
|
||||
'price_amount' => str_replace(',', '.', $this->price_amount),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:200'],
|
||||
'street' => ['nullable', 'string', 'max:200'],
|
||||
'zip' => ['nullable', 'string', 'max:16'],
|
||||
'city' => ['nullable', 'string', 'max:100'],
|
||||
'contact_name' => ['nullable', 'string', 'max:200'],
|
||||
'contact_email' => ['nullable', 'email', 'max:200'],
|
||||
'contact_phone' => ['nullable', 'string', 'max:50'],
|
||||
'price_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'price_unit' => ['nullable', 'string', 'in:per_job,monthly,seasonal'],
|
||||
'site_notes' => ['nullable', 'string'],
|
||||
'plow_threshold_cm' => ['nullable', 'integer', 'min:0', 'max:255'],
|
||||
'salt_enabled' => ['boolean'],
|
||||
'access_notes' => ['nullable', 'string'],
|
||||
'lat' => ['nullable', 'numeric', 'between:-90,90'],
|
||||
'lon' => ['nullable', 'numeric', 'between:-180,180'],
|
||||
'auto_notify_email' => ['boolean'],
|
||||
'notification_email' => ['nullable', 'required_if:auto_notify_email,1', 'email', 'max:200'],
|
||||
'notify_recipients' => ['nullable', 'string', 'max:1000'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'notification_email.required_if' => __('customer_object.validation_notification_email_required'),
|
||||
];
|
||||
}
|
||||
|
||||
public function validated($key = null, $default = null): mixed
|
||||
{
|
||||
$data = parent::validated($key, $default);
|
||||
|
||||
if ($key === null && isset($data['price_amount'])) {
|
||||
$data['price_amount_cents'] = (int) round((float) $data['price_amount'] * 100);
|
||||
unset($data['price_amount']);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'name' => __('customer_object.field_name'),
|
||||
'street' => __('customer_object.field_street'),
|
||||
'zip' => __('customer_object.field_zip'),
|
||||
'city' => __('customer_object.field_city'),
|
||||
'contact_name' => __('customer_object.field_contact_name'),
|
||||
'contact_email' => __('customer_object.field_contact_email'),
|
||||
'contact_phone' => __('customer_object.field_contact_phone'),
|
||||
'price_amount' => __('customer_object.field_price_amount'),
|
||||
'price_unit' => __('customer_object.field_price_unit'),
|
||||
'site_notes' => __('customer_object.field_site_notes'),
|
||||
'plow_threshold_cm' => __('customer_object.field_plow_threshold'),
|
||||
'salt_enabled' => __('customer_object.field_salt_enabled'),
|
||||
'access_notes' => __('customer_object.field_access_notes'),
|
||||
'lat' => __('customer_object.field_lat'),
|
||||
'lon' => __('customer_object.field_lon'),
|
||||
'auto_notify_email' => __('customer_object.field_auto_notify'),
|
||||
'notification_email' => __('customer_object.field_notification_email'),
|
||||
'notify_recipients' => __('customer_object.field_notify_recipients'),
|
||||
];
|
||||
}
|
||||
}
|
||||
53
app/Http/Requests/Admin/StoreCustomerRequest.php
Normal file
53
app/Http/Requests/Admin/StoreCustomerRequest.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreCustomerRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:200'],
|
||||
'contact_name' => ['nullable', 'string', 'max:200'],
|
||||
'email' => ['nullable', 'email', 'max:200'],
|
||||
'phone' => ['nullable', 'string', 'max:50'],
|
||||
'auto_notify_email' => ['boolean'],
|
||||
'notification_email' => ['nullable', 'required_if:auto_notify_email,1', 'email', 'max:200'],
|
||||
'locale' => ['sometimes', 'in:de,en'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'notification_email.required_if' => __('customer.validation_notification_email_required'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'name' => __('customer.field_name'),
|
||||
'contact_name' => __('customer.field_contact_name'),
|
||||
'email' => __('customer.field_email'),
|
||||
'phone' => __('customer.field_phone'),
|
||||
'auto_notify_email' => __('customer.field_auto_notify'),
|
||||
'notification_email' => __('customer.field_notification_email'),
|
||||
'locale' => __('customer.field_locale'),
|
||||
];
|
||||
}
|
||||
}
|
||||
44
app/Http/Requests/Admin/StoreDriverRequest.php
Normal file
44
app/Http/Requests/Admin/StoreDriverRequest.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreDriverRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:200'],
|
||||
'email' => ['required', 'email', 'max:200', 'unique:users'],
|
||||
'password' => ['required', 'string', 'min:8'],
|
||||
'phone' => ['nullable', 'string', 'max:50'],
|
||||
'notes' => ['nullable', 'string'],
|
||||
'default_vehicle_id' => ['nullable', 'exists:vehicles,id'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'name' => __('driver.field_name'),
|
||||
'email' => __('driver.field_email'),
|
||||
'password' => __('driver.field_password'),
|
||||
'phone' => __('driver.field_phone'),
|
||||
'notes' => __('driver.field_notes'),
|
||||
'default_vehicle_id' => __('driver.field_default_vehicle'),
|
||||
];
|
||||
}
|
||||
}
|
||||
48
app/Http/Requests/Admin/StoreManualJobRequest.php
Normal file
48
app/Http/Requests/Admin/StoreManualJobRequest.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Enums\JobType;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreManualJobRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => ['required', Rule::exists('users', 'id')->where('role', 'driver')],
|
||||
'customer_object_id' => ['required', 'exists:customer_objects,id'],
|
||||
'type' => ['required', Rule::enum(JobType::class)],
|
||||
'started_at' => ['required', 'date', 'before_or_equal:now'],
|
||||
'ended_at' => ['required', 'date', 'after:started_at', 'before_or_equal:now'],
|
||||
'notes' => ['nullable', 'string', 'max:1000'],
|
||||
'vehicle_id' => ['nullable', 'exists:vehicles,id'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => __('job.field_driver'),
|
||||
'customer_object_id' => __('job.field_object'),
|
||||
'type' => __('job.field_type'),
|
||||
'started_at' => __('job.field_started_at'),
|
||||
'ended_at' => __('job.field_ended_at'),
|
||||
'notes' => __('job.field_notes'),
|
||||
'vehicle_id' => __('job.field_vehicle'),
|
||||
];
|
||||
}
|
||||
}
|
||||
40
app/Http/Requests/Admin/StoreVehicleRequest.php
Normal file
40
app/Http/Requests/Admin/StoreVehicleRequest.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreVehicleRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:200'],
|
||||
'license_plate' => ['nullable', 'string', 'max:32'],
|
||||
'owntracks_device_id' => ['nullable', 'string', 'max:64', 'unique:vehicles,owntracks_device_id'],
|
||||
'notes' => ['nullable', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'name' => __('vehicle.field_name'),
|
||||
'license_plate' => __('vehicle.field_license_plate'),
|
||||
'owntracks_device_id' => __('vehicle.field_owntracks_device_id'),
|
||||
'notes' => __('vehicle.field_notes'),
|
||||
];
|
||||
}
|
||||
}
|
||||
7
app/Http/Requests/Admin/UpdateCustomerObjectRequest.php
Normal file
7
app/Http/Requests/Admin/UpdateCustomerObjectRequest.php
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
class UpdateCustomerObjectRequest extends StoreCustomerObjectRequest
|
||||
{
|
||||
}
|
||||
53
app/Http/Requests/Admin/UpdateCustomerRequest.php
Normal file
53
app/Http/Requests/Admin/UpdateCustomerRequest.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateCustomerRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:200'],
|
||||
'contact_name' => ['nullable', 'string', 'max:200'],
|
||||
'email' => ['nullable', 'email', 'max:200'],
|
||||
'phone' => ['nullable', 'string', 'max:50'],
|
||||
'auto_notify_email' => ['boolean'],
|
||||
'notification_email' => ['nullable', 'required_if:auto_notify_email,1', 'email', 'max:200'],
|
||||
'locale' => ['sometimes', 'in:de,en'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'notification_email.required_if' => __('customer.validation_notification_email_required'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'name' => __('customer.field_name'),
|
||||
'contact_name' => __('customer.field_contact_name'),
|
||||
'email' => __('customer.field_email'),
|
||||
'phone' => __('customer.field_phone'),
|
||||
'auto_notify_email' => __('customer.field_auto_notify'),
|
||||
'notification_email' => __('customer.field_notification_email'),
|
||||
'locale' => __('customer.field_locale'),
|
||||
];
|
||||
}
|
||||
}
|
||||
45
app/Http/Requests/Admin/UpdateDriverRequest.php
Normal file
45
app/Http/Requests/Admin/UpdateDriverRequest.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateDriverRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:200'],
|
||||
'email' => ['required', 'email', 'max:200', Rule::unique('users')->ignore($this->route('driver'))],
|
||||
'password' => ['nullable', 'string', 'min:8'],
|
||||
'phone' => ['nullable', 'string', 'max:50'],
|
||||
'notes' => ['nullable', 'string'],
|
||||
'default_vehicle_id' => ['nullable', 'exists:vehicles,id'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'name' => __('driver.field_name'),
|
||||
'email' => __('driver.field_email'),
|
||||
'password' => __('driver.field_password'),
|
||||
'phone' => __('driver.field_phone'),
|
||||
'notes' => __('driver.field_notes'),
|
||||
'default_vehicle_id' => __('driver.field_default_vehicle'),
|
||||
];
|
||||
}
|
||||
}
|
||||
46
app/Http/Requests/Admin/UpdateVehicleRequest.php
Normal file
46
app/Http/Requests/Admin/UpdateVehicleRequest.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateVehicleRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:200'],
|
||||
'license_plate' => ['nullable', 'string', 'max:32'],
|
||||
'owntracks_device_id' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:64',
|
||||
Rule::unique('vehicles', 'owntracks_device_id')->ignore($this->route('vehicle')),
|
||||
],
|
||||
'notes' => ['nullable', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'name' => __('vehicle.field_name'),
|
||||
'license_plate' => __('vehicle.field_license_plate'),
|
||||
'owntracks_device_id' => __('vehicle.field_owntracks_device_id'),
|
||||
'notes' => __('vehicle.field_notes'),
|
||||
];
|
||||
}
|
||||
}
|
||||
86
app/Http/Requests/Auth/LoginRequest.php
Normal file
86
app/Http/Requests/Auth/LoginRequest.php
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to authenticate the request's credentials.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function authenticate(): void
|
||||
{
|
||||
$this->ensureIsNotRateLimited();
|
||||
|
||||
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
|
||||
RateLimiter::hit($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => trans('auth.failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
RateLimiter::clear($this->throttleKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the login request is not rate limited.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function ensureIsNotRateLimited(): void
|
||||
{
|
||||
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event(new Lockout($this));
|
||||
|
||||
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => trans('auth.throttle', [
|
||||
'seconds' => $seconds,
|
||||
'minutes' => ceil($seconds / 60),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rate limiting throttle key for the request.
|
||||
*/
|
||||
public function throttleKey(): string
|
||||
{
|
||||
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
|
||||
}
|
||||
}
|
||||
42
app/Http/Requests/ConfirmDsgvoRequest.php
Normal file
42
app/Http/Requests/ConfirmDsgvoRequest.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ConfirmDsgvoRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'accepted' => 'required|accepted',
|
||||
'signed_by' => 'required|string|max:200',
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator) {
|
||||
if ($validator->errors()->isNotEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$signed = mb_strtolower($this->input('signed_by'));
|
||||
$expected = mb_strtolower($this->user()->name);
|
||||
|
||||
if ($signed !== $expected) {
|
||||
$validator->errors()->add(
|
||||
'signed_by',
|
||||
__('dsgvo.validation_name_mismatch'),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
46
app/Http/Requests/Driver/StoreManualJobRequest.php
Normal file
46
app/Http/Requests/Driver/StoreManualJobRequest.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Driver;
|
||||
|
||||
use App\Enums\JobType;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreManualJobRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'customer_object_id' => ['required', 'exists:customer_objects,id'],
|
||||
'type' => ['required', Rule::enum(JobType::class)],
|
||||
'started_at' => ['required', 'date', 'before_or_equal:now'],
|
||||
'ended_at' => ['required', 'date', 'after:started_at', 'before_or_equal:now'],
|
||||
'notes' => ['nullable', 'string', 'max:1000'],
|
||||
'vehicle_id' => ['nullable', 'exists:vehicles,id'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'customer_object_id' => __('job.field_object'),
|
||||
'type' => __('job.field_type'),
|
||||
'started_at' => __('job.field_started_at'),
|
||||
'ended_at' => __('job.field_ended_at'),
|
||||
'notes' => __('job.field_notes'),
|
||||
'vehicle_id' => __('job.field_vehicle'),
|
||||
];
|
||||
}
|
||||
}
|
||||
31
app/Http/Requests/ProfileUpdateRequest.php
Normal file
31
app/Http/Requests/ProfileUpdateRequest.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ProfileUpdateRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'lowercase',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique(User::class)->ignore($this->user()->id),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue