Initial commit of the Asset Management System, including project structure, Docker configuration, database migrations, and core application files. Added user authentication, asset management features, and basic UI components.

This commit is contained in:
2025-08-22 21:41:02 +02:00
parent b43a98f0ec
commit 677f70a19c
52 changed files with 5186 additions and 2 deletions

14
Dockerfile.nginx Normal file
View File

@@ -0,0 +1,14 @@
FROM nginx:stable-alpine
# Copy nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
# Copy static assets
COPY public/assets /var/www/html/public/assets
# Create necessary directories
RUN mkdir -p /var/www/html/public
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

58
Dockerfile.php Normal file
View File

@@ -0,0 +1,58 @@
FROM php:8.2-fpm-alpine
# Install system dependencies
RUN apk add --no-cache \
git \
curl \
libpng-dev \
libxml2-dev \
zip \
unzip \
oniguruma-dev \
libzip-dev \
freetype-dev \
libjpeg-turbo-dev \
libwebp-dev
# Install PHP extensions
RUN docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
&& docker-php-ext-install \
pdo_mysql \
mbstring \
intl \
zip \
gd \
exif \
opcache
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Set working directory
WORKDIR /var/www/html
# Copy composer files
COPY composer.json composer.lock ./
# Install dependencies
RUN composer install --no-dev --optimize-autoloader --no-interaction
# Copy application code
COPY . .
# Create necessary directories
RUN mkdir -p storage/logs storage/uploads storage/cache \
&& chown -R www-data:www-data storage \
&& chmod -R 755 storage
# Copy PHP configuration
COPY php.ini /usr/local/etc/php/conf.d/custom.ini
# Copy entrypoint script
COPY scripts/entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/entrypoint.sh
EXPOSE 9000
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["php-fpm"]

336
README.md
View File

@@ -1,3 +1,335 @@
# ai-zensur # Asset Management System
Ein Tool um Daten aus Dokumenten für weitere KI Verarbeitung vorzubereiten. Ein vollwertiges Inventar- und Asset-Management-System entwickelt mit PHP, MySQL und Docker.
## Features
### 🔐 Benutzer & Rollen
- **Benutzerverwaltung**: Anlegen, Bearbeiten, Deaktivieren von Benutzern
- **Rollen**: Admin, Auditor, Mitarbeiter mit unterschiedlichen Berechtigungen
- **Authentifizierung**: Sichere Login/Logout mit Passwort-Hashing (Argon2id)
- **Passwort-Reset**: E-Mail-basierter Passwort-Reset mit Token
- **Sitzungsmanagement**: Sichere Cookies mit HttpOnly und SameSite=Strict
### 📦 Assets / Inventar
- **Vollständiges CRUD**: Erstellen, Lesen, Aktualisieren, Löschen von Assets
- **Detaillierte Felder**: Inventarnummer, Bezeichnung, Kategorie, Standort, Abteilung, Lieferant, Anschaffungsdatum, Preis, Zustand, Seriennummer, Garantie, Kostenstelle, Status, Notizen
- **Lebenszyklus**: Zuweisung an Benutzer, Check-in/Check-out, Historie
- **Dateianhänge**: Upload und Download von Dokumenten pro Asset
- **Etikettendruck**: PDF-Labels mit QR/Barcode für Inventarnummer + Bezeichnung
- **Erweiterte Suche**: Nach Standort, Kategorie, Zustand, Status, Volltext
### 🏢 Stammdaten
- **Kategorien**: Verwaltung von Asset-Kategorien
- **Standorte**: Verwaltung von Standorten mit Adressen
- **Abteilungen**: Verwaltung von Abteilungen mit Kostenstellen
### 📊 Inventur & Zählungen
- **Inventurdurchläufe**: Starten, Offene Positionen, Abgleich Ist/Soll
- **Erfassung**: Per Eingabe Inventarnummer/QR-Code
- **Abschlussbericht**: PDF mit Differenzen und Statistiken
### 📈 Berichte & Export
- **Verschiedene Reports**: Aktive Assets, nach Standort, nach Kategorie, ausgemusterte Geräte, Garantie läuft ab
- **Export-Formate**: CSV (Pflicht), XLSX (optional), PDF
- **CSV-Import**: Mit Validierung, Vorschau, Duplikat-Erkennung, transaktionale Übernahme
### 🔍 Protokollierung & Nachvollziehbarkeit
- **Audit-Log**: Vollständige Protokollierung aller CRUD-Aktionen
- **Historie**: Wer/was/wann geändert wurde
- **Nachverfolgung**: Komplette Änderungshistorie pro Asset
### 🌍 Internationalisierung
- **Mehrsprachig**: Deutsch (Standard), Englisch
- **Benutzer-spezifisch**: Umschaltbar pro Benutzer
- **PHP-Array-basiert**: Einfache Übersetzungsstruktur
### 🎨 UI/UX
- **Responsive Design**: Optimiert für Desktop, Tablet und Mobile
- **Barrierefrei**: Basis-WCAG-Konformität
- **Klare Navigation**: Dashboard, Assets, Inventur, Berichte, Stammdaten, Benutzer, Einstellungen
- **Tabellen**: Mit Paginierung, Sortierung (serverseitig), Filterfeldern
- **Druckfreundlich**: Optimierte Styles für Berichte und Etiketten
## Technische Architektur
### 🏗️ MVC-ähnliche Struktur
- **Front-Controller**: `public/index.php`
- **Router**: Einfaches URL-Routing mit Parameter-Extraktion
- **Controller**: Geschäftslogik und Request-Handling
- **Models**: Datenzugriff mit PDO und Prepared Statements
- **Views**: PHP-Templates mit Layout-System
### 🔒 Sicherheit
- **CSRF-Schutz**: Token-basiert für alle POST-Formulare
- **XSS-Schutz**: HTML-Escaping bei Ausgabe
- **SQL-Injection-Schutz**: Prepared Statements überall
- **Upload-Validierung**: Whitelist-MIME, Größenlimit, sichere Speicherung
- **Rate-Limiting**: IP-basiert für Login-Versuche
- **Rollenbasierte Zugriffskontrolle**: Middleware auf Controller-Ebene
### 🚀 Leistung
- **Indizes**: Optimierte Datenbank-Indizes
- **Pagination**: Serverseitige Paginierung
- **Caching**: File-basiertes Caching für Stammdaten
- **OPcache**: PHP-Bytecode-Caching aktiviert
## Installation & Setup
### Voraussetzungen
- Docker und Docker Compose
- Mindestens 4GB RAM
- 2GB freier Speicherplatz
### Schnellstart
1. **Repository klonen**
```bash
git clone <repository-url>
cd asset-management
```
2. **Umgebungsvariablen konfigurieren**
```bash
cp env.example .env
# Bearbeiten Sie .env nach Ihren Bedürfnissen
```
3. **Anwendung starten**
```bash
docker compose up -d --build
```
4. **Auf Anwendung zugreifen**
- **Hauptanwendung**: http://localhost:8080
- **phpMyAdmin**: http://localhost:8081
### Standard-Anmeldedaten
- **E-Mail**: admin@example.com
- **Passwort**: Admin!234
- **⚠️ Wichtig**: Ändern Sie das Passwort beim ersten Login!
## Konfiguration
### Umgebungsvariablen (.env)
```env
# Application Settings
APP_ENV=production
APP_URL=http://localhost:8080
APP_DEBUG=false
APP_KEY=base64:your-secret-key-here
# Database Settings
DB_HOST=db
DB_PORT=3306
DB_NAME=inventory
DB_USER=inventory
DB_PASS=changeMe
DB_ROOT_PASS=rootPassword123
# Session Settings
SESSION_SECURE=false
SESSION_LIFETIME=3600
# File Upload Settings
UPLOAD_MAX_SIZE=50M
ALLOWED_FILE_TYPES=pdf,jpg,jpeg,png,gif,doc,docx,xls,xlsx
# Security Settings
CSRF_TOKEN_LIFETIME=3600
PASSWORD_MIN_LENGTH=8
LOGIN_MAX_ATTEMPTS=5
LOGIN_LOCKOUT_TIME=900
```
### Docker Services
- **nginx**: Web-Server (Port 8080)
- **php**: PHP-FPM 8.2 mit allen Extensions
- **mysql**: MySQL 8.0 Datenbank (Port 3306)
- **phpmyadmin**: Datenbank-Verwaltung (Port 8081)
## Verwendung
### Dashboard
Das Dashboard zeigt eine Übersicht über:
- Gesamtzahl der Assets
- Aktive vs. ausgemusterte Assets
- Gesamtwert des Inventars
- Letzte Aktivitäten
- Assets mit ablaufender Garantie
- Offene Inventuren
### Asset-Verwaltung
1. **Asset erstellen**: Klicken Sie auf "Neues Asset" und füllen Sie alle Felder aus
2. **Asset bearbeiten**: Klicken Sie auf das Bearbeiten-Symbol bei einem Asset
3. **Asset zuweisen**: Verwenden Sie die Zuweisungs-Funktion für Benutzer
4. **Dateien anhängen**: Laden Sie Dokumente, Bilder oder andere Dateien hoch
5. **Etiketten drucken**: Generieren Sie QR/Barcode-Etiketten für Assets
### Inventur
1. **Inventur starten**: Erstellen Sie einen neuen Inventurdurchlauf
2. **Assets scannen**: Verwenden Sie die Scan-Funktion mit Inventarnummer oder QR-Code
3. **Status erfassen**: Markieren Sie Assets als gefunden, nicht gefunden, defekt oder verschoben
4. **Inventur abschließen**: Generieren Sie einen Abschlussbericht mit Differenzen
### Berichte
- **Asset-Berichte**: Nach verschiedenen Kriterien gefiltert
- **Garantie-Berichte**: Assets mit ablaufender Garantie
- **Standort-Berichte**: Assets nach Standort gruppiert
- **Export-Funktionen**: CSV, Excel, PDF-Export
## Backup & Restore
### Datenbank-Backup
```bash
# Backup erstellen
docker compose exec db mysqldump -u root -p inventory > backup_$(date +%Y%m%d_%H%M%S).sql
# Backup wiederherstellen
docker compose exec -T db mysql -u root -p inventory < backup_file.sql
```
### Uploads-Backup
```bash
# Uploads-Ordner sichern
tar -czf uploads_backup_$(date +%Y%m%d_%H%M%S).tar.gz storage/uploads/
# Uploads wiederherstellen
tar -xzf uploads_backup_file.tar.gz
```
## Wartung
### Logs anzeigen
```bash
# Alle Services
docker compose logs
# Spezifischer Service
docker compose logs php
docker compose logs nginx
docker compose logs db
```
### Datenbank-Migrationen
```bash
# Migrationen manuell ausführen
docker compose exec php php scripts/migrate.php
# Seeder manuell ausführen
docker compose exec php php scripts/seed.php
```
### Cache leeren
```bash
# Storage-Cache leeren
docker compose exec php rm -rf storage/cache/*
```
## Entwicklung
### Projektstruktur
```
asset-management/
├── app/
│ ├── Controllers/ # Controller-Klassen
│ ├── Models/ # Datenmodelle
│ ├── Views/ # PHP-Templates
│ ├── Services/ # Geschäftslogik
│ ├── Middleware/ # HTTP-Middleware
│ ├── Helpers/ # Hilfsfunktionen
│ └── Core/ # Framework-Core
├── config/ # Konfigurationsdateien
├── database/
│ ├── migrations/ # Datenbank-Migrationen
│ └── seeds/ # Testdaten
├── public/ # Web-Root
│ ├── assets/ # CSS, JS, Bilder
│ └── index.php # Front-Controller
├── storage/ # Uploads, Logs, Cache
├── lang/ # Übersetzungsdateien
├── scripts/ # CLI-Skripte
├── docker-compose.yml # Docker-Konfiguration
└── README.md # Diese Datei
```
### Neue Features hinzufügen
1. **Controller erstellen**: Erben Sie von `BaseController`
2. **Model erstellen**: Verwenden Sie PDO mit Prepared Statements
3. **View erstellen**: PHP-Template mit Layout-System
4. **Route hinzufügen**: In `config/routes.php` definieren
5. **Migration erstellen**: Für Datenbankänderungen
### Code-Standards
- **PSR-4**: Autoloading-Standard
- **PSR-12**: Coding Style
- **Prepared Statements**: Für alle Datenbankabfragen
- **CSRF-Schutz**: Für alle POST-Requests
- **Audit-Logging**: Für alle CRUD-Operationen
## Troubleshooting
### Häufige Probleme
**Anwendung nicht erreichbar**
```bash
# Services Status prüfen
docker compose ps
# Logs prüfen
docker compose logs nginx
docker compose logs php
```
**Datenbank-Verbindung fehlgeschlagen**
```bash
# Datenbank-Container Status
docker compose logs db
# Datenbank-Verbindung testen
docker compose exec db mysql -u inventory -p
```
**Upload-Fehler**
```bash
# Berechtigungen prüfen
docker compose exec php ls -la storage/uploads/
# Berechtigungen korrigieren
docker compose exec php chown -R www-data:www-data storage/
```
**Performance-Probleme**
```bash
# OPcache Status prüfen
docker compose exec php php -m | grep opcache
# Cache leeren
docker compose exec php php -r "opcache_reset();"
```
## Support
Bei Problemen oder Fragen:
1. **Logs prüfen**: `docker compose logs`
2. **Dokumentation**: Diese README-Datei
3. **Issues**: GitHub Issues verwenden
## Lizenz
Dieses Projekt steht unter der MIT-Lizenz. Siehe LICENSE-Datei für Details.
## Changelog
### Version 1.0.0
- Initiale Version
- Vollständige Asset-Verwaltung
- Inventur-System
- Benutzer- und Rollenverwaltung
- Berichte und Export-Funktionen
- Docker-Integration
- Mehrsprachigkeit (DE/EN)

View File

@@ -0,0 +1,209 @@
<?php
namespace App\Controllers;
use App\Core\BaseController;
use App\Models\User;
use App\Models\PasswordReset;
class AuthController extends BaseController
{
public function showLogin(): string
{
if ($this->session->isLoggedIn()) {
return $this->redirect('/dashboard');
}
return $this->render('auth/login');
}
public function login(): Response
{
$email = $this->request->post('email');
$password = $this->request->post('password');
// Check if account is locked
if ($this->session->isLockedOut()) {
$this->flash('error', 'Account ist gesperrt. Bitte warten Sie 15 Minuten.');
return $this->redirect('/login');
}
// Validate input
$errors = $this->validate([
'email' => 'required|email',
'password' => 'required'
]);
if (!empty($errors)) {
foreach ($errors as $field => $fieldErrors) {
foreach ($fieldErrors as $error) {
$this->flash('error', $error);
}
}
return $this->redirect('/login');
}
// Find user
$user = (new User($this->database))->findByEmail($email);
if (!$user || !$user['active']) {
$this->incrementLoginAttempts();
$this->flash('error', 'Ungültige Anmeldedaten oder Account inaktiv.');
return $this->redirect('/login');
}
// Verify password
if (!password_verify($password, $user['passhash'])) {
$this->incrementLoginAttempts();
$this->flash('error', 'Ungültige Anmeldedaten.');
return $this->redirect('/login');
}
// Check if password needs rehash
if (password_needs_rehash($user['passhash'], PASSWORD_ARGON2ID)) {
$newHash = password_hash($password, PASSWORD_ARGON2ID);
(new User($this->database))->update($user['id'], ['passhash' => $newHash]);
}
// Reset login attempts
$this->session->setLoginAttempts(0);
// Set user session
$this->session->setUser($user);
$this->session->setLastActivity();
// Log audit
$this->logAudit('login', 'users', $user['id']);
$this->flash('success', 'Erfolgreich angemeldet.');
return $this->redirect('/dashboard');
}
public function logout(): Response
{
if ($this->session->isLoggedIn()) {
$userId = $this->session->getUserId();
$this->logAudit('logout', 'users', $userId);
}
$this->session->logout();
$this->flash('success', 'Erfolgreich abgemeldet.');
return $this->redirect('/login');
}
public function showForgotPassword(): string
{
return $this->render('auth/forgot-password');
}
public function forgotPassword(): Response
{
$email = $this->request->post('email');
$errors = $this->validate([
'email' => 'required|email'
]);
if (!empty($errors)) {
foreach ($errors as $field => $fieldErrors) {
foreach ($fieldErrors as $error) {
$this->flash('error', $error);
}
}
return $this->redirect('/password/forgot');
}
$user = (new User($this->database))->findByEmail($email);
if ($user && $user['active']) {
$token = bin2hex(random_bytes(32));
$expiresAt = date('Y-m-d H:i:s', strtotime('+1 hour'));
$passwordReset = new PasswordReset($this->database);
$passwordReset->create([
'user_id' => $user['id'],
'token' => $token,
'expires_at' => $expiresAt
]);
// Send email (in production, implement email service)
// $this->sendPasswordResetEmail($user['email'], $token);
}
// Always show success message for security
$this->flash('success', 'Falls die E-Mail-Adresse existiert, wurde ein Reset-Link gesendet.');
return $this->redirect('/login');
}
public function showResetPassword(): string
{
$token = $this->request->get('token');
if (!$token) {
$this->flash('error', 'Ungültiger Reset-Link.');
return $this->redirect('/login');
}
$passwordReset = new PasswordReset($this->database);
$reset = $passwordReset->findByToken($token);
if (!$reset || strtotime($reset['expires_at']) < time()) {
$this->flash('error', 'Reset-Link ist ungültig oder abgelaufen.');
return $this->redirect('/login');
}
return $this->render('auth/reset-password', ['token' => $token]);
}
public function resetPassword(): Response
{
$token = $this->request->post('token');
$password = $this->request->post('password');
$passwordConfirm = $this->request->post('password_confirm');
$errors = $this->validate([
'password' => 'required|min:8',
'password_confirm' => 'required'
]);
if ($password !== $passwordConfirm) {
$this->flash('error', 'Passwörter stimmen nicht überein.');
return $this->redirect('/password/reset?token=' . $token);
}
if (!empty($errors)) {
foreach ($errors as $field => $fieldErrors) {
foreach ($fieldErrors as $error) {
$this->flash('error', $error);
}
}
return $this->redirect('/password/reset?token=' . $token);
}
$passwordReset = new PasswordReset($this->database);
$reset = $passwordReset->findByToken($token);
if (!$reset || strtotime($reset['expires_at']) < time()) {
$this->flash('error', 'Reset-Link ist ungültig oder abgelaufen.');
return $this->redirect('/login');
}
// Update user password
$userModel = new User($this->database);
$userModel->update($reset['user_id'], [
'passhash' => password_hash($password, PASSWORD_ARGON2ID)
]);
// Delete used reset token
$passwordReset->delete($reset['id']);
$this->flash('success', 'Passwort erfolgreich geändert. Sie können sich jetzt anmelden.');
return $this->redirect('/login');
}
private function incrementLoginAttempts(): void
{
$attempts = $this->session->getLoginAttempts();
$this->session->setLoginAttempts($attempts + 1);
}
}

View File

@@ -0,0 +1,236 @@
<?php
namespace App\Controllers;
use App\Core\Request;
use App\Core\Response;
use App\Core\Session;
use App\Core\Database;
use App\Services\AuditService;
abstract class BaseController
{
protected Request $request;
protected Response $response;
protected Session $session;
protected Database $database;
protected AuditService $auditService;
public function setRequest(Request $request): void
{
$this->request = $request;
}
public function setResponse(Response $response): void
{
$this->response = $response;
}
public function setSession(Session $session): void
{
$this->session = $session;
}
public function setDatabase(Database $database): void
{
$this->database = $database;
$this->auditService = new AuditService($database, $session);
}
protected function render(string $view, array $data = []): string
{
// Extract data to variables
extract($data);
// Add common data
$user = $this->session->getUser();
$flashMessages = $this->session->getFlashMessages();
$csrfToken = $this->session->getCsrfToken();
$locale = $this->session->getLocale();
// Load language file
$lang = $this->loadLanguage($locale);
// Start output buffering
ob_start();
// Include the view file
$viewPath = APP_PATH . '/Views/' . $view . '.php';
if (!file_exists($viewPath)) {
throw new \Exception("View not found: {$view}");
}
require $viewPath;
// Return the buffered content
return ob_get_clean();
}
protected function renderLayout(string $view, array $data = []): string
{
$content = $this->render($view, $data);
// Add layout wrapper
$user = $this->session->getUser();
$flashMessages = $this->session->getFlashMessages();
$csrfToken = $this->session->getCsrfToken();
$locale = $this->session->getLocale();
$lang = $this->loadLanguage($locale);
ob_start();
require APP_PATH . '/Views/layouts/main.php';
return ob_get_clean();
}
protected function redirect(string $url): Response
{
return $this->response->redirect($url);
}
protected function json(array $data, int $statusCode = 200): Response
{
return $this->response->json($data, $statusCode);
}
protected function validate(array $rules): array
{
return $this->request->validate($rules);
}
protected function flash(string $key, string $message): void
{
$this->session->flash($key, $message);
}
protected function getUserId(): ?int
{
return $this->session->getUserId();
}
protected function getUserRole(): ?string
{
return $this->session->getUserRole();
}
protected function isAdmin(): bool
{
return $this->session->isAdmin();
}
protected function isAuditor(): bool
{
return $this->session->isAuditor();
}
protected function isEmployee(): bool
{
return $this->session->isEmployee();
}
protected function hasPermission(string $permission): bool
{
$role = $this->getUserRole();
switch ($permission) {
case 'admin':
return $role === 'admin';
case 'auditor':
return in_array($role, ['admin', 'auditor']);
case 'employee':
return in_array($role, ['admin', 'auditor', 'employee']);
default:
return false;
}
}
protected function paginate(string $sql, array $params = [], int $perPage = 20): array
{
$page = (int) ($this->request->get('page', 1));
$offset = ($page - 1) * $perPage;
// Get total count
$countSql = preg_replace('/SELECT .* FROM/', 'SELECT COUNT(*) as total FROM', $sql);
$countSql = preg_replace('/ORDER BY .*/', '', $countSql);
$countSql = preg_replace('/LIMIT .*/', '', $countSql);
$totalResult = $this->database->fetch($countSql, $params);
$total = $totalResult['total'] ?? 0;
// Get paginated data
$dataSql = $sql . " LIMIT {$perPage} OFFSET {$offset}";
$data = $this->database->fetchAll($dataSql, $params);
$totalPages = ceil($total / $perPage);
return [
'data' => $data,
'pagination' => [
'current_page' => $page,
'per_page' => $perPage,
'total' => $total,
'total_pages' => $totalPages,
'has_previous' => $page > 1,
'has_next' => $page < $totalPages,
'previous_page' => $page > 1 ? $page - 1 : null,
'next_page' => $page < $totalPages ? $page + 1 : null,
]
];
}
protected function logAudit(string $action, string $table, int $recordId, array $oldValue = null, array $newValue = null): void
{
$this->auditService->log($action, $table, $recordId, $oldValue, $newValue);
}
private function loadLanguage(string $locale): array
{
$langFile = LANG_PATH . '/' . $locale . '.php';
if (file_exists($langFile)) {
return require $langFile;
}
// Fallback to German
$langFile = LANG_PATH . '/de.php';
return file_exists($langFile) ? require $langFile : [];
}
protected function trans(string $key, array $params = []): string
{
$lang = $this->loadLanguage($this->session->getLocale());
$text = $lang[$key] ?? $key;
foreach ($params as $param => $value) {
$text = str_replace(':' . $param, $value, $text);
}
return $text;
}
protected function formatDate(string $date, string $format = 'd.m.Y'): string
{
return date($format, strtotime($date));
}
protected function formatDateTime(string $date, string $format = 'd.m.Y H:i'): string
{
return date($format, strtotime($date));
}
protected function formatCurrency(float $amount): string
{
return number_format($amount, 2, ',', '.') . ' €';
}
protected function sanitizeInput(string $input): string
{
return htmlspecialchars(trim($input), ENT_QUOTES, 'UTF-8');
}
protected function generateSlug(string $text): string
{
$text = strtolower($text);
$text = preg_replace('/[^a-z0-9\s-]/', '', $text);
$text = preg_replace('/[\s-]+/', '-', $text);
return trim($text, '-');
}
}

120
app/Core/Application.php Normal file
View File

@@ -0,0 +1,120 @@
<?php
namespace App\Core;
use App\Middleware\AuthMiddleware;
use App\Middleware\AdminMiddleware;
use App\Middleware\CsrfMiddleware;
class Application
{
private Router $router;
private Database $database;
private Session $session;
private array $middleware = [];
public function __construct()
{
$this->router = Router::getInstance();
$this->database = new Database();
$this->session = new Session();
// Register middleware
$this->registerMiddleware();
}
public function handle(): void
{
try {
$request = new Request();
$response = new Response();
// Apply global middleware
$this->applyMiddleware($request, $response);
// Route the request
$route = $this->router->match($request->getMethod(), $request->getPath());
if (!$route) {
$this->handleNotFound();
return;
}
// Apply route-specific middleware
if (isset($route['middleware'])) {
$this->applyRouteMiddleware($route['middleware'], $request, $response);
}
// Execute controller
$controller = new $route['controller']();
$method = $route['method'];
// Inject dependencies
$controller->setRequest($request);
$controller->setResponse($response);
$controller->setSession($this->session);
$controller->setDatabase($this->database);
// Execute with parameters
$params = $route['params'] ?? [];
$result = call_user_func_array([$controller, $method], $params);
// Send response
if ($result instanceof Response) {
$result->send();
} else {
$response->setContent($result);
$response->send();
}
} catch (\Exception $e) {
$this->handleError($e);
}
}
private function registerMiddleware(): void
{
$this->middleware = [
'auth' => AuthMiddleware::class,
'admin' => AdminMiddleware::class,
'csrf' => CsrfMiddleware::class,
];
}
private function applyMiddleware(Request $request, Response $response): void
{
// Apply CSRF middleware to all POST requests
if ($request->getMethod() === 'POST') {
$csrfMiddleware = new CsrfMiddleware();
$csrfMiddleware->handle($request, $response);
}
}
private function applyRouteMiddleware(array $middlewareNames, Request $request, Response $response): void
{
foreach ($middlewareNames as $name) {
if (isset($this->middleware[$name])) {
$middlewareClass = $this->middleware[$name];
$middleware = new $middlewareClass();
$middleware->handle($request, $response);
}
}
}
private function handleNotFound(): void
{
http_response_code(404);
require APP_PATH . '/Views/errors/404.php';
}
private function handleError(\Exception $e): void
{
if (APP_DEBUG) {
throw $e;
}
error_log($e->getMessage());
http_response_code(500);
require APP_PATH . '/Views/errors/500.php';
}
}

132
app/Core/Database.php Normal file
View File

@@ -0,0 +1,132 @@
<?php
namespace App\Core;
use PDO;
use PDOException;
class Database
{
private ?PDO $connection = null;
private static ?Database $instance = null;
public static function getInstance(): Database
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function getConnection(): PDO
{
if ($this->connection === null) {
$this->connect();
}
return $this->connection;
}
private function connect(): void
{
try {
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
DB_HOST,
DB_PORT,
DB_NAME
);
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci"
];
$this->connection = new PDO($dsn, DB_USER, DB_PASS, $options);
} catch (PDOException $e) {
error_log('Database connection failed: ' . $e->getMessage());
throw new \Exception('Database connection failed');
}
}
public function query(string $sql, array $params = []): \PDOStatement
{
$stmt = $this->getConnection()->prepare($sql);
$stmt->execute($params);
return $stmt;
}
public function fetch(string $sql, array $params = []): ?array
{
$stmt = $this->query($sql, $params);
$result = $stmt->fetch();
return $result ?: null;
}
public function fetchAll(string $sql, array $params = []): array
{
$stmt = $this->query($sql, $params);
return $stmt->fetchAll();
}
public function insert(string $table, array $data): int
{
$columns = implode(', ', array_keys($data));
$placeholders = ':' . implode(', :', array_keys($data));
$sql = "INSERT INTO {$table} ({$columns}) VALUES ({$placeholders})";
$this->query($sql, $data);
return (int) $this->getConnection()->lastInsertId();
}
public function update(string $table, array $data, string $where, array $whereParams = []): int
{
$setParts = [];
foreach (array_keys($data) as $column) {
$setParts[] = "{$column} = :{$column}";
}
$setClause = implode(', ', $setParts);
$sql = "UPDATE {$table} SET {$setClause} WHERE {$where}";
$params = array_merge($data, $whereParams);
$stmt = $this->query($sql, $params);
return $stmt->rowCount();
}
public function delete(string $table, string $where, array $params = []): int
{
$sql = "DELETE FROM {$table} WHERE {$where}";
$stmt = $this->query($sql, $params);
return $stmt->rowCount();
}
public function beginTransaction(): bool
{
return $this->getConnection()->beginTransaction();
}
public function commit(): bool
{
return $this->getConnection()->commit();
}
public function rollback(): bool
{
return $this->getConnection()->rollback();
}
public function inTransaction(): bool
{
return $this->getConnection()->inTransaction();
}
public function quote(string $value): string
{
return $this->getConnection()->quote($value);
}
}

182
app/Core/Request.php Normal file
View File

@@ -0,0 +1,182 @@
<?php
namespace App\Core;
class Request
{
private array $get;
private array $post;
private array $files;
private array $server;
private array $headers;
public function __construct()
{
$this->get = $_GET;
$this->post = $_POST;
$this->files = $_FILES;
$this->server = $_SERVER;
$this->headers = $this->getRequestHeaders();
}
public function getMethod(): string
{
return strtoupper($this->server['REQUEST_METHOD'] ?? 'GET');
}
public function getPath(): string
{
$path = $this->server['REQUEST_URI'] ?? '/';
$position = strpos($path, '?');
if ($position !== false) {
$path = substr($path, 0, $position);
}
return $path;
}
public function get(string $key, $default = null)
{
return $this->get[$key] ?? $default;
}
public function post(string $key, $default = null)
{
return $this->post[$key] ?? $default;
}
public function file(string $key)
{
return $this->files[$key] ?? null;
}
public function all(): array
{
return array_merge($this->get, $this->post);
}
public function only(array $keys): array
{
$data = $this->all();
return array_intersect_key($data, array_flip($keys));
}
public function except(array $keys): array
{
$data = $this->all();
return array_diff_key($data, array_flip($keys));
}
public function has(string $key): bool
{
return isset($this->get[$key]) || isset($this->post[$key]);
}
public function isPost(): bool
{
return $this->getMethod() === 'POST';
}
public function isGet(): bool
{
return $this->getMethod() === 'GET';
}
public function isAjax(): bool
{
return isset($this->headers['X-Requested-With']) &&
$this->headers['X-Requested-With'] === 'XMLHttpRequest';
}
public function getHeader(string $name): ?string
{
return $this->headers[$name] ?? null;
}
public function getHeaders(): array
{
return $this->headers;
}
public function getIp(): string
{
return $this->server['REMOTE_ADDR'] ?? '0.0.0.0';
}
public function getUserAgent(): string
{
return $this->server['HTTP_USER_AGENT'] ?? '';
}
public function getContentType(): string
{
return $this->server['CONTENT_TYPE'] ?? '';
}
public function getContentLength(): int
{
return (int) ($this->server['CONTENT_LENGTH'] ?? 0);
}
public function validate(array $rules): array
{
$errors = [];
$data = $this->all();
foreach ($rules as $field => $rule) {
$value = $data[$field] ?? null;
if (strpos($rule, 'required') !== false && empty($value)) {
$errors[$field][] = 'Das Feld ist erforderlich.';
continue;
}
if (!empty($value)) {
if (strpos($rule, 'email') !== false && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
$errors[$field][] = 'Ungültige E-Mail-Adresse.';
}
if (strpos($rule, 'min:') !== false) {
preg_match('/min:(\d+)/', $rule, $matches);
$min = (int) $matches[1];
if (strlen($value) < $min) {
$errors[$field][] = "Mindestens {$min} Zeichen erforderlich.";
}
}
if (strpos($rule, 'max:') !== false) {
preg_match('/max:(\d+)/', $rule, $matches);
$max = (int) $matches[1];
if (strlen($value) > $max) {
$errors[$field][] = "Maximal {$max} Zeichen erlaubt.";
}
}
if (strpos($rule, 'numeric') !== false && !is_numeric($value)) {
$errors[$field][] = 'Nur Zahlen erlaubt.';
}
if (strpos($rule, 'date') !== false && !strtotime($value)) {
$errors[$field][] = 'Ungültiges Datum.';
}
}
}
return $errors;
}
private function getRequestHeaders(): array
{
$headers = [];
foreach ($this->server as $key => $value) {
if (strpos($key, 'HTTP_') === 0) {
$header = str_replace('_', '-', strtolower(substr($key, 5)));
$headers[$header] = $value;
}
}
return $headers;
}
}

141
app/Core/Response.php Normal file
View File

@@ -0,0 +1,141 @@
<?php
namespace App\Core;
class Response
{
private string $content = '';
private int $statusCode = 200;
private array $headers = [];
private string $contentType = 'text/html; charset=utf-8';
public function setContent(string $content): self
{
$this->content = $content;
return $this;
}
public function getContent(): string
{
return $this->content;
}
public function setStatusCode(int $statusCode): self
{
$this->statusCode = $statusCode;
return $this;
}
public function getStatusCode(): int
{
return $this->statusCode;
}
public function setHeader(string $name, string $value): self
{
$this->headers[$name] = $value;
return $this;
}
public function setHeaders(array $headers): self
{
$this->headers = array_merge($this->headers, $headers);
return $this;
}
public function getHeaders(): array
{
return $this->headers;
}
public function setContentType(string $contentType): self
{
$this->contentType = $contentType;
return $this;
}
public function getContentType(): string
{
return $this->contentType;
}
public function redirect(string $url, int $statusCode = 302): self
{
$this->setHeader('Location', $url);
$this->setStatusCode($statusCode);
return $this;
}
public function json(array $data, int $statusCode = 200): self
{
$this->setContentType('application/json');
$this->setContent(json_encode($data, JSON_UNESCAPED_UNICODE));
$this->setStatusCode($statusCode);
return $this;
}
public function download(string $filePath, string $filename = null): self
{
if (!file_exists($filePath)) {
throw new \Exception('File not found: ' . $filePath);
}
$filename = $filename ?: basename($filePath);
$mimeType = mime_content_type($filePath) ?: 'application/octet-stream';
$this->setHeader('Content-Type', $mimeType);
$this->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"');
$this->setHeader('Content-Length', (string) filesize($filePath));
$this->setHeader('Cache-Control', 'no-cache, must-revalidate');
$this->setHeader('Pragma', 'no-cache');
$this->setContent(file_get_contents($filePath));
return $this;
}
public function send(): void
{
// Set status code
http_response_code($this->statusCode);
// Set content type
header('Content-Type: ' . $this->contentType);
// Set additional headers
foreach ($this->headers as $name => $value) {
header($name . ': ' . $value);
}
// Output content
echo $this->content;
exit;
}
public function notFound(string $message = 'Seite nicht gefunden'): self
{
$this->setStatusCode(404);
$this->setContent($message);
return $this;
}
public function forbidden(string $message = 'Zugriff verweigert'): self
{
$this->setStatusCode(403);
$this->setContent($message);
return $this;
}
public function unauthorized(string $message = 'Nicht autorisiert'): self
{
$this->setStatusCode(401);
$this->setContent($message);
return $this;
}
public function serverError(string $message = 'Interner Serverfehler'): self
{
$this->setStatusCode(500);
$this->setContent($message);
return $this;
}
}

108
app/Core/Router.php Normal file
View File

@@ -0,0 +1,108 @@
<?php
namespace App\Core;
class Router
{
private static ?Router $instance = null;
private array $routes = [];
private array $groups = [];
public static function getInstance(): Router
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function get(string $path, array $handler, array $options = []): void
{
$this->addRoute('GET', $path, $handler, $options);
}
public function post(string $path, array $handler, array $options = []): void
{
$this->addRoute('POST', $path, $handler, $options);
}
public function put(string $path, array $handler, array $options = []): void
{
$this->addRoute('PUT', $path, $handler, $options);
}
public function delete(string $path, array $handler, array $options = []): void
{
$this->addRoute('DELETE', $path, $handler, $options);
}
public function group(array $attributes, callable $callback): void
{
$this->groups[] = $attributes;
$callback($this);
array_pop($this->groups);
}
private function addRoute(string $method, string $path, array $handler, array $options = []): void
{
// Apply group middleware
$middleware = $options['middleware'] ?? [];
foreach ($this->groups as $group) {
if (isset($group['middleware'])) {
$middleware = array_merge($middleware, (array)$group['middleware']);
}
}
$route = [
'method' => $method,
'path' => $path,
'controller' => $handler[0],
'method' => $handler[1],
'middleware' => $middleware,
'pattern' => $this->buildPattern($path),
];
$this->routes[] = $route;
}
public function match(string $method, string $path): ?array
{
foreach ($this->routes as $route) {
if ($route['method'] !== $method) {
continue;
}
if (preg_match($route['pattern'], $path, $matches)) {
// Extract parameters
$params = [];
preg_match_all('/\{([^}]+)\}/', $route['path'], $paramNames);
for ($i = 0; $i < count($paramNames[1]); $i++) {
$paramName = $paramNames[1][$i];
$params[$paramName] = $matches[$i + 1] ?? null;
}
return [
'controller' => $route['controller'],
'method' => $route['method'],
'middleware' => $route['middleware'],
'params' => $params,
];
}
}
return null;
}
private function buildPattern(string $path): string
{
// Convert route parameters to regex pattern
$pattern = preg_replace('/\{([^}]+)\}/', '([^/]+)', $path);
return '#^' . $pattern . '$#';
}
public function getRoutes(): array
{
return $this->routes;
}
}

198
app/Core/Session.php Normal file
View File

@@ -0,0 +1,198 @@
<?php
namespace App\Core;
class Session
{
public function __construct()
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
}
public function set(string $key, $value): void
{
$_SESSION[$key] = $value;
}
public function get(string $key, $default = null)
{
return $_SESSION[$key] ?? $default;
}
public function has(string $key): bool
{
return isset($_SESSION[$key]);
}
public function remove(string $key): void
{
unset($_SESSION[$key]);
}
public function destroy(): void
{
session_destroy();
}
public function regenerate(): bool
{
return session_regenerate_id(true);
}
public function flash(string $key, $value): void
{
$_SESSION['flash'][$key] = $value;
}
public function getFlash(string $key, $default = null)
{
$value = $_SESSION['flash'][$key] ?? $default;
unset($_SESSION['flash'][$key]);
return $value;
}
public function hasFlash(string $key): bool
{
return isset($_SESSION['flash'][$key]);
}
public function getFlashMessages(): array
{
$messages = $_SESSION['flash'] ?? [];
unset($_SESSION['flash']);
return $messages;
}
public function setUser(array $user): void
{
$this->set('user', $user);
$this->regenerate();
}
public function getUser(): ?array
{
return $this->get('user');
}
public function isLoggedIn(): bool
{
return $this->has('user');
}
public function logout(): void
{
$this->remove('user');
$this->destroy();
}
public function getUserId(): ?int
{
$user = $this->getUser();
return $user['id'] ?? null;
}
public function getUserRole(): ?string
{
$user = $this->getUser();
return $user['role'] ?? null;
}
public function isAdmin(): bool
{
return $this->getUserRole() === 'admin';
}
public function isAuditor(): bool
{
return $this->getUserRole() === 'auditor';
}
public function isEmployee(): bool
{
return $this->getUserRole() === 'employee';
}
public function generateCsrfToken(): string
{
$token = bin2hex(random_bytes(32));
$this->set('csrf_token', $token);
$this->set('csrf_token_time', time());
return $token;
}
public function validateCsrfToken(string $token): bool
{
$storedToken = $this->get('csrf_token');
$tokenTime = $this->get('csrf_token_time', 0);
if (!$storedToken || !$tokenTime) {
return false;
}
// Check if token is expired (1 hour)
if (time() - $tokenTime > CSRF_TOKEN_LIFETIME) {
$this->remove('csrf_token');
$this->remove('csrf_token_time');
return false;
}
return hash_equals($storedToken, $token);
}
public function getCsrfToken(): string
{
$token = $this->get('csrf_token');
if (!$token) {
$token = $this->generateCsrfToken();
}
return $token;
}
public function setLocale(string $locale): void
{
$this->set('locale', $locale);
}
public function getLocale(): string
{
return $this->get('locale', 'de');
}
public function setLastActivity(): void
{
$this->set('last_activity', time());
}
public function isExpired(int $timeout = 3600): bool
{
$lastActivity = $this->get('last_activity', 0);
return (time() - $lastActivity) > $timeout;
}
public function setLoginAttempts(int $attempts): void
{
$this->set('login_attempts', $attempts);
$this->set('login_attempts_time', time());
}
public function getLoginAttempts(): int
{
$attempts = $this->get('login_attempts', 0);
$attemptsTime = $this->get('login_attempts_time', 0);
// Reset attempts if lockout time has passed
if (time() - $attemptsTime > LOGIN_LOCKOUT_TIME) {
$this->setLoginAttempts(0);
return 0;
}
return $attempts;
}
public function isLockedOut(): bool
{
return $this->getLoginAttempts() >= LOGIN_MAX_ATTEMPTS;
}
}

378
app/Helpers/functions.php Normal file
View File

@@ -0,0 +1,378 @@
<?php
/**
* Helper Functions
*/
/**
* Escape HTML output
*/
function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}
/**
* Generate CSRF token
*/
function csrf_token(): string
{
$session = new \App\Core\Session();
return $session->getCsrfToken();
}
/**
* Generate CSRF field
*/
function csrf_field(): string
{
return '<input type="hidden" name="csrf_token" value="' . csrf_token() . '">';
}
/**
* Format date
*/
function format_date(string $date, string $format = 'd.m.Y'): string
{
return date($format, strtotime($date));
}
/**
* Format datetime
*/
function format_datetime(string $date, string $format = 'd.m.Y H:i'): string
{
return date($format, strtotime($date));
}
/**
* Format currency
*/
function format_currency(float $amount): string
{
return number_format($amount, 2, ',', '.') . ' €';
}
/**
* Generate asset number
*/
function generate_asset_number(string $prefix = 'ASSET'): string
{
return $prefix . '-' . date('Y') . '-' . str_pad(rand(1, 9999), 4, '0', STR_PAD_LEFT);
}
/**
* Validate file upload
*/
function validate_file_upload(array $file, array $allowedTypes = [], int $maxSize = 52428800): array
{
$errors = [];
if ($file['error'] !== UPLOAD_ERR_OK) {
$errors[] = 'Upload error: ' . $file['error'];
return $errors;
}
if ($file['size'] > $maxSize) {
$errors[] = 'File too large. Maximum size: ' . format_bytes($maxSize);
}
if (!empty($allowedTypes)) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mimeType, $allowedTypes)) {
$errors[] = 'File type not allowed. Allowed types: ' . implode(', ', $allowedTypes);
}
}
return $errors;
}
/**
* Format bytes to human readable
*/
function format_bytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
$bytes /= 1024;
}
return round($bytes, 2) . ' ' . $units[$i];
}
/**
* Generate random string
*/
function random_string(int $length = 32): string
{
return bin2hex(random_bytes($length / 2));
}
/**
* Check if user has permission
*/
function has_permission(string $permission): bool
{
$session = new \App\Core\Session();
$role = $session->getUserRole();
switch ($permission) {
case 'admin':
return $role === 'admin';
case 'auditor':
return in_array($role, ['admin', 'auditor']);
case 'employee':
return in_array($role, ['admin', 'auditor', 'employee']);
default:
return false;
}
}
/**
* Get user role name
*/
function get_role_name(string $role): string
{
$roles = [
'admin' => 'Administrator',
'auditor' => 'Auditor',
'employee' => 'Mitarbeiter'
];
return $roles[$role] ?? $role;
}
/**
* Get asset status name
*/
function get_asset_status_name(string $status): string
{
$statuses = [
'aktiv' => 'Aktiv',
'inaktiv' => 'Inaktiv',
'ausgemustert' => 'Ausgemustert'
];
return $statuses[$status] ?? $status;
}
/**
* Get asset condition name
*/
function get_asset_condition_name(string $condition): string
{
$conditions = [
'neu' => 'Neu',
'gut' => 'Gut',
'befriedigend' => 'Befriedigend',
'schlecht' => 'Schlecht',
'defekt' => 'Defekt'
];
return $conditions[$condition] ?? $condition;
}
/**
* Get inventory status name
*/
function get_inventory_status_name(string $status): string
{
$statuses = [
'offen' => 'Offen',
'abgeschlossen' => 'Abgeschlossen'
];
return $statuses[$status] ?? $status;
}
/**
* Get inventory item status name
*/
function get_inventory_item_status_name(string $status): string
{
$statuses = [
'gefunden' => 'Gefunden',
'nicht_gefunden' => 'Nicht gefunden',
'defekt' => 'Defekt',
'verschoben' => 'Verschoben'
];
return $statuses[$status] ?? $status;
}
/**
* Generate QR code data for asset
*/
function generate_qr_data(array $asset): string
{
return json_encode([
'id' => $asset['id'],
'inventarnummer' => $asset['inventarnummer'],
'bezeichnung' => $asset['bezeichnung']
]);
}
/**
* Check if warranty is expiring soon
*/
function is_warranty_expiring_soon(string $warrantyDate, int $days = 30): bool
{
if (empty($warrantyDate)) {
return false;
}
$warranty = strtotime($warrantyDate);
$now = time();
$expiring = strtotime("+{$days} days", $now);
return $warranty <= $expiring && $warranty > $now;
}
/**
* Calculate asset age in years
*/
function calculate_asset_age(string $purchaseDate): int
{
if (empty($purchaseDate)) {
return 0;
}
$purchase = new DateTime($purchaseDate);
$now = new DateTime();
$diff = $now->diff($purchase);
return $diff->y;
}
/**
* Get asset value depreciation
*/
function calculate_depreciation(float $purchasePrice, string $purchaseDate, float $depreciationRate = 0.1): float
{
$age = calculate_asset_age($purchaseDate);
$depreciation = $purchasePrice * $depreciationRate * $age;
return max(0, $purchasePrice - $depreciation);
}
/**
* Sanitize filename
*/
function sanitize_filename(string $filename): string
{
// Remove special characters
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename);
// Remove multiple underscores
$filename = preg_replace('/_+/', '_', $filename);
// Remove leading/trailing underscores
$filename = trim($filename, '_');
return $filename;
}
/**
* Get file extension from mime type
*/
function get_extension_from_mime(string $mimeType): string
{
$extensions = [
'application/pdf' => 'pdf',
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'application/msword' => 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
'application/vnd.ms-excel' => 'xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
'text/plain' => 'txt',
'text/csv' => 'csv'
];
return $extensions[$mimeType] ?? 'bin';
}
/**
* Check if string is valid JSON
*/
function is_valid_json(string $string): bool
{
json_decode($string);
return json_last_error() === JSON_ERROR_NONE;
}
/**
* Get current user
*/
function current_user(): ?array
{
$session = new \App\Core\Session();
return $session->getUser();
}
/**
* Get current user ID
*/
function current_user_id(): ?int
{
$session = new \App\Core\Session();
return $session->getUserId();
}
/**
* Check if user is logged in
*/
function is_logged_in(): bool
{
$session = new \App\Core\Session();
return $session->isLoggedIn();
}
/**
* Check if user is admin
*/
function is_admin(): bool
{
$session = new \App\Core\Session();
return $session->isAdmin();
}
/**
* Redirect to URL
*/
function redirect(string $url): void
{
header("Location: {$url}");
exit;
}
/**
* Get base URL
*/
function base_url(string $path = ''): string
{
$baseUrl = rtrim(APP_URL, '/');
$path = ltrim($path, '/');
return $baseUrl . '/' . $path;
}
/**
* Asset URL
*/
function asset_url(string $path): string
{
return base_url('assets/' . ltrim($path, '/'));
}
/**
* Storage URL
*/
function storage_url(string $path): string
{
return base_url('storage/' . ltrim($path, '/'));
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Middleware;
use App\Core\Request;
use App\Core\Response;
use App\Core\Session;
class AdminMiddleware
{
public function handle(Request $request, Response $response): void
{
$session = new Session();
// First check if user is logged in
if (!$session->isLoggedIn()) {
$session->flash('error', 'Bitte melden Sie sich an, um fortzufahren.');
$response->redirect('/login')->send();
}
// Check if user has admin role
if (!$session->isAdmin()) {
$session->flash('error', 'Sie haben keine Berechtigung für diese Aktion.');
$response->redirect('/dashboard')->send();
}
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Middleware;
use App\Core\Request;
use App\Core\Response;
use App\Core\Session;
class AuthMiddleware
{
public function handle(Request $request, Response $response): void
{
$session = new Session();
// Check if user is logged in
if (!$session->isLoggedIn()) {
$session->flash('error', 'Bitte melden Sie sich an, um fortzufahren.');
$response->redirect('/login')->send();
}
// Check if session is expired
if ($session->isExpired()) {
$session->logout();
$session->flash('error', 'Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.');
$response->redirect('/login')->send();
}
// Update last activity
$session->setLastActivity();
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Middleware;
use App\Core\Request;
use App\Core\Response;
use App\Core\Session;
class CsrfMiddleware
{
public function handle(Request $request, Response $response): void
{
$session = new Session();
// Skip CSRF check for GET requests
if ($request->getMethod() === 'GET') {
return;
}
// Get CSRF token from request
$token = $request->post('csrf_token') ?: $request->getHeader('X-CSRF-TOKEN');
if (!$token) {
$session->flash('error', 'CSRF-Token fehlt.');
$response->redirect('/dashboard')->send();
}
// Validate CSRF token
if (!$session->validateCsrfToken($token)) {
$session->flash('error', 'Ungültiger CSRF-Token.');
$response->redirect('/dashboard')->send();
}
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Models;
use App\Core\Database;
class PasswordReset
{
private Database $database;
public function __construct(Database $database)
{
$this->database = $database;
}
public function create(array $data): int
{
return $this->database->insert('password_resets', $data);
}
public function findByToken(string $token): ?array
{
return $this->database->fetch(
"SELECT * FROM password_resets WHERE token = :token",
['token' => $token]
);
}
public function delete(int $id): bool
{
return $this->database->delete('password_resets', 'id = :id', ['id' => $id]) > 0;
}
public function deleteExpired(): int
{
return $this->database->query(
"DELETE FROM password_resets WHERE expires_at < NOW()"
)->rowCount();
}
public function deleteByUserId(int $userId): int
{
return $this->database->delete('password_resets', 'user_id = :user_id', ['user_id' => $userId]);
}
}

204
app/Models/User.php Normal file
View File

@@ -0,0 +1,204 @@
<?php
namespace App\Models;
use App\Core\Database;
class User
{
private Database $database;
public function __construct(Database $database)
{
$this->database = $database;
}
public function find(int $id): ?array
{
return $this->database->fetch(
"SELECT * FROM users WHERE id = :id",
['id' => $id]
);
}
public function findByEmail(string $email): ?array
{
return $this->database->fetch(
"SELECT * FROM users WHERE email = :email",
['email' => $email]
);
}
public function findAll(array $filters = [], int $limit = null, int $offset = 0): array
{
$sql = "SELECT * FROM users WHERE 1=1";
$params = [];
if (!empty($filters['role'])) {
$sql .= " AND role = :role";
$params['role'] = $filters['role'];
}
if (isset($filters['active'])) {
$sql .= " AND active = :active";
$params['active'] = $filters['active'];
}
if (!empty($filters['search'])) {
$sql .= " AND (name LIKE :search OR email LIKE :search)";
$params['search'] = '%' . $filters['search'] . '%';
}
$sql .= " ORDER BY name ASC";
if ($limit) {
$sql .= " LIMIT :limit OFFSET :offset";
$params['limit'] = $limit;
$params['offset'] = $offset;
}
return $this->database->fetchAll($sql, $params);
}
public function create(array $data): int
{
$data['passhash'] = password_hash($data['password'], PASSWORD_ARGON2ID);
unset($data['password']);
$data['created_at'] = date('Y-m-d H:i:s');
$data['updated_at'] = date('Y-m-d H:i:s');
return $this->database->insert('users', $data);
}
public function update(int $id, array $data): bool
{
if (isset($data['password']) && !empty($data['password'])) {
$data['passhash'] = password_hash($data['password'], PASSWORD_ARGON2ID);
unset($data['password']);
}
$data['updated_at'] = date('Y-m-d H:i:s');
return $this->database->update('users', $data, 'id = :id', ['id' => $id]) > 0;
}
public function delete(int $id): bool
{
return $this->database->delete('users', 'id = :id', ['id' => $id]) > 0;
}
public function toggleStatus(int $id): bool
{
$user = $this->find($id);
if (!$user) {
return false;
}
$newStatus = !$user['active'];
return $this->update($id, ['active' => $newStatus]);
}
public function count(array $filters = []): int
{
$sql = "SELECT COUNT(*) as total FROM users WHERE 1=1";
$params = [];
if (!empty($filters['role'])) {
$sql .= " AND role = :role";
$params['role'] = $filters['role'];
}
if (isset($filters['active'])) {
$sql .= " AND active = :active";
$params['active'] = $filters['active'];
}
$result = $this->database->fetch($sql, $params);
return $result['total'] ?? 0;
}
public function getActiveUsers(): array
{
return $this->findAll(['active' => true]);
}
public function getUsersByRole(string $role): array
{
return $this->findAll(['role' => $role]);
}
public function searchUsers(string $search): array
{
return $this->findAll(['search' => $search]);
}
public function updateLastLogin(int $id): bool
{
return $this->update($id, ['last_login' => date('Y-m-d H:i:s')]);
}
public function changePassword(int $id, string $newPassword): bool
{
return $this->update($id, ['password' => $newPassword]);
}
public function getUsersWithStats(): array
{
$sql = "SELECT
u.*,
COUNT(DISTINCT a.id) as asset_count,
COUNT(DISTINCT al.id) as audit_count
FROM users u
LEFT JOIN assets a ON u.id = a.created_by
LEFT JOIN audit_log al ON u.id = al.user_id
GROUP BY u.id
ORDER BY u.name ASC";
return $this->database->fetchAll($sql);
}
public function getRecentActivity(int $userId, int $limit = 10): array
{
$sql = "SELECT al.*, a.inventarnummer, a.bezeichnung
FROM audit_log al
LEFT JOIN assets a ON al.table_name = 'assets' AND al.record_id = a.id
WHERE al.user_id = :user_id
ORDER BY al.created_at DESC
LIMIT :limit";
return $this->database->fetchAll($sql, [
'user_id' => $userId,
'limit' => $limit
]);
}
public function validateEmail(string $email, int $excludeId = null): bool
{
$sql = "SELECT COUNT(*) as total FROM users WHERE email = :email";
$params = ['email' => $email];
if ($excludeId) {
$sql .= " AND id != :exclude_id";
$params['exclude_id'] = $excludeId;
}
$result = $this->database->fetch($sql, $params);
return ($result['total'] ?? 0) === 0;
}
public function getRoles(): array
{
return [
'admin' => 'Administrator',
'auditor' => 'Auditor',
'employee' => 'Mitarbeiter'
];
}
public function getRoleName(string $role): string
{
$roles = $this->getRoles();
return $roles[$role] ?? $role;
}
}

View File

@@ -0,0 +1,200 @@
<?php
namespace App\Services;
use App\Core\Database;
use App\Core\Session;
class AuditService
{
private Database $database;
private Session $session;
public function __construct(Database $database, Session $session)
{
$this->database = $database;
$this->session = $session;
}
public function log(string $action, string $table, int $recordId, array $oldValue = null, array $newValue = null): void
{
$userId = $this->session->getUserId();
if (!$userId) {
return; // Skip logging if no user
}
$data = [
'user_id' => $userId,
'action' => $action,
'table_name' => $table,
'record_id' => $recordId,
'old_value' => $oldValue ? json_encode($oldValue) : null,
'new_value' => $newValue ? json_encode($newValue) : null,
'created_at' => date('Y-m-d H:i:s')
];
try {
$this->database->insert('audit_log', $data);
} catch (\Exception $e) {
error_log('Audit logging failed: ' . $e->getMessage());
}
}
public function getAuditLog(string $table = null, int $recordId = null, int $limit = 100): array
{
$sql = "SELECT al.*, u.name as user_name
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.id
WHERE 1=1";
$params = [];
if ($table) {
$sql .= " AND al.table_name = :table";
$params['table'] = $table;
}
if ($recordId) {
$sql .= " AND al.record_id = :record_id";
$params['record_id'] = $recordId;
}
$sql .= " ORDER BY al.created_at DESC LIMIT :limit";
$params['limit'] = $limit;
return $this->database->fetchAll($sql, $params);
}
public function getAssetHistory(int $assetId): array
{
return $this->getAuditLog('assets', $assetId);
}
public function getUserActivity(int $userId, int $limit = 50): array
{
$sql = "SELECT al.*, u.name as user_name
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.id
WHERE al.user_id = :user_id
ORDER BY al.created_at DESC
LIMIT :limit";
return $this->database->fetchAll($sql, [
'user_id' => $userId,
'limit' => $limit
]);
}
public function getRecentActivity(int $limit = 20): array
{
$sql = "SELECT al.*, u.name as user_name
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.id
ORDER BY al.created_at DESC
LIMIT :limit";
return $this->database->fetchAll($sql, ['limit' => $limit]);
}
public function getActivityByDateRange(string $startDate, string $endDate): array
{
$sql = "SELECT al.*, u.name as user_name
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.id
WHERE DATE(al.created_at) BETWEEN :start_date AND :end_date
ORDER BY al.created_at DESC";
return $this->database->fetchAll($sql, [
'start_date' => $startDate,
'end_date' => $endDate
]);
}
public function getActivityByAction(string $action, int $limit = 100): array
{
$sql = "SELECT al.*, u.name as user_name
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.id
WHERE al.action = :action
ORDER BY al.created_at DESC
LIMIT :limit";
return $this->database->fetchAll($sql, [
'action' => $action,
'limit' => $limit
]);
}
public function getAssetChanges(int $assetId): array
{
$sql = "SELECT al.*, u.name as user_name
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.id
WHERE al.table_name = 'assets'
AND al.record_id = :asset_id
AND al.action IN ('update', 'delete')
ORDER BY al.created_at DESC";
return $this->database->fetchAll($sql, ['asset_id' => $assetId]);
}
public function getInventoryActivity(int $inventoryId): array
{
$sql = "SELECT al.*, u.name as user_name
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.id
WHERE al.table_name = 'inventories'
AND al.record_id = :inventory_id
ORDER BY al.created_at DESC";
return $this->database->fetchAll($sql, ['inventory_id' => $inventoryId]);
}
public function cleanupOldLogs(int $daysToKeep = 365): int
{
$sql = "DELETE FROM audit_log
WHERE created_at < DATE_SUB(NOW(), INTERVAL :days DAY)";
return $this->database->query($sql, ['days' => $daysToKeep])->rowCount();
}
public function getAuditSummary(): array
{
$sql = "SELECT
COUNT(*) as total_entries,
COUNT(DISTINCT user_id) as unique_users,
COUNT(DISTINCT table_name) as tables_affected,
MIN(created_at) as first_entry,
MAX(created_at) as last_entry
FROM audit_log";
return $this->database->fetch($sql) ?: [];
}
public function getTopUsers(int $limit = 10): array
{
$sql = "SELECT
u.name as user_name,
COUNT(*) as activity_count
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.id
GROUP BY al.user_id, u.name
ORDER BY activity_count DESC
LIMIT :limit";
return $this->database->fetchAll($sql, ['limit' => $limit]);
}
public function getMostActiveTables(int $limit = 10): array
{
$sql = "SELECT
table_name,
COUNT(*) as activity_count
FROM audit_log
GROUP BY table_name
ORDER BY activity_count DESC
LIMIT :limit";
return $this->database->fetchAll($sql, ['limit' => $limit]);
}
}

42
app/Views/auth/login.php Normal file
View File

@@ -0,0 +1,42 @@
<div class="login-container">
<div class="login-box">
<div class="login-header">
<h2><?= $lang['login_title'] ?></h2>
<p>Asset Management System</p>
</div>
<form method="POST" action="/login" class="login-form">
<input type="hidden" name="csrf_token" value="<?= $csrfToken ?>">
<div class="form-group">
<label for="email"><?= $lang['login_email'] ?></label>
<input type="email" id="email" name="email" required class="form-control">
</div>
<div class="form-group">
<label for="password"><?= $lang['login_password'] ?></label>
<input type="password" id="password" name="password" required class="form-control">
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="remember" value="1">
<span class="checkmark"></span>
<?= $lang['login_remember'] ?>
</label>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary btn-block">
<?= $lang['login_submit'] ?>
</button>
</div>
</form>
<div class="login-footer">
<a href="/password/forgot" class="forgot-password">
<?= $lang['forgot_password'] ?>
</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="<?= $locale ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $lang['dashboard'] ?> - Asset Management System</title>
<!-- CSS -->
<link rel="stylesheet" href="/assets/css/style.css">
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="/assets/img/favicon.ico">
<!-- Security Headers -->
<meta http-equiv="X-Content-Type-Options" content="nosniff">
<meta http-equiv="X-Frame-Options" content="SAMEORIGIN">
<meta http-equiv="X-XSS-Protection" content="1; mode=block">
</head>
<body>
<!-- Header -->
<header class="header">
<div class="container">
<div class="header-content">
<div class="logo">
<h1>Asset Management</h1>
</div>
<?php if ($user): ?>
<nav class="nav">
<ul class="nav-list">
<li><a href="/dashboard" class="nav-link"><?= $lang['dashboard'] ?></a></li>
<li><a href="/assets" class="nav-link"><?= $lang['assets'] ?></a></li>
<li><a href="/inventories" class="nav-link"><?= $lang['inventory'] ?></a></li>
<li><a href="/reports" class="nav-link"><?= $lang['reports'] ?></a></li>
<?php if ($user['role'] === 'admin'): ?>
<li class="nav-dropdown">
<a href="#" class="nav-link"><?= $lang['master_data'] ?></a>
<ul class="nav-dropdown-menu">
<li><a href="/categories"><?= $lang['categories_nav'] ?></a></li>
<li><a href="/locations"><?= $lang['locations_nav'] ?></a></li>
<li><a href="/departments"><?= $lang['departments_nav'] ?></a></li>
</ul>
</li>
<li><a href="/users" class="nav-link"><?= $lang['users'] ?></a></li>
<?php endif; ?>
</ul>
</nav>
<div class="user-menu">
<span class="user-name"><?= htmlspecialchars($user['name']) ?></span>
<div class="user-dropdown">
<a href="#" class="user-link"><?= $user['name'] ?></a>
<ul class="user-dropdown-menu">
<li><a href="/settings"><?= $lang['settings'] ?></a></li>
<li><a href="/logout"><?= $lang['logout'] ?></a></li>
</ul>
</div>
</div>
<?php endif; ?>
</div>
</div>
</header>
<!-- Main Content -->
<main class="main">
<div class="container">
<!-- Flash Messages -->
<?php if (!empty($flashMessages)): ?>
<div class="flash-messages">
<?php foreach ($flashMessages as $type => $message): ?>
<div class="alert alert-<?= $type ?>">
<?= htmlspecialchars($message) ?>
<button type="button" class="alert-close" onclick="this.parentElement.remove()">&times;</button>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- Page Content -->
<div class="content">
<?= $content ?>
</div>
</div>
</main>
<!-- Footer -->
<footer class="footer">
<div class="container">
<p>&copy; <?= date('Y') ?> Asset Management System. Alle Rechte vorbehalten.</p>
</div>
</footer>
<!-- JavaScript -->
<script src="/assets/js/app.js"></script>
</body>
</html>

26
composer.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "inventory/asset-management",
"description": "Professional Asset Management System",
"type": "project",
"require": {
"php": ">=8.2",
"dompdf/dompdf": "^2.0",
"picqer/php-barcode-generator": "^2.2",
"phpoffice/phpspreadsheet": "^1.29",
"vlucas/phpdotenv": "^5.5"
},
"autoload": {
"psr-4": {
"App\\": "app/"
},
"files": [
"app/Helpers/functions.php"
]
},
"config": {
"optimize-autoloader": true,
"sort-packages": true
},
"minimum-stability": "stable",
"prefer-stable": true
}

23
composer.lock generated Normal file
View File

@@ -0,0 +1,23 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "d751713988987e9331980363e24189ce",
"packages": [],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": ">=8.2"
},
"platform-dev": [],
"platform-overrides": {
"php": "8.2"
},
"plugin-api-version": "2.3.0"
}

58
config/config.php Normal file
View File

@@ -0,0 +1,58 @@
<?php
/**
* Application Configuration
*/
// Database configuration
define('DB_HOST', $_ENV['DB_HOST'] ?? 'localhost');
define('DB_PORT', $_ENV['DB_PORT'] ?? 3306);
define('DB_NAME', $_ENV['DB_NAME'] ?? 'inventory');
define('DB_USER', $_ENV['DB_USER'] ?? 'root');
define('DB_PASS', $_ENV['DB_PASS'] ?? '');
// Application settings
define('APP_ENV', $_ENV['APP_ENV'] ?? 'production');
define('APP_URL', $_ENV['APP_URL'] ?? 'http://localhost:8080');
define('APP_DEBUG', $_ENV['APP_DEBUG'] ?? false);
define('APP_KEY', $_ENV['APP_KEY'] ?? 'base64:default-key-change-in-production');
// Session settings
define('SESSION_SECURE', $_ENV['SESSION_SECURE'] ?? false);
define('SESSION_LIFETIME', $_ENV['SESSION_LIFETIME'] ?? 3600);
// File upload settings
define('UPLOAD_MAX_SIZE', $_ENV['UPLOAD_MAX_SIZE'] ?? '50M');
define('ALLOWED_FILE_TYPES', $_ENV['ALLOWED_FILE_TYPES'] ?? 'pdf,jpg,jpeg,png,gif,doc,docx,xls,xlsx');
// Security settings
define('CSRF_TOKEN_LIFETIME', $_ENV['CSRF_TOKEN_LIFETIME'] ?? 3600);
define('PASSWORD_MIN_LENGTH', $_ENV['PASSWORD_MIN_LENGTH'] ?? 8);
define('LOGIN_MAX_ATTEMPTS', $_ENV['LOGIN_MAX_ATTEMPTS'] ?? 5);
define('LOGIN_LOCKOUT_TIME', $_ENV['LOGIN_LOCKOUT_TIME'] ?? 900);
// Paths
define('ROOT_PATH', dirname(__DIR__));
define('APP_PATH', ROOT_PATH . '/app');
define('CONFIG_PATH', ROOT_PATH . '/config');
define('STORAGE_PATH', ROOT_PATH . '/storage');
define('PUBLIC_PATH', ROOT_PATH . '/public');
define('LANG_PATH', ROOT_PATH . '/lang');
// Error reporting
if (APP_DEBUG) {
error_reporting(E_ALL);
ini_set('display_errors', 1);
} else {
error_reporting(0);
ini_set('display_errors', 0);
}
// Set timezone
date_default_timezone_set('Europe/Berlin');
// Configure session
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure', SESSION_SECURE ? 1 : 0);
ini_set('session.cookie_samesite', 'Strict');
ini_set('session.use_strict_mode', 1);
ini_set('session.gc_maxlifetime', SESSION_LIFETIME);

123
config/routes.php Normal file
View File

@@ -0,0 +1,123 @@
<?php
/**
* Application Routes
*/
use App\Core\Router;
use App\Controllers\AuthController;
use App\Controllers\DashboardController;
use App\Controllers\AssetController;
use App\Controllers\InventoryController;
use App\Controllers\ReportController;
use App\Controllers\UserController;
use App\Controllers\CategoryController;
use App\Controllers\LocationController;
use App\Controllers\DepartmentController;
use App\Controllers\FileController;
use App\Controllers\ExportController;
use App\Controllers\ImportController;
$router = Router::getInstance();
// Authentication routes
$router->get('/login', [AuthController::class, 'showLogin']);
$router->post('/login', [AuthController::class, 'login']);
$router->post('/logout', [AuthController::class, 'logout']);
$router->get('/password/forgot', [AuthController::class, 'showForgotPassword']);
$router->post('/password/forgot', [AuthController::class, 'forgotPassword']);
$router->get('/password/reset', [AuthController::class, 'showResetPassword']);
$router->post('/password/reset', [AuthController::class, 'resetPassword']);
// Protected routes (require authentication)
$router->group(['middleware' => 'auth'], function($router) {
// Dashboard
$router->get('/', [DashboardController::class, 'index']);
$router->get('/dashboard', [DashboardController::class, 'index']);
// Assets
$router->get('/assets', [AssetController::class, 'index']);
$router->get('/assets/create', [AssetController::class, 'create']);
$router->post('/assets', [AssetController::class, 'store']);
$router->get('/assets/{id}', [AssetController::class, 'show']);
$router->get('/assets/{id}/edit', [AssetController::class, 'edit']);
$router->post('/assets/{id}', [AssetController::class, 'update']);
$router->post('/assets/{id}/delete', [AssetController::class, 'delete']);
$router->post('/assets/{id}/assign', [AssetController::class, 'assign']);
$router->post('/assets/{id}/checkout', [AssetController::class, 'checkout']);
$router->post('/assets/{id}/checkin', [AssetController::class, 'checkin']);
// Asset files
$router->post('/assets/{id}/files', [FileController::class, 'upload']);
$router->get('/files/{id}/download', [FileController::class, 'download']);
$router->post('/files/{id}/delete', [FileController::class, 'delete']);
// Inventory
$router->get('/inventories', [InventoryController::class, 'index']);
$router->get('/inventories/create', [InventoryController::class, 'create']);
$router->post('/inventories', [InventoryController::class, 'store']);
$router->get('/inventories/{id}', [InventoryController::class, 'show']);
$router->post('/inventories/{id}/close', [InventoryController::class, 'close']);
$router->post('/inventories/{id}/scan', [InventoryController::class, 'scan']);
$router->get('/inventories/{id}/report', [InventoryController::class, 'report']);
// Reports
$router->get('/reports', [ReportController::class, 'index']);
$router->get('/reports/assets', [ReportController::class, 'assets']);
$router->get('/reports/warranty', [ReportController::class, 'warranty']);
$router->get('/reports/retired', [ReportController::class, 'retired']);
$router->get('/reports/location', [ReportController::class, 'location']);
$router->get('/reports/category', [ReportController::class, 'category']);
// Exports
$router->get('/exports/csv', [ExportController::class, 'csv']);
$router->get('/exports/xlsx', [ExportController::class, 'xlsx']);
$router->get('/exports/labels', [ExportController::class, 'labels']);
// Imports
$router->get('/imports/csv', [ImportController::class, 'show']);
$router->post('/imports/csv', [ImportController::class, 'import']);
// Master data
$router->get('/categories', [CategoryController::class, 'index']);
$router->get('/categories/create', [CategoryController::class, 'create']);
$router->post('/categories', [CategoryController::class, 'store']);
$router->get('/categories/{id}/edit', [CategoryController::class, 'edit']);
$router->post('/categories/{id}', [CategoryController::class, 'update']);
$router->post('/categories/{id}/delete', [CategoryController::class, 'delete']);
$router->get('/locations', [LocationController::class, 'index']);
$router->get('/locations/create', [LocationController::class, 'create']);
$router->post('/locations', [LocationController::class, 'store']);
$router->get('/locations/{id}/edit', [LocationController::class, 'edit']);
$router->post('/locations/{id}', [LocationController::class, 'update']);
$router->post('/locations/{id}/delete', [LocationController::class, 'delete']);
$router->get('/departments', [DepartmentController::class, 'index']);
$router->get('/departments/create', [DepartmentController::class, 'create']);
$router->post('/departments', [DepartmentController::class, 'store']);
$router->get('/departments/{id}/edit', [DepartmentController::class, 'edit']);
$router->post('/departments/{id}', [DepartmentController::class, 'update']);
$router->post('/departments/{id}/delete', [DepartmentController::class, 'delete']);
// Admin routes
$router->group(['middleware' => 'admin'], function($router) {
$router->get('/users', [UserController::class, 'index']);
$router->get('/users/create', [UserController::class, 'create']);
$router->post('/users', [UserController::class, 'store']);
$router->get('/users/{id}/edit', [UserController::class, 'edit']);
$router->post('/users/{id}', [UserController::class, 'update']);
$router->post('/users/{id}/delete', [UserController::class, 'delete']);
$router->post('/users/{id}/toggle', [UserController::class, 'toggleStatus']);
});
});
// Error routes
$router->get('/404', function() {
http_response_code(404);
require APP_PATH . '/Views/errors/404.php';
});
$router->get('/500', function() {
http_response_code(500);
require APP_PATH . '/Views/errors/500.php';
});

View File

@@ -0,0 +1,14 @@
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
role ENUM('admin', 'auditor', 'employee') NOT NULL DEFAULT 'employee',
passhash VARCHAR(255) NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
locale VARCHAR(5) NOT NULL DEFAULT 'de',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_email (email),
INDEX idx_role (role),
INDEX idx_active (active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,10 @@
CREATE TABLE password_resets (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token VARCHAR(255) UNIQUE NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_token (token),
INDEX idx_expires_at (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,8 @@
CREATE TABLE categories (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,11 @@
CREATE TABLE locations (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) UNIQUE NOT NULL,
address TEXT,
contact_person VARCHAR(255),
contact_email VARCHAR(255),
contact_phone VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,11 @@
CREATE TABLE departments (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) UNIQUE NOT NULL,
cost_center VARCHAR(50),
manager VARCHAR(255),
manager_email VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_name (name),
INDEX idx_cost_center (cost_center)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,34 @@
CREATE TABLE assets (
id INT AUTO_INCREMENT PRIMARY KEY,
inventarnummer VARCHAR(255) UNIQUE NOT NULL,
bezeichnung VARCHAR(500) NOT NULL,
kategorie_id INT,
standort_id INT,
abteilung_id INT,
raum VARCHAR(100),
lieferant VARCHAR(255),
seriennummer VARCHAR(255),
anschaffungsdatum DATE,
anschaffungspreis DECIMAL(10,2),
garantie_bis DATE,
zustand ENUM('neu', 'gut', 'befriedigend', 'schlecht', 'defekt') DEFAULT 'gut',
status ENUM('aktiv', 'inaktiv', 'ausgemustert') DEFAULT 'aktiv',
kostenstelle VARCHAR(50),
notizen TEXT,
created_by INT,
updated_by INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (kategorie_id) REFERENCES categories(id) ON DELETE SET NULL,
FOREIGN KEY (standort_id) REFERENCES locations(id) ON DELETE SET NULL,
FOREIGN KEY (abteilung_id) REFERENCES departments(id) ON DELETE SET NULL,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_inventarnummer (inventarnummer),
INDEX idx_kategorie_id (kategorie_id),
INDEX idx_standort_id (standort_id),
INDEX idx_status (status),
INDEX idx_seriennummer (seriennummer),
INDEX idx_anschaffungsdatum (anschaffungsdatum),
INDEX idx_garantie_bis (garantie_bis)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,15 @@
CREATE TABLE asset_assignments (
id INT AUTO_INCREMENT PRIMARY KEY,
asset_id INT NOT NULL,
user_id INT NOT NULL,
von DATE NOT NULL,
bis DATE,
kommentar TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_asset_id (asset_id),
INDEX idx_user_id (user_id),
INDEX idx_von (von),
INDEX idx_bis (bis)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,16 @@
CREATE TABLE asset_files (
id INT AUTO_INCREMENT PRIMARY KEY,
asset_id INT NOT NULL,
filename VARCHAR(255) NOT NULL,
original_filename VARCHAR(255) NOT NULL,
path VARCHAR(500) NOT NULL,
mime VARCHAR(100) NOT NULL,
size INT NOT NULL,
uploaded_by INT,
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE,
FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_asset_id (asset_id),
INDEX idx_uploaded_by (uploaded_by),
INDEX idx_uploaded_at (uploaded_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,15 @@
CREATE TABLE inventories (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
started_by INT,
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
closed_at TIMESTAMP NULL,
status ENUM('offen', 'abgeschlossen') DEFAULT 'offen',
kommentar TEXT,
FOREIGN KEY (started_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_started_by (started_by),
INDEX idx_status (status),
INDEX idx_started_at (started_at),
INDEX idx_closed_at (closed_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,19 @@
CREATE TABLE inventory_items (
id INT AUTO_INCREMENT PRIMARY KEY,
inventory_id INT NOT NULL,
asset_id INT NOT NULL,
soll_status ENUM('aktiv', 'inaktiv', 'ausgemustert') NOT NULL,
ist_status ENUM('gefunden', 'nicht_gefunden', 'defekt', 'verschoben') NOT NULL,
checked_at TIMESTAMP NULL,
checked_by INT,
bemerkung TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (inventory_id) REFERENCES inventories(id) ON DELETE CASCADE,
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE,
FOREIGN KEY (checked_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_inventory_id (inventory_id),
INDEX idx_asset_id (asset_id),
INDEX idx_checked_by (checked_by),
INDEX idx_ist_status (ist_status),
UNIQUE KEY unique_inventory_asset (inventory_id, asset_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,16 @@
CREATE TABLE audit_log (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT,
action VARCHAR(50) NOT NULL,
table_name VARCHAR(100) NOT NULL,
record_id INT NOT NULL,
old_value JSON,
new_value JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_user_id (user_id),
INDEX idx_action (action),
INDEX idx_table_name (table_name),
INDEX idx_record_id (record_id),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,6 @@
-- Insert default admin user
-- Password: Admin!234 (will be changed on first login)
INSERT INTO users (name, email, role, passhash, active, locale) VALUES
('Administrator', 'admin@example.com', 'admin', '$argon2id$v=19$m=65536,t=4,p=1$dXNlcl9zYWx0$hashed_password_here', TRUE, 'de'),
('Max Mustermann', 'max.mustermann@example.com', 'auditor', '$argon2id$v=19$m=65536,t=4,p=1$dXNlcl9zYWx0$hashed_password_here', TRUE, 'de'),
('Anna Schmidt', 'anna.schmidt@example.com', 'employee', '$argon2id$v=19$m=65536,t=4,p=1$dXNlcl9zYWx0$hashed_password_here', TRUE, 'de');

View File

@@ -0,0 +1,11 @@
INSERT INTO categories (name, description) VALUES
('Computer & Hardware', 'Desktop-Computer, Laptops, Server und Zubehör'),
('Netzwerk & Kommunikation', 'Router, Switches, Telefone, WLAN-Geräte'),
('Büroausstattung', 'Tische, Stühle, Schränke, Drucker'),
('Software & Lizenzen', 'Betriebssysteme, Anwendungssoftware, Lizenzen'),
('Fahrzeuge & Transport', 'Firmenwagen, Gabelstapler, Transportgeräte'),
('Werkzeuge & Maschinen', 'Handwerkzeuge, Maschinen, Produktionsanlagen'),
('Sicherheit & Überwachung', 'Kameras, Alarmanlagen, Zutrittskontrollen'),
('Möbel & Einrichtung', 'Büromöbel, Einrichtungsgegenstände'),
('Elektronik & Unterhaltung', 'TV-Geräte, Audio-Systeme, Präsentationstechnik'),
('Sonstiges', 'Verschiedene andere Anlagen und Gegenstände');

View File

@@ -0,0 +1,6 @@
INSERT INTO locations (name, address, contact_person, contact_email, contact_phone) VALUES
('Hauptsitz - Gebäude A', 'Musterstraße 1, 12345 Musterstadt', 'Peter Müller', 'peter.mueller@example.com', '+49 123 456789'),
('Niederlassung - Gebäude B', 'Beispielweg 15, 54321 Beispielort', 'Maria Weber', 'maria.weber@example.com', '+49 987 654321'),
('Lager - Gebäude C', 'Industriepark 7, 67890 Industriestadt', 'Hans Schmidt', 'hans.schmidt@example.com', '+49 555 123456'),
('Außenstelle Nord', 'Nordstraße 42, 11111 Nordstadt', 'Lisa Fischer', 'lisa.fischer@example.com', '+49 111 222333'),
('Außenstelle Süd', 'Südallee 8, 99999 Südstadt', 'Tom Wagner', 'tom.wagner@example.com', '+49 999 888777');

View File

@@ -0,0 +1,11 @@
INSERT INTO departments (name, cost_center, manager, manager_email) VALUES
('Geschäftsführung', 'GF-001', 'Dr. Michael Bauer', 'michael.bauer@example.com'),
('IT & Systemadministration', 'IT-001', 'Sarah Klein', 'sarah.klein@example.com'),
('Buchhaltung & Finanzen', 'BU-001', 'Frank Meyer', 'frank.meyer@example.com'),
('Personal & Verwaltung', 'PE-001', 'Claudia Schulz', 'claudia.schulz@example.com'),
('Vertrieb & Marketing', 'VM-001', 'Andreas Wolf', 'andreas.wolf@example.com'),
('Produktion & Fertigung', 'PR-001', 'Robert Koch', 'robert.koch@example.com'),
('Einkauf & Logistik', 'EL-001', 'Petra Lange', 'petra.lange@example.com'),
('Forschung & Entwicklung', 'FE-001', 'Dr. Thomas Richter', 'thomas.richter@example.com'),
('Kundenservice', 'KS-001', 'Nina Becker', 'nina.becker@example.com'),
('Qualitätssicherung', 'QS-001', 'Markus Hoffmann', 'markus.hoffmann@example.com');

View File

@@ -0,0 +1,11 @@
INSERT INTO assets (inventarnummer, bezeichnung, kategorie_id, standort_id, abteilung_id, raum, lieferant, seriennummer, anschaffungsdatum, anschaffungspreis, garantie_bis, zustand, status, kostenstelle, notizen, created_by) VALUES
('PC-2024-001', 'Dell OptiPlex 7090 Desktop Computer', 1, 1, 2, 'Büro 101', 'Dell Technologies', 'SN123456789', '2024-01-15', 899.00, '2027-01-15', 'neu', 'aktiv', 'IT-001', 'Standard-Arbeitsplatz', 1),
('PC-2024-002', 'HP EliteBook 840 G8 Laptop', 1, 1, 2, 'Büro 102', 'HP Inc.', 'SN987654321', '2024-02-01', 1299.00, '2027-02-01', 'neu', 'aktiv', 'IT-001', 'Mobiler Arbeitsplatz', 1),
('NET-2024-001', 'Cisco Catalyst 2960 Switch', 2, 1, 2, 'Serverraum', 'Cisco Systems', 'SN456789123', '2024-01-10', 450.00, '2027-01-10', 'neu', 'aktiv', 'IT-001', '24-Port Switch', 1),
('BÜRO-2024-001', 'Herman Miller Aeron Bürostuhl', 3, 1, 4, 'Büro 103', 'Herman Miller', 'SN789123456', '2024-03-01', 850.00, '2027-03-01', 'neu', 'aktiv', 'PE-001', 'Ergonomischer Bürostuhl', 1),
('SW-2024-001', 'Microsoft Office 365 Business Premium', 4, 1, 2, 'Virtuell', 'Microsoft', 'LIC-2024-001', '2024-01-01', 12.50, '2025-01-01', 'neu', 'aktiv', 'IT-001', 'Jährliche Lizenz', 1),
('FAHR-2024-001', 'VW Passat Variant Firmenwagen', 5, 1, 1, 'Parkplatz A1', 'Volkswagen AG', 'SN111222333', '2024-01-20', 35000.00, '2027-01-20', 'neu', 'aktiv', 'GF-001', 'Geschäftsführer', 1),
('WERK-2024-001', 'Bosch Professional Bohrmaschine', 6, 3, 6, 'Werkstatt', 'Bosch', 'SN444555666', '2024-02-15', 89.00, '2026-02-15', 'neu', 'aktiv', 'PR-001', 'Werkstattausstattung', 1),
('SICH-2024-001', 'Hikvision IP-Kamera', 7, 1, 2, 'Eingang', 'Hikvision', 'SN777888999', '2024-01-05', 120.00, '2027-01-05', 'neu', 'aktiv', 'IT-001', 'Überwachungskamera', 1),
('MÖB-2024-001', 'IKEA BEKANT Schreibtisch', 8, 1, 4, 'Büro 104', 'IKEA', 'SN000111222', '2024-03-10', 199.00, '2027-03-10', 'neu', 'aktiv', 'PE-001', 'Standard-Schreibtisch', 1),
('ELEK-2024-001', 'Samsung 55" Smart TV', 9, 1, 5, 'Konferenzraum', 'Samsung Electronics', 'SN333444555', '2024-02-20', 599.00, '2027-02-20', 'neu', 'aktiv', 'VM-001', 'Präsentationstechnik', 1);

77
docker-compose.yml Normal file
View File

@@ -0,0 +1,77 @@
version: '3.8'
services:
nginx:
build:
context: .
dockerfile: Dockerfile.nginx
ports:
- "8080:80"
depends_on:
- php
volumes:
- ./storage/uploads:/var/www/html/storage/uploads:ro
networks:
- inventory-network
php:
build:
context: .
dockerfile: Dockerfile.php
volumes:
- ./storage/uploads:/var/www/html/storage/uploads
- ./storage/logs:/var/www/html/storage/logs
depends_on:
db:
condition: service_healthy
environment:
- DB_HOST=${DB_HOST}
- DB_PORT=${DB_PORT}
- DB_NAME=${DB_NAME}
- DB_USER=${DB_USER}
- DB_PASS=${DB_PASS}
- APP_ENV=${APP_ENV}
- APP_URL=${APP_URL}
- SESSION_SECURE=${SESSION_SECURE}
networks:
- inventory-network
db:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASS}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASS}
volumes:
- mysql_data:/var/lib/mysql
- ./database/init:/docker-entrypoint-initdb.d
ports:
- "3306:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASS}"]
timeout: 20s
retries: 10
networks:
- inventory-network
phpmyadmin:
image: phpmyadmin/phpmyadmin:latest
environment:
PMA_HOST: db
PMA_PORT: 3306
PMA_USER: ${DB_USER}
PMA_PASSWORD: ${DB_PASS}
ports:
- "8081:80"
depends_on:
- db
networks:
- inventory-network
volumes:
mysql_data:
networks:
inventory-network:
driver: bridge

40
env.example Normal file
View File

@@ -0,0 +1,40 @@
# Application Settings
APP_ENV=production
APP_URL=http://localhost:8080
APP_DEBUG=false
APP_KEY=base64:your-secret-key-here
# Database Settings
DB_HOST=db
DB_PORT=3306
DB_NAME=inventory
DB_USER=inventory
DB_PASS=changeMe
DB_ROOT_PASS=rootPassword123
# Session Settings
SESSION_SECURE=false
SESSION_LIFETIME=3600
# File Upload Settings
UPLOAD_MAX_SIZE=50M
ALLOWED_FILE_TYPES=pdf,jpg,jpeg,png,gif,doc,docx,xls,xlsx
# Email Settings (for password reset)
MAIL_HOST=smtp.example.com
MAIL_PORT=587
MAIL_USERNAME=noreply@example.com
MAIL_PASSWORD=your-email-password
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=noreply@example.com
MAIL_FROM_NAME="Asset Management System"
# Security Settings
CSRF_TOKEN_LIFETIME=3600
PASSWORD_MIN_LENGTH=8
LOGIN_MAX_ATTEMPTS=5
LOGIN_LOCKOUT_TIME=900
# Cache Settings
CACHE_DRIVER=file
CACHE_TTL=3600

233
lang/de.php Normal file
View File

@@ -0,0 +1,233 @@
<?php
return [
// Allgemein
'dashboard' => 'Dashboard',
'assets' => 'Anlagen',
'inventory' => 'Inventur',
'reports' => 'Berichte',
'users' => 'Benutzer',
'settings' => 'Einstellungen',
'logout' => 'Abmelden',
'login' => 'Anmelden',
'save' => 'Speichern',
'cancel' => 'Abbrechen',
'delete' => 'Löschen',
'edit' => 'Bearbeiten',
'create' => 'Erstellen',
'search' => 'Suchen',
'filter' => 'Filter',
'export' => 'Exportieren',
'import' => 'Importieren',
'print' => 'Drucken',
'download' => 'Herunterladen',
'upload' => 'Hochladen',
'back' => 'Zurück',
'next' => 'Weiter',
'previous' => 'Zurück',
'close' => 'Schließen',
'open' => 'Öffnen',
'yes' => 'Ja',
'no' => 'Nein',
'ok' => 'OK',
'error' => 'Fehler',
'success' => 'Erfolg',
'warning' => 'Warnung',
'info' => 'Information',
'loading' => 'Laden...',
'no_data' => 'Keine Daten verfügbar',
'actions' => 'Aktionen',
'status' => 'Status',
'date' => 'Datum',
'time' => 'Zeit',
'user' => 'Benutzer',
'description' => 'Beschreibung',
'notes' => 'Notizen',
'comments' => 'Kommentare',
'created_at' => 'Erstellt am',
'updated_at' => 'Aktualisiert am',
'created_by' => 'Erstellt von',
'updated_by' => 'Aktualisiert von',
// Navigation
'navigation' => 'Navigation',
'dashboard_nav' => 'Dashboard',
'assets_nav' => 'Anlagen',
'inventory_nav' => 'Inventur',
'reports_nav' => 'Berichte',
'master_data' => 'Stammdaten',
'categories_nav' => 'Kategorien',
'locations_nav' => 'Standorte',
'departments_nav' => 'Abteilungen',
'users_nav' => 'Benutzer',
'settings_nav' => 'Einstellungen',
// Assets
'asset' => 'Anlage',
'asset_id' => 'Anlagen-ID',
'inventory_number' => 'Inventarnummer',
'designation' => 'Bezeichnung',
'category' => 'Kategorie',
'location' => 'Standort',
'department' => 'Abteilung',
'room' => 'Raum',
'supplier' => 'Lieferant',
'serial_number' => 'Seriennummer',
'purchase_date' => 'Anschaffungsdatum',
'purchase_price' => 'Anschaffungspreis',
'warranty_until' => 'Garantie bis',
'condition' => 'Zustand',
'cost_center' => 'Kostenstelle',
'assign' => 'Zuweisen',
'checkout' => 'Ausleihen',
'checkin' => 'Zurückgeben',
'asset_details' => 'Anlagendetails',
'asset_history' => 'Anlagenhistorie',
'asset_files' => 'Anlagendateien',
'asset_assignments' => 'Anlagenzuweisungen',
// Asset Conditions
'condition_new' => 'Neu',
'condition_good' => 'Gut',
'condition_satisfactory' => 'Befriedigend',
'condition_poor' => 'Schlecht',
'condition_defective' => 'Defekt',
// Asset Status
'status_active' => 'Aktiv',
'status_inactive' => 'Inaktiv',
'status_retired' => 'Ausgemustert',
// Inventory
'inventory' => 'Inventur',
'inventory_run' => 'Inventurdurchlauf',
'inventory_item' => 'Inventurposition',
'inventory_status' => 'Inventurstatus',
'inventory_open' => 'Offen',
'inventory_closed' => 'Abgeschlossen',
'inventory_found' => 'Gefunden',
'inventory_not_found' => 'Nicht gefunden',
'inventory_defective' => 'Defekt',
'inventory_moved' => 'Verschoben',
'inventory_scan' => 'Scannen',
'inventory_report' => 'Inventurbericht',
// Users
'user' => 'Benutzer',
'username' => 'Benutzername',
'email' => 'E-Mail',
'password' => 'Passwort',
'password_confirm' => 'Passwort bestätigen',
'role' => 'Rolle',
'active' => 'Aktiv',
'inactive' => 'Inaktiv',
'last_login' => 'Letzter Login',
'login_attempts' => 'Login-Versuche',
'password_reset' => 'Passwort zurücksetzen',
'forgot_password' => 'Passwort vergessen',
// User Roles
'role_admin' => 'Administrator',
'role_auditor' => 'Auditor',
'role_employee' => 'Mitarbeiter',
// Authentication
'login_title' => 'Anmeldung',
'login_email' => 'E-Mail-Adresse',
'login_password' => 'Passwort',
'login_remember' => 'Angemeldet bleiben',
'login_submit' => 'Anmelden',
'login_failed' => 'Anmeldung fehlgeschlagen',
'login_locked' => 'Account gesperrt',
'logout_success' => 'Erfolgreich abgemeldet',
'password_reset_title' => 'Passwort zurücksetzen',
'password_reset_email' => 'E-Mail-Adresse',
'password_reset_submit' => 'Link senden',
'password_reset_sent' => 'E-Mail mit Reset-Link gesendet',
'password_reset_new' => 'Neues Passwort',
'password_reset_confirm' => 'Neues Passwort bestätigen',
'password_reset_success' => 'Passwort erfolgreich geändert',
// Messages
'message_saved' => 'Daten erfolgreich gespeichert',
'message_deleted' => 'Datensatz erfolgreich gelöscht',
'message_created' => 'Datensatz erfolgreich erstellt',
'message_updated' => 'Datensatz erfolgreich aktualisiert',
'message_error' => 'Ein Fehler ist aufgetreten',
'message_not_found' => 'Datensatz nicht gefunden',
'message_unauthorized' => 'Nicht autorisiert',
'message_forbidden' => 'Zugriff verweigert',
'message_validation_error' => 'Validierungsfehler',
// Validation
'validation_required' => 'Das Feld ist erforderlich',
'validation_email' => 'Ungültige E-Mail-Adresse',
'validation_min' => 'Mindestens :min Zeichen erforderlich',
'validation_max' => 'Maximal :max Zeichen erlaubt',
'validation_numeric' => 'Nur Zahlen erlaubt',
'validation_date' => 'Ungültiges Datum',
'validation_unique' => 'Wert bereits vorhanden',
// Reports
'report' => 'Bericht',
'report_assets' => 'Anlagenbericht',
'report_warranty' => 'Garantiebericht',
'report_retired' => 'Ausmusterungsbericht',
'report_location' => 'Standortbericht',
'report_category' => 'Kategoriebericht',
'report_inventory' => 'Inventurbericht',
'report_export' => 'Bericht exportieren',
'report_print' => 'Bericht drucken',
// Export/Import
'export_csv' => 'CSV exportieren',
'export_xlsx' => 'Excel exportieren',
'export_pdf' => 'PDF exportieren',
'import_csv' => 'CSV importieren',
'import_file' => 'Datei auswählen',
'import_preview' => 'Vorschau',
'import_confirm' => 'Import bestätigen',
'import_success' => 'Import erfolgreich',
'import_error' => 'Import fehlgeschlagen',
// Labels
'label_print' => 'Etiketten drucken',
'label_barcode' => 'Barcode',
'label_qr' => 'QR-Code',
'label_inventory_number' => 'Inventarnummer',
'label_designation' => 'Bezeichnung',
// Dashboard
'dashboard_welcome' => 'Willkommen im Asset Management System',
'dashboard_stats' => 'Statistiken',
'dashboard_total_assets' => 'Gesamte Anlagen',
'dashboard_active_assets' => 'Aktive Anlagen',
'dashboard_retired_assets' => 'Ausgemusterte Anlagen',
'dashboard_total_value' => 'Gesamtwert',
'dashboard_recent_activity' => 'Letzte Aktivitäten',
'dashboard_warranty_expiring' => 'Garantie läuft ab',
'dashboard_inventory_open' => 'Offene Inventuren',
// Months
'month_01' => 'Januar',
'month_02' => 'Februar',
'month_03' => 'März',
'month_04' => 'April',
'month_05' => 'Mai',
'month_06' => 'Juni',
'month_07' => 'Juli',
'month_08' => 'August',
'month_09' => 'September',
'month_10' => 'Oktober',
'month_11' => 'November',
'month_12' => 'Dezember',
// Days
'day_monday' => 'Montag',
'day_tuesday' => 'Dienstag',
'day_wednesday' => 'Mittwoch',
'day_thursday' => 'Donnerstag',
'day_friday' => 'Freitag',
'day_saturday' => 'Samstag',
'day_sunday' => 'Sonntag',
];

233
lang/en.php Normal file
View File

@@ -0,0 +1,233 @@
<?php
return [
// General
'dashboard' => 'Dashboard',
'assets' => 'Assets',
'inventory' => 'Inventory',
'reports' => 'Reports',
'users' => 'Users',
'settings' => 'Settings',
'logout' => 'Logout',
'login' => 'Login',
'save' => 'Save',
'cancel' => 'Cancel',
'delete' => 'Delete',
'edit' => 'Edit',
'create' => 'Create',
'search' => 'Search',
'filter' => 'Filter',
'export' => 'Export',
'import' => 'Import',
'print' => 'Print',
'download' => 'Download',
'upload' => 'Upload',
'back' => 'Back',
'next' => 'Next',
'previous' => 'Previous',
'close' => 'Close',
'open' => 'Open',
'yes' => 'Yes',
'no' => 'No',
'ok' => 'OK',
'error' => 'Error',
'success' => 'Success',
'warning' => 'Warning',
'info' => 'Information',
'loading' => 'Loading...',
'no_data' => 'No data available',
'actions' => 'Actions',
'status' => 'Status',
'date' => 'Date',
'time' => 'Time',
'user' => 'User',
'description' => 'Description',
'notes' => 'Notes',
'comments' => 'Comments',
'created_at' => 'Created at',
'updated_at' => 'Updated at',
'created_by' => 'Created by',
'updated_by' => 'Updated by',
// Navigation
'navigation' => 'Navigation',
'dashboard_nav' => 'Dashboard',
'assets_nav' => 'Assets',
'inventory_nav' => 'Inventory',
'reports_nav' => 'Reports',
'master_data' => 'Master Data',
'categories_nav' => 'Categories',
'locations_nav' => 'Locations',
'departments_nav' => 'Departments',
'users_nav' => 'Users',
'settings_nav' => 'Settings',
// Assets
'asset' => 'Asset',
'asset_id' => 'Asset ID',
'inventory_number' => 'Inventory Number',
'designation' => 'Designation',
'category' => 'Category',
'location' => 'Location',
'department' => 'Department',
'room' => 'Room',
'supplier' => 'Supplier',
'serial_number' => 'Serial Number',
'purchase_date' => 'Purchase Date',
'purchase_price' => 'Purchase Price',
'warranty_until' => 'Warranty Until',
'condition' => 'Condition',
'cost_center' => 'Cost Center',
'assign' => 'Assign',
'checkout' => 'Check Out',
'checkin' => 'Check In',
'asset_details' => 'Asset Details',
'asset_history' => 'Asset History',
'asset_files' => 'Asset Files',
'asset_assignments' => 'Asset Assignments',
// Asset Conditions
'condition_new' => 'New',
'condition_good' => 'Good',
'condition_satisfactory' => 'Satisfactory',
'condition_poor' => 'Poor',
'condition_defective' => 'Defective',
// Asset Status
'status_active' => 'Active',
'status_inactive' => 'Inactive',
'status_retired' => 'Retired',
// Inventory
'inventory' => 'Inventory',
'inventory_run' => 'Inventory Run',
'inventory_item' => 'Inventory Item',
'inventory_status' => 'Inventory Status',
'inventory_open' => 'Open',
'inventory_closed' => 'Closed',
'inventory_found' => 'Found',
'inventory_not_found' => 'Not Found',
'inventory_defective' => 'Defective',
'inventory_moved' => 'Moved',
'inventory_scan' => 'Scan',
'inventory_report' => 'Inventory Report',
// Users
'user' => 'User',
'username' => 'Username',
'email' => 'Email',
'password' => 'Password',
'password_confirm' => 'Confirm Password',
'role' => 'Role',
'active' => 'Active',
'inactive' => 'Inactive',
'last_login' => 'Last Login',
'login_attempts' => 'Login Attempts',
'password_reset' => 'Password Reset',
'forgot_password' => 'Forgot Password',
// User Roles
'role_admin' => 'Administrator',
'role_auditor' => 'Auditor',
'role_employee' => 'Employee',
// Authentication
'login_title' => 'Login',
'login_email' => 'Email Address',
'login_password' => 'Password',
'login_remember' => 'Remember Me',
'login_submit' => 'Login',
'login_failed' => 'Login Failed',
'login_locked' => 'Account Locked',
'logout_success' => 'Successfully Logged Out',
'password_reset_title' => 'Password Reset',
'password_reset_email' => 'Email Address',
'password_reset_submit' => 'Send Link',
'password_reset_sent' => 'Email with Reset Link Sent',
'password_reset_new' => 'New Password',
'password_reset_confirm' => 'Confirm New Password',
'password_reset_success' => 'Password Successfully Changed',
// Messages
'message_saved' => 'Data Successfully Saved',
'message_deleted' => 'Record Successfully Deleted',
'message_created' => 'Record Successfully Created',
'message_updated' => 'Record Successfully Updated',
'message_error' => 'An Error Occurred',
'message_not_found' => 'Record Not Found',
'message_unauthorized' => 'Unauthorized',
'message_forbidden' => 'Access Denied',
'message_validation_error' => 'Validation Error',
// Validation
'validation_required' => 'This field is required',
'validation_email' => 'Invalid email address',
'validation_min' => 'Minimum :min characters required',
'validation_max' => 'Maximum :max characters allowed',
'validation_numeric' => 'Numbers only allowed',
'validation_date' => 'Invalid date',
'validation_unique' => 'Value already exists',
// Reports
'report' => 'Report',
'report_assets' => 'Assets Report',
'report_warranty' => 'Warranty Report',
'report_retired' => 'Retirement Report',
'report_location' => 'Location Report',
'report_category' => 'Category Report',
'report_inventory' => 'Inventory Report',
'report_export' => 'Export Report',
'report_print' => 'Print Report',
// Export/Import
'export_csv' => 'Export CSV',
'export_xlsx' => 'Export Excel',
'export_pdf' => 'Export PDF',
'import_csv' => 'Import CSV',
'import_file' => 'Select File',
'import_preview' => 'Preview',
'import_confirm' => 'Confirm Import',
'import_success' => 'Import Successful',
'import_error' => 'Import Failed',
// Labels
'label_print' => 'Print Labels',
'label_barcode' => 'Barcode',
'label_qr' => 'QR Code',
'label_inventory_number' => 'Inventory Number',
'label_designation' => 'Designation',
// Dashboard
'dashboard_welcome' => 'Welcome to Asset Management System',
'dashboard_stats' => 'Statistics',
'dashboard_total_assets' => 'Total Assets',
'dashboard_active_assets' => 'Active Assets',
'dashboard_retired_assets' => 'Retired Assets',
'dashboard_total_value' => 'Total Value',
'dashboard_recent_activity' => 'Recent Activity',
'dashboard_warranty_expiring' => 'Warranty Expiring',
'dashboard_inventory_open' => 'Open Inventories',
// Months
'month_01' => 'January',
'month_02' => 'February',
'month_03' => 'March',
'month_04' => 'April',
'month_05' => 'May',
'month_06' => 'June',
'month_07' => 'July',
'month_08' => 'August',
'month_09' => 'September',
'month_10' => 'October',
'month_11' => 'November',
'month_12' => 'December',
// Days
'day_monday' => 'Monday',
'day_tuesday' => 'Tuesday',
'day_wednesday' => 'Wednesday',
'day_thursday' => 'Thursday',
'day_friday' => 'Friday',
'day_saturday' => 'Saturday',
'day_sunday' => 'Sunday',
];

94
nginx.conf Normal file
View File

@@ -0,0 +1,94 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
server {
listen 80;
server_name localhost;
root /var/www/html/public;
index index.php index.html;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
# Handle PHP files
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass php:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param HTTP_AUTHORIZATION $http_authorization;
}
# Handle static files
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Handle file downloads
location /files/ {
internal;
alias /var/www/html/storage/uploads/;
}
# Main location block
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# Deny access to sensitive files
location ~ /\. {
deny all;
}
location ~ \.(htaccess|htpasswd)$ {
deny all;
}
location ~ \.(env|log)$ {
deny all;
}
}
}

53
php.ini Normal file
View File

@@ -0,0 +1,53 @@
[PHP]
; Basic settings
memory_limit = 256M
max_execution_time = 300
max_input_time = 300
post_max_size = 50M
upload_max_filesize = 50M
max_file_uploads = 20
; Error handling
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /var/www/html/storage/logs/php_errors.log
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT
; Session settings
session.cookie_httponly = 1
session.cookie_secure = 0
session.cookie_samesite = "Strict"
session.use_strict_mode = 1
session.gc_maxlifetime = 3600
session.gc_probability = 1
session.gc_divisor = 100
; Security
expose_php = Off
allow_url_fopen = Off
allow_url_include = Off
; OPcache settings
opcache.enable = 1
opcache.enable_cli = 1
opcache.memory_consumption = 128
opcache.interned_strings_buffer = 8
opcache.max_accelerated_files = 4000
opcache.revalidate_freq = 2
opcache.fast_shutdown = 1
opcache.enable_file_override = 1
opcache.validate_timestamps = 0
; Date settings
date.timezone = Europe/Berlin
; File uploads
file_uploads = On
upload_tmp_dir = /tmp
; Database
pdo_mysql.default_socket = /var/run/mysqld/mysqld.sock
; GD settings
gd.jpeg_ignore_warning = 1

534
public/assets/css/style.css Normal file
View File

@@ -0,0 +1,534 @@
/* Reset and Base Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
/* Header */
.header {
background: #2c3e50;
color: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
position: sticky;
top: 0;
z-index: 1000;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 0;
}
.logo h1 {
font-size: 1.5rem;
font-weight: 600;
}
/* Navigation */
.nav-list {
display: flex;
list-style: none;
gap: 2rem;
}
.nav-link {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.3s;
}
.nav-link:hover {
background-color: rgba(255,255,255,0.1);
}
.nav-dropdown {
position: relative;
}
.nav-dropdown-menu {
position: absolute;
top: 100%;
left: 0;
background: white;
color: #333;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
border-radius: 4px;
min-width: 200px;
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition: all 0.3s;
}
.nav-dropdown:hover .nav-dropdown-menu {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.nav-dropdown-menu li {
list-style: none;
}
.nav-dropdown-menu a {
color: #333;
text-decoration: none;
padding: 0.75rem 1rem;
display: block;
border-bottom: 1px solid #eee;
}
.nav-dropdown-menu a:hover {
background-color: #f8f9fa;
}
/* User Menu */
.user-menu {
position: relative;
}
.user-dropdown {
position: relative;
}
.user-link {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
background-color: rgba(255,255,255,0.1);
}
.user-dropdown-menu {
position: absolute;
top: 100%;
right: 0;
background: white;
color: #333;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
border-radius: 4px;
min-width: 150px;
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition: all 0.3s;
}
.user-dropdown:hover .user-dropdown-menu {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.user-dropdown-menu li {
list-style: none;
}
.user-dropdown-menu a {
color: #333;
text-decoration: none;
padding: 0.75rem 1rem;
display: block;
border-bottom: 1px solid #eee;
}
.user-dropdown-menu a:hover {
background-color: #f8f9fa;
}
/* Main Content */
.main {
min-height: calc(100vh - 140px);
padding: 2rem 0;
}
/* Flash Messages */
.flash-messages {
margin-bottom: 2rem;
}
.alert {
padding: 1rem;
margin-bottom: 1rem;
border-radius: 4px;
position: relative;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.alert-warning {
background-color: #fff3cd;
color: #856404;
border: 1px solid #ffeaa7;
}
.alert-info {
background-color: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.alert-close {
position: absolute;
top: 0.5rem;
right: 1rem;
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: inherit;
}
/* Login Styles */
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-box {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
width: 100%;
max-width: 400px;
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-header h2 {
color: #2c3e50;
margin-bottom: 0.5rem;
}
.login-header p {
color: #7f8c8d;
}
/* Forms */
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #2c3e50;
}
.form-control {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.3s;
}
.form-control:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
}
/* Buttons */
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background-color: #3498db;
color: white;
}
.btn-primary:hover {
background-color: #2980b9;
}
.btn-secondary {
background-color: #95a5a6;
color: white;
}
.btn-secondary:hover {
background-color: #7f8c8d;
}
.btn-success {
background-color: #27ae60;
color: white;
}
.btn-success:hover {
background-color: #229954;
}
.btn-danger {
background-color: #e74c3c;
color: white;
}
.btn-danger:hover {
background-color: #c0392b;
}
.btn-block {
display: block;
width: 100%;
}
/* Tables */
.table-container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
overflow: hidden;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid #eee;
}
.table th {
background-color: #f8f9fa;
font-weight: 600;
color: #2c3e50;
}
.table tbody tr:hover {
background-color: #f8f9fa;
}
/* Cards */
.card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.card-header {
border-bottom: 1px solid #eee;
padding-bottom: 1rem;
margin-bottom: 1rem;
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
color: #2c3e50;
margin: 0;
}
/* Dashboard Stats */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
text-align: center;
}
.stat-number {
font-size: 2rem;
font-weight: 700;
color: #3498db;
margin-bottom: 0.5rem;
}
.stat-label {
color: #7f8c8d;
font-size: 0.9rem;
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
margin-top: 2rem;
}
.pagination a,
.pagination span {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
border-radius: 4px;
text-decoration: none;
color: #333;
transition: all 0.3s;
}
.pagination a:hover {
background-color: #f8f9fa;
}
.pagination .current {
background-color: #3498db;
color: white;
border-color: #3498db;
}
/* Footer */
.footer {
background: #2c3e50;
color: white;
text-align: center;
padding: 1rem 0;
margin-top: auto;
}
/* Responsive Design */
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: 1rem;
}
.nav-list {
flex-direction: column;
gap: 0.5rem;
}
.nav-dropdown-menu,
.user-dropdown-menu {
position: static;
opacity: 1;
visibility: visible;
transform: none;
box-shadow: none;
background: transparent;
color: white;
}
.nav-dropdown-menu a,
.user-dropdown-menu a {
color: white;
border-bottom: none;
}
.stats-grid {
grid-template-columns: 1fr;
}
.table-container {
overflow-x: auto;
}
.table {
min-width: 600px;
}
}
/* Utility Classes */
.text-center { text-align: center; }
.text-right { text-align: right; }
.text-left { text-align: left; }
.mb-1 { margin-bottom: 0.5rem; }
.mb-2 { margin-bottom: 1rem; }
.mb-3 { margin-bottom: 1.5rem; }
.mb-4 { margin-bottom: 2rem; }
.mt-1 { margin-top: 0.5rem; }
.mt-2 { margin-top: 1rem; }
.mt-3 { margin-top: 1.5rem; }
.mt-4 { margin-top: 2rem; }
.d-none { display: none; }
.d-block { display: block; }
.d-flex { display: flex; }
.d-grid { display: grid; }
.justify-between { justify-content: space-between; }
.justify-center { justify-content: center; }
.align-center { align-items: center; }
.w-100 { width: 100%; }
.h-100 { height: 100%; }
/* Print Styles */
@media print {
.header,
.footer,
.nav,
.user-menu,
.btn,
.pagination {
display: none !important;
}
.main {
padding: 0;
}
.container {
max-width: none;
padding: 0;
}
.card {
box-shadow: none;
border: 1px solid #ddd;
}
}

466
public/assets/js/app.js Normal file
View File

@@ -0,0 +1,466 @@
/**
* Asset Management System - JavaScript
*/
document.addEventListener('DOMContentLoaded', function() {
// Initialize all components
initAlerts();
initDropdowns();
initForms();
initTables();
initModals();
initFileUploads();
});
/**
* Alert handling
*/
function initAlerts() {
// Auto-hide alerts after 5 seconds
const alerts = document.querySelectorAll('.alert');
alerts.forEach(alert => {
setTimeout(() => {
if (alert.parentElement) {
alert.style.opacity = '0';
setTimeout(() => {
alert.remove();
}, 300);
}
}, 5000);
});
// Close button functionality
document.addEventListener('click', function(e) {
if (e.target.classList.contains('alert-close')) {
const alert = e.target.closest('.alert');
if (alert) {
alert.style.opacity = '0';
setTimeout(() => {
alert.remove();
}, 300);
}
}
});
}
/**
* Dropdown handling
*/
function initDropdowns() {
// Close dropdowns when clicking outside
document.addEventListener('click', function(e) {
if (!e.target.closest('.nav-dropdown, .user-dropdown')) {
const dropdowns = document.querySelectorAll('.nav-dropdown-menu, .user-dropdown-menu');
dropdowns.forEach(dropdown => {
dropdown.style.opacity = '0';
dropdown.style.visibility = 'hidden';
dropdown.style.transform = 'translateY(-10px)';
});
}
});
}
/**
* Form handling
*/
function initForms() {
// Form validation
const forms = document.querySelectorAll('form');
forms.forEach(form => {
form.addEventListener('submit', function(e) {
const requiredFields = form.querySelectorAll('[required]');
let isValid = true;
requiredFields.forEach(field => {
if (!field.value.trim()) {
isValid = false;
field.classList.add('error');
} else {
field.classList.remove('error');
}
});
if (!isValid) {
e.preventDefault();
showAlert('Bitte füllen Sie alle erforderlichen Felder aus.', 'error');
}
});
});
// Real-time validation
document.addEventListener('input', function(e) {
if (e.target.hasAttribute('required')) {
if (e.target.value.trim()) {
e.target.classList.remove('error');
} else {
e.target.classList.add('error');
}
}
});
}
/**
* Table handling
*/
function initTables() {
// Sortable tables
const sortableHeaders = document.querySelectorAll('.table th[data-sort]');
sortableHeaders.forEach(header => {
header.addEventListener('click', function() {
const table = this.closest('.table');
const column = this.dataset.sort;
const currentOrder = this.dataset.order || 'asc';
const newOrder = currentOrder === 'asc' ? 'desc' : 'asc';
// Update URL with sort parameters
const url = new URL(window.location);
url.searchParams.set('sort', column);
url.searchParams.set('order', newOrder);
window.location.href = url.toString();
});
});
// Bulk actions
const bulkCheckbox = document.getElementById('bulk-select-all');
if (bulkCheckbox) {
bulkCheckbox.addEventListener('change', function() {
const checkboxes = document.querySelectorAll('.bulk-select');
checkboxes.forEach(checkbox => {
checkbox.checked = this.checked;
});
updateBulkActions();
});
}
document.addEventListener('change', function(e) {
if (e.target.classList.contains('bulk-select')) {
updateBulkActions();
}
});
}
/**
* Update bulk actions visibility
*/
function updateBulkActions() {
const selectedItems = document.querySelectorAll('.bulk-select:checked');
const bulkActions = document.querySelector('.bulk-actions');
if (bulkActions) {
if (selectedItems.length > 0) {
bulkActions.style.display = 'block';
bulkActions.querySelector('.selected-count').textContent = selectedItems.length;
} else {
bulkActions.style.display = 'none';
}
}
}
/**
* Modal handling
*/
function initModals() {
// Open modal
document.addEventListener('click', function(e) {
if (e.target.hasAttribute('data-modal')) {
const modalId = e.target.dataset.modal;
const modal = document.getElementById(modalId);
if (modal) {
modal.style.display = 'block';
document.body.style.overflow = 'hidden';
}
}
});
// Close modal
document.addEventListener('click', function(e) {
if (e.target.classList.contains('modal-close') || e.target.classList.contains('modal')) {
const modal = e.target.closest('.modal');
if (modal) {
modal.style.display = 'none';
document.body.style.overflow = 'auto';
}
}
});
// Close modal with Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const openModal = document.querySelector('.modal[style*="display: block"]');
if (openModal) {
openModal.style.display = 'none';
document.body.style.overflow = 'auto';
}
}
});
}
/**
* File upload handling
*/
function initFileUploads() {
const fileInputs = document.querySelectorAll('input[type="file"]');
fileInputs.forEach(input => {
input.addEventListener('change', function() {
const files = Array.from(this.files);
const maxSize = this.dataset.maxSize || 52428800; // 50MB default
const allowedTypes = this.dataset.allowedTypes ? this.dataset.allowedTypes.split(',') : [];
files.forEach(file => {
// Check file size
if (file.size > maxSize) {
showAlert(`Datei "${file.name}" ist zu groß. Maximale Größe: ${formatBytes(maxSize)}`, 'error');
this.value = '';
return;
}
// Check file type
if (allowedTypes.length > 0 && !allowedTypes.includes(file.type)) {
showAlert(`Dateityp "${file.type}" ist nicht erlaubt.`, 'error');
this.value = '';
return;
}
});
});
});
}
/**
* Search functionality
*/
function initSearch() {
const searchInput = document.getElementById('search-input');
if (searchInput) {
let searchTimeout;
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const query = this.value.trim();
if (query.length >= 2 || query.length === 0) {
performSearch(query);
}
}, 300);
});
}
}
/**
* Perform search
*/
function performSearch(query) {
const url = new URL(window.location);
if (query) {
url.searchParams.set('search', query);
} else {
url.searchParams.delete('search');
}
url.searchParams.delete('page'); // Reset to first page
window.location.href = url.toString();
}
/**
* Export functionality
*/
function exportData(format) {
const url = new URL(window.location);
url.searchParams.set('export', format);
window.location.href = url.toString();
}
/**
* Print functionality
*/
function printPage() {
window.print();
}
/**
* QR Code scanner
*/
function initQRScanner() {
const scanButton = document.getElementById('scan-qr');
if (scanButton) {
scanButton.addEventListener('click', function() {
// This would integrate with a QR code scanner library
// For now, we'll show a prompt
const code = prompt('Bitte QR-Code scannen oder Inventarnummer eingeben:');
if (code) {
document.getElementById('inventory-number').value = code;
document.getElementById('scan-form').submit();
}
});
}
}
/**
* Asset assignment
*/
function assignAsset(assetId) {
const userId = prompt('Bitte Benutzer-ID eingeben:');
if (userId) {
const form = document.createElement('form');
form.method = 'POST';
form.action = `/assets/${assetId}/assign`;
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = document.querySelector('input[name="csrf_token"]').value;
const userInput = document.createElement('input');
userInput.type = 'hidden';
userInput.name = 'user_id';
userInput.value = userId;
form.appendChild(csrfInput);
form.appendChild(userInput);
document.body.appendChild(form);
form.submit();
}
}
/**
* Delete confirmation
*/
function confirmDelete(message = 'Sind Sie sicher, dass Sie diesen Eintrag löschen möchten?') {
return confirm(message);
}
/**
* Show alert message
*/
function showAlert(message, type = 'info') {
const alertContainer = document.querySelector('.flash-messages') || document.body;
const alert = document.createElement('div');
alert.className = `alert alert-${type}`;
alert.innerHTML = `
${message}
<button type="button" class="alert-close">&times;</button>
`;
alertContainer.appendChild(alert);
// Auto-hide after 5 seconds
setTimeout(() => {
alert.style.opacity = '0';
setTimeout(() => {
alert.remove();
}, 300);
}, 5000);
}
/**
* Format bytes to human readable
*/
function formatBytes(bytes) {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
}
/**
* Debounce function
*/
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
/**
* Throttle function
*/
function throttle(func, limit) {
let inThrottle;
return function() {
const args = arguments;
const context = this;
if (!inThrottle) {
func.apply(context, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
/**
* AJAX helper
*/
function ajax(url, options = {}) {
const defaultOptions = {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
};
const config = { ...defaultOptions, ...options };
return fetch(url, config)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.catch(error => {
console.error('AJAX error:', error);
throw error;
});
}
/**
* Local storage helper
*/
const Storage = {
set: function(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
console.error('Error saving to localStorage', e);
}
},
get: function(key, defaultValue = null) {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : defaultValue;
} catch (e) {
console.error('Error reading from localStorage', e);
return defaultValue;
}
},
remove: function(key) {
try {
localStorage.removeItem(key);
} catch (e) {
console.error('Error removing from localStorage', e);
}
}
};
// Initialize search if on a page with search
if (document.getElementById('search-input')) {
initSearch();
}
// Initialize QR scanner if on inventory page
if (document.getElementById('scan-qr')) {
initQRScanner();
}

26
public/index.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
/**
* Front Controller - Asset Management System
*/
// Start session
session_start();
// Load Composer autoloader
require_once __DIR__ . '/../vendor/autoload.php';
// Load environment variables
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->load();
// Load configuration
require_once __DIR__ . '/../config/config.php';
// Load routes
require_once __DIR__ . '/../config/routes.php';
// Initialize application
$app = new App\Core\Application();
// Handle the request
$app->handle();

26
scripts/entrypoint.sh Normal file
View File

@@ -0,0 +1,26 @@
#!/bin/sh
# Wait for database to be ready
echo "Waiting for database to be ready..."
while ! mysqladmin ping -h"$DB_HOST" -P"$DB_PORT" -u"$DB_USER" -p"$DB_PASS" --silent; do
sleep 1
done
echo "Database is ready!"
# Run migrations
echo "Running database migrations..."
php /var/www/html/scripts/migrate.php
# Run seeders if database is empty
echo "Checking if seeders need to be run..."
php /var/www/html/scripts/seed.php
# Set proper permissions
chown -R www-data:www-data /var/www/html/storage
chmod -R 755 /var/www/html/storage
echo "Application is ready!"
# Start PHP-FPM
exec php-fpm

87
scripts/migrate.php Normal file
View File

@@ -0,0 +1,87 @@
<?php
/**
* Database Migration Script
*/
require_once __DIR__ . '/../vendor/autoload.php';
// Load environment variables
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->load();
// Load configuration
require_once __DIR__ . '/../config/config.php';
try {
$pdo = new PDO(
sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', DB_HOST, DB_PORT, DB_NAME),
DB_USER,
DB_PASS,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
echo "Connected to database successfully.\n";
// Create migrations table if it doesn't exist
$pdo->exec("
CREATE TABLE IF NOT EXISTS migrations (
id INT AUTO_INCREMENT PRIMARY KEY,
migration VARCHAR(255) NOT NULL,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
");
// Get all migration files
$migrationFiles = glob(__DIR__ . '/../database/migrations/*.sql');
sort($migrationFiles);
// Get executed migrations
$stmt = $pdo->query("SELECT migration FROM migrations");
$executedMigrations = $stmt->fetchAll(PDO::FETCH_COLUMN);
$executedCount = 0;
foreach ($migrationFiles as $file) {
$migrationName = basename($file);
if (in_array($migrationName, $executedMigrations)) {
echo "Migration {$migrationName} already executed.\n";
continue;
}
echo "Executing migration: {$migrationName}\n";
// Read and execute migration
$sql = file_get_contents($file);
try {
$pdo->exec($sql);
// Record migration as executed
$stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?)");
$stmt->execute([$migrationName]);
echo "Migration {$migrationName} executed successfully.\n";
$executedCount++;
} catch (PDOException $e) {
echo "Error executing migration {$migrationName}: " . $e->getMessage() . "\n";
exit(1);
}
}
if ($executedCount > 0) {
echo "Executed {$executedCount} new migrations.\n";
} else {
echo "No new migrations to execute.\n";
}
echo "Database migration completed successfully.\n";
} catch (PDOException $e) {
echo "Database connection failed: " . $e->getMessage() . "\n";
exit(1);
}

83
scripts/seed.php Normal file
View File

@@ -0,0 +1,83 @@
<?php
/**
* Database Seeder Script
*/
require_once __DIR__ . '/../vendor/autoload.php';
// Load environment variables
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->load();
// Load configuration
require_once __DIR__ . '/../config/config.php';
try {
$pdo = new PDO(
sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', DB_HOST, DB_PORT, DB_NAME),
DB_USER,
DB_PASS,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
echo "Connected to database successfully.\n";
// Check if users table has data
$stmt = $pdo->query("SELECT COUNT(*) as count FROM users");
$userCount = $stmt->fetch()['count'];
if ($userCount > 0) {
echo "Database already has data. Skipping seeders.\n";
exit(0);
}
echo "Database is empty. Running seeders...\n";
// Get all seeder files
$seederFiles = glob(__DIR__ . '/../database/seeds/*.sql');
sort($seederFiles);
foreach ($seederFiles as $file) {
$seederName = basename($file);
echo "Running seeder: {$seederName}\n";
// Read and execute seeder
$sql = file_get_contents($file);
try {
$pdo->exec($sql);
echo "Seeder {$seederName} executed successfully.\n";
} catch (PDOException $e) {
echo "Error executing seeder {$seederName}: " . $e->getMessage() . "\n";
exit(1);
}
}
// Create default admin user with proper password hash
echo "Creating default admin user...\n";
$adminPassword = 'Admin!234';
$adminHash = password_hash($adminPassword, PASSWORD_ARGON2ID);
$stmt = $pdo->prepare("
UPDATE users
SET passhash = ?
WHERE email = 'admin@example.com'
");
$stmt->execute([$adminHash]);
echo "Default admin user created:\n";
echo "Email: admin@example.com\n";
echo "Password: {$adminPassword}\n";
echo "Please change the password on first login!\n";
echo "Database seeding completed successfully.\n";
} catch (PDOException $e) {
echo "Database connection failed: " . $e->getMessage() . "\n";
exit(1);
}