Initial commit of the Asset Management System, including project structure, Docker configuration, database migrations, and core application files. Added user authentication, asset management features, and basic UI components.
This commit is contained in:
14
Dockerfile.nginx
Normal file
14
Dockerfile.nginx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM nginx:stable-alpine
|
||||||
|
|
||||||
|
# Copy nginx configuration
|
||||||
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
# Copy static assets
|
||||||
|
COPY public/assets /var/www/html/public/assets
|
||||||
|
|
||||||
|
# Create necessary directories
|
||||||
|
RUN mkdir -p /var/www/html/public
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
58
Dockerfile.php
Normal file
58
Dockerfile.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
FROM php:8.2-fpm-alpine
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
git \
|
||||||
|
curl \
|
||||||
|
libpng-dev \
|
||||||
|
libxml2-dev \
|
||||||
|
zip \
|
||||||
|
unzip \
|
||||||
|
oniguruma-dev \
|
||||||
|
libzip-dev \
|
||||||
|
freetype-dev \
|
||||||
|
libjpeg-turbo-dev \
|
||||||
|
libwebp-dev
|
||||||
|
|
||||||
|
# Install PHP extensions
|
||||||
|
RUN docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
|
||||||
|
&& docker-php-ext-install \
|
||||||
|
pdo_mysql \
|
||||||
|
mbstring \
|
||||||
|
intl \
|
||||||
|
zip \
|
||||||
|
gd \
|
||||||
|
exif \
|
||||||
|
opcache
|
||||||
|
|
||||||
|
# Install Composer
|
||||||
|
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /var/www/html
|
||||||
|
|
||||||
|
# Copy composer files
|
||||||
|
COPY composer.json composer.lock ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN composer install --no-dev --optimize-autoloader --no-interaction
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create necessary directories
|
||||||
|
RUN mkdir -p storage/logs storage/uploads storage/cache \
|
||||||
|
&& chown -R www-data:www-data storage \
|
||||||
|
&& chmod -R 755 storage
|
||||||
|
|
||||||
|
# Copy PHP configuration
|
||||||
|
COPY php.ini /usr/local/etc/php/conf.d/custom.ini
|
||||||
|
|
||||||
|
# Copy entrypoint script
|
||||||
|
COPY scripts/entrypoint.sh /usr/local/bin/
|
||||||
|
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 9000
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
|
CMD ["php-fpm"]
|
||||||
336
README.md
336
README.md
@@ -1,3 +1,335 @@
|
|||||||
# ai-zensur
|
# Asset Management System
|
||||||
|
|
||||||
Ein Tool um Daten aus Dokumenten für weitere KI Verarbeitung vorzubereiten.
|
Ein vollwertiges Inventar- und Asset-Management-System entwickelt mit PHP, MySQL und Docker.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 🔐 Benutzer & Rollen
|
||||||
|
- **Benutzerverwaltung**: Anlegen, Bearbeiten, Deaktivieren von Benutzern
|
||||||
|
- **Rollen**: Admin, Auditor, Mitarbeiter mit unterschiedlichen Berechtigungen
|
||||||
|
- **Authentifizierung**: Sichere Login/Logout mit Passwort-Hashing (Argon2id)
|
||||||
|
- **Passwort-Reset**: E-Mail-basierter Passwort-Reset mit Token
|
||||||
|
- **Sitzungsmanagement**: Sichere Cookies mit HttpOnly und SameSite=Strict
|
||||||
|
|
||||||
|
### 📦 Assets / Inventar
|
||||||
|
- **Vollständiges CRUD**: Erstellen, Lesen, Aktualisieren, Löschen von Assets
|
||||||
|
- **Detaillierte Felder**: Inventarnummer, Bezeichnung, Kategorie, Standort, Abteilung, Lieferant, Anschaffungsdatum, Preis, Zustand, Seriennummer, Garantie, Kostenstelle, Status, Notizen
|
||||||
|
- **Lebenszyklus**: Zuweisung an Benutzer, Check-in/Check-out, Historie
|
||||||
|
- **Dateianhänge**: Upload und Download von Dokumenten pro Asset
|
||||||
|
- **Etikettendruck**: PDF-Labels mit QR/Barcode für Inventarnummer + Bezeichnung
|
||||||
|
- **Erweiterte Suche**: Nach Standort, Kategorie, Zustand, Status, Volltext
|
||||||
|
|
||||||
|
### 🏢 Stammdaten
|
||||||
|
- **Kategorien**: Verwaltung von Asset-Kategorien
|
||||||
|
- **Standorte**: Verwaltung von Standorten mit Adressen
|
||||||
|
- **Abteilungen**: Verwaltung von Abteilungen mit Kostenstellen
|
||||||
|
|
||||||
|
### 📊 Inventur & Zählungen
|
||||||
|
- **Inventurdurchläufe**: Starten, Offene Positionen, Abgleich Ist/Soll
|
||||||
|
- **Erfassung**: Per Eingabe Inventarnummer/QR-Code
|
||||||
|
- **Abschlussbericht**: PDF mit Differenzen und Statistiken
|
||||||
|
|
||||||
|
### 📈 Berichte & Export
|
||||||
|
- **Verschiedene Reports**: Aktive Assets, nach Standort, nach Kategorie, ausgemusterte Geräte, Garantie läuft ab
|
||||||
|
- **Export-Formate**: CSV (Pflicht), XLSX (optional), PDF
|
||||||
|
- **CSV-Import**: Mit Validierung, Vorschau, Duplikat-Erkennung, transaktionale Übernahme
|
||||||
|
|
||||||
|
### 🔍 Protokollierung & Nachvollziehbarkeit
|
||||||
|
- **Audit-Log**: Vollständige Protokollierung aller CRUD-Aktionen
|
||||||
|
- **Historie**: Wer/was/wann geändert wurde
|
||||||
|
- **Nachverfolgung**: Komplette Änderungshistorie pro Asset
|
||||||
|
|
||||||
|
### 🌍 Internationalisierung
|
||||||
|
- **Mehrsprachig**: Deutsch (Standard), Englisch
|
||||||
|
- **Benutzer-spezifisch**: Umschaltbar pro Benutzer
|
||||||
|
- **PHP-Array-basiert**: Einfache Übersetzungsstruktur
|
||||||
|
|
||||||
|
### 🎨 UI/UX
|
||||||
|
- **Responsive Design**: Optimiert für Desktop, Tablet und Mobile
|
||||||
|
- **Barrierefrei**: Basis-WCAG-Konformität
|
||||||
|
- **Klare Navigation**: Dashboard, Assets, Inventur, Berichte, Stammdaten, Benutzer, Einstellungen
|
||||||
|
- **Tabellen**: Mit Paginierung, Sortierung (serverseitig), Filterfeldern
|
||||||
|
- **Druckfreundlich**: Optimierte Styles für Berichte und Etiketten
|
||||||
|
|
||||||
|
## Technische Architektur
|
||||||
|
|
||||||
|
### 🏗️ MVC-ähnliche Struktur
|
||||||
|
- **Front-Controller**: `public/index.php`
|
||||||
|
- **Router**: Einfaches URL-Routing mit Parameter-Extraktion
|
||||||
|
- **Controller**: Geschäftslogik und Request-Handling
|
||||||
|
- **Models**: Datenzugriff mit PDO und Prepared Statements
|
||||||
|
- **Views**: PHP-Templates mit Layout-System
|
||||||
|
|
||||||
|
### 🔒 Sicherheit
|
||||||
|
- **CSRF-Schutz**: Token-basiert für alle POST-Formulare
|
||||||
|
- **XSS-Schutz**: HTML-Escaping bei Ausgabe
|
||||||
|
- **SQL-Injection-Schutz**: Prepared Statements überall
|
||||||
|
- **Upload-Validierung**: Whitelist-MIME, Größenlimit, sichere Speicherung
|
||||||
|
- **Rate-Limiting**: IP-basiert für Login-Versuche
|
||||||
|
- **Rollenbasierte Zugriffskontrolle**: Middleware auf Controller-Ebene
|
||||||
|
|
||||||
|
### 🚀 Leistung
|
||||||
|
- **Indizes**: Optimierte Datenbank-Indizes
|
||||||
|
- **Pagination**: Serverseitige Paginierung
|
||||||
|
- **Caching**: File-basiertes Caching für Stammdaten
|
||||||
|
- **OPcache**: PHP-Bytecode-Caching aktiviert
|
||||||
|
|
||||||
|
## Installation & Setup
|
||||||
|
|
||||||
|
### Voraussetzungen
|
||||||
|
- Docker und Docker Compose
|
||||||
|
- Mindestens 4GB RAM
|
||||||
|
- 2GB freier Speicherplatz
|
||||||
|
|
||||||
|
### Schnellstart
|
||||||
|
|
||||||
|
1. **Repository klonen**
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd asset-management
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Umgebungsvariablen konfigurieren**
|
||||||
|
```bash
|
||||||
|
cp env.example .env
|
||||||
|
# Bearbeiten Sie .env nach Ihren Bedürfnissen
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Anwendung starten**
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Auf Anwendung zugreifen**
|
||||||
|
- **Hauptanwendung**: http://localhost:8080
|
||||||
|
- **phpMyAdmin**: http://localhost:8081
|
||||||
|
|
||||||
|
### Standard-Anmeldedaten
|
||||||
|
- **E-Mail**: admin@example.com
|
||||||
|
- **Passwort**: Admin!234
|
||||||
|
- **⚠️ Wichtig**: Ändern Sie das Passwort beim ersten Login!
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
### Umgebungsvariablen (.env)
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Application Settings
|
||||||
|
APP_ENV=production
|
||||||
|
APP_URL=http://localhost:8080
|
||||||
|
APP_DEBUG=false
|
||||||
|
APP_KEY=base64:your-secret-key-here
|
||||||
|
|
||||||
|
# Database Settings
|
||||||
|
DB_HOST=db
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_NAME=inventory
|
||||||
|
DB_USER=inventory
|
||||||
|
DB_PASS=changeMe
|
||||||
|
DB_ROOT_PASS=rootPassword123
|
||||||
|
|
||||||
|
# Session Settings
|
||||||
|
SESSION_SECURE=false
|
||||||
|
SESSION_LIFETIME=3600
|
||||||
|
|
||||||
|
# File Upload Settings
|
||||||
|
UPLOAD_MAX_SIZE=50M
|
||||||
|
ALLOWED_FILE_TYPES=pdf,jpg,jpeg,png,gif,doc,docx,xls,xlsx
|
||||||
|
|
||||||
|
# Security Settings
|
||||||
|
CSRF_TOKEN_LIFETIME=3600
|
||||||
|
PASSWORD_MIN_LENGTH=8
|
||||||
|
LOGIN_MAX_ATTEMPTS=5
|
||||||
|
LOGIN_LOCKOUT_TIME=900
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Services
|
||||||
|
|
||||||
|
- **nginx**: Web-Server (Port 8080)
|
||||||
|
- **php**: PHP-FPM 8.2 mit allen Extensions
|
||||||
|
- **mysql**: MySQL 8.0 Datenbank (Port 3306)
|
||||||
|
- **phpmyadmin**: Datenbank-Verwaltung (Port 8081)
|
||||||
|
|
||||||
|
## Verwendung
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
Das Dashboard zeigt eine Übersicht über:
|
||||||
|
- Gesamtzahl der Assets
|
||||||
|
- Aktive vs. ausgemusterte Assets
|
||||||
|
- Gesamtwert des Inventars
|
||||||
|
- Letzte Aktivitäten
|
||||||
|
- Assets mit ablaufender Garantie
|
||||||
|
- Offene Inventuren
|
||||||
|
|
||||||
|
### Asset-Verwaltung
|
||||||
|
1. **Asset erstellen**: Klicken Sie auf "Neues Asset" und füllen Sie alle Felder aus
|
||||||
|
2. **Asset bearbeiten**: Klicken Sie auf das Bearbeiten-Symbol bei einem Asset
|
||||||
|
3. **Asset zuweisen**: Verwenden Sie die Zuweisungs-Funktion für Benutzer
|
||||||
|
4. **Dateien anhängen**: Laden Sie Dokumente, Bilder oder andere Dateien hoch
|
||||||
|
5. **Etiketten drucken**: Generieren Sie QR/Barcode-Etiketten für Assets
|
||||||
|
|
||||||
|
### Inventur
|
||||||
|
1. **Inventur starten**: Erstellen Sie einen neuen Inventurdurchlauf
|
||||||
|
2. **Assets scannen**: Verwenden Sie die Scan-Funktion mit Inventarnummer oder QR-Code
|
||||||
|
3. **Status erfassen**: Markieren Sie Assets als gefunden, nicht gefunden, defekt oder verschoben
|
||||||
|
4. **Inventur abschließen**: Generieren Sie einen Abschlussbericht mit Differenzen
|
||||||
|
|
||||||
|
### Berichte
|
||||||
|
- **Asset-Berichte**: Nach verschiedenen Kriterien gefiltert
|
||||||
|
- **Garantie-Berichte**: Assets mit ablaufender Garantie
|
||||||
|
- **Standort-Berichte**: Assets nach Standort gruppiert
|
||||||
|
- **Export-Funktionen**: CSV, Excel, PDF-Export
|
||||||
|
|
||||||
|
## Backup & Restore
|
||||||
|
|
||||||
|
### Datenbank-Backup
|
||||||
|
```bash
|
||||||
|
# Backup erstellen
|
||||||
|
docker compose exec db mysqldump -u root -p inventory > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||||
|
|
||||||
|
# Backup wiederherstellen
|
||||||
|
docker compose exec -T db mysql -u root -p inventory < backup_file.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Uploads-Backup
|
||||||
|
```bash
|
||||||
|
# Uploads-Ordner sichern
|
||||||
|
tar -czf uploads_backup_$(date +%Y%m%d_%H%M%S).tar.gz storage/uploads/
|
||||||
|
|
||||||
|
# Uploads wiederherstellen
|
||||||
|
tar -xzf uploads_backup_file.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wartung
|
||||||
|
|
||||||
|
### Logs anzeigen
|
||||||
|
```bash
|
||||||
|
# Alle Services
|
||||||
|
docker compose logs
|
||||||
|
|
||||||
|
# Spezifischer Service
|
||||||
|
docker compose logs php
|
||||||
|
docker compose logs nginx
|
||||||
|
docker compose logs db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Datenbank-Migrationen
|
||||||
|
```bash
|
||||||
|
# Migrationen manuell ausführen
|
||||||
|
docker compose exec php php scripts/migrate.php
|
||||||
|
|
||||||
|
# Seeder manuell ausführen
|
||||||
|
docker compose exec php php scripts/seed.php
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cache leeren
|
||||||
|
```bash
|
||||||
|
# Storage-Cache leeren
|
||||||
|
docker compose exec php rm -rf storage/cache/*
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
|
||||||
|
### Projektstruktur
|
||||||
|
```
|
||||||
|
asset-management/
|
||||||
|
├── app/
|
||||||
|
│ ├── Controllers/ # Controller-Klassen
|
||||||
|
│ ├── Models/ # Datenmodelle
|
||||||
|
│ ├── Views/ # PHP-Templates
|
||||||
|
│ ├── Services/ # Geschäftslogik
|
||||||
|
│ ├── Middleware/ # HTTP-Middleware
|
||||||
|
│ ├── Helpers/ # Hilfsfunktionen
|
||||||
|
│ └── Core/ # Framework-Core
|
||||||
|
├── config/ # Konfigurationsdateien
|
||||||
|
├── database/
|
||||||
|
│ ├── migrations/ # Datenbank-Migrationen
|
||||||
|
│ └── seeds/ # Testdaten
|
||||||
|
├── public/ # Web-Root
|
||||||
|
│ ├── assets/ # CSS, JS, Bilder
|
||||||
|
│ └── index.php # Front-Controller
|
||||||
|
├── storage/ # Uploads, Logs, Cache
|
||||||
|
├── lang/ # Übersetzungsdateien
|
||||||
|
├── scripts/ # CLI-Skripte
|
||||||
|
├── docker-compose.yml # Docker-Konfiguration
|
||||||
|
└── README.md # Diese Datei
|
||||||
|
```
|
||||||
|
|
||||||
|
### Neue Features hinzufügen
|
||||||
|
|
||||||
|
1. **Controller erstellen**: Erben Sie von `BaseController`
|
||||||
|
2. **Model erstellen**: Verwenden Sie PDO mit Prepared Statements
|
||||||
|
3. **View erstellen**: PHP-Template mit Layout-System
|
||||||
|
4. **Route hinzufügen**: In `config/routes.php` definieren
|
||||||
|
5. **Migration erstellen**: Für Datenbankänderungen
|
||||||
|
|
||||||
|
### Code-Standards
|
||||||
|
- **PSR-4**: Autoloading-Standard
|
||||||
|
- **PSR-12**: Coding Style
|
||||||
|
- **Prepared Statements**: Für alle Datenbankabfragen
|
||||||
|
- **CSRF-Schutz**: Für alle POST-Requests
|
||||||
|
- **Audit-Logging**: Für alle CRUD-Operationen
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Häufige Probleme
|
||||||
|
|
||||||
|
**Anwendung nicht erreichbar**
|
||||||
|
```bash
|
||||||
|
# Services Status prüfen
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# Logs prüfen
|
||||||
|
docker compose logs nginx
|
||||||
|
docker compose logs php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Datenbank-Verbindung fehlgeschlagen**
|
||||||
|
```bash
|
||||||
|
# Datenbank-Container Status
|
||||||
|
docker compose logs db
|
||||||
|
|
||||||
|
# Datenbank-Verbindung testen
|
||||||
|
docker compose exec db mysql -u inventory -p
|
||||||
|
```
|
||||||
|
|
||||||
|
**Upload-Fehler**
|
||||||
|
```bash
|
||||||
|
# Berechtigungen prüfen
|
||||||
|
docker compose exec php ls -la storage/uploads/
|
||||||
|
|
||||||
|
# Berechtigungen korrigieren
|
||||||
|
docker compose exec php chown -R www-data:www-data storage/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Performance-Probleme**
|
||||||
|
```bash
|
||||||
|
# OPcache Status prüfen
|
||||||
|
docker compose exec php php -m | grep opcache
|
||||||
|
|
||||||
|
# Cache leeren
|
||||||
|
docker compose exec php php -r "opcache_reset();"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
Bei Problemen oder Fragen:
|
||||||
|
|
||||||
|
1. **Logs prüfen**: `docker compose logs`
|
||||||
|
2. **Dokumentation**: Diese README-Datei
|
||||||
|
3. **Issues**: GitHub Issues verwenden
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
Dieses Projekt steht unter der MIT-Lizenz. Siehe LICENSE-Datei für Details.
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### Version 1.0.0
|
||||||
|
- Initiale Version
|
||||||
|
- Vollständige Asset-Verwaltung
|
||||||
|
- Inventur-System
|
||||||
|
- Benutzer- und Rollenverwaltung
|
||||||
|
- Berichte und Export-Funktionen
|
||||||
|
- Docker-Integration
|
||||||
|
- Mehrsprachigkeit (DE/EN)
|
||||||
209
app/Controllers/AuthController.php
Normal file
209
app/Controllers/AuthController.php
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Core\BaseController;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\PasswordReset;
|
||||||
|
|
||||||
|
class AuthController extends BaseController
|
||||||
|
{
|
||||||
|
public function showLogin(): string
|
||||||
|
{
|
||||||
|
if ($this->session->isLoggedIn()) {
|
||||||
|
return $this->redirect('/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('auth/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function login(): Response
|
||||||
|
{
|
||||||
|
$email = $this->request->post('email');
|
||||||
|
$password = $this->request->post('password');
|
||||||
|
|
||||||
|
// Check if account is locked
|
||||||
|
if ($this->session->isLockedOut()) {
|
||||||
|
$this->flash('error', 'Account ist gesperrt. Bitte warten Sie 15 Minuten.');
|
||||||
|
return $this->redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
$errors = $this->validate([
|
||||||
|
'email' => 'required|email',
|
||||||
|
'password' => 'required'
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!empty($errors)) {
|
||||||
|
foreach ($errors as $field => $fieldErrors) {
|
||||||
|
foreach ($fieldErrors as $error) {
|
||||||
|
$this->flash('error', $error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $this->redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find user
|
||||||
|
$user = (new User($this->database))->findByEmail($email);
|
||||||
|
|
||||||
|
if (!$user || !$user['active']) {
|
||||||
|
$this->incrementLoginAttempts();
|
||||||
|
$this->flash('error', 'Ungültige Anmeldedaten oder Account inaktiv.');
|
||||||
|
return $this->redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
if (!password_verify($password, $user['passhash'])) {
|
||||||
|
$this->incrementLoginAttempts();
|
||||||
|
$this->flash('error', 'Ungültige Anmeldedaten.');
|
||||||
|
return $this->redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if password needs rehash
|
||||||
|
if (password_needs_rehash($user['passhash'], PASSWORD_ARGON2ID)) {
|
||||||
|
$newHash = password_hash($password, PASSWORD_ARGON2ID);
|
||||||
|
(new User($this->database))->update($user['id'], ['passhash' => $newHash]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset login attempts
|
||||||
|
$this->session->setLoginAttempts(0);
|
||||||
|
|
||||||
|
// Set user session
|
||||||
|
$this->session->setUser($user);
|
||||||
|
$this->session->setLastActivity();
|
||||||
|
|
||||||
|
// Log audit
|
||||||
|
$this->logAudit('login', 'users', $user['id']);
|
||||||
|
|
||||||
|
$this->flash('success', 'Erfolgreich angemeldet.');
|
||||||
|
return $this->redirect('/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(): Response
|
||||||
|
{
|
||||||
|
if ($this->session->isLoggedIn()) {
|
||||||
|
$userId = $this->session->getUserId();
|
||||||
|
$this->logAudit('logout', 'users', $userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->session->logout();
|
||||||
|
$this->flash('success', 'Erfolgreich abgemeldet.');
|
||||||
|
return $this->redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function showForgotPassword(): string
|
||||||
|
{
|
||||||
|
return $this->render('auth/forgot-password');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forgotPassword(): Response
|
||||||
|
{
|
||||||
|
$email = $this->request->post('email');
|
||||||
|
|
||||||
|
$errors = $this->validate([
|
||||||
|
'email' => 'required|email'
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!empty($errors)) {
|
||||||
|
foreach ($errors as $field => $fieldErrors) {
|
||||||
|
foreach ($fieldErrors as $error) {
|
||||||
|
$this->flash('error', $error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $this->redirect('/password/forgot');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = (new User($this->database))->findByEmail($email);
|
||||||
|
|
||||||
|
if ($user && $user['active']) {
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
$expiresAt = date('Y-m-d H:i:s', strtotime('+1 hour'));
|
||||||
|
|
||||||
|
$passwordReset = new PasswordReset($this->database);
|
||||||
|
$passwordReset->create([
|
||||||
|
'user_id' => $user['id'],
|
||||||
|
'token' => $token,
|
||||||
|
'expires_at' => $expiresAt
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Send email (in production, implement email service)
|
||||||
|
// $this->sendPasswordResetEmail($user['email'], $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always show success message for security
|
||||||
|
$this->flash('success', 'Falls die E-Mail-Adresse existiert, wurde ein Reset-Link gesendet.');
|
||||||
|
return $this->redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function showResetPassword(): string
|
||||||
|
{
|
||||||
|
$token = $this->request->get('token');
|
||||||
|
|
||||||
|
if (!$token) {
|
||||||
|
$this->flash('error', 'Ungültiger Reset-Link.');
|
||||||
|
return $this->redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
$passwordReset = new PasswordReset($this->database);
|
||||||
|
$reset = $passwordReset->findByToken($token);
|
||||||
|
|
||||||
|
if (!$reset || strtotime($reset['expires_at']) < time()) {
|
||||||
|
$this->flash('error', 'Reset-Link ist ungültig oder abgelaufen.');
|
||||||
|
return $this->redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('auth/reset-password', ['token' => $token]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetPassword(): Response
|
||||||
|
{
|
||||||
|
$token = $this->request->post('token');
|
||||||
|
$password = $this->request->post('password');
|
||||||
|
$passwordConfirm = $this->request->post('password_confirm');
|
||||||
|
|
||||||
|
$errors = $this->validate([
|
||||||
|
'password' => 'required|min:8',
|
||||||
|
'password_confirm' => 'required'
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($password !== $passwordConfirm) {
|
||||||
|
$this->flash('error', 'Passwörter stimmen nicht überein.');
|
||||||
|
return $this->redirect('/password/reset?token=' . $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($errors)) {
|
||||||
|
foreach ($errors as $field => $fieldErrors) {
|
||||||
|
foreach ($fieldErrors as $error) {
|
||||||
|
$this->flash('error', $error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $this->redirect('/password/reset?token=' . $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
$passwordReset = new PasswordReset($this->database);
|
||||||
|
$reset = $passwordReset->findByToken($token);
|
||||||
|
|
||||||
|
if (!$reset || strtotime($reset['expires_at']) < time()) {
|
||||||
|
$this->flash('error', 'Reset-Link ist ungültig oder abgelaufen.');
|
||||||
|
return $this->redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user password
|
||||||
|
$userModel = new User($this->database);
|
||||||
|
$userModel->update($reset['user_id'], [
|
||||||
|
'passhash' => password_hash($password, PASSWORD_ARGON2ID)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Delete used reset token
|
||||||
|
$passwordReset->delete($reset['id']);
|
||||||
|
|
||||||
|
$this->flash('success', 'Passwort erfolgreich geändert. Sie können sich jetzt anmelden.');
|
||||||
|
return $this->redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function incrementLoginAttempts(): void
|
||||||
|
{
|
||||||
|
$attempts = $this->session->getLoginAttempts();
|
||||||
|
$this->session->setLoginAttempts($attempts + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
236
app/Controllers/BaseController.php
Normal file
236
app/Controllers/BaseController.php
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Core\Request;
|
||||||
|
use App\Core\Response;
|
||||||
|
use App\Core\Session;
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Services\AuditService;
|
||||||
|
|
||||||
|
abstract class BaseController
|
||||||
|
{
|
||||||
|
protected Request $request;
|
||||||
|
protected Response $response;
|
||||||
|
protected Session $session;
|
||||||
|
protected Database $database;
|
||||||
|
protected AuditService $auditService;
|
||||||
|
|
||||||
|
public function setRequest(Request $request): void
|
||||||
|
{
|
||||||
|
$this->request = $request;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setResponse(Response $response): void
|
||||||
|
{
|
||||||
|
$this->response = $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSession(Session $session): void
|
||||||
|
{
|
||||||
|
$this->session = $session;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDatabase(Database $database): void
|
||||||
|
{
|
||||||
|
$this->database = $database;
|
||||||
|
$this->auditService = new AuditService($database, $session);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function render(string $view, array $data = []): string
|
||||||
|
{
|
||||||
|
// Extract data to variables
|
||||||
|
extract($data);
|
||||||
|
|
||||||
|
// Add common data
|
||||||
|
$user = $this->session->getUser();
|
||||||
|
$flashMessages = $this->session->getFlashMessages();
|
||||||
|
$csrfToken = $this->session->getCsrfToken();
|
||||||
|
$locale = $this->session->getLocale();
|
||||||
|
|
||||||
|
// Load language file
|
||||||
|
$lang = $this->loadLanguage($locale);
|
||||||
|
|
||||||
|
// Start output buffering
|
||||||
|
ob_start();
|
||||||
|
|
||||||
|
// Include the view file
|
||||||
|
$viewPath = APP_PATH . '/Views/' . $view . '.php';
|
||||||
|
if (!file_exists($viewPath)) {
|
||||||
|
throw new \Exception("View not found: {$view}");
|
||||||
|
}
|
||||||
|
|
||||||
|
require $viewPath;
|
||||||
|
|
||||||
|
// Return the buffered content
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function renderLayout(string $view, array $data = []): string
|
||||||
|
{
|
||||||
|
$content = $this->render($view, $data);
|
||||||
|
|
||||||
|
// Add layout wrapper
|
||||||
|
$user = $this->session->getUser();
|
||||||
|
$flashMessages = $this->session->getFlashMessages();
|
||||||
|
$csrfToken = $this->session->getCsrfToken();
|
||||||
|
$locale = $this->session->getLocale();
|
||||||
|
$lang = $this->loadLanguage($locale);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
require APP_PATH . '/Views/layouts/main.php';
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function redirect(string $url): Response
|
||||||
|
{
|
||||||
|
return $this->response->redirect($url);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function json(array $data, int $statusCode = 200): Response
|
||||||
|
{
|
||||||
|
return $this->response->json($data, $statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function validate(array $rules): array
|
||||||
|
{
|
||||||
|
return $this->request->validate($rules);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function flash(string $key, string $message): void
|
||||||
|
{
|
||||||
|
$this->session->flash($key, $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getUserId(): ?int
|
||||||
|
{
|
||||||
|
return $this->session->getUserId();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getUserRole(): ?string
|
||||||
|
{
|
||||||
|
return $this->session->getUserRole();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function isAdmin(): bool
|
||||||
|
{
|
||||||
|
return $this->session->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function isAuditor(): bool
|
||||||
|
{
|
||||||
|
return $this->session->isAuditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function isEmployee(): bool
|
||||||
|
{
|
||||||
|
return $this->session->isEmployee();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function hasPermission(string $permission): bool
|
||||||
|
{
|
||||||
|
$role = $this->getUserRole();
|
||||||
|
|
||||||
|
switch ($permission) {
|
||||||
|
case 'admin':
|
||||||
|
return $role === 'admin';
|
||||||
|
case 'auditor':
|
||||||
|
return in_array($role, ['admin', 'auditor']);
|
||||||
|
case 'employee':
|
||||||
|
return in_array($role, ['admin', 'auditor', 'employee']);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function paginate(string $sql, array $params = [], int $perPage = 20): array
|
||||||
|
{
|
||||||
|
$page = (int) ($this->request->get('page', 1));
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
$countSql = preg_replace('/SELECT .* FROM/', 'SELECT COUNT(*) as total FROM', $sql);
|
||||||
|
$countSql = preg_replace('/ORDER BY .*/', '', $countSql);
|
||||||
|
$countSql = preg_replace('/LIMIT .*/', '', $countSql);
|
||||||
|
|
||||||
|
$totalResult = $this->database->fetch($countSql, $params);
|
||||||
|
$total = $totalResult['total'] ?? 0;
|
||||||
|
|
||||||
|
// Get paginated data
|
||||||
|
$dataSql = $sql . " LIMIT {$perPage} OFFSET {$offset}";
|
||||||
|
$data = $this->database->fetchAll($dataSql, $params);
|
||||||
|
|
||||||
|
$totalPages = ceil($total / $perPage);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'data' => $data,
|
||||||
|
'pagination' => [
|
||||||
|
'current_page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
'total' => $total,
|
||||||
|
'total_pages' => $totalPages,
|
||||||
|
'has_previous' => $page > 1,
|
||||||
|
'has_next' => $page < $totalPages,
|
||||||
|
'previous_page' => $page > 1 ? $page - 1 : null,
|
||||||
|
'next_page' => $page < $totalPages ? $page + 1 : null,
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function logAudit(string $action, string $table, int $recordId, array $oldValue = null, array $newValue = null): void
|
||||||
|
{
|
||||||
|
$this->auditService->log($action, $table, $recordId, $oldValue, $newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadLanguage(string $locale): array
|
||||||
|
{
|
||||||
|
$langFile = LANG_PATH . '/' . $locale . '.php';
|
||||||
|
if (file_exists($langFile)) {
|
||||||
|
return require $langFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to German
|
||||||
|
$langFile = LANG_PATH . '/de.php';
|
||||||
|
return file_exists($langFile) ? require $langFile : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function trans(string $key, array $params = []): string
|
||||||
|
{
|
||||||
|
$lang = $this->loadLanguage($this->session->getLocale());
|
||||||
|
$text = $lang[$key] ?? $key;
|
||||||
|
|
||||||
|
foreach ($params as $param => $value) {
|
||||||
|
$text = str_replace(':' . $param, $value, $text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function formatDate(string $date, string $format = 'd.m.Y'): string
|
||||||
|
{
|
||||||
|
return date($format, strtotime($date));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function formatDateTime(string $date, string $format = 'd.m.Y H:i'): string
|
||||||
|
{
|
||||||
|
return date($format, strtotime($date));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function formatCurrency(float $amount): string
|
||||||
|
{
|
||||||
|
return number_format($amount, 2, ',', '.') . ' €';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function sanitizeInput(string $input): string
|
||||||
|
{
|
||||||
|
return htmlspecialchars(trim($input), ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function generateSlug(string $text): string
|
||||||
|
{
|
||||||
|
$text = strtolower($text);
|
||||||
|
$text = preg_replace('/[^a-z0-9\s-]/', '', $text);
|
||||||
|
$text = preg_replace('/[\s-]+/', '-', $text);
|
||||||
|
return trim($text, '-');
|
||||||
|
}
|
||||||
|
}
|
||||||
120
app/Core/Application.php
Normal file
120
app/Core/Application.php
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
use App\Middleware\AuthMiddleware;
|
||||||
|
use App\Middleware\AdminMiddleware;
|
||||||
|
use App\Middleware\CsrfMiddleware;
|
||||||
|
|
||||||
|
class Application
|
||||||
|
{
|
||||||
|
private Router $router;
|
||||||
|
private Database $database;
|
||||||
|
private Session $session;
|
||||||
|
private array $middleware = [];
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->router = Router::getInstance();
|
||||||
|
$this->database = new Database();
|
||||||
|
$this->session = new Session();
|
||||||
|
|
||||||
|
// Register middleware
|
||||||
|
$this->registerMiddleware();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$request = new Request();
|
||||||
|
$response = new Response();
|
||||||
|
|
||||||
|
// Apply global middleware
|
||||||
|
$this->applyMiddleware($request, $response);
|
||||||
|
|
||||||
|
// Route the request
|
||||||
|
$route = $this->router->match($request->getMethod(), $request->getPath());
|
||||||
|
|
||||||
|
if (!$route) {
|
||||||
|
$this->handleNotFound();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply route-specific middleware
|
||||||
|
if (isset($route['middleware'])) {
|
||||||
|
$this->applyRouteMiddleware($route['middleware'], $request, $response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute controller
|
||||||
|
$controller = new $route['controller']();
|
||||||
|
$method = $route['method'];
|
||||||
|
|
||||||
|
// Inject dependencies
|
||||||
|
$controller->setRequest($request);
|
||||||
|
$controller->setResponse($response);
|
||||||
|
$controller->setSession($this->session);
|
||||||
|
$controller->setDatabase($this->database);
|
||||||
|
|
||||||
|
// Execute with parameters
|
||||||
|
$params = $route['params'] ?? [];
|
||||||
|
$result = call_user_func_array([$controller, $method], $params);
|
||||||
|
|
||||||
|
// Send response
|
||||||
|
if ($result instanceof Response) {
|
||||||
|
$result->send();
|
||||||
|
} else {
|
||||||
|
$response->setContent($result);
|
||||||
|
$response->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->handleError($e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function registerMiddleware(): void
|
||||||
|
{
|
||||||
|
$this->middleware = [
|
||||||
|
'auth' => AuthMiddleware::class,
|
||||||
|
'admin' => AdminMiddleware::class,
|
||||||
|
'csrf' => CsrfMiddleware::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyMiddleware(Request $request, Response $response): void
|
||||||
|
{
|
||||||
|
// Apply CSRF middleware to all POST requests
|
||||||
|
if ($request->getMethod() === 'POST') {
|
||||||
|
$csrfMiddleware = new CsrfMiddleware();
|
||||||
|
$csrfMiddleware->handle($request, $response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyRouteMiddleware(array $middlewareNames, Request $request, Response $response): void
|
||||||
|
{
|
||||||
|
foreach ($middlewareNames as $name) {
|
||||||
|
if (isset($this->middleware[$name])) {
|
||||||
|
$middlewareClass = $this->middleware[$name];
|
||||||
|
$middleware = new $middlewareClass();
|
||||||
|
$middleware->handle($request, $response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleNotFound(): void
|
||||||
|
{
|
||||||
|
http_response_code(404);
|
||||||
|
require APP_PATH . '/Views/errors/404.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleError(\Exception $e): void
|
||||||
|
{
|
||||||
|
if (APP_DEBUG) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_log($e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
require APP_PATH . '/Views/errors/500.php';
|
||||||
|
}
|
||||||
|
}
|
||||||
132
app/Core/Database.php
Normal file
132
app/Core/Database.php
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use PDOException;
|
||||||
|
|
||||||
|
class Database
|
||||||
|
{
|
||||||
|
private ?PDO $connection = null;
|
||||||
|
private static ?Database $instance = null;
|
||||||
|
|
||||||
|
public static function getInstance(): Database
|
||||||
|
{
|
||||||
|
if (self::$instance === null) {
|
||||||
|
self::$instance = new self();
|
||||||
|
}
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getConnection(): PDO
|
||||||
|
{
|
||||||
|
if ($this->connection === null) {
|
||||||
|
$this->connect();
|
||||||
|
}
|
||||||
|
return $this->connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function connect(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$dsn = sprintf(
|
||||||
|
'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
|
||||||
|
DB_HOST,
|
||||||
|
DB_PORT,
|
||||||
|
DB_NAME
|
||||||
|
);
|
||||||
|
|
||||||
|
$options = [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci"
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->connection = new PDO($dsn, DB_USER, DB_PASS, $options);
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('Database connection failed: ' . $e->getMessage());
|
||||||
|
throw new \Exception('Database connection failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function query(string $sql, array $params = []): \PDOStatement
|
||||||
|
{
|
||||||
|
$stmt = $this->getConnection()->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
return $stmt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fetch(string $sql, array $params = []): ?array
|
||||||
|
{
|
||||||
|
$stmt = $this->query($sql, $params);
|
||||||
|
$result = $stmt->fetch();
|
||||||
|
return $result ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fetchAll(string $sql, array $params = []): array
|
||||||
|
{
|
||||||
|
$stmt = $this->query($sql, $params);
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function insert(string $table, array $data): int
|
||||||
|
{
|
||||||
|
$columns = implode(', ', array_keys($data));
|
||||||
|
$placeholders = ':' . implode(', :', array_keys($data));
|
||||||
|
|
||||||
|
$sql = "INSERT INTO {$table} ({$columns}) VALUES ({$placeholders})";
|
||||||
|
|
||||||
|
$this->query($sql, $data);
|
||||||
|
return (int) $this->getConnection()->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(string $table, array $data, string $where, array $whereParams = []): int
|
||||||
|
{
|
||||||
|
$setParts = [];
|
||||||
|
foreach (array_keys($data) as $column) {
|
||||||
|
$setParts[] = "{$column} = :{$column}";
|
||||||
|
}
|
||||||
|
$setClause = implode(', ', $setParts);
|
||||||
|
|
||||||
|
$sql = "UPDATE {$table} SET {$setClause} WHERE {$where}";
|
||||||
|
|
||||||
|
$params = array_merge($data, $whereParams);
|
||||||
|
$stmt = $this->query($sql, $params);
|
||||||
|
|
||||||
|
return $stmt->rowCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $table, string $where, array $params = []): int
|
||||||
|
{
|
||||||
|
$sql = "DELETE FROM {$table} WHERE {$where}";
|
||||||
|
$stmt = $this->query($sql, $params);
|
||||||
|
return $stmt->rowCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function beginTransaction(): bool
|
||||||
|
{
|
||||||
|
return $this->getConnection()->beginTransaction();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function commit(): bool
|
||||||
|
{
|
||||||
|
return $this->getConnection()->commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rollback(): bool
|
||||||
|
{
|
||||||
|
return $this->getConnection()->rollback();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inTransaction(): bool
|
||||||
|
{
|
||||||
|
return $this->getConnection()->inTransaction();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function quote(string $value): string
|
||||||
|
{
|
||||||
|
return $this->getConnection()->quote($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
182
app/Core/Request.php
Normal file
182
app/Core/Request.php
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
class Request
|
||||||
|
{
|
||||||
|
private array $get;
|
||||||
|
private array $post;
|
||||||
|
private array $files;
|
||||||
|
private array $server;
|
||||||
|
private array $headers;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->get = $_GET;
|
||||||
|
$this->post = $_POST;
|
||||||
|
$this->files = $_FILES;
|
||||||
|
$this->server = $_SERVER;
|
||||||
|
$this->headers = $this->getRequestHeaders();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMethod(): string
|
||||||
|
{
|
||||||
|
return strtoupper($this->server['REQUEST_METHOD'] ?? 'GET');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPath(): string
|
||||||
|
{
|
||||||
|
$path = $this->server['REQUEST_URI'] ?? '/';
|
||||||
|
$position = strpos($path, '?');
|
||||||
|
|
||||||
|
if ($position !== false) {
|
||||||
|
$path = substr($path, 0, $position);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $key, $default = null)
|
||||||
|
{
|
||||||
|
return $this->get[$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function post(string $key, $default = null)
|
||||||
|
{
|
||||||
|
return $this->post[$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function file(string $key)
|
||||||
|
{
|
||||||
|
return $this->files[$key] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
return array_merge($this->get, $this->post);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function only(array $keys): array
|
||||||
|
{
|
||||||
|
$data = $this->all();
|
||||||
|
return array_intersect_key($data, array_flip($keys));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function except(array $keys): array
|
||||||
|
{
|
||||||
|
$data = $this->all();
|
||||||
|
return array_diff_key($data, array_flip($keys));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function has(string $key): bool
|
||||||
|
{
|
||||||
|
return isset($this->get[$key]) || isset($this->post[$key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isPost(): bool
|
||||||
|
{
|
||||||
|
return $this->getMethod() === 'POST';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isGet(): bool
|
||||||
|
{
|
||||||
|
return $this->getMethod() === 'GET';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAjax(): bool
|
||||||
|
{
|
||||||
|
return isset($this->headers['X-Requested-With']) &&
|
||||||
|
$this->headers['X-Requested-With'] === 'XMLHttpRequest';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHeader(string $name): ?string
|
||||||
|
{
|
||||||
|
return $this->headers[$name] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHeaders(): array
|
||||||
|
{
|
||||||
|
return $this->headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIp(): string
|
||||||
|
{
|
||||||
|
return $this->server['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserAgent(): string
|
||||||
|
{
|
||||||
|
return $this->server['HTTP_USER_AGENT'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getContentType(): string
|
||||||
|
{
|
||||||
|
return $this->server['CONTENT_TYPE'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getContentLength(): int
|
||||||
|
{
|
||||||
|
return (int) ($this->server['CONTENT_LENGTH'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validate(array $rules): array
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
$data = $this->all();
|
||||||
|
|
||||||
|
foreach ($rules as $field => $rule) {
|
||||||
|
$value = $data[$field] ?? null;
|
||||||
|
|
||||||
|
if (strpos($rule, 'required') !== false && empty($value)) {
|
||||||
|
$errors[$field][] = 'Das Feld ist erforderlich.';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($value)) {
|
||||||
|
if (strpos($rule, 'email') !== false && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$errors[$field][] = 'Ungültige E-Mail-Adresse.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strpos($rule, 'min:') !== false) {
|
||||||
|
preg_match('/min:(\d+)/', $rule, $matches);
|
||||||
|
$min = (int) $matches[1];
|
||||||
|
if (strlen($value) < $min) {
|
||||||
|
$errors[$field][] = "Mindestens {$min} Zeichen erforderlich.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strpos($rule, 'max:') !== false) {
|
||||||
|
preg_match('/max:(\d+)/', $rule, $matches);
|
||||||
|
$max = (int) $matches[1];
|
||||||
|
if (strlen($value) > $max) {
|
||||||
|
$errors[$field][] = "Maximal {$max} Zeichen erlaubt.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strpos($rule, 'numeric') !== false && !is_numeric($value)) {
|
||||||
|
$errors[$field][] = 'Nur Zahlen erlaubt.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strpos($rule, 'date') !== false && !strtotime($value)) {
|
||||||
|
$errors[$field][] = 'Ungültiges Datum.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getRequestHeaders(): array
|
||||||
|
{
|
||||||
|
$headers = [];
|
||||||
|
|
||||||
|
foreach ($this->server as $key => $value) {
|
||||||
|
if (strpos($key, 'HTTP_') === 0) {
|
||||||
|
$header = str_replace('_', '-', strtolower(substr($key, 5)));
|
||||||
|
$headers[$header] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $headers;
|
||||||
|
}
|
||||||
|
}
|
||||||
141
app/Core/Response.php
Normal file
141
app/Core/Response.php
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
class Response
|
||||||
|
{
|
||||||
|
private string $content = '';
|
||||||
|
private int $statusCode = 200;
|
||||||
|
private array $headers = [];
|
||||||
|
private string $contentType = 'text/html; charset=utf-8';
|
||||||
|
|
||||||
|
public function setContent(string $content): self
|
||||||
|
{
|
||||||
|
$this->content = $content;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getContent(): string
|
||||||
|
{
|
||||||
|
return $this->content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStatusCode(int $statusCode): self
|
||||||
|
{
|
||||||
|
$this->statusCode = $statusCode;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatusCode(): int
|
||||||
|
{
|
||||||
|
return $this->statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHeader(string $name, string $value): self
|
||||||
|
{
|
||||||
|
$this->headers[$name] = $value;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHeaders(array $headers): self
|
||||||
|
{
|
||||||
|
$this->headers = array_merge($this->headers, $headers);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHeaders(): array
|
||||||
|
{
|
||||||
|
return $this->headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setContentType(string $contentType): self
|
||||||
|
{
|
||||||
|
$this->contentType = $contentType;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getContentType(): string
|
||||||
|
{
|
||||||
|
return $this->contentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function redirect(string $url, int $statusCode = 302): self
|
||||||
|
{
|
||||||
|
$this->setHeader('Location', $url);
|
||||||
|
$this->setStatusCode($statusCode);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function json(array $data, int $statusCode = 200): self
|
||||||
|
{
|
||||||
|
$this->setContentType('application/json');
|
||||||
|
$this->setContent(json_encode($data, JSON_UNESCAPED_UNICODE));
|
||||||
|
$this->setStatusCode($statusCode);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function download(string $filePath, string $filename = null): self
|
||||||
|
{
|
||||||
|
if (!file_exists($filePath)) {
|
||||||
|
throw new \Exception('File not found: ' . $filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
$filename = $filename ?: basename($filePath);
|
||||||
|
$mimeType = mime_content_type($filePath) ?: 'application/octet-stream';
|
||||||
|
|
||||||
|
$this->setHeader('Content-Type', $mimeType);
|
||||||
|
$this->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"');
|
||||||
|
$this->setHeader('Content-Length', (string) filesize($filePath));
|
||||||
|
$this->setHeader('Cache-Control', 'no-cache, must-revalidate');
|
||||||
|
$this->setHeader('Pragma', 'no-cache');
|
||||||
|
|
||||||
|
$this->setContent(file_get_contents($filePath));
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function send(): void
|
||||||
|
{
|
||||||
|
// Set status code
|
||||||
|
http_response_code($this->statusCode);
|
||||||
|
|
||||||
|
// Set content type
|
||||||
|
header('Content-Type: ' . $this->contentType);
|
||||||
|
|
||||||
|
// Set additional headers
|
||||||
|
foreach ($this->headers as $name => $value) {
|
||||||
|
header($name . ': ' . $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output content
|
||||||
|
echo $this->content;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function notFound(string $message = 'Seite nicht gefunden'): self
|
||||||
|
{
|
||||||
|
$this->setStatusCode(404);
|
||||||
|
$this->setContent($message);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forbidden(string $message = 'Zugriff verweigert'): self
|
||||||
|
{
|
||||||
|
$this->setStatusCode(403);
|
||||||
|
$this->setContent($message);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unauthorized(string $message = 'Nicht autorisiert'): self
|
||||||
|
{
|
||||||
|
$this->setStatusCode(401);
|
||||||
|
$this->setContent($message);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function serverError(string $message = 'Interner Serverfehler'): self
|
||||||
|
{
|
||||||
|
$this->setStatusCode(500);
|
||||||
|
$this->setContent($message);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
108
app/Core/Router.php
Normal file
108
app/Core/Router.php
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
class Router
|
||||||
|
{
|
||||||
|
private static ?Router $instance = null;
|
||||||
|
private array $routes = [];
|
||||||
|
private array $groups = [];
|
||||||
|
|
||||||
|
public static function getInstance(): Router
|
||||||
|
{
|
||||||
|
if (self::$instance === null) {
|
||||||
|
self::$instance = new self();
|
||||||
|
}
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $path, array $handler, array $options = []): void
|
||||||
|
{
|
||||||
|
$this->addRoute('GET', $path, $handler, $options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function post(string $path, array $handler, array $options = []): void
|
||||||
|
{
|
||||||
|
$this->addRoute('POST', $path, $handler, $options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function put(string $path, array $handler, array $options = []): void
|
||||||
|
{
|
||||||
|
$this->addRoute('PUT', $path, $handler, $options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $path, array $handler, array $options = []): void
|
||||||
|
{
|
||||||
|
$this->addRoute('DELETE', $path, $handler, $options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function group(array $attributes, callable $callback): void
|
||||||
|
{
|
||||||
|
$this->groups[] = $attributes;
|
||||||
|
$callback($this);
|
||||||
|
array_pop($this->groups);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addRoute(string $method, string $path, array $handler, array $options = []): void
|
||||||
|
{
|
||||||
|
// Apply group middleware
|
||||||
|
$middleware = $options['middleware'] ?? [];
|
||||||
|
foreach ($this->groups as $group) {
|
||||||
|
if (isset($group['middleware'])) {
|
||||||
|
$middleware = array_merge($middleware, (array)$group['middleware']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$route = [
|
||||||
|
'method' => $method,
|
||||||
|
'path' => $path,
|
||||||
|
'controller' => $handler[0],
|
||||||
|
'method' => $handler[1],
|
||||||
|
'middleware' => $middleware,
|
||||||
|
'pattern' => $this->buildPattern($path),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->routes[] = $route;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function match(string $method, string $path): ?array
|
||||||
|
{
|
||||||
|
foreach ($this->routes as $route) {
|
||||||
|
if ($route['method'] !== $method) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match($route['pattern'], $path, $matches)) {
|
||||||
|
// Extract parameters
|
||||||
|
$params = [];
|
||||||
|
preg_match_all('/\{([^}]+)\}/', $route['path'], $paramNames);
|
||||||
|
|
||||||
|
for ($i = 0; $i < count($paramNames[1]); $i++) {
|
||||||
|
$paramName = $paramNames[1][$i];
|
||||||
|
$params[$paramName] = $matches[$i + 1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'controller' => $route['controller'],
|
||||||
|
'method' => $route['method'],
|
||||||
|
'middleware' => $route['middleware'],
|
||||||
|
'params' => $params,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildPattern(string $path): string
|
||||||
|
{
|
||||||
|
// Convert route parameters to regex pattern
|
||||||
|
$pattern = preg_replace('/\{([^}]+)\}/', '([^/]+)', $path);
|
||||||
|
return '#^' . $pattern . '$#';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRoutes(): array
|
||||||
|
{
|
||||||
|
return $this->routes;
|
||||||
|
}
|
||||||
|
}
|
||||||
198
app/Core/Session.php
Normal file
198
app/Core/Session.php
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
class Session
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function set(string $key, $value): void
|
||||||
|
{
|
||||||
|
$_SESSION[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $key, $default = null)
|
||||||
|
{
|
||||||
|
return $_SESSION[$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function has(string $key): bool
|
||||||
|
{
|
||||||
|
return isset($_SESSION[$key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function remove(string $key): void
|
||||||
|
{
|
||||||
|
unset($_SESSION[$key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(): void
|
||||||
|
{
|
||||||
|
session_destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function regenerate(): bool
|
||||||
|
{
|
||||||
|
return session_regenerate_id(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function flash(string $key, $value): void
|
||||||
|
{
|
||||||
|
$_SESSION['flash'][$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFlash(string $key, $default = null)
|
||||||
|
{
|
||||||
|
$value = $_SESSION['flash'][$key] ?? $default;
|
||||||
|
unset($_SESSION['flash'][$key]);
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasFlash(string $key): bool
|
||||||
|
{
|
||||||
|
return isset($_SESSION['flash'][$key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFlashMessages(): array
|
||||||
|
{
|
||||||
|
$messages = $_SESSION['flash'] ?? [];
|
||||||
|
unset($_SESSION['flash']);
|
||||||
|
return $messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUser(array $user): void
|
||||||
|
{
|
||||||
|
$this->set('user', $user);
|
||||||
|
$this->regenerate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUser(): ?array
|
||||||
|
{
|
||||||
|
return $this->get('user');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isLoggedIn(): bool
|
||||||
|
{
|
||||||
|
return $this->has('user');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(): void
|
||||||
|
{
|
||||||
|
$this->remove('user');
|
||||||
|
$this->destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserId(): ?int
|
||||||
|
{
|
||||||
|
$user = $this->getUser();
|
||||||
|
return $user['id'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserRole(): ?string
|
||||||
|
{
|
||||||
|
$user = $this->getUser();
|
||||||
|
return $user['role'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAdmin(): bool
|
||||||
|
{
|
||||||
|
return $this->getUserRole() === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAuditor(): bool
|
||||||
|
{
|
||||||
|
return $this->getUserRole() === 'auditor';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isEmployee(): bool
|
||||||
|
{
|
||||||
|
return $this->getUserRole() === 'employee';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generateCsrfToken(): string
|
||||||
|
{
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
$this->set('csrf_token', $token);
|
||||||
|
$this->set('csrf_token_time', time());
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validateCsrfToken(string $token): bool
|
||||||
|
{
|
||||||
|
$storedToken = $this->get('csrf_token');
|
||||||
|
$tokenTime = $this->get('csrf_token_time', 0);
|
||||||
|
|
||||||
|
if (!$storedToken || !$tokenTime) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token is expired (1 hour)
|
||||||
|
if (time() - $tokenTime > CSRF_TOKEN_LIFETIME) {
|
||||||
|
$this->remove('csrf_token');
|
||||||
|
$this->remove('csrf_token_time');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash_equals($storedToken, $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCsrfToken(): string
|
||||||
|
{
|
||||||
|
$token = $this->get('csrf_token');
|
||||||
|
if (!$token) {
|
||||||
|
$token = $this->generateCsrfToken();
|
||||||
|
}
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLocale(string $locale): void
|
||||||
|
{
|
||||||
|
$this->set('locale', $locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLocale(): string
|
||||||
|
{
|
||||||
|
return $this->get('locale', 'de');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLastActivity(): void
|
||||||
|
{
|
||||||
|
$this->set('last_activity', time());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isExpired(int $timeout = 3600): bool
|
||||||
|
{
|
||||||
|
$lastActivity = $this->get('last_activity', 0);
|
||||||
|
return (time() - $lastActivity) > $timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLoginAttempts(int $attempts): void
|
||||||
|
{
|
||||||
|
$this->set('login_attempts', $attempts);
|
||||||
|
$this->set('login_attempts_time', time());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLoginAttempts(): int
|
||||||
|
{
|
||||||
|
$attempts = $this->get('login_attempts', 0);
|
||||||
|
$attemptsTime = $this->get('login_attempts_time', 0);
|
||||||
|
|
||||||
|
// Reset attempts if lockout time has passed
|
||||||
|
if (time() - $attemptsTime > LOGIN_LOCKOUT_TIME) {
|
||||||
|
$this->setLoginAttempts(0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $attempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isLockedOut(): bool
|
||||||
|
{
|
||||||
|
return $this->getLoginAttempts() >= LOGIN_MAX_ATTEMPTS;
|
||||||
|
}
|
||||||
|
}
|
||||||
378
app/Helpers/functions.php
Normal file
378
app/Helpers/functions.php
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Helper Functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML output
|
||||||
|
*/
|
||||||
|
function e(string $value): string
|
||||||
|
{
|
||||||
|
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate CSRF token
|
||||||
|
*/
|
||||||
|
function csrf_token(): string
|
||||||
|
{
|
||||||
|
$session = new \App\Core\Session();
|
||||||
|
return $session->getCsrfToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate CSRF field
|
||||||
|
*/
|
||||||
|
function csrf_field(): string
|
||||||
|
{
|
||||||
|
return '<input type="hidden" name="csrf_token" value="' . csrf_token() . '">';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date
|
||||||
|
*/
|
||||||
|
function format_date(string $date, string $format = 'd.m.Y'): string
|
||||||
|
{
|
||||||
|
return date($format, strtotime($date));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format datetime
|
||||||
|
*/
|
||||||
|
function format_datetime(string $date, string $format = 'd.m.Y H:i'): string
|
||||||
|
{
|
||||||
|
return date($format, strtotime($date));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format currency
|
||||||
|
*/
|
||||||
|
function format_currency(float $amount): string
|
||||||
|
{
|
||||||
|
return number_format($amount, 2, ',', '.') . ' €';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate asset number
|
||||||
|
*/
|
||||||
|
function generate_asset_number(string $prefix = 'ASSET'): string
|
||||||
|
{
|
||||||
|
return $prefix . '-' . date('Y') . '-' . str_pad(rand(1, 9999), 4, '0', STR_PAD_LEFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate file upload
|
||||||
|
*/
|
||||||
|
function validate_file_upload(array $file, array $allowedTypes = [], int $maxSize = 52428800): array
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
$errors[] = 'Upload error: ' . $file['error'];
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($file['size'] > $maxSize) {
|
||||||
|
$errors[] = 'File too large. Maximum size: ' . format_bytes($maxSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($allowedTypes)) {
|
||||||
|
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||||
|
$mimeType = finfo_file($finfo, $file['tmp_name']);
|
||||||
|
finfo_close($finfo);
|
||||||
|
|
||||||
|
if (!in_array($mimeType, $allowedTypes)) {
|
||||||
|
$errors[] = 'File type not allowed. Allowed types: ' . implode(', ', $allowedTypes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format bytes to human readable
|
||||||
|
*/
|
||||||
|
function format_bytes(int $bytes): string
|
||||||
|
{
|
||||||
|
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
|
||||||
|
for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
|
||||||
|
$bytes /= 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round($bytes, 2) . ' ' . $units[$i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate random string
|
||||||
|
*/
|
||||||
|
function random_string(int $length = 32): string
|
||||||
|
{
|
||||||
|
return bin2hex(random_bytes($length / 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has permission
|
||||||
|
*/
|
||||||
|
function has_permission(string $permission): bool
|
||||||
|
{
|
||||||
|
$session = new \App\Core\Session();
|
||||||
|
$role = $session->getUserRole();
|
||||||
|
|
||||||
|
switch ($permission) {
|
||||||
|
case 'admin':
|
||||||
|
return $role === 'admin';
|
||||||
|
case 'auditor':
|
||||||
|
return in_array($role, ['admin', 'auditor']);
|
||||||
|
case 'employee':
|
||||||
|
return in_array($role, ['admin', 'auditor', 'employee']);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user role name
|
||||||
|
*/
|
||||||
|
function get_role_name(string $role): string
|
||||||
|
{
|
||||||
|
$roles = [
|
||||||
|
'admin' => 'Administrator',
|
||||||
|
'auditor' => 'Auditor',
|
||||||
|
'employee' => 'Mitarbeiter'
|
||||||
|
];
|
||||||
|
|
||||||
|
return $roles[$role] ?? $role;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get asset status name
|
||||||
|
*/
|
||||||
|
function get_asset_status_name(string $status): string
|
||||||
|
{
|
||||||
|
$statuses = [
|
||||||
|
'aktiv' => 'Aktiv',
|
||||||
|
'inaktiv' => 'Inaktiv',
|
||||||
|
'ausgemustert' => 'Ausgemustert'
|
||||||
|
];
|
||||||
|
|
||||||
|
return $statuses[$status] ?? $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get asset condition name
|
||||||
|
*/
|
||||||
|
function get_asset_condition_name(string $condition): string
|
||||||
|
{
|
||||||
|
$conditions = [
|
||||||
|
'neu' => 'Neu',
|
||||||
|
'gut' => 'Gut',
|
||||||
|
'befriedigend' => 'Befriedigend',
|
||||||
|
'schlecht' => 'Schlecht',
|
||||||
|
'defekt' => 'Defekt'
|
||||||
|
];
|
||||||
|
|
||||||
|
return $conditions[$condition] ?? $condition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get inventory status name
|
||||||
|
*/
|
||||||
|
function get_inventory_status_name(string $status): string
|
||||||
|
{
|
||||||
|
$statuses = [
|
||||||
|
'offen' => 'Offen',
|
||||||
|
'abgeschlossen' => 'Abgeschlossen'
|
||||||
|
];
|
||||||
|
|
||||||
|
return $statuses[$status] ?? $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get inventory item status name
|
||||||
|
*/
|
||||||
|
function get_inventory_item_status_name(string $status): string
|
||||||
|
{
|
||||||
|
$statuses = [
|
||||||
|
'gefunden' => 'Gefunden',
|
||||||
|
'nicht_gefunden' => 'Nicht gefunden',
|
||||||
|
'defekt' => 'Defekt',
|
||||||
|
'verschoben' => 'Verschoben'
|
||||||
|
];
|
||||||
|
|
||||||
|
return $statuses[$status] ?? $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate QR code data for asset
|
||||||
|
*/
|
||||||
|
function generate_qr_data(array $asset): string
|
||||||
|
{
|
||||||
|
return json_encode([
|
||||||
|
'id' => $asset['id'],
|
||||||
|
'inventarnummer' => $asset['inventarnummer'],
|
||||||
|
'bezeichnung' => $asset['bezeichnung']
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if warranty is expiring soon
|
||||||
|
*/
|
||||||
|
function is_warranty_expiring_soon(string $warrantyDate, int $days = 30): bool
|
||||||
|
{
|
||||||
|
if (empty($warrantyDate)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$warranty = strtotime($warrantyDate);
|
||||||
|
$now = time();
|
||||||
|
$expiring = strtotime("+{$days} days", $now);
|
||||||
|
|
||||||
|
return $warranty <= $expiring && $warranty > $now;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate asset age in years
|
||||||
|
*/
|
||||||
|
function calculate_asset_age(string $purchaseDate): int
|
||||||
|
{
|
||||||
|
if (empty($purchaseDate)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$purchase = new DateTime($purchaseDate);
|
||||||
|
$now = new DateTime();
|
||||||
|
$diff = $now->diff($purchase);
|
||||||
|
|
||||||
|
return $diff->y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get asset value depreciation
|
||||||
|
*/
|
||||||
|
function calculate_depreciation(float $purchasePrice, string $purchaseDate, float $depreciationRate = 0.1): float
|
||||||
|
{
|
||||||
|
$age = calculate_asset_age($purchaseDate);
|
||||||
|
$depreciation = $purchasePrice * $depreciationRate * $age;
|
||||||
|
|
||||||
|
return max(0, $purchasePrice - $depreciation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize filename
|
||||||
|
*/
|
||||||
|
function sanitize_filename(string $filename): string
|
||||||
|
{
|
||||||
|
// Remove special characters
|
||||||
|
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename);
|
||||||
|
|
||||||
|
// Remove multiple underscores
|
||||||
|
$filename = preg_replace('/_+/', '_', $filename);
|
||||||
|
|
||||||
|
// Remove leading/trailing underscores
|
||||||
|
$filename = trim($filename, '_');
|
||||||
|
|
||||||
|
return $filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file extension from mime type
|
||||||
|
*/
|
||||||
|
function get_extension_from_mime(string $mimeType): string
|
||||||
|
{
|
||||||
|
$extensions = [
|
||||||
|
'application/pdf' => 'pdf',
|
||||||
|
'image/jpeg' => 'jpg',
|
||||||
|
'image/png' => 'png',
|
||||||
|
'image/gif' => 'gif',
|
||||||
|
'application/msword' => 'doc',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
|
||||||
|
'application/vnd.ms-excel' => 'xls',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
|
||||||
|
'text/plain' => 'txt',
|
||||||
|
'text/csv' => 'csv'
|
||||||
|
];
|
||||||
|
|
||||||
|
return $extensions[$mimeType] ?? 'bin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if string is valid JSON
|
||||||
|
*/
|
||||||
|
function is_valid_json(string $string): bool
|
||||||
|
{
|
||||||
|
json_decode($string);
|
||||||
|
return json_last_error() === JSON_ERROR_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user
|
||||||
|
*/
|
||||||
|
function current_user(): ?array
|
||||||
|
{
|
||||||
|
$session = new \App\Core\Session();
|
||||||
|
return $session->getUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user ID
|
||||||
|
*/
|
||||||
|
function current_user_id(): ?int
|
||||||
|
{
|
||||||
|
$session = new \App\Core\Session();
|
||||||
|
return $session->getUserId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is logged in
|
||||||
|
*/
|
||||||
|
function is_logged_in(): bool
|
||||||
|
{
|
||||||
|
$session = new \App\Core\Session();
|
||||||
|
return $session->isLoggedIn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is admin
|
||||||
|
*/
|
||||||
|
function is_admin(): bool
|
||||||
|
{
|
||||||
|
$session = new \App\Core\Session();
|
||||||
|
return $session->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect to URL
|
||||||
|
*/
|
||||||
|
function redirect(string $url): void
|
||||||
|
{
|
||||||
|
header("Location: {$url}");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get base URL
|
||||||
|
*/
|
||||||
|
function base_url(string $path = ''): string
|
||||||
|
{
|
||||||
|
$baseUrl = rtrim(APP_URL, '/');
|
||||||
|
$path = ltrim($path, '/');
|
||||||
|
|
||||||
|
return $baseUrl . '/' . $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asset URL
|
||||||
|
*/
|
||||||
|
function asset_url(string $path): string
|
||||||
|
{
|
||||||
|
return base_url('assets/' . ltrim($path, '/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage URL
|
||||||
|
*/
|
||||||
|
function storage_url(string $path): string
|
||||||
|
{
|
||||||
|
return base_url('storage/' . ltrim($path, '/'));
|
||||||
|
}
|
||||||
27
app/Middleware/AdminMiddleware.php
Normal file
27
app/Middleware/AdminMiddleware.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Middleware;
|
||||||
|
|
||||||
|
use App\Core\Request;
|
||||||
|
use App\Core\Response;
|
||||||
|
use App\Core\Session;
|
||||||
|
|
||||||
|
class AdminMiddleware
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Response $response): void
|
||||||
|
{
|
||||||
|
$session = new Session();
|
||||||
|
|
||||||
|
// First check if user is logged in
|
||||||
|
if (!$session->isLoggedIn()) {
|
||||||
|
$session->flash('error', 'Bitte melden Sie sich an, um fortzufahren.');
|
||||||
|
$response->redirect('/login')->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has admin role
|
||||||
|
if (!$session->isAdmin()) {
|
||||||
|
$session->flash('error', 'Sie haben keine Berechtigung für diese Aktion.');
|
||||||
|
$response->redirect('/dashboard')->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Middleware/AuthMiddleware.php
Normal file
31
app/Middleware/AuthMiddleware.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Middleware;
|
||||||
|
|
||||||
|
use App\Core\Request;
|
||||||
|
use App\Core\Response;
|
||||||
|
use App\Core\Session;
|
||||||
|
|
||||||
|
class AuthMiddleware
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Response $response): void
|
||||||
|
{
|
||||||
|
$session = new Session();
|
||||||
|
|
||||||
|
// Check if user is logged in
|
||||||
|
if (!$session->isLoggedIn()) {
|
||||||
|
$session->flash('error', 'Bitte melden Sie sich an, um fortzufahren.');
|
||||||
|
$response->redirect('/login')->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if session is expired
|
||||||
|
if ($session->isExpired()) {
|
||||||
|
$session->logout();
|
||||||
|
$session->flash('error', 'Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.');
|
||||||
|
$response->redirect('/login')->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last activity
|
||||||
|
$session->setLastActivity();
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Middleware/CsrfMiddleware.php
Normal file
34
app/Middleware/CsrfMiddleware.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Middleware;
|
||||||
|
|
||||||
|
use App\Core\Request;
|
||||||
|
use App\Core\Response;
|
||||||
|
use App\Core\Session;
|
||||||
|
|
||||||
|
class CsrfMiddleware
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Response $response): void
|
||||||
|
{
|
||||||
|
$session = new Session();
|
||||||
|
|
||||||
|
// Skip CSRF check for GET requests
|
||||||
|
if ($request->getMethod() === 'GET') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get CSRF token from request
|
||||||
|
$token = $request->post('csrf_token') ?: $request->getHeader('X-CSRF-TOKEN');
|
||||||
|
|
||||||
|
if (!$token) {
|
||||||
|
$session->flash('error', 'CSRF-Token fehlt.');
|
||||||
|
$response->redirect('/dashboard')->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate CSRF token
|
||||||
|
if (!$session->validateCsrfToken($token)) {
|
||||||
|
$session->flash('error', 'Ungültiger CSRF-Token.');
|
||||||
|
$response->redirect('/dashboard')->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/Models/PasswordReset.php
Normal file
45
app/Models/PasswordReset.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
|
||||||
|
class PasswordReset
|
||||||
|
{
|
||||||
|
private Database $database;
|
||||||
|
|
||||||
|
public function __construct(Database $database)
|
||||||
|
{
|
||||||
|
$this->database = $database;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(array $data): int
|
||||||
|
{
|
||||||
|
return $this->database->insert('password_resets', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findByToken(string $token): ?array
|
||||||
|
{
|
||||||
|
return $this->database->fetch(
|
||||||
|
"SELECT * FROM password_resets WHERE token = :token",
|
||||||
|
['token' => $token]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(int $id): bool
|
||||||
|
{
|
||||||
|
return $this->database->delete('password_resets', 'id = :id', ['id' => $id]) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteExpired(): int
|
||||||
|
{
|
||||||
|
return $this->database->query(
|
||||||
|
"DELETE FROM password_resets WHERE expires_at < NOW()"
|
||||||
|
)->rowCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteByUserId(int $userId): int
|
||||||
|
{
|
||||||
|
return $this->database->delete('password_resets', 'user_id = :user_id', ['user_id' => $userId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
204
app/Models/User.php
Normal file
204
app/Models/User.php
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
|
||||||
|
class User
|
||||||
|
{
|
||||||
|
private Database $database;
|
||||||
|
|
||||||
|
public function __construct(Database $database)
|
||||||
|
{
|
||||||
|
$this->database = $database;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function find(int $id): ?array
|
||||||
|
{
|
||||||
|
return $this->database->fetch(
|
||||||
|
"SELECT * FROM users WHERE id = :id",
|
||||||
|
['id' => $id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findByEmail(string $email): ?array
|
||||||
|
{
|
||||||
|
return $this->database->fetch(
|
||||||
|
"SELECT * FROM users WHERE email = :email",
|
||||||
|
['email' => $email]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findAll(array $filters = [], int $limit = null, int $offset = 0): array
|
||||||
|
{
|
||||||
|
$sql = "SELECT * FROM users WHERE 1=1";
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if (!empty($filters['role'])) {
|
||||||
|
$sql .= " AND role = :role";
|
||||||
|
$params['role'] = $filters['role'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($filters['active'])) {
|
||||||
|
$sql .= " AND active = :active";
|
||||||
|
$params['active'] = $filters['active'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($filters['search'])) {
|
||||||
|
$sql .= " AND (name LIKE :search OR email LIKE :search)";
|
||||||
|
$params['search'] = '%' . $filters['search'] . '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= " ORDER BY name ASC";
|
||||||
|
|
||||||
|
if ($limit) {
|
||||||
|
$sql .= " LIMIT :limit OFFSET :offset";
|
||||||
|
$params['limit'] = $limit;
|
||||||
|
$params['offset'] = $offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->database->fetchAll($sql, $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(array $data): int
|
||||||
|
{
|
||||||
|
$data['passhash'] = password_hash($data['password'], PASSWORD_ARGON2ID);
|
||||||
|
unset($data['password']);
|
||||||
|
|
||||||
|
$data['created_at'] = date('Y-m-d H:i:s');
|
||||||
|
$data['updated_at'] = date('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
return $this->database->insert('users', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(int $id, array $data): bool
|
||||||
|
{
|
||||||
|
if (isset($data['password']) && !empty($data['password'])) {
|
||||||
|
$data['passhash'] = password_hash($data['password'], PASSWORD_ARGON2ID);
|
||||||
|
unset($data['password']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['updated_at'] = date('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
return $this->database->update('users', $data, 'id = :id', ['id' => $id]) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(int $id): bool
|
||||||
|
{
|
||||||
|
return $this->database->delete('users', 'id = :id', ['id' => $id]) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleStatus(int $id): bool
|
||||||
|
{
|
||||||
|
$user = $this->find($id);
|
||||||
|
if (!$user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$newStatus = !$user['active'];
|
||||||
|
return $this->update($id, ['active' => $newStatus]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function count(array $filters = []): int
|
||||||
|
{
|
||||||
|
$sql = "SELECT COUNT(*) as total FROM users WHERE 1=1";
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if (!empty($filters['role'])) {
|
||||||
|
$sql .= " AND role = :role";
|
||||||
|
$params['role'] = $filters['role'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($filters['active'])) {
|
||||||
|
$sql .= " AND active = :active";
|
||||||
|
$params['active'] = $filters['active'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->database->fetch($sql, $params);
|
||||||
|
return $result['total'] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActiveUsers(): array
|
||||||
|
{
|
||||||
|
return $this->findAll(['active' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUsersByRole(string $role): array
|
||||||
|
{
|
||||||
|
return $this->findAll(['role' => $role]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function searchUsers(string $search): array
|
||||||
|
{
|
||||||
|
return $this->findAll(['search' => $search]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateLastLogin(int $id): bool
|
||||||
|
{
|
||||||
|
return $this->update($id, ['last_login' => date('Y-m-d H:i:s')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function changePassword(int $id, string $newPassword): bool
|
||||||
|
{
|
||||||
|
return $this->update($id, ['password' => $newPassword]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUsersWithStats(): array
|
||||||
|
{
|
||||||
|
$sql = "SELECT
|
||||||
|
u.*,
|
||||||
|
COUNT(DISTINCT a.id) as asset_count,
|
||||||
|
COUNT(DISTINCT al.id) as audit_count
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN assets a ON u.id = a.created_by
|
||||||
|
LEFT JOIN audit_log al ON u.id = al.user_id
|
||||||
|
GROUP BY u.id
|
||||||
|
ORDER BY u.name ASC";
|
||||||
|
|
||||||
|
return $this->database->fetchAll($sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRecentActivity(int $userId, int $limit = 10): array
|
||||||
|
{
|
||||||
|
$sql = "SELECT al.*, a.inventarnummer, a.bezeichnung
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN assets a ON al.table_name = 'assets' AND al.record_id = a.id
|
||||||
|
WHERE al.user_id = :user_id
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT :limit";
|
||||||
|
|
||||||
|
return $this->database->fetchAll($sql, [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'limit' => $limit
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validateEmail(string $email, int $excludeId = null): bool
|
||||||
|
{
|
||||||
|
$sql = "SELECT COUNT(*) as total FROM users WHERE email = :email";
|
||||||
|
$params = ['email' => $email];
|
||||||
|
|
||||||
|
if ($excludeId) {
|
||||||
|
$sql .= " AND id != :exclude_id";
|
||||||
|
$params['exclude_id'] = $excludeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->database->fetch($sql, $params);
|
||||||
|
return ($result['total'] ?? 0) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRoles(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'admin' => 'Administrator',
|
||||||
|
'auditor' => 'Auditor',
|
||||||
|
'employee' => 'Mitarbeiter'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRoleName(string $role): string
|
||||||
|
{
|
||||||
|
$roles = $this->getRoles();
|
||||||
|
return $roles[$role] ?? $role;
|
||||||
|
}
|
||||||
|
}
|
||||||
200
app/Services/AuditService.php
Normal file
200
app/Services/AuditService.php
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\Session;
|
||||||
|
|
||||||
|
class AuditService
|
||||||
|
{
|
||||||
|
private Database $database;
|
||||||
|
private Session $session;
|
||||||
|
|
||||||
|
public function __construct(Database $database, Session $session)
|
||||||
|
{
|
||||||
|
$this->database = $database;
|
||||||
|
$this->session = $session;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function log(string $action, string $table, int $recordId, array $oldValue = null, array $newValue = null): void
|
||||||
|
{
|
||||||
|
$userId = $this->session->getUserId();
|
||||||
|
|
||||||
|
if (!$userId) {
|
||||||
|
return; // Skip logging if no user
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'action' => $action,
|
||||||
|
'table_name' => $table,
|
||||||
|
'record_id' => $recordId,
|
||||||
|
'old_value' => $oldValue ? json_encode($oldValue) : null,
|
||||||
|
'new_value' => $newValue ? json_encode($newValue) : null,
|
||||||
|
'created_at' => date('Y-m-d H:i:s')
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->database->insert('audit_log', $data);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log('Audit logging failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAuditLog(string $table = null, int $recordId = null, int $limit = 100): array
|
||||||
|
{
|
||||||
|
$sql = "SELECT al.*, u.name as user_name
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.id
|
||||||
|
WHERE 1=1";
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($table) {
|
||||||
|
$sql .= " AND al.table_name = :table";
|
||||||
|
$params['table'] = $table;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($recordId) {
|
||||||
|
$sql .= " AND al.record_id = :record_id";
|
||||||
|
$params['record_id'] = $recordId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= " ORDER BY al.created_at DESC LIMIT :limit";
|
||||||
|
$params['limit'] = $limit;
|
||||||
|
|
||||||
|
return $this->database->fetchAll($sql, $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAssetHistory(int $assetId): array
|
||||||
|
{
|
||||||
|
return $this->getAuditLog('assets', $assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserActivity(int $userId, int $limit = 50): array
|
||||||
|
{
|
||||||
|
$sql = "SELECT al.*, u.name as user_name
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.id
|
||||||
|
WHERE al.user_id = :user_id
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT :limit";
|
||||||
|
|
||||||
|
return $this->database->fetchAll($sql, [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'limit' => $limit
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRecentActivity(int $limit = 20): array
|
||||||
|
{
|
||||||
|
$sql = "SELECT al.*, u.name as user_name
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.id
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT :limit";
|
||||||
|
|
||||||
|
return $this->database->fetchAll($sql, ['limit' => $limit]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActivityByDateRange(string $startDate, string $endDate): array
|
||||||
|
{
|
||||||
|
$sql = "SELECT al.*, u.name as user_name
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.id
|
||||||
|
WHERE DATE(al.created_at) BETWEEN :start_date AND :end_date
|
||||||
|
ORDER BY al.created_at DESC";
|
||||||
|
|
||||||
|
return $this->database->fetchAll($sql, [
|
||||||
|
'start_date' => $startDate,
|
||||||
|
'end_date' => $endDate
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActivityByAction(string $action, int $limit = 100): array
|
||||||
|
{
|
||||||
|
$sql = "SELECT al.*, u.name as user_name
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.id
|
||||||
|
WHERE al.action = :action
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT :limit";
|
||||||
|
|
||||||
|
return $this->database->fetchAll($sql, [
|
||||||
|
'action' => $action,
|
||||||
|
'limit' => $limit
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAssetChanges(int $assetId): array
|
||||||
|
{
|
||||||
|
$sql = "SELECT al.*, u.name as user_name
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.id
|
||||||
|
WHERE al.table_name = 'assets'
|
||||||
|
AND al.record_id = :asset_id
|
||||||
|
AND al.action IN ('update', 'delete')
|
||||||
|
ORDER BY al.created_at DESC";
|
||||||
|
|
||||||
|
return $this->database->fetchAll($sql, ['asset_id' => $assetId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getInventoryActivity(int $inventoryId): array
|
||||||
|
{
|
||||||
|
$sql = "SELECT al.*, u.name as user_name
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.id
|
||||||
|
WHERE al.table_name = 'inventories'
|
||||||
|
AND al.record_id = :inventory_id
|
||||||
|
ORDER BY al.created_at DESC";
|
||||||
|
|
||||||
|
return $this->database->fetchAll($sql, ['inventory_id' => $inventoryId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cleanupOldLogs(int $daysToKeep = 365): int
|
||||||
|
{
|
||||||
|
$sql = "DELETE FROM audit_log
|
||||||
|
WHERE created_at < DATE_SUB(NOW(), INTERVAL :days DAY)";
|
||||||
|
|
||||||
|
return $this->database->query($sql, ['days' => $daysToKeep])->rowCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAuditSummary(): array
|
||||||
|
{
|
||||||
|
$sql = "SELECT
|
||||||
|
COUNT(*) as total_entries,
|
||||||
|
COUNT(DISTINCT user_id) as unique_users,
|
||||||
|
COUNT(DISTINCT table_name) as tables_affected,
|
||||||
|
MIN(created_at) as first_entry,
|
||||||
|
MAX(created_at) as last_entry
|
||||||
|
FROM audit_log";
|
||||||
|
|
||||||
|
return $this->database->fetch($sql) ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTopUsers(int $limit = 10): array
|
||||||
|
{
|
||||||
|
$sql = "SELECT
|
||||||
|
u.name as user_name,
|
||||||
|
COUNT(*) as activity_count
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.id
|
||||||
|
GROUP BY al.user_id, u.name
|
||||||
|
ORDER BY activity_count DESC
|
||||||
|
LIMIT :limit";
|
||||||
|
|
||||||
|
return $this->database->fetchAll($sql, ['limit' => $limit]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMostActiveTables(int $limit = 10): array
|
||||||
|
{
|
||||||
|
$sql = "SELECT
|
||||||
|
table_name,
|
||||||
|
COUNT(*) as activity_count
|
||||||
|
FROM audit_log
|
||||||
|
GROUP BY table_name
|
||||||
|
ORDER BY activity_count DESC
|
||||||
|
LIMIT :limit";
|
||||||
|
|
||||||
|
return $this->database->fetchAll($sql, ['limit' => $limit]);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/Views/auth/login.php
Normal file
42
app/Views/auth/login.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<div class="login-container">
|
||||||
|
<div class="login-box">
|
||||||
|
<div class="login-header">
|
||||||
|
<h2><?= $lang['login_title'] ?></h2>
|
||||||
|
<p>Asset Management System</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="/login" class="login-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= $csrfToken ?>">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email"><?= $lang['login_email'] ?></label>
|
||||||
|
<input type="email" id="email" name="email" required class="form-control">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password"><?= $lang['login_password'] ?></label>
|
||||||
|
<input type="password" id="password" name="password" required class="form-control">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" name="remember" value="1">
|
||||||
|
<span class="checkmark"></span>
|
||||||
|
<?= $lang['login_remember'] ?>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">
|
||||||
|
<?= $lang['login_submit'] ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="login-footer">
|
||||||
|
<a href="/password/forgot" class="forgot-password">
|
||||||
|
<?= $lang['forgot_password'] ?>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
97
app/Views/layouts/main.php
Normal file
97
app/Views/layouts/main.php
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="<?= $locale ?>">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><?= $lang['dashboard'] ?> - Asset Management System</title>
|
||||||
|
|
||||||
|
<!-- CSS -->
|
||||||
|
<link rel="stylesheet" href="/assets/css/style.css">
|
||||||
|
|
||||||
|
<!-- Favicon -->
|
||||||
|
<link rel="icon" type="image/x-icon" href="/assets/img/favicon.ico">
|
||||||
|
|
||||||
|
<!-- Security Headers -->
|
||||||
|
<meta http-equiv="X-Content-Type-Options" content="nosniff">
|
||||||
|
<meta http-equiv="X-Frame-Options" content="SAMEORIGIN">
|
||||||
|
<meta http-equiv="X-XSS-Protection" content="1; mode=block">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="header">
|
||||||
|
<div class="container">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="logo">
|
||||||
|
<h1>Asset Management</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($user): ?>
|
||||||
|
<nav class="nav">
|
||||||
|
<ul class="nav-list">
|
||||||
|
<li><a href="/dashboard" class="nav-link"><?= $lang['dashboard'] ?></a></li>
|
||||||
|
<li><a href="/assets" class="nav-link"><?= $lang['assets'] ?></a></li>
|
||||||
|
<li><a href="/inventories" class="nav-link"><?= $lang['inventory'] ?></a></li>
|
||||||
|
<li><a href="/reports" class="nav-link"><?= $lang['reports'] ?></a></li>
|
||||||
|
|
||||||
|
<?php if ($user['role'] === 'admin'): ?>
|
||||||
|
<li class="nav-dropdown">
|
||||||
|
<a href="#" class="nav-link"><?= $lang['master_data'] ?></a>
|
||||||
|
<ul class="nav-dropdown-menu">
|
||||||
|
<li><a href="/categories"><?= $lang['categories_nav'] ?></a></li>
|
||||||
|
<li><a href="/locations"><?= $lang['locations_nav'] ?></a></li>
|
||||||
|
<li><a href="/departments"><?= $lang['departments_nav'] ?></a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li><a href="/users" class="nav-link"><?= $lang['users'] ?></a></li>
|
||||||
|
<?php endif; ?>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="user-menu">
|
||||||
|
<span class="user-name"><?= htmlspecialchars($user['name']) ?></span>
|
||||||
|
<div class="user-dropdown">
|
||||||
|
<a href="#" class="user-link"><?= $user['name'] ?></a>
|
||||||
|
<ul class="user-dropdown-menu">
|
||||||
|
<li><a href="/settings"><?= $lang['settings'] ?></a></li>
|
||||||
|
<li><a href="/logout"><?= $lang['logout'] ?></a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="main">
|
||||||
|
<div class="container">
|
||||||
|
<!-- Flash Messages -->
|
||||||
|
<?php if (!empty($flashMessages)): ?>
|
||||||
|
<div class="flash-messages">
|
||||||
|
<?php foreach ($flashMessages as $type => $message): ?>
|
||||||
|
<div class="alert alert-<?= $type ?>">
|
||||||
|
<?= htmlspecialchars($message) ?>
|
||||||
|
<button type="button" class="alert-close" onclick="this.parentElement.remove()">×</button>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Page Content -->
|
||||||
|
<div class="content">
|
||||||
|
<?= $content ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="container">
|
||||||
|
<p>© <?= date('Y') ?> Asset Management System. Alle Rechte vorbehalten.</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- JavaScript -->
|
||||||
|
<script src="/assets/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
26
composer.json
Normal file
26
composer.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "inventory/asset-management",
|
||||||
|
"description": "Professional Asset Management System",
|
||||||
|
"type": "project",
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.2",
|
||||||
|
"dompdf/dompdf": "^2.0",
|
||||||
|
"picqer/php-barcode-generator": "^2.2",
|
||||||
|
"phpoffice/phpspreadsheet": "^1.29",
|
||||||
|
"vlucas/phpdotenv": "^5.5"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "app/"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"app/Helpers/functions.php"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"optimize-autoloader": true,
|
||||||
|
"sort-packages": true
|
||||||
|
},
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"prefer-stable": true
|
||||||
|
}
|
||||||
23
composer.lock
generated
Normal file
23
composer.lock
generated
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"_readme": [
|
||||||
|
"This file locks the dependencies of your project to a known state",
|
||||||
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
|
"This file is @generated automatically"
|
||||||
|
],
|
||||||
|
"content-hash": "d751713988987e9331980363e24189ce",
|
||||||
|
"packages": [],
|
||||||
|
"packages-dev": [],
|
||||||
|
"aliases": [],
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"stability-flags": [],
|
||||||
|
"prefer-stable": false,
|
||||||
|
"prefer-lowest": false,
|
||||||
|
"platform": {
|
||||||
|
"php": ">=8.2"
|
||||||
|
},
|
||||||
|
"platform-dev": [],
|
||||||
|
"platform-overrides": {
|
||||||
|
"php": "8.2"
|
||||||
|
},
|
||||||
|
"plugin-api-version": "2.3.0"
|
||||||
|
}
|
||||||
58
config/config.php
Normal file
58
config/config.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Application Configuration
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Database configuration
|
||||||
|
define('DB_HOST', $_ENV['DB_HOST'] ?? 'localhost');
|
||||||
|
define('DB_PORT', $_ENV['DB_PORT'] ?? 3306);
|
||||||
|
define('DB_NAME', $_ENV['DB_NAME'] ?? 'inventory');
|
||||||
|
define('DB_USER', $_ENV['DB_USER'] ?? 'root');
|
||||||
|
define('DB_PASS', $_ENV['DB_PASS'] ?? '');
|
||||||
|
|
||||||
|
// Application settings
|
||||||
|
define('APP_ENV', $_ENV['APP_ENV'] ?? 'production');
|
||||||
|
define('APP_URL', $_ENV['APP_URL'] ?? 'http://localhost:8080');
|
||||||
|
define('APP_DEBUG', $_ENV['APP_DEBUG'] ?? false);
|
||||||
|
define('APP_KEY', $_ENV['APP_KEY'] ?? 'base64:default-key-change-in-production');
|
||||||
|
|
||||||
|
// Session settings
|
||||||
|
define('SESSION_SECURE', $_ENV['SESSION_SECURE'] ?? false);
|
||||||
|
define('SESSION_LIFETIME', $_ENV['SESSION_LIFETIME'] ?? 3600);
|
||||||
|
|
||||||
|
// File upload settings
|
||||||
|
define('UPLOAD_MAX_SIZE', $_ENV['UPLOAD_MAX_SIZE'] ?? '50M');
|
||||||
|
define('ALLOWED_FILE_TYPES', $_ENV['ALLOWED_FILE_TYPES'] ?? 'pdf,jpg,jpeg,png,gif,doc,docx,xls,xlsx');
|
||||||
|
|
||||||
|
// Security settings
|
||||||
|
define('CSRF_TOKEN_LIFETIME', $_ENV['CSRF_TOKEN_LIFETIME'] ?? 3600);
|
||||||
|
define('PASSWORD_MIN_LENGTH', $_ENV['PASSWORD_MIN_LENGTH'] ?? 8);
|
||||||
|
define('LOGIN_MAX_ATTEMPTS', $_ENV['LOGIN_MAX_ATTEMPTS'] ?? 5);
|
||||||
|
define('LOGIN_LOCKOUT_TIME', $_ENV['LOGIN_LOCKOUT_TIME'] ?? 900);
|
||||||
|
|
||||||
|
// Paths
|
||||||
|
define('ROOT_PATH', dirname(__DIR__));
|
||||||
|
define('APP_PATH', ROOT_PATH . '/app');
|
||||||
|
define('CONFIG_PATH', ROOT_PATH . '/config');
|
||||||
|
define('STORAGE_PATH', ROOT_PATH . '/storage');
|
||||||
|
define('PUBLIC_PATH', ROOT_PATH . '/public');
|
||||||
|
define('LANG_PATH', ROOT_PATH . '/lang');
|
||||||
|
|
||||||
|
// Error reporting
|
||||||
|
if (APP_DEBUG) {
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 1);
|
||||||
|
} else {
|
||||||
|
error_reporting(0);
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set timezone
|
||||||
|
date_default_timezone_set('Europe/Berlin');
|
||||||
|
|
||||||
|
// Configure session
|
||||||
|
ini_set('session.cookie_httponly', 1);
|
||||||
|
ini_set('session.cookie_secure', SESSION_SECURE ? 1 : 0);
|
||||||
|
ini_set('session.cookie_samesite', 'Strict');
|
||||||
|
ini_set('session.use_strict_mode', 1);
|
||||||
|
ini_set('session.gc_maxlifetime', SESSION_LIFETIME);
|
||||||
123
config/routes.php
Normal file
123
config/routes.php
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Application Routes
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Core\Router;
|
||||||
|
use App\Controllers\AuthController;
|
||||||
|
use App\Controllers\DashboardController;
|
||||||
|
use App\Controllers\AssetController;
|
||||||
|
use App\Controllers\InventoryController;
|
||||||
|
use App\Controllers\ReportController;
|
||||||
|
use App\Controllers\UserController;
|
||||||
|
use App\Controllers\CategoryController;
|
||||||
|
use App\Controllers\LocationController;
|
||||||
|
use App\Controllers\DepartmentController;
|
||||||
|
use App\Controllers\FileController;
|
||||||
|
use App\Controllers\ExportController;
|
||||||
|
use App\Controllers\ImportController;
|
||||||
|
|
||||||
|
$router = Router::getInstance();
|
||||||
|
|
||||||
|
// Authentication routes
|
||||||
|
$router->get('/login', [AuthController::class, 'showLogin']);
|
||||||
|
$router->post('/login', [AuthController::class, 'login']);
|
||||||
|
$router->post('/logout', [AuthController::class, 'logout']);
|
||||||
|
$router->get('/password/forgot', [AuthController::class, 'showForgotPassword']);
|
||||||
|
$router->post('/password/forgot', [AuthController::class, 'forgotPassword']);
|
||||||
|
$router->get('/password/reset', [AuthController::class, 'showResetPassword']);
|
||||||
|
$router->post('/password/reset', [AuthController::class, 'resetPassword']);
|
||||||
|
|
||||||
|
// Protected routes (require authentication)
|
||||||
|
$router->group(['middleware' => 'auth'], function($router) {
|
||||||
|
// Dashboard
|
||||||
|
$router->get('/', [DashboardController::class, 'index']);
|
||||||
|
$router->get('/dashboard', [DashboardController::class, 'index']);
|
||||||
|
|
||||||
|
// Assets
|
||||||
|
$router->get('/assets', [AssetController::class, 'index']);
|
||||||
|
$router->get('/assets/create', [AssetController::class, 'create']);
|
||||||
|
$router->post('/assets', [AssetController::class, 'store']);
|
||||||
|
$router->get('/assets/{id}', [AssetController::class, 'show']);
|
||||||
|
$router->get('/assets/{id}/edit', [AssetController::class, 'edit']);
|
||||||
|
$router->post('/assets/{id}', [AssetController::class, 'update']);
|
||||||
|
$router->post('/assets/{id}/delete', [AssetController::class, 'delete']);
|
||||||
|
$router->post('/assets/{id}/assign', [AssetController::class, 'assign']);
|
||||||
|
$router->post('/assets/{id}/checkout', [AssetController::class, 'checkout']);
|
||||||
|
$router->post('/assets/{id}/checkin', [AssetController::class, 'checkin']);
|
||||||
|
|
||||||
|
// Asset files
|
||||||
|
$router->post('/assets/{id}/files', [FileController::class, 'upload']);
|
||||||
|
$router->get('/files/{id}/download', [FileController::class, 'download']);
|
||||||
|
$router->post('/files/{id}/delete', [FileController::class, 'delete']);
|
||||||
|
|
||||||
|
// Inventory
|
||||||
|
$router->get('/inventories', [InventoryController::class, 'index']);
|
||||||
|
$router->get('/inventories/create', [InventoryController::class, 'create']);
|
||||||
|
$router->post('/inventories', [InventoryController::class, 'store']);
|
||||||
|
$router->get('/inventories/{id}', [InventoryController::class, 'show']);
|
||||||
|
$router->post('/inventories/{id}/close', [InventoryController::class, 'close']);
|
||||||
|
$router->post('/inventories/{id}/scan', [InventoryController::class, 'scan']);
|
||||||
|
$router->get('/inventories/{id}/report', [InventoryController::class, 'report']);
|
||||||
|
|
||||||
|
// Reports
|
||||||
|
$router->get('/reports', [ReportController::class, 'index']);
|
||||||
|
$router->get('/reports/assets', [ReportController::class, 'assets']);
|
||||||
|
$router->get('/reports/warranty', [ReportController::class, 'warranty']);
|
||||||
|
$router->get('/reports/retired', [ReportController::class, 'retired']);
|
||||||
|
$router->get('/reports/location', [ReportController::class, 'location']);
|
||||||
|
$router->get('/reports/category', [ReportController::class, 'category']);
|
||||||
|
|
||||||
|
// Exports
|
||||||
|
$router->get('/exports/csv', [ExportController::class, 'csv']);
|
||||||
|
$router->get('/exports/xlsx', [ExportController::class, 'xlsx']);
|
||||||
|
$router->get('/exports/labels', [ExportController::class, 'labels']);
|
||||||
|
|
||||||
|
// Imports
|
||||||
|
$router->get('/imports/csv', [ImportController::class, 'show']);
|
||||||
|
$router->post('/imports/csv', [ImportController::class, 'import']);
|
||||||
|
|
||||||
|
// Master data
|
||||||
|
$router->get('/categories', [CategoryController::class, 'index']);
|
||||||
|
$router->get('/categories/create', [CategoryController::class, 'create']);
|
||||||
|
$router->post('/categories', [CategoryController::class, 'store']);
|
||||||
|
$router->get('/categories/{id}/edit', [CategoryController::class, 'edit']);
|
||||||
|
$router->post('/categories/{id}', [CategoryController::class, 'update']);
|
||||||
|
$router->post('/categories/{id}/delete', [CategoryController::class, 'delete']);
|
||||||
|
|
||||||
|
$router->get('/locations', [LocationController::class, 'index']);
|
||||||
|
$router->get('/locations/create', [LocationController::class, 'create']);
|
||||||
|
$router->post('/locations', [LocationController::class, 'store']);
|
||||||
|
$router->get('/locations/{id}/edit', [LocationController::class, 'edit']);
|
||||||
|
$router->post('/locations/{id}', [LocationController::class, 'update']);
|
||||||
|
$router->post('/locations/{id}/delete', [LocationController::class, 'delete']);
|
||||||
|
|
||||||
|
$router->get('/departments', [DepartmentController::class, 'index']);
|
||||||
|
$router->get('/departments/create', [DepartmentController::class, 'create']);
|
||||||
|
$router->post('/departments', [DepartmentController::class, 'store']);
|
||||||
|
$router->get('/departments/{id}/edit', [DepartmentController::class, 'edit']);
|
||||||
|
$router->post('/departments/{id}', [DepartmentController::class, 'update']);
|
||||||
|
$router->post('/departments/{id}/delete', [DepartmentController::class, 'delete']);
|
||||||
|
|
||||||
|
// Admin routes
|
||||||
|
$router->group(['middleware' => 'admin'], function($router) {
|
||||||
|
$router->get('/users', [UserController::class, 'index']);
|
||||||
|
$router->get('/users/create', [UserController::class, 'create']);
|
||||||
|
$router->post('/users', [UserController::class, 'store']);
|
||||||
|
$router->get('/users/{id}/edit', [UserController::class, 'edit']);
|
||||||
|
$router->post('/users/{id}', [UserController::class, 'update']);
|
||||||
|
$router->post('/users/{id}/delete', [UserController::class, 'delete']);
|
||||||
|
$router->post('/users/{id}/toggle', [UserController::class, 'toggleStatus']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error routes
|
||||||
|
$router->get('/404', function() {
|
||||||
|
http_response_code(404);
|
||||||
|
require APP_PATH . '/Views/errors/404.php';
|
||||||
|
});
|
||||||
|
|
||||||
|
$router->get('/500', function() {
|
||||||
|
http_response_code(500);
|
||||||
|
require APP_PATH . '/Views/errors/500.php';
|
||||||
|
});
|
||||||
14
database/migrations/001_create_users_table.sql
Normal file
14
database/migrations/001_create_users_table.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE users (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
role ENUM('admin', 'auditor', 'employee') NOT NULL DEFAULT 'employee',
|
||||||
|
passhash VARCHAR(255) NOT NULL,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
locale VARCHAR(5) NOT NULL DEFAULT 'de',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_email (email),
|
||||||
|
INDEX idx_role (role),
|
||||||
|
INDEX idx_active (active)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
10
database/migrations/002_create_password_resets_table.sql
Normal file
10
database/migrations/002_create_password_resets_table.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE password_resets (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
token VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
expires_at TIMESTAMP NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_token (token),
|
||||||
|
INDEX idx_expires_at (expires_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
8
database/migrations/003_create_categories_table.sql
Normal file
8
database/migrations/003_create_categories_table.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE categories (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_name (name)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
11
database/migrations/004_create_locations_table.sql
Normal file
11
database/migrations/004_create_locations_table.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE locations (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
address TEXT,
|
||||||
|
contact_person VARCHAR(255),
|
||||||
|
contact_email VARCHAR(255),
|
||||||
|
contact_phone VARCHAR(50),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_name (name)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
11
database/migrations/005_create_departments_table.sql
Normal file
11
database/migrations/005_create_departments_table.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE departments (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
cost_center VARCHAR(50),
|
||||||
|
manager VARCHAR(255),
|
||||||
|
manager_email VARCHAR(255),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_name (name),
|
||||||
|
INDEX idx_cost_center (cost_center)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
34
database/migrations/006_create_assets_table.sql
Normal file
34
database/migrations/006_create_assets_table.sql
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
CREATE TABLE assets (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
inventarnummer VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
bezeichnung VARCHAR(500) NOT NULL,
|
||||||
|
kategorie_id INT,
|
||||||
|
standort_id INT,
|
||||||
|
abteilung_id INT,
|
||||||
|
raum VARCHAR(100),
|
||||||
|
lieferant VARCHAR(255),
|
||||||
|
seriennummer VARCHAR(255),
|
||||||
|
anschaffungsdatum DATE,
|
||||||
|
anschaffungspreis DECIMAL(10,2),
|
||||||
|
garantie_bis DATE,
|
||||||
|
zustand ENUM('neu', 'gut', 'befriedigend', 'schlecht', 'defekt') DEFAULT 'gut',
|
||||||
|
status ENUM('aktiv', 'inaktiv', 'ausgemustert') DEFAULT 'aktiv',
|
||||||
|
kostenstelle VARCHAR(50),
|
||||||
|
notizen TEXT,
|
||||||
|
created_by INT,
|
||||||
|
updated_by INT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (kategorie_id) REFERENCES categories(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (standort_id) REFERENCES locations(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (abteilung_id) REFERENCES departments(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_inventarnummer (inventarnummer),
|
||||||
|
INDEX idx_kategorie_id (kategorie_id),
|
||||||
|
INDEX idx_standort_id (standort_id),
|
||||||
|
INDEX idx_status (status),
|
||||||
|
INDEX idx_seriennummer (seriennummer),
|
||||||
|
INDEX idx_anschaffungsdatum (anschaffungsdatum),
|
||||||
|
INDEX idx_garantie_bis (garantie_bis)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
15
database/migrations/007_create_asset_assignments_table.sql
Normal file
15
database/migrations/007_create_asset_assignments_table.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE asset_assignments (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
asset_id INT NOT NULL,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
von DATE NOT NULL,
|
||||||
|
bis DATE,
|
||||||
|
kommentar TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_asset_id (asset_id),
|
||||||
|
INDEX idx_user_id (user_id),
|
||||||
|
INDEX idx_von (von),
|
||||||
|
INDEX idx_bis (bis)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
16
database/migrations/008_create_asset_files_table.sql
Normal file
16
database/migrations/008_create_asset_files_table.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE asset_files (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
asset_id INT NOT NULL,
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
original_filename VARCHAR(255) NOT NULL,
|
||||||
|
path VARCHAR(500) NOT NULL,
|
||||||
|
mime VARCHAR(100) NOT NULL,
|
||||||
|
size INT NOT NULL,
|
||||||
|
uploaded_by INT,
|
||||||
|
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_asset_id (asset_id),
|
||||||
|
INDEX idx_uploaded_by (uploaded_by),
|
||||||
|
INDEX idx_uploaded_at (uploaded_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
15
database/migrations/009_create_inventories_table.sql
Normal file
15
database/migrations/009_create_inventories_table.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE inventories (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
started_by INT,
|
||||||
|
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
closed_at TIMESTAMP NULL,
|
||||||
|
status ENUM('offen', 'abgeschlossen') DEFAULT 'offen',
|
||||||
|
kommentar TEXT,
|
||||||
|
FOREIGN KEY (started_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_started_by (started_by),
|
||||||
|
INDEX idx_status (status),
|
||||||
|
INDEX idx_started_at (started_at),
|
||||||
|
INDEX idx_closed_at (closed_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
19
database/migrations/010_create_inventory_items_table.sql
Normal file
19
database/migrations/010_create_inventory_items_table.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
CREATE TABLE inventory_items (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
inventory_id INT NOT NULL,
|
||||||
|
asset_id INT NOT NULL,
|
||||||
|
soll_status ENUM('aktiv', 'inaktiv', 'ausgemustert') NOT NULL,
|
||||||
|
ist_status ENUM('gefunden', 'nicht_gefunden', 'defekt', 'verschoben') NOT NULL,
|
||||||
|
checked_at TIMESTAMP NULL,
|
||||||
|
checked_by INT,
|
||||||
|
bemerkung TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (inventory_id) REFERENCES inventories(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (checked_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_inventory_id (inventory_id),
|
||||||
|
INDEX idx_asset_id (asset_id),
|
||||||
|
INDEX idx_checked_by (checked_by),
|
||||||
|
INDEX idx_ist_status (ist_status),
|
||||||
|
UNIQUE KEY unique_inventory_asset (inventory_id, asset_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
16
database/migrations/011_create_audit_log_table.sql
Normal file
16
database/migrations/011_create_audit_log_table.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE audit_log (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT,
|
||||||
|
action VARCHAR(50) NOT NULL,
|
||||||
|
table_name VARCHAR(100) NOT NULL,
|
||||||
|
record_id INT NOT NULL,
|
||||||
|
old_value JSON,
|
||||||
|
new_value JSON,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_user_id (user_id),
|
||||||
|
INDEX idx_action (action),
|
||||||
|
INDEX idx_table_name (table_name),
|
||||||
|
INDEX idx_record_id (record_id),
|
||||||
|
INDEX idx_created_at (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
6
database/seeds/001_seed_users.sql
Normal file
6
database/seeds/001_seed_users.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- Insert default admin user
|
||||||
|
-- Password: Admin!234 (will be changed on first login)
|
||||||
|
INSERT INTO users (name, email, role, passhash, active, locale) VALUES
|
||||||
|
('Administrator', 'admin@example.com', 'admin', '$argon2id$v=19$m=65536,t=4,p=1$dXNlcl9zYWx0$hashed_password_here', TRUE, 'de'),
|
||||||
|
('Max Mustermann', 'max.mustermann@example.com', 'auditor', '$argon2id$v=19$m=65536,t=4,p=1$dXNlcl9zYWx0$hashed_password_here', TRUE, 'de'),
|
||||||
|
('Anna Schmidt', 'anna.schmidt@example.com', 'employee', '$argon2id$v=19$m=65536,t=4,p=1$dXNlcl9zYWx0$hashed_password_here', TRUE, 'de');
|
||||||
11
database/seeds/002_seed_categories.sql
Normal file
11
database/seeds/002_seed_categories.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
INSERT INTO categories (name, description) VALUES
|
||||||
|
('Computer & Hardware', 'Desktop-Computer, Laptops, Server und Zubehör'),
|
||||||
|
('Netzwerk & Kommunikation', 'Router, Switches, Telefone, WLAN-Geräte'),
|
||||||
|
('Büroausstattung', 'Tische, Stühle, Schränke, Drucker'),
|
||||||
|
('Software & Lizenzen', 'Betriebssysteme, Anwendungssoftware, Lizenzen'),
|
||||||
|
('Fahrzeuge & Transport', 'Firmenwagen, Gabelstapler, Transportgeräte'),
|
||||||
|
('Werkzeuge & Maschinen', 'Handwerkzeuge, Maschinen, Produktionsanlagen'),
|
||||||
|
('Sicherheit & Überwachung', 'Kameras, Alarmanlagen, Zutrittskontrollen'),
|
||||||
|
('Möbel & Einrichtung', 'Büromöbel, Einrichtungsgegenstände'),
|
||||||
|
('Elektronik & Unterhaltung', 'TV-Geräte, Audio-Systeme, Präsentationstechnik'),
|
||||||
|
('Sonstiges', 'Verschiedene andere Anlagen und Gegenstände');
|
||||||
6
database/seeds/003_seed_locations.sql
Normal file
6
database/seeds/003_seed_locations.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
INSERT INTO locations (name, address, contact_person, contact_email, contact_phone) VALUES
|
||||||
|
('Hauptsitz - Gebäude A', 'Musterstraße 1, 12345 Musterstadt', 'Peter Müller', 'peter.mueller@example.com', '+49 123 456789'),
|
||||||
|
('Niederlassung - Gebäude B', 'Beispielweg 15, 54321 Beispielort', 'Maria Weber', 'maria.weber@example.com', '+49 987 654321'),
|
||||||
|
('Lager - Gebäude C', 'Industriepark 7, 67890 Industriestadt', 'Hans Schmidt', 'hans.schmidt@example.com', '+49 555 123456'),
|
||||||
|
('Außenstelle Nord', 'Nordstraße 42, 11111 Nordstadt', 'Lisa Fischer', 'lisa.fischer@example.com', '+49 111 222333'),
|
||||||
|
('Außenstelle Süd', 'Südallee 8, 99999 Südstadt', 'Tom Wagner', 'tom.wagner@example.com', '+49 999 888777');
|
||||||
11
database/seeds/004_seed_departments.sql
Normal file
11
database/seeds/004_seed_departments.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
INSERT INTO departments (name, cost_center, manager, manager_email) VALUES
|
||||||
|
('Geschäftsführung', 'GF-001', 'Dr. Michael Bauer', 'michael.bauer@example.com'),
|
||||||
|
('IT & Systemadministration', 'IT-001', 'Sarah Klein', 'sarah.klein@example.com'),
|
||||||
|
('Buchhaltung & Finanzen', 'BU-001', 'Frank Meyer', 'frank.meyer@example.com'),
|
||||||
|
('Personal & Verwaltung', 'PE-001', 'Claudia Schulz', 'claudia.schulz@example.com'),
|
||||||
|
('Vertrieb & Marketing', 'VM-001', 'Andreas Wolf', 'andreas.wolf@example.com'),
|
||||||
|
('Produktion & Fertigung', 'PR-001', 'Robert Koch', 'robert.koch@example.com'),
|
||||||
|
('Einkauf & Logistik', 'EL-001', 'Petra Lange', 'petra.lange@example.com'),
|
||||||
|
('Forschung & Entwicklung', 'FE-001', 'Dr. Thomas Richter', 'thomas.richter@example.com'),
|
||||||
|
('Kundenservice', 'KS-001', 'Nina Becker', 'nina.becker@example.com'),
|
||||||
|
('Qualitätssicherung', 'QS-001', 'Markus Hoffmann', 'markus.hoffmann@example.com');
|
||||||
11
database/seeds/005_seed_sample_assets.sql
Normal file
11
database/seeds/005_seed_sample_assets.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
INSERT INTO assets (inventarnummer, bezeichnung, kategorie_id, standort_id, abteilung_id, raum, lieferant, seriennummer, anschaffungsdatum, anschaffungspreis, garantie_bis, zustand, status, kostenstelle, notizen, created_by) VALUES
|
||||||
|
('PC-2024-001', 'Dell OptiPlex 7090 Desktop Computer', 1, 1, 2, 'Büro 101', 'Dell Technologies', 'SN123456789', '2024-01-15', 899.00, '2027-01-15', 'neu', 'aktiv', 'IT-001', 'Standard-Arbeitsplatz', 1),
|
||||||
|
('PC-2024-002', 'HP EliteBook 840 G8 Laptop', 1, 1, 2, 'Büro 102', 'HP Inc.', 'SN987654321', '2024-02-01', 1299.00, '2027-02-01', 'neu', 'aktiv', 'IT-001', 'Mobiler Arbeitsplatz', 1),
|
||||||
|
('NET-2024-001', 'Cisco Catalyst 2960 Switch', 2, 1, 2, 'Serverraum', 'Cisco Systems', 'SN456789123', '2024-01-10', 450.00, '2027-01-10', 'neu', 'aktiv', 'IT-001', '24-Port Switch', 1),
|
||||||
|
('BÜRO-2024-001', 'Herman Miller Aeron Bürostuhl', 3, 1, 4, 'Büro 103', 'Herman Miller', 'SN789123456', '2024-03-01', 850.00, '2027-03-01', 'neu', 'aktiv', 'PE-001', 'Ergonomischer Bürostuhl', 1),
|
||||||
|
('SW-2024-001', 'Microsoft Office 365 Business Premium', 4, 1, 2, 'Virtuell', 'Microsoft', 'LIC-2024-001', '2024-01-01', 12.50, '2025-01-01', 'neu', 'aktiv', 'IT-001', 'Jährliche Lizenz', 1),
|
||||||
|
('FAHR-2024-001', 'VW Passat Variant Firmenwagen', 5, 1, 1, 'Parkplatz A1', 'Volkswagen AG', 'SN111222333', '2024-01-20', 35000.00, '2027-01-20', 'neu', 'aktiv', 'GF-001', 'Geschäftsführer', 1),
|
||||||
|
('WERK-2024-001', 'Bosch Professional Bohrmaschine', 6, 3, 6, 'Werkstatt', 'Bosch', 'SN444555666', '2024-02-15', 89.00, '2026-02-15', 'neu', 'aktiv', 'PR-001', 'Werkstattausstattung', 1),
|
||||||
|
('SICH-2024-001', 'Hikvision IP-Kamera', 7, 1, 2, 'Eingang', 'Hikvision', 'SN777888999', '2024-01-05', 120.00, '2027-01-05', 'neu', 'aktiv', 'IT-001', 'Überwachungskamera', 1),
|
||||||
|
('MÖB-2024-001', 'IKEA BEKANT Schreibtisch', 8, 1, 4, 'Büro 104', 'IKEA', 'SN000111222', '2024-03-10', 199.00, '2027-03-10', 'neu', 'aktiv', 'PE-001', 'Standard-Schreibtisch', 1),
|
||||||
|
('ELEK-2024-001', 'Samsung 55" Smart TV', 9, 1, 5, 'Konferenzraum', 'Samsung Electronics', 'SN333444555', '2024-02-20', 599.00, '2027-02-20', 'neu', 'aktiv', 'VM-001', 'Präsentationstechnik', 1);
|
||||||
77
docker-compose.yml
Normal file
77
docker-compose.yml
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.nginx
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
depends_on:
|
||||||
|
- php
|
||||||
|
volumes:
|
||||||
|
- ./storage/uploads:/var/www/html/storage/uploads:ro
|
||||||
|
networks:
|
||||||
|
- inventory-network
|
||||||
|
|
||||||
|
php:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.php
|
||||||
|
volumes:
|
||||||
|
- ./storage/uploads:/var/www/html/storage/uploads
|
||||||
|
- ./storage/logs:/var/www/html/storage/logs
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
- DB_HOST=${DB_HOST}
|
||||||
|
- DB_PORT=${DB_PORT}
|
||||||
|
- DB_NAME=${DB_NAME}
|
||||||
|
- DB_USER=${DB_USER}
|
||||||
|
- DB_PASS=${DB_PASS}
|
||||||
|
- APP_ENV=${APP_ENV}
|
||||||
|
- APP_URL=${APP_URL}
|
||||||
|
- SESSION_SECURE=${SESSION_SECURE}
|
||||||
|
networks:
|
||||||
|
- inventory-network
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: mysql:8.0
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASS}
|
||||||
|
MYSQL_DATABASE: ${DB_NAME}
|
||||||
|
MYSQL_USER: ${DB_USER}
|
||||||
|
MYSQL_PASSWORD: ${DB_PASS}
|
||||||
|
volumes:
|
||||||
|
- mysql_data:/var/lib/mysql
|
||||||
|
- ./database/init:/docker-entrypoint-initdb.d
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASS}"]
|
||||||
|
timeout: 20s
|
||||||
|
retries: 10
|
||||||
|
networks:
|
||||||
|
- inventory-network
|
||||||
|
|
||||||
|
phpmyadmin:
|
||||||
|
image: phpmyadmin/phpmyadmin:latest
|
||||||
|
environment:
|
||||||
|
PMA_HOST: db
|
||||||
|
PMA_PORT: 3306
|
||||||
|
PMA_USER: ${DB_USER}
|
||||||
|
PMA_PASSWORD: ${DB_PASS}
|
||||||
|
ports:
|
||||||
|
- "8081:80"
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
networks:
|
||||||
|
- inventory-network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
inventory-network:
|
||||||
|
driver: bridge
|
||||||
40
env.example
Normal file
40
env.example
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Application Settings
|
||||||
|
APP_ENV=production
|
||||||
|
APP_URL=http://localhost:8080
|
||||||
|
APP_DEBUG=false
|
||||||
|
APP_KEY=base64:your-secret-key-here
|
||||||
|
|
||||||
|
# Database Settings
|
||||||
|
DB_HOST=db
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_NAME=inventory
|
||||||
|
DB_USER=inventory
|
||||||
|
DB_PASS=changeMe
|
||||||
|
DB_ROOT_PASS=rootPassword123
|
||||||
|
|
||||||
|
# Session Settings
|
||||||
|
SESSION_SECURE=false
|
||||||
|
SESSION_LIFETIME=3600
|
||||||
|
|
||||||
|
# File Upload Settings
|
||||||
|
UPLOAD_MAX_SIZE=50M
|
||||||
|
ALLOWED_FILE_TYPES=pdf,jpg,jpeg,png,gif,doc,docx,xls,xlsx
|
||||||
|
|
||||||
|
# Email Settings (for password reset)
|
||||||
|
MAIL_HOST=smtp.example.com
|
||||||
|
MAIL_PORT=587
|
||||||
|
MAIL_USERNAME=noreply@example.com
|
||||||
|
MAIL_PASSWORD=your-email-password
|
||||||
|
MAIL_ENCRYPTION=tls
|
||||||
|
MAIL_FROM_ADDRESS=noreply@example.com
|
||||||
|
MAIL_FROM_NAME="Asset Management System"
|
||||||
|
|
||||||
|
# Security Settings
|
||||||
|
CSRF_TOKEN_LIFETIME=3600
|
||||||
|
PASSWORD_MIN_LENGTH=8
|
||||||
|
LOGIN_MAX_ATTEMPTS=5
|
||||||
|
LOGIN_LOCKOUT_TIME=900
|
||||||
|
|
||||||
|
# Cache Settings
|
||||||
|
CACHE_DRIVER=file
|
||||||
|
CACHE_TTL=3600
|
||||||
233
lang/de.php
Normal file
233
lang/de.php
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
// Allgemein
|
||||||
|
'dashboard' => 'Dashboard',
|
||||||
|
'assets' => 'Anlagen',
|
||||||
|
'inventory' => 'Inventur',
|
||||||
|
'reports' => 'Berichte',
|
||||||
|
'users' => 'Benutzer',
|
||||||
|
'settings' => 'Einstellungen',
|
||||||
|
'logout' => 'Abmelden',
|
||||||
|
'login' => 'Anmelden',
|
||||||
|
'save' => 'Speichern',
|
||||||
|
'cancel' => 'Abbrechen',
|
||||||
|
'delete' => 'Löschen',
|
||||||
|
'edit' => 'Bearbeiten',
|
||||||
|
'create' => 'Erstellen',
|
||||||
|
'search' => 'Suchen',
|
||||||
|
'filter' => 'Filter',
|
||||||
|
'export' => 'Exportieren',
|
||||||
|
'import' => 'Importieren',
|
||||||
|
'print' => 'Drucken',
|
||||||
|
'download' => 'Herunterladen',
|
||||||
|
'upload' => 'Hochladen',
|
||||||
|
'back' => 'Zurück',
|
||||||
|
'next' => 'Weiter',
|
||||||
|
'previous' => 'Zurück',
|
||||||
|
'close' => 'Schließen',
|
||||||
|
'open' => 'Öffnen',
|
||||||
|
'yes' => 'Ja',
|
||||||
|
'no' => 'Nein',
|
||||||
|
'ok' => 'OK',
|
||||||
|
'error' => 'Fehler',
|
||||||
|
'success' => 'Erfolg',
|
||||||
|
'warning' => 'Warnung',
|
||||||
|
'info' => 'Information',
|
||||||
|
'loading' => 'Laden...',
|
||||||
|
'no_data' => 'Keine Daten verfügbar',
|
||||||
|
'actions' => 'Aktionen',
|
||||||
|
'status' => 'Status',
|
||||||
|
'date' => 'Datum',
|
||||||
|
'time' => 'Zeit',
|
||||||
|
'user' => 'Benutzer',
|
||||||
|
'description' => 'Beschreibung',
|
||||||
|
'notes' => 'Notizen',
|
||||||
|
'comments' => 'Kommentare',
|
||||||
|
'created_at' => 'Erstellt am',
|
||||||
|
'updated_at' => 'Aktualisiert am',
|
||||||
|
'created_by' => 'Erstellt von',
|
||||||
|
'updated_by' => 'Aktualisiert von',
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
'navigation' => 'Navigation',
|
||||||
|
'dashboard_nav' => 'Dashboard',
|
||||||
|
'assets_nav' => 'Anlagen',
|
||||||
|
'inventory_nav' => 'Inventur',
|
||||||
|
'reports_nav' => 'Berichte',
|
||||||
|
'master_data' => 'Stammdaten',
|
||||||
|
'categories_nav' => 'Kategorien',
|
||||||
|
'locations_nav' => 'Standorte',
|
||||||
|
'departments_nav' => 'Abteilungen',
|
||||||
|
'users_nav' => 'Benutzer',
|
||||||
|
'settings_nav' => 'Einstellungen',
|
||||||
|
|
||||||
|
// Assets
|
||||||
|
'asset' => 'Anlage',
|
||||||
|
'asset_id' => 'Anlagen-ID',
|
||||||
|
'inventory_number' => 'Inventarnummer',
|
||||||
|
'designation' => 'Bezeichnung',
|
||||||
|
'category' => 'Kategorie',
|
||||||
|
'location' => 'Standort',
|
||||||
|
'department' => 'Abteilung',
|
||||||
|
'room' => 'Raum',
|
||||||
|
'supplier' => 'Lieferant',
|
||||||
|
'serial_number' => 'Seriennummer',
|
||||||
|
'purchase_date' => 'Anschaffungsdatum',
|
||||||
|
'purchase_price' => 'Anschaffungspreis',
|
||||||
|
'warranty_until' => 'Garantie bis',
|
||||||
|
'condition' => 'Zustand',
|
||||||
|
'cost_center' => 'Kostenstelle',
|
||||||
|
'assign' => 'Zuweisen',
|
||||||
|
'checkout' => 'Ausleihen',
|
||||||
|
'checkin' => 'Zurückgeben',
|
||||||
|
'asset_details' => 'Anlagendetails',
|
||||||
|
'asset_history' => 'Anlagenhistorie',
|
||||||
|
'asset_files' => 'Anlagendateien',
|
||||||
|
'asset_assignments' => 'Anlagenzuweisungen',
|
||||||
|
|
||||||
|
// Asset Conditions
|
||||||
|
'condition_new' => 'Neu',
|
||||||
|
'condition_good' => 'Gut',
|
||||||
|
'condition_satisfactory' => 'Befriedigend',
|
||||||
|
'condition_poor' => 'Schlecht',
|
||||||
|
'condition_defective' => 'Defekt',
|
||||||
|
|
||||||
|
// Asset Status
|
||||||
|
'status_active' => 'Aktiv',
|
||||||
|
'status_inactive' => 'Inaktiv',
|
||||||
|
'status_retired' => 'Ausgemustert',
|
||||||
|
|
||||||
|
// Inventory
|
||||||
|
'inventory' => 'Inventur',
|
||||||
|
'inventory_run' => 'Inventurdurchlauf',
|
||||||
|
'inventory_item' => 'Inventurposition',
|
||||||
|
'inventory_status' => 'Inventurstatus',
|
||||||
|
'inventory_open' => 'Offen',
|
||||||
|
'inventory_closed' => 'Abgeschlossen',
|
||||||
|
'inventory_found' => 'Gefunden',
|
||||||
|
'inventory_not_found' => 'Nicht gefunden',
|
||||||
|
'inventory_defective' => 'Defekt',
|
||||||
|
'inventory_moved' => 'Verschoben',
|
||||||
|
'inventory_scan' => 'Scannen',
|
||||||
|
'inventory_report' => 'Inventurbericht',
|
||||||
|
|
||||||
|
// Users
|
||||||
|
'user' => 'Benutzer',
|
||||||
|
'username' => 'Benutzername',
|
||||||
|
'email' => 'E-Mail',
|
||||||
|
'password' => 'Passwort',
|
||||||
|
'password_confirm' => 'Passwort bestätigen',
|
||||||
|
'role' => 'Rolle',
|
||||||
|
'active' => 'Aktiv',
|
||||||
|
'inactive' => 'Inaktiv',
|
||||||
|
'last_login' => 'Letzter Login',
|
||||||
|
'login_attempts' => 'Login-Versuche',
|
||||||
|
'password_reset' => 'Passwort zurücksetzen',
|
||||||
|
'forgot_password' => 'Passwort vergessen',
|
||||||
|
|
||||||
|
// User Roles
|
||||||
|
'role_admin' => 'Administrator',
|
||||||
|
'role_auditor' => 'Auditor',
|
||||||
|
'role_employee' => 'Mitarbeiter',
|
||||||
|
|
||||||
|
// Authentication
|
||||||
|
'login_title' => 'Anmeldung',
|
||||||
|
'login_email' => 'E-Mail-Adresse',
|
||||||
|
'login_password' => 'Passwort',
|
||||||
|
'login_remember' => 'Angemeldet bleiben',
|
||||||
|
'login_submit' => 'Anmelden',
|
||||||
|
'login_failed' => 'Anmeldung fehlgeschlagen',
|
||||||
|
'login_locked' => 'Account gesperrt',
|
||||||
|
'logout_success' => 'Erfolgreich abgemeldet',
|
||||||
|
'password_reset_title' => 'Passwort zurücksetzen',
|
||||||
|
'password_reset_email' => 'E-Mail-Adresse',
|
||||||
|
'password_reset_submit' => 'Link senden',
|
||||||
|
'password_reset_sent' => 'E-Mail mit Reset-Link gesendet',
|
||||||
|
'password_reset_new' => 'Neues Passwort',
|
||||||
|
'password_reset_confirm' => 'Neues Passwort bestätigen',
|
||||||
|
'password_reset_success' => 'Passwort erfolgreich geändert',
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
'message_saved' => 'Daten erfolgreich gespeichert',
|
||||||
|
'message_deleted' => 'Datensatz erfolgreich gelöscht',
|
||||||
|
'message_created' => 'Datensatz erfolgreich erstellt',
|
||||||
|
'message_updated' => 'Datensatz erfolgreich aktualisiert',
|
||||||
|
'message_error' => 'Ein Fehler ist aufgetreten',
|
||||||
|
'message_not_found' => 'Datensatz nicht gefunden',
|
||||||
|
'message_unauthorized' => 'Nicht autorisiert',
|
||||||
|
'message_forbidden' => 'Zugriff verweigert',
|
||||||
|
'message_validation_error' => 'Validierungsfehler',
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
'validation_required' => 'Das Feld ist erforderlich',
|
||||||
|
'validation_email' => 'Ungültige E-Mail-Adresse',
|
||||||
|
'validation_min' => 'Mindestens :min Zeichen erforderlich',
|
||||||
|
'validation_max' => 'Maximal :max Zeichen erlaubt',
|
||||||
|
'validation_numeric' => 'Nur Zahlen erlaubt',
|
||||||
|
'validation_date' => 'Ungültiges Datum',
|
||||||
|
'validation_unique' => 'Wert bereits vorhanden',
|
||||||
|
|
||||||
|
// Reports
|
||||||
|
'report' => 'Bericht',
|
||||||
|
'report_assets' => 'Anlagenbericht',
|
||||||
|
'report_warranty' => 'Garantiebericht',
|
||||||
|
'report_retired' => 'Ausmusterungsbericht',
|
||||||
|
'report_location' => 'Standortbericht',
|
||||||
|
'report_category' => 'Kategoriebericht',
|
||||||
|
'report_inventory' => 'Inventurbericht',
|
||||||
|
'report_export' => 'Bericht exportieren',
|
||||||
|
'report_print' => 'Bericht drucken',
|
||||||
|
|
||||||
|
// Export/Import
|
||||||
|
'export_csv' => 'CSV exportieren',
|
||||||
|
'export_xlsx' => 'Excel exportieren',
|
||||||
|
'export_pdf' => 'PDF exportieren',
|
||||||
|
'import_csv' => 'CSV importieren',
|
||||||
|
'import_file' => 'Datei auswählen',
|
||||||
|
'import_preview' => 'Vorschau',
|
||||||
|
'import_confirm' => 'Import bestätigen',
|
||||||
|
'import_success' => 'Import erfolgreich',
|
||||||
|
'import_error' => 'Import fehlgeschlagen',
|
||||||
|
|
||||||
|
// Labels
|
||||||
|
'label_print' => 'Etiketten drucken',
|
||||||
|
'label_barcode' => 'Barcode',
|
||||||
|
'label_qr' => 'QR-Code',
|
||||||
|
'label_inventory_number' => 'Inventarnummer',
|
||||||
|
'label_designation' => 'Bezeichnung',
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
'dashboard_welcome' => 'Willkommen im Asset Management System',
|
||||||
|
'dashboard_stats' => 'Statistiken',
|
||||||
|
'dashboard_total_assets' => 'Gesamte Anlagen',
|
||||||
|
'dashboard_active_assets' => 'Aktive Anlagen',
|
||||||
|
'dashboard_retired_assets' => 'Ausgemusterte Anlagen',
|
||||||
|
'dashboard_total_value' => 'Gesamtwert',
|
||||||
|
'dashboard_recent_activity' => 'Letzte Aktivitäten',
|
||||||
|
'dashboard_warranty_expiring' => 'Garantie läuft ab',
|
||||||
|
'dashboard_inventory_open' => 'Offene Inventuren',
|
||||||
|
|
||||||
|
// Months
|
||||||
|
'month_01' => 'Januar',
|
||||||
|
'month_02' => 'Februar',
|
||||||
|
'month_03' => 'März',
|
||||||
|
'month_04' => 'April',
|
||||||
|
'month_05' => 'Mai',
|
||||||
|
'month_06' => 'Juni',
|
||||||
|
'month_07' => 'Juli',
|
||||||
|
'month_08' => 'August',
|
||||||
|
'month_09' => 'September',
|
||||||
|
'month_10' => 'Oktober',
|
||||||
|
'month_11' => 'November',
|
||||||
|
'month_12' => 'Dezember',
|
||||||
|
|
||||||
|
// Days
|
||||||
|
'day_monday' => 'Montag',
|
||||||
|
'day_tuesday' => 'Dienstag',
|
||||||
|
'day_wednesday' => 'Mittwoch',
|
||||||
|
'day_thursday' => 'Donnerstag',
|
||||||
|
'day_friday' => 'Freitag',
|
||||||
|
'day_saturday' => 'Samstag',
|
||||||
|
'day_sunday' => 'Sonntag',
|
||||||
|
];
|
||||||
233
lang/en.php
Normal file
233
lang/en.php
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
// General
|
||||||
|
'dashboard' => 'Dashboard',
|
||||||
|
'assets' => 'Assets',
|
||||||
|
'inventory' => 'Inventory',
|
||||||
|
'reports' => 'Reports',
|
||||||
|
'users' => 'Users',
|
||||||
|
'settings' => 'Settings',
|
||||||
|
'logout' => 'Logout',
|
||||||
|
'login' => 'Login',
|
||||||
|
'save' => 'Save',
|
||||||
|
'cancel' => 'Cancel',
|
||||||
|
'delete' => 'Delete',
|
||||||
|
'edit' => 'Edit',
|
||||||
|
'create' => 'Create',
|
||||||
|
'search' => 'Search',
|
||||||
|
'filter' => 'Filter',
|
||||||
|
'export' => 'Export',
|
||||||
|
'import' => 'Import',
|
||||||
|
'print' => 'Print',
|
||||||
|
'download' => 'Download',
|
||||||
|
'upload' => 'Upload',
|
||||||
|
'back' => 'Back',
|
||||||
|
'next' => 'Next',
|
||||||
|
'previous' => 'Previous',
|
||||||
|
'close' => 'Close',
|
||||||
|
'open' => 'Open',
|
||||||
|
'yes' => 'Yes',
|
||||||
|
'no' => 'No',
|
||||||
|
'ok' => 'OK',
|
||||||
|
'error' => 'Error',
|
||||||
|
'success' => 'Success',
|
||||||
|
'warning' => 'Warning',
|
||||||
|
'info' => 'Information',
|
||||||
|
'loading' => 'Loading...',
|
||||||
|
'no_data' => 'No data available',
|
||||||
|
'actions' => 'Actions',
|
||||||
|
'status' => 'Status',
|
||||||
|
'date' => 'Date',
|
||||||
|
'time' => 'Time',
|
||||||
|
'user' => 'User',
|
||||||
|
'description' => 'Description',
|
||||||
|
'notes' => 'Notes',
|
||||||
|
'comments' => 'Comments',
|
||||||
|
'created_at' => 'Created at',
|
||||||
|
'updated_at' => 'Updated at',
|
||||||
|
'created_by' => 'Created by',
|
||||||
|
'updated_by' => 'Updated by',
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
'navigation' => 'Navigation',
|
||||||
|
'dashboard_nav' => 'Dashboard',
|
||||||
|
'assets_nav' => 'Assets',
|
||||||
|
'inventory_nav' => 'Inventory',
|
||||||
|
'reports_nav' => 'Reports',
|
||||||
|
'master_data' => 'Master Data',
|
||||||
|
'categories_nav' => 'Categories',
|
||||||
|
'locations_nav' => 'Locations',
|
||||||
|
'departments_nav' => 'Departments',
|
||||||
|
'users_nav' => 'Users',
|
||||||
|
'settings_nav' => 'Settings',
|
||||||
|
|
||||||
|
// Assets
|
||||||
|
'asset' => 'Asset',
|
||||||
|
'asset_id' => 'Asset ID',
|
||||||
|
'inventory_number' => 'Inventory Number',
|
||||||
|
'designation' => 'Designation',
|
||||||
|
'category' => 'Category',
|
||||||
|
'location' => 'Location',
|
||||||
|
'department' => 'Department',
|
||||||
|
'room' => 'Room',
|
||||||
|
'supplier' => 'Supplier',
|
||||||
|
'serial_number' => 'Serial Number',
|
||||||
|
'purchase_date' => 'Purchase Date',
|
||||||
|
'purchase_price' => 'Purchase Price',
|
||||||
|
'warranty_until' => 'Warranty Until',
|
||||||
|
'condition' => 'Condition',
|
||||||
|
'cost_center' => 'Cost Center',
|
||||||
|
'assign' => 'Assign',
|
||||||
|
'checkout' => 'Check Out',
|
||||||
|
'checkin' => 'Check In',
|
||||||
|
'asset_details' => 'Asset Details',
|
||||||
|
'asset_history' => 'Asset History',
|
||||||
|
'asset_files' => 'Asset Files',
|
||||||
|
'asset_assignments' => 'Asset Assignments',
|
||||||
|
|
||||||
|
// Asset Conditions
|
||||||
|
'condition_new' => 'New',
|
||||||
|
'condition_good' => 'Good',
|
||||||
|
'condition_satisfactory' => 'Satisfactory',
|
||||||
|
'condition_poor' => 'Poor',
|
||||||
|
'condition_defective' => 'Defective',
|
||||||
|
|
||||||
|
// Asset Status
|
||||||
|
'status_active' => 'Active',
|
||||||
|
'status_inactive' => 'Inactive',
|
||||||
|
'status_retired' => 'Retired',
|
||||||
|
|
||||||
|
// Inventory
|
||||||
|
'inventory' => 'Inventory',
|
||||||
|
'inventory_run' => 'Inventory Run',
|
||||||
|
'inventory_item' => 'Inventory Item',
|
||||||
|
'inventory_status' => 'Inventory Status',
|
||||||
|
'inventory_open' => 'Open',
|
||||||
|
'inventory_closed' => 'Closed',
|
||||||
|
'inventory_found' => 'Found',
|
||||||
|
'inventory_not_found' => 'Not Found',
|
||||||
|
'inventory_defective' => 'Defective',
|
||||||
|
'inventory_moved' => 'Moved',
|
||||||
|
'inventory_scan' => 'Scan',
|
||||||
|
'inventory_report' => 'Inventory Report',
|
||||||
|
|
||||||
|
// Users
|
||||||
|
'user' => 'User',
|
||||||
|
'username' => 'Username',
|
||||||
|
'email' => 'Email',
|
||||||
|
'password' => 'Password',
|
||||||
|
'password_confirm' => 'Confirm Password',
|
||||||
|
'role' => 'Role',
|
||||||
|
'active' => 'Active',
|
||||||
|
'inactive' => 'Inactive',
|
||||||
|
'last_login' => 'Last Login',
|
||||||
|
'login_attempts' => 'Login Attempts',
|
||||||
|
'password_reset' => 'Password Reset',
|
||||||
|
'forgot_password' => 'Forgot Password',
|
||||||
|
|
||||||
|
// User Roles
|
||||||
|
'role_admin' => 'Administrator',
|
||||||
|
'role_auditor' => 'Auditor',
|
||||||
|
'role_employee' => 'Employee',
|
||||||
|
|
||||||
|
// Authentication
|
||||||
|
'login_title' => 'Login',
|
||||||
|
'login_email' => 'Email Address',
|
||||||
|
'login_password' => 'Password',
|
||||||
|
'login_remember' => 'Remember Me',
|
||||||
|
'login_submit' => 'Login',
|
||||||
|
'login_failed' => 'Login Failed',
|
||||||
|
'login_locked' => 'Account Locked',
|
||||||
|
'logout_success' => 'Successfully Logged Out',
|
||||||
|
'password_reset_title' => 'Password Reset',
|
||||||
|
'password_reset_email' => 'Email Address',
|
||||||
|
'password_reset_submit' => 'Send Link',
|
||||||
|
'password_reset_sent' => 'Email with Reset Link Sent',
|
||||||
|
'password_reset_new' => 'New Password',
|
||||||
|
'password_reset_confirm' => 'Confirm New Password',
|
||||||
|
'password_reset_success' => 'Password Successfully Changed',
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
'message_saved' => 'Data Successfully Saved',
|
||||||
|
'message_deleted' => 'Record Successfully Deleted',
|
||||||
|
'message_created' => 'Record Successfully Created',
|
||||||
|
'message_updated' => 'Record Successfully Updated',
|
||||||
|
'message_error' => 'An Error Occurred',
|
||||||
|
'message_not_found' => 'Record Not Found',
|
||||||
|
'message_unauthorized' => 'Unauthorized',
|
||||||
|
'message_forbidden' => 'Access Denied',
|
||||||
|
'message_validation_error' => 'Validation Error',
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
'validation_required' => 'This field is required',
|
||||||
|
'validation_email' => 'Invalid email address',
|
||||||
|
'validation_min' => 'Minimum :min characters required',
|
||||||
|
'validation_max' => 'Maximum :max characters allowed',
|
||||||
|
'validation_numeric' => 'Numbers only allowed',
|
||||||
|
'validation_date' => 'Invalid date',
|
||||||
|
'validation_unique' => 'Value already exists',
|
||||||
|
|
||||||
|
// Reports
|
||||||
|
'report' => 'Report',
|
||||||
|
'report_assets' => 'Assets Report',
|
||||||
|
'report_warranty' => 'Warranty Report',
|
||||||
|
'report_retired' => 'Retirement Report',
|
||||||
|
'report_location' => 'Location Report',
|
||||||
|
'report_category' => 'Category Report',
|
||||||
|
'report_inventory' => 'Inventory Report',
|
||||||
|
'report_export' => 'Export Report',
|
||||||
|
'report_print' => 'Print Report',
|
||||||
|
|
||||||
|
// Export/Import
|
||||||
|
'export_csv' => 'Export CSV',
|
||||||
|
'export_xlsx' => 'Export Excel',
|
||||||
|
'export_pdf' => 'Export PDF',
|
||||||
|
'import_csv' => 'Import CSV',
|
||||||
|
'import_file' => 'Select File',
|
||||||
|
'import_preview' => 'Preview',
|
||||||
|
'import_confirm' => 'Confirm Import',
|
||||||
|
'import_success' => 'Import Successful',
|
||||||
|
'import_error' => 'Import Failed',
|
||||||
|
|
||||||
|
// Labels
|
||||||
|
'label_print' => 'Print Labels',
|
||||||
|
'label_barcode' => 'Barcode',
|
||||||
|
'label_qr' => 'QR Code',
|
||||||
|
'label_inventory_number' => 'Inventory Number',
|
||||||
|
'label_designation' => 'Designation',
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
'dashboard_welcome' => 'Welcome to Asset Management System',
|
||||||
|
'dashboard_stats' => 'Statistics',
|
||||||
|
'dashboard_total_assets' => 'Total Assets',
|
||||||
|
'dashboard_active_assets' => 'Active Assets',
|
||||||
|
'dashboard_retired_assets' => 'Retired Assets',
|
||||||
|
'dashboard_total_value' => 'Total Value',
|
||||||
|
'dashboard_recent_activity' => 'Recent Activity',
|
||||||
|
'dashboard_warranty_expiring' => 'Warranty Expiring',
|
||||||
|
'dashboard_inventory_open' => 'Open Inventories',
|
||||||
|
|
||||||
|
// Months
|
||||||
|
'month_01' => 'January',
|
||||||
|
'month_02' => 'February',
|
||||||
|
'month_03' => 'March',
|
||||||
|
'month_04' => 'April',
|
||||||
|
'month_05' => 'May',
|
||||||
|
'month_06' => 'June',
|
||||||
|
'month_07' => 'July',
|
||||||
|
'month_08' => 'August',
|
||||||
|
'month_09' => 'September',
|
||||||
|
'month_10' => 'October',
|
||||||
|
'month_11' => 'November',
|
||||||
|
'month_12' => 'December',
|
||||||
|
|
||||||
|
// Days
|
||||||
|
'day_monday' => 'Monday',
|
||||||
|
'day_tuesday' => 'Tuesday',
|
||||||
|
'day_wednesday' => 'Wednesday',
|
||||||
|
'day_thursday' => 'Thursday',
|
||||||
|
'day_friday' => 'Friday',
|
||||||
|
'day_saturday' => 'Saturday',
|
||||||
|
'day_sunday' => 'Sunday',
|
||||||
|
];
|
||||||
94
nginx.conf
Normal file
94
nginx.conf
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
error_log /var/log/nginx/error.log;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
types_hash_max_size 2048;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_types
|
||||||
|
text/plain
|
||||||
|
text/css
|
||||||
|
text/xml
|
||||||
|
text/javascript
|
||||||
|
application/json
|
||||||
|
application/javascript
|
||||||
|
application/xml+rss
|
||||||
|
application/atom+xml
|
||||||
|
image/svg+xml;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /var/www/html/public;
|
||||||
|
index index.php index.html;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
|
||||||
|
|
||||||
|
# Handle PHP files
|
||||||
|
location ~ \.php$ {
|
||||||
|
try_files $uri =404;
|
||||||
|
fastcgi_split_path_info ^(.+\.php)(/.+)$;
|
||||||
|
fastcgi_pass php:9000;
|
||||||
|
fastcgi_index index.php;
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
fastcgi_param PATH_INFO $fastcgi_path_info;
|
||||||
|
fastcgi_param HTTP_AUTHORIZATION $http_authorization;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle static files
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle file downloads
|
||||||
|
location /files/ {
|
||||||
|
internal;
|
||||||
|
alias /var/www/html/storage/uploads/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main location block
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.php?$query_string;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Deny access to sensitive files
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ \.(htaccess|htpasswd)$ {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ \.(env|log)$ {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
php.ini
Normal file
53
php.ini
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
[PHP]
|
||||||
|
; Basic settings
|
||||||
|
memory_limit = 256M
|
||||||
|
max_execution_time = 300
|
||||||
|
max_input_time = 300
|
||||||
|
post_max_size = 50M
|
||||||
|
upload_max_filesize = 50M
|
||||||
|
max_file_uploads = 20
|
||||||
|
|
||||||
|
; Error handling
|
||||||
|
display_errors = Off
|
||||||
|
display_startup_errors = Off
|
||||||
|
log_errors = On
|
||||||
|
error_log = /var/www/html/storage/logs/php_errors.log
|
||||||
|
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT
|
||||||
|
|
||||||
|
; Session settings
|
||||||
|
session.cookie_httponly = 1
|
||||||
|
session.cookie_secure = 0
|
||||||
|
session.cookie_samesite = "Strict"
|
||||||
|
session.use_strict_mode = 1
|
||||||
|
session.gc_maxlifetime = 3600
|
||||||
|
session.gc_probability = 1
|
||||||
|
session.gc_divisor = 100
|
||||||
|
|
||||||
|
; Security
|
||||||
|
expose_php = Off
|
||||||
|
allow_url_fopen = Off
|
||||||
|
allow_url_include = Off
|
||||||
|
|
||||||
|
; OPcache settings
|
||||||
|
opcache.enable = 1
|
||||||
|
opcache.enable_cli = 1
|
||||||
|
opcache.memory_consumption = 128
|
||||||
|
opcache.interned_strings_buffer = 8
|
||||||
|
opcache.max_accelerated_files = 4000
|
||||||
|
opcache.revalidate_freq = 2
|
||||||
|
opcache.fast_shutdown = 1
|
||||||
|
opcache.enable_file_override = 1
|
||||||
|
opcache.validate_timestamps = 0
|
||||||
|
|
||||||
|
; Date settings
|
||||||
|
date.timezone = Europe/Berlin
|
||||||
|
|
||||||
|
; File uploads
|
||||||
|
file_uploads = On
|
||||||
|
upload_tmp_dir = /tmp
|
||||||
|
|
||||||
|
; Database
|
||||||
|
pdo_mysql.default_socket = /var/run/mysqld/mysqld.sock
|
||||||
|
|
||||||
|
; GD settings
|
||||||
|
gd.jpeg_ignore_warning = 1
|
||||||
534
public/assets/css/style.css
Normal file
534
public/assets/css/style.css
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
/* Reset and Base Styles */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
background: #2c3e50;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation */
|
||||||
|
.nav-list {
|
||||||
|
display: flex;
|
||||||
|
list-style: none;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
background-color: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
background: white;
|
||||||
|
color: #333;
|
||||||
|
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 200px;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown:hover .nav-dropdown-menu {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-menu li {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-menu a {
|
||||||
|
color: #333;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
display: block;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-menu a:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User Menu */
|
||||||
|
.user-menu {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-link {
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
background: white;
|
||||||
|
color: #333;
|
||||||
|
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 150px;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown:hover .user-dropdown-menu {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown-menu li {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown-menu a {
|
||||||
|
color: #333;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
display: block;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown-menu a:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Content */
|
||||||
|
.main {
|
||||||
|
min-height: calc(100vh - 140px);
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flash Messages */
|
||||||
|
.flash-messages {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background-color: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-warning {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-info {
|
||||||
|
background-color: #d1ecf1;
|
||||||
|
color: #0c5460;
|
||||||
|
border: 1px solid #bee5eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login Styles */
|
||||||
|
.login-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header h2 {
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header p {
|
||||||
|
color: #7f8c8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3498db;
|
||||||
|
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #95a5a6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #7f8c8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background-color: #27ae60;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover {
|
||||||
|
background-color: #229954;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-block {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.table-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th,
|
||||||
|
.table td {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c3e50;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard Stats */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: white;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #3498db;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
color: #7f8c8d;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination a,
|
||||||
|
.pagination span {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #333;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination a:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .current {
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
border-color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
background: #2c3e50;
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem 0;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header-content {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-menu,
|
||||||
|
.user-dropdown-menu {
|
||||||
|
position: static;
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
background: transparent;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-menu a,
|
||||||
|
.user-dropdown-menu a {
|
||||||
|
color: white;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
min-width: 600px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility Classes */
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.text-right { text-align: right; }
|
||||||
|
.text-left { text-align: left; }
|
||||||
|
|
||||||
|
.mb-1 { margin-bottom: 0.5rem; }
|
||||||
|
.mb-2 { margin-bottom: 1rem; }
|
||||||
|
.mb-3 { margin-bottom: 1.5rem; }
|
||||||
|
.mb-4 { margin-bottom: 2rem; }
|
||||||
|
|
||||||
|
.mt-1 { margin-top: 0.5rem; }
|
||||||
|
.mt-2 { margin-top: 1rem; }
|
||||||
|
.mt-3 { margin-top: 1.5rem; }
|
||||||
|
.mt-4 { margin-top: 2rem; }
|
||||||
|
|
||||||
|
.d-none { display: none; }
|
||||||
|
.d-block { display: block; }
|
||||||
|
.d-flex { display: flex; }
|
||||||
|
.d-grid { display: grid; }
|
||||||
|
|
||||||
|
.justify-between { justify-content: space-between; }
|
||||||
|
.justify-center { justify-content: center; }
|
||||||
|
.align-center { align-items: center; }
|
||||||
|
|
||||||
|
.w-100 { width: 100%; }
|
||||||
|
.h-100 { height: 100%; }
|
||||||
|
|
||||||
|
/* Print Styles */
|
||||||
|
@media print {
|
||||||
|
.header,
|
||||||
|
.footer,
|
||||||
|
.nav,
|
||||||
|
.user-menu,
|
||||||
|
.btn,
|
||||||
|
.pagination {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
box-shadow: none;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
}
|
||||||
466
public/assets/js/app.js
Normal file
466
public/assets/js/app.js
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
/**
|
||||||
|
* Asset Management System - JavaScript
|
||||||
|
*/
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Initialize all components
|
||||||
|
initAlerts();
|
||||||
|
initDropdowns();
|
||||||
|
initForms();
|
||||||
|
initTables();
|
||||||
|
initModals();
|
||||||
|
initFileUploads();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alert handling
|
||||||
|
*/
|
||||||
|
function initAlerts() {
|
||||||
|
// Auto-hide alerts after 5 seconds
|
||||||
|
const alerts = document.querySelectorAll('.alert');
|
||||||
|
alerts.forEach(alert => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (alert.parentElement) {
|
||||||
|
alert.style.opacity = '0';
|
||||||
|
setTimeout(() => {
|
||||||
|
alert.remove();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close button functionality
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (e.target.classList.contains('alert-close')) {
|
||||||
|
const alert = e.target.closest('.alert');
|
||||||
|
if (alert) {
|
||||||
|
alert.style.opacity = '0';
|
||||||
|
setTimeout(() => {
|
||||||
|
alert.remove();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dropdown handling
|
||||||
|
*/
|
||||||
|
function initDropdowns() {
|
||||||
|
// Close dropdowns when clicking outside
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!e.target.closest('.nav-dropdown, .user-dropdown')) {
|
||||||
|
const dropdowns = document.querySelectorAll('.nav-dropdown-menu, .user-dropdown-menu');
|
||||||
|
dropdowns.forEach(dropdown => {
|
||||||
|
dropdown.style.opacity = '0';
|
||||||
|
dropdown.style.visibility = 'hidden';
|
||||||
|
dropdown.style.transform = 'translateY(-10px)';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form handling
|
||||||
|
*/
|
||||||
|
function initForms() {
|
||||||
|
// Form validation
|
||||||
|
const forms = document.querySelectorAll('form');
|
||||||
|
forms.forEach(form => {
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
const requiredFields = form.querySelectorAll('[required]');
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
requiredFields.forEach(field => {
|
||||||
|
if (!field.value.trim()) {
|
||||||
|
isValid = false;
|
||||||
|
field.classList.add('error');
|
||||||
|
} else {
|
||||||
|
field.classList.remove('error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
e.preventDefault();
|
||||||
|
showAlert('Bitte füllen Sie alle erforderlichen Felder aus.', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Real-time validation
|
||||||
|
document.addEventListener('input', function(e) {
|
||||||
|
if (e.target.hasAttribute('required')) {
|
||||||
|
if (e.target.value.trim()) {
|
||||||
|
e.target.classList.remove('error');
|
||||||
|
} else {
|
||||||
|
e.target.classList.add('error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table handling
|
||||||
|
*/
|
||||||
|
function initTables() {
|
||||||
|
// Sortable tables
|
||||||
|
const sortableHeaders = document.querySelectorAll('.table th[data-sort]');
|
||||||
|
sortableHeaders.forEach(header => {
|
||||||
|
header.addEventListener('click', function() {
|
||||||
|
const table = this.closest('.table');
|
||||||
|
const column = this.dataset.sort;
|
||||||
|
const currentOrder = this.dataset.order || 'asc';
|
||||||
|
const newOrder = currentOrder === 'asc' ? 'desc' : 'asc';
|
||||||
|
|
||||||
|
// Update URL with sort parameters
|
||||||
|
const url = new URL(window.location);
|
||||||
|
url.searchParams.set('sort', column);
|
||||||
|
url.searchParams.set('order', newOrder);
|
||||||
|
window.location.href = url.toString();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bulk actions
|
||||||
|
const bulkCheckbox = document.getElementById('bulk-select-all');
|
||||||
|
if (bulkCheckbox) {
|
||||||
|
bulkCheckbox.addEventListener('change', function() {
|
||||||
|
const checkboxes = document.querySelectorAll('.bulk-select');
|
||||||
|
checkboxes.forEach(checkbox => {
|
||||||
|
checkbox.checked = this.checked;
|
||||||
|
});
|
||||||
|
updateBulkActions();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('change', function(e) {
|
||||||
|
if (e.target.classList.contains('bulk-select')) {
|
||||||
|
updateBulkActions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update bulk actions visibility
|
||||||
|
*/
|
||||||
|
function updateBulkActions() {
|
||||||
|
const selectedItems = document.querySelectorAll('.bulk-select:checked');
|
||||||
|
const bulkActions = document.querySelector('.bulk-actions');
|
||||||
|
|
||||||
|
if (bulkActions) {
|
||||||
|
if (selectedItems.length > 0) {
|
||||||
|
bulkActions.style.display = 'block';
|
||||||
|
bulkActions.querySelector('.selected-count').textContent = selectedItems.length;
|
||||||
|
} else {
|
||||||
|
bulkActions.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal handling
|
||||||
|
*/
|
||||||
|
function initModals() {
|
||||||
|
// Open modal
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (e.target.hasAttribute('data-modal')) {
|
||||||
|
const modalId = e.target.dataset.modal;
|
||||||
|
const modal = document.getElementById(modalId);
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = 'block';
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (e.target.classList.contains('modal-close') || e.target.classList.contains('modal')) {
|
||||||
|
const modal = e.target.closest('.modal');
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
document.body.style.overflow = 'auto';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal with Escape key
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
const openModal = document.querySelector('.modal[style*="display: block"]');
|
||||||
|
if (openModal) {
|
||||||
|
openModal.style.display = 'none';
|
||||||
|
document.body.style.overflow = 'auto';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File upload handling
|
||||||
|
*/
|
||||||
|
function initFileUploads() {
|
||||||
|
const fileInputs = document.querySelectorAll('input[type="file"]');
|
||||||
|
fileInputs.forEach(input => {
|
||||||
|
input.addEventListener('change', function() {
|
||||||
|
const files = Array.from(this.files);
|
||||||
|
const maxSize = this.dataset.maxSize || 52428800; // 50MB default
|
||||||
|
const allowedTypes = this.dataset.allowedTypes ? this.dataset.allowedTypes.split(',') : [];
|
||||||
|
|
||||||
|
files.forEach(file => {
|
||||||
|
// Check file size
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
showAlert(`Datei "${file.name}" ist zu groß. Maximale Größe: ${formatBytes(maxSize)}`, 'error');
|
||||||
|
this.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file type
|
||||||
|
if (allowedTypes.length > 0 && !allowedTypes.includes(file.type)) {
|
||||||
|
showAlert(`Dateityp "${file.type}" ist nicht erlaubt.`, 'error');
|
||||||
|
this.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search functionality
|
||||||
|
*/
|
||||||
|
function initSearch() {
|
||||||
|
const searchInput = document.getElementById('search-input');
|
||||||
|
if (searchInput) {
|
||||||
|
let searchTimeout;
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', function() {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
const query = this.value.trim();
|
||||||
|
if (query.length >= 2 || query.length === 0) {
|
||||||
|
performSearch(query);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform search
|
||||||
|
*/
|
||||||
|
function performSearch(query) {
|
||||||
|
const url = new URL(window.location);
|
||||||
|
if (query) {
|
||||||
|
url.searchParams.set('search', query);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('search');
|
||||||
|
}
|
||||||
|
url.searchParams.delete('page'); // Reset to first page
|
||||||
|
window.location.href = url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export functionality
|
||||||
|
*/
|
||||||
|
function exportData(format) {
|
||||||
|
const url = new URL(window.location);
|
||||||
|
url.searchParams.set('export', format);
|
||||||
|
window.location.href = url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print functionality
|
||||||
|
*/
|
||||||
|
function printPage() {
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QR Code scanner
|
||||||
|
*/
|
||||||
|
function initQRScanner() {
|
||||||
|
const scanButton = document.getElementById('scan-qr');
|
||||||
|
if (scanButton) {
|
||||||
|
scanButton.addEventListener('click', function() {
|
||||||
|
// This would integrate with a QR code scanner library
|
||||||
|
// For now, we'll show a prompt
|
||||||
|
const code = prompt('Bitte QR-Code scannen oder Inventarnummer eingeben:');
|
||||||
|
if (code) {
|
||||||
|
document.getElementById('inventory-number').value = code;
|
||||||
|
document.getElementById('scan-form').submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asset assignment
|
||||||
|
*/
|
||||||
|
function assignAsset(assetId) {
|
||||||
|
const userId = prompt('Bitte Benutzer-ID eingeben:');
|
||||||
|
if (userId) {
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = `/assets/${assetId}/assign`;
|
||||||
|
|
||||||
|
const csrfInput = document.createElement('input');
|
||||||
|
csrfInput.type = 'hidden';
|
||||||
|
csrfInput.name = 'csrf_token';
|
||||||
|
csrfInput.value = document.querySelector('input[name="csrf_token"]').value;
|
||||||
|
|
||||||
|
const userInput = document.createElement('input');
|
||||||
|
userInput.type = 'hidden';
|
||||||
|
userInput.name = 'user_id';
|
||||||
|
userInput.value = userId;
|
||||||
|
|
||||||
|
form.appendChild(csrfInput);
|
||||||
|
form.appendChild(userInput);
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete confirmation
|
||||||
|
*/
|
||||||
|
function confirmDelete(message = 'Sind Sie sicher, dass Sie diesen Eintrag löschen möchten?') {
|
||||||
|
return confirm(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show alert message
|
||||||
|
*/
|
||||||
|
function showAlert(message, type = 'info') {
|
||||||
|
const alertContainer = document.querySelector('.flash-messages') || document.body;
|
||||||
|
const alert = document.createElement('div');
|
||||||
|
alert.className = `alert alert-${type}`;
|
||||||
|
alert.innerHTML = `
|
||||||
|
${message}
|
||||||
|
<button type="button" class="alert-close">×</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
alertContainer.appendChild(alert);
|
||||||
|
|
||||||
|
// Auto-hide after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
alert.style.opacity = '0';
|
||||||
|
setTimeout(() => {
|
||||||
|
alert.remove();
|
||||||
|
}, 300);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format bytes to human readable
|
||||||
|
*/
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let size = bytes;
|
||||||
|
let unitIndex = 0;
|
||||||
|
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
unitIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounce function
|
||||||
|
*/
|
||||||
|
function debounce(func, wait) {
|
||||||
|
let timeout;
|
||||||
|
return function executedFunction(...args) {
|
||||||
|
const later = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
func(...args);
|
||||||
|
};
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(later, wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throttle function
|
||||||
|
*/
|
||||||
|
function throttle(func, limit) {
|
||||||
|
let inThrottle;
|
||||||
|
return function() {
|
||||||
|
const args = arguments;
|
||||||
|
const context = this;
|
||||||
|
if (!inThrottle) {
|
||||||
|
func.apply(context, args);
|
||||||
|
inThrottle = true;
|
||||||
|
setTimeout(() => inThrottle = false, limit);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX helper
|
||||||
|
*/
|
||||||
|
function ajax(url, options = {}) {
|
||||||
|
const defaultOptions = {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = { ...defaultOptions, ...options };
|
||||||
|
|
||||||
|
return fetch(url, config)
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('AJAX error:', error);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local storage helper
|
||||||
|
*/
|
||||||
|
const Storage = {
|
||||||
|
set: function(key, value) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error saving to localStorage', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
get: function(key, defaultValue = null) {
|
||||||
|
try {
|
||||||
|
const item = localStorage.getItem(key);
|
||||||
|
return item ? JSON.parse(item) : defaultValue;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error reading from localStorage', e);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
remove: function(key) {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error removing from localStorage', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize search if on a page with search
|
||||||
|
if (document.getElementById('search-input')) {
|
||||||
|
initSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize QR scanner if on inventory page
|
||||||
|
if (document.getElementById('scan-qr')) {
|
||||||
|
initQRScanner();
|
||||||
|
}
|
||||||
26
public/index.php
Normal file
26
public/index.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Front Controller - Asset Management System
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Start session
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
// Load Composer autoloader
|
||||||
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/..');
|
||||||
|
$dotenv->load();
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
require_once __DIR__ . '/../config/config.php';
|
||||||
|
|
||||||
|
// Load routes
|
||||||
|
require_once __DIR__ . '/../config/routes.php';
|
||||||
|
|
||||||
|
// Initialize application
|
||||||
|
$app = new App\Core\Application();
|
||||||
|
|
||||||
|
// Handle the request
|
||||||
|
$app->handle();
|
||||||
26
scripts/entrypoint.sh
Normal file
26
scripts/entrypoint.sh
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Wait for database to be ready
|
||||||
|
echo "Waiting for database to be ready..."
|
||||||
|
while ! mysqladmin ping -h"$DB_HOST" -P"$DB_PORT" -u"$DB_USER" -p"$DB_PASS" --silent; do
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Database is ready!"
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
echo "Running database migrations..."
|
||||||
|
php /var/www/html/scripts/migrate.php
|
||||||
|
|
||||||
|
# Run seeders if database is empty
|
||||||
|
echo "Checking if seeders need to be run..."
|
||||||
|
php /var/www/html/scripts/seed.php
|
||||||
|
|
||||||
|
# Set proper permissions
|
||||||
|
chown -R www-data:www-data /var/www/html/storage
|
||||||
|
chmod -R 755 /var/www/html/storage
|
||||||
|
|
||||||
|
echo "Application is ready!"
|
||||||
|
|
||||||
|
# Start PHP-FPM
|
||||||
|
exec php-fpm
|
||||||
87
scripts/migrate.php
Normal file
87
scripts/migrate.php
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Database Migration Script
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/..');
|
||||||
|
$dotenv->load();
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
require_once __DIR__ . '/../config/config.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = new PDO(
|
||||||
|
sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', DB_HOST, DB_PORT, DB_NAME),
|
||||||
|
DB_USER,
|
||||||
|
DB_PASS,
|
||||||
|
[
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
echo "Connected to database successfully.\n";
|
||||||
|
|
||||||
|
// Create migrations table if it doesn't exist
|
||||||
|
$pdo->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS migrations (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
migration VARCHAR(255) NOT NULL,
|
||||||
|
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
");
|
||||||
|
|
||||||
|
// Get all migration files
|
||||||
|
$migrationFiles = glob(__DIR__ . '/../database/migrations/*.sql');
|
||||||
|
sort($migrationFiles);
|
||||||
|
|
||||||
|
// Get executed migrations
|
||||||
|
$stmt = $pdo->query("SELECT migration FROM migrations");
|
||||||
|
$executedMigrations = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
|
||||||
|
$executedCount = 0;
|
||||||
|
|
||||||
|
foreach ($migrationFiles as $file) {
|
||||||
|
$migrationName = basename($file);
|
||||||
|
|
||||||
|
if (in_array($migrationName, $executedMigrations)) {
|
||||||
|
echo "Migration {$migrationName} already executed.\n";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Executing migration: {$migrationName}\n";
|
||||||
|
|
||||||
|
// Read and execute migration
|
||||||
|
$sql = file_get_contents($file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo->exec($sql);
|
||||||
|
|
||||||
|
// Record migration as executed
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?)");
|
||||||
|
$stmt->execute([$migrationName]);
|
||||||
|
|
||||||
|
echo "Migration {$migrationName} executed successfully.\n";
|
||||||
|
$executedCount++;
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
echo "Error executing migration {$migrationName}: " . $e->getMessage() . "\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($executedCount > 0) {
|
||||||
|
echo "Executed {$executedCount} new migrations.\n";
|
||||||
|
} else {
|
||||||
|
echo "No new migrations to execute.\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Database migration completed successfully.\n";
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
echo "Database connection failed: " . $e->getMessage() . "\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
83
scripts/seed.php
Normal file
83
scripts/seed.php
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Database Seeder Script
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/..');
|
||||||
|
$dotenv->load();
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
require_once __DIR__ . '/../config/config.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = new PDO(
|
||||||
|
sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', DB_HOST, DB_PORT, DB_NAME),
|
||||||
|
DB_USER,
|
||||||
|
DB_PASS,
|
||||||
|
[
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
echo "Connected to database successfully.\n";
|
||||||
|
|
||||||
|
// Check if users table has data
|
||||||
|
$stmt = $pdo->query("SELECT COUNT(*) as count FROM users");
|
||||||
|
$userCount = $stmt->fetch()['count'];
|
||||||
|
|
||||||
|
if ($userCount > 0) {
|
||||||
|
echo "Database already has data. Skipping seeders.\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Database is empty. Running seeders...\n";
|
||||||
|
|
||||||
|
// Get all seeder files
|
||||||
|
$seederFiles = glob(__DIR__ . '/../database/seeds/*.sql');
|
||||||
|
sort($seederFiles);
|
||||||
|
|
||||||
|
foreach ($seederFiles as $file) {
|
||||||
|
$seederName = basename($file);
|
||||||
|
echo "Running seeder: {$seederName}\n";
|
||||||
|
|
||||||
|
// Read and execute seeder
|
||||||
|
$sql = file_get_contents($file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo->exec($sql);
|
||||||
|
echo "Seeder {$seederName} executed successfully.\n";
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
echo "Error executing seeder {$seederName}: " . $e->getMessage() . "\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create default admin user with proper password hash
|
||||||
|
echo "Creating default admin user...\n";
|
||||||
|
|
||||||
|
$adminPassword = 'Admin!234';
|
||||||
|
$adminHash = password_hash($adminPassword, PASSWORD_ARGON2ID);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
UPDATE users
|
||||||
|
SET passhash = ?
|
||||||
|
WHERE email = 'admin@example.com'
|
||||||
|
");
|
||||||
|
$stmt->execute([$adminHash]);
|
||||||
|
|
||||||
|
echo "Default admin user created:\n";
|
||||||
|
echo "Email: admin@example.com\n";
|
||||||
|
echo "Password: {$adminPassword}\n";
|
||||||
|
echo "Please change the password on first login!\n";
|
||||||
|
|
||||||
|
echo "Database seeding completed successfully.\n";
|
||||||
|
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
echo "Database connection failed: " . $e->getMessage() . "\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user