diff --git a/Dockerfile.nginx b/Dockerfile.nginx new file mode 100644 index 0000000..1cd3e9f --- /dev/null +++ b/Dockerfile.nginx @@ -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;"] diff --git a/Dockerfile.php b/Dockerfile.php new file mode 100644 index 0000000..13ddf52 --- /dev/null +++ b/Dockerfile.php @@ -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"] diff --git a/README.md b/README.md index ef63ea9..f2a2928 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,335 @@ -# ai-zensur +# Asset Management System -Ein Tool um Daten aus Dokumenten für weitere KI Verarbeitung vorzubereiten. \ No newline at end of file +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 + 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) \ No newline at end of file diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php new file mode 100644 index 0000000..a55b441 --- /dev/null +++ b/app/Controllers/AuthController.php @@ -0,0 +1,209 @@ +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); + } +} diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php new file mode 100644 index 0000000..bf43bd9 --- /dev/null +++ b/app/Controllers/BaseController.php @@ -0,0 +1,236 @@ +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, '-'); + } +} diff --git a/app/Core/Application.php b/app/Core/Application.php new file mode 100644 index 0000000..c3a3e60 --- /dev/null +++ b/app/Core/Application.php @@ -0,0 +1,120 @@ +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'; + } +} diff --git a/app/Core/Database.php b/app/Core/Database.php new file mode 100644 index 0000000..639e8df --- /dev/null +++ b/app/Core/Database.php @@ -0,0 +1,132 @@ +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); + } +} diff --git a/app/Core/Request.php b/app/Core/Request.php new file mode 100644 index 0000000..a2bb9dd --- /dev/null +++ b/app/Core/Request.php @@ -0,0 +1,182 @@ +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; + } +} diff --git a/app/Core/Response.php b/app/Core/Response.php new file mode 100644 index 0000000..c4a9209 --- /dev/null +++ b/app/Core/Response.php @@ -0,0 +1,141 @@ +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; + } +} diff --git a/app/Core/Router.php b/app/Core/Router.php new file mode 100644 index 0000000..ccd5913 --- /dev/null +++ b/app/Core/Router.php @@ -0,0 +1,108 @@ +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; + } +} diff --git a/app/Core/Session.php b/app/Core/Session.php new file mode 100644 index 0000000..fe2cfb1 --- /dev/null +++ b/app/Core/Session.php @@ -0,0 +1,198 @@ +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; + } +} diff --git a/app/Helpers/functions.php b/app/Helpers/functions.php new file mode 100644 index 0000000..010d3b4 --- /dev/null +++ b/app/Helpers/functions.php @@ -0,0 +1,378 @@ +getCsrfToken(); +} + +/** + * Generate CSRF field + */ +function csrf_field(): string +{ + return ''; +} + +/** + * 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, '/')); +} diff --git a/app/Middleware/AdminMiddleware.php b/app/Middleware/AdminMiddleware.php new file mode 100644 index 0000000..ff2d70b --- /dev/null +++ b/app/Middleware/AdminMiddleware.php @@ -0,0 +1,27 @@ +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(); + } + } +} diff --git a/app/Middleware/AuthMiddleware.php b/app/Middleware/AuthMiddleware.php new file mode 100644 index 0000000..21271d7 --- /dev/null +++ b/app/Middleware/AuthMiddleware.php @@ -0,0 +1,31 @@ +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(); + } +} diff --git a/app/Middleware/CsrfMiddleware.php b/app/Middleware/CsrfMiddleware.php new file mode 100644 index 0000000..bfae81e --- /dev/null +++ b/app/Middleware/CsrfMiddleware.php @@ -0,0 +1,34 @@ +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(); + } + } +} diff --git a/app/Models/PasswordReset.php b/app/Models/PasswordReset.php new file mode 100644 index 0000000..3fcea7e --- /dev/null +++ b/app/Models/PasswordReset.php @@ -0,0 +1,45 @@ +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]); + } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..737e1e6 --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,204 @@ +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; + } +} diff --git a/app/Services/AuditService.php b/app/Services/AuditService.php new file mode 100644 index 0000000..694f14b --- /dev/null +++ b/app/Services/AuditService.php @@ -0,0 +1,200 @@ +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]); + } +} diff --git a/app/Views/auth/login.php b/app/Views/auth/login.php new file mode 100644 index 0000000..9bb6079 --- /dev/null +++ b/app/Views/auth/login.php @@ -0,0 +1,42 @@ +
+ +
diff --git a/app/Views/layouts/main.php b/app/Views/layouts/main.php new file mode 100644 index 0000000..e525932 --- /dev/null +++ b/app/Views/layouts/main.php @@ -0,0 +1,97 @@ + + + + + + <?= $lang['dashboard'] ?> - Asset Management System + + + + + + + + + + + + + + +
+
+
+ + + + + +
+ +
+ +
    +
  • +
  • +
+
+
+ +
+
+
+ + +
+
+ + +
+ $message): ?> +
+ + +
+ +
+ + + +
+ +
+
+
+ + + + + + + + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..33907ac --- /dev/null +++ b/composer.json @@ -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 +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..39804c3 --- /dev/null +++ b/composer.lock @@ -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" +} diff --git a/config/config.php b/config/config.php new file mode 100644 index 0000000..7e9aa14 --- /dev/null +++ b/config/config.php @@ -0,0 +1,58 @@ +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'; +}); diff --git a/database/migrations/001_create_users_table.sql b/database/migrations/001_create_users_table.sql new file mode 100644 index 0000000..046f203 --- /dev/null +++ b/database/migrations/001_create_users_table.sql @@ -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; diff --git a/database/migrations/002_create_password_resets_table.sql b/database/migrations/002_create_password_resets_table.sql new file mode 100644 index 0000000..e1128ff --- /dev/null +++ b/database/migrations/002_create_password_resets_table.sql @@ -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; diff --git a/database/migrations/003_create_categories_table.sql b/database/migrations/003_create_categories_table.sql new file mode 100644 index 0000000..064a258 --- /dev/null +++ b/database/migrations/003_create_categories_table.sql @@ -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; diff --git a/database/migrations/004_create_locations_table.sql b/database/migrations/004_create_locations_table.sql new file mode 100644 index 0000000..d24180b --- /dev/null +++ b/database/migrations/004_create_locations_table.sql @@ -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; diff --git a/database/migrations/005_create_departments_table.sql b/database/migrations/005_create_departments_table.sql new file mode 100644 index 0000000..bb06732 --- /dev/null +++ b/database/migrations/005_create_departments_table.sql @@ -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; diff --git a/database/migrations/006_create_assets_table.sql b/database/migrations/006_create_assets_table.sql new file mode 100644 index 0000000..b1d1738 --- /dev/null +++ b/database/migrations/006_create_assets_table.sql @@ -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; diff --git a/database/migrations/007_create_asset_assignments_table.sql b/database/migrations/007_create_asset_assignments_table.sql new file mode 100644 index 0000000..4cb14d5 --- /dev/null +++ b/database/migrations/007_create_asset_assignments_table.sql @@ -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; diff --git a/database/migrations/008_create_asset_files_table.sql b/database/migrations/008_create_asset_files_table.sql new file mode 100644 index 0000000..3a28f8d --- /dev/null +++ b/database/migrations/008_create_asset_files_table.sql @@ -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; diff --git a/database/migrations/009_create_inventories_table.sql b/database/migrations/009_create_inventories_table.sql new file mode 100644 index 0000000..a52d642 --- /dev/null +++ b/database/migrations/009_create_inventories_table.sql @@ -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; diff --git a/database/migrations/010_create_inventory_items_table.sql b/database/migrations/010_create_inventory_items_table.sql new file mode 100644 index 0000000..96b8c06 --- /dev/null +++ b/database/migrations/010_create_inventory_items_table.sql @@ -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; diff --git a/database/migrations/011_create_audit_log_table.sql b/database/migrations/011_create_audit_log_table.sql new file mode 100644 index 0000000..fdfb89e --- /dev/null +++ b/database/migrations/011_create_audit_log_table.sql @@ -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; diff --git a/database/seeds/001_seed_users.sql b/database/seeds/001_seed_users.sql new file mode 100644 index 0000000..1611d80 --- /dev/null +++ b/database/seeds/001_seed_users.sql @@ -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'); diff --git a/database/seeds/002_seed_categories.sql b/database/seeds/002_seed_categories.sql new file mode 100644 index 0000000..c1a3639 --- /dev/null +++ b/database/seeds/002_seed_categories.sql @@ -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'); diff --git a/database/seeds/003_seed_locations.sql b/database/seeds/003_seed_locations.sql new file mode 100644 index 0000000..8a168ff --- /dev/null +++ b/database/seeds/003_seed_locations.sql @@ -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'); diff --git a/database/seeds/004_seed_departments.sql b/database/seeds/004_seed_departments.sql new file mode 100644 index 0000000..a989233 --- /dev/null +++ b/database/seeds/004_seed_departments.sql @@ -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'); diff --git a/database/seeds/005_seed_sample_assets.sql b/database/seeds/005_seed_sample_assets.sql new file mode 100644 index 0000000..dc43572 --- /dev/null +++ b/database/seeds/005_seed_sample_assets.sql @@ -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); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a3cf668 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/env.example b/env.example new file mode 100644 index 0000000..3570b1c --- /dev/null +++ b/env.example @@ -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 diff --git a/lang/de.php b/lang/de.php new file mode 100644 index 0000000..306b5f4 --- /dev/null +++ b/lang/de.php @@ -0,0 +1,233 @@ + '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', +]; diff --git a/lang/en.php b/lang/en.php new file mode 100644 index 0000000..e1f67a1 --- /dev/null +++ b/lang/en.php @@ -0,0 +1,233 @@ + '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', +]; diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..0f94d6d --- /dev/null +++ b/nginx.conf @@ -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; + } + } +} diff --git a/php.ini b/php.ini new file mode 100644 index 0000000..240f32b --- /dev/null +++ b/php.ini @@ -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 diff --git a/public/assets/css/style.css b/public/assets/css/style.css new file mode 100644 index 0000000..541c9dd --- /dev/null +++ b/public/assets/css/style.css @@ -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; + } +} diff --git a/public/assets/js/app.js b/public/assets/js/app.js new file mode 100644 index 0000000..1eb5d64 --- /dev/null +++ b/public/assets/js/app.js @@ -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} + + `; + + 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(); +} diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..cf91a04 --- /dev/null +++ b/public/index.php @@ -0,0 +1,26 @@ +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(); diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh new file mode 100644 index 0000000..d234d0f --- /dev/null +++ b/scripts/entrypoint.sh @@ -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 diff --git a/scripts/migrate.php b/scripts/migrate.php new file mode 100644 index 0000000..e74e2ab --- /dev/null +++ b/scripts/migrate.php @@ -0,0 +1,87 @@ +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); +} diff --git a/scripts/seed.php b/scripts/seed.php new file mode 100644 index 0000000..ffc07ed --- /dev/null +++ b/scripts/seed.php @@ -0,0 +1,83 @@ +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); +}