Compare commits
No commits in common. "main" and "v1.0.4" have entirely different histories.
30 changed files with 1132 additions and 7925 deletions
50
.gitignore
vendored
50
.gitignore
vendored
|
|
@ -1,44 +1,40 @@
|
||||||
# ── Dependencies ──
|
# Dependencies
|
||||||
/node_modules/
|
|
||||||
/vendor/
|
|
||||||
/schneespur/node_modules/
|
|
||||||
/schneespur/vendor/
|
/schneespur/vendor/
|
||||||
|
/node_modules/
|
||||||
|
|
||||||
# ── Local env (keep .env.example tracked) ──
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.backup
|
||||||
!.env.example
|
.env.production
|
||||||
|
|
||||||
# ── Laravel runtime ──
|
# IDE / Editor
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Laravel
|
||||||
/schneespur/storage/logs/*.log
|
/schneespur/storage/logs/*.log
|
||||||
/schneespur/storage/framework/cache/*
|
/schneespur/storage/framework/cache/*
|
||||||
/schneespur/storage/framework/sessions/*
|
/schneespur/storage/framework/sessions/*
|
||||||
/schneespur/storage/framework/views/*
|
/schneespur/storage/framework/views/*
|
||||||
/schneespur/storage/testing/
|
/schneespur/storage/testing/
|
||||||
|
|
||||||
# ── Build artifacts ──
|
# Build artifacts
|
||||||
/schneespur/public/build/
|
/schneespur/public/build/
|
||||||
/schneespur/public/hot
|
/schneespur/public/hot
|
||||||
/release/
|
|
||||||
|
|
||||||
# ── Internal dev files (not part of distributable app) ──
|
# Testing
|
||||||
/build.sh
|
|
||||||
/moduldoku.md
|
|
||||||
/package-lock.json
|
|
||||||
|
|
||||||
# ── Test caches ──
|
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
.phpunit.cache/
|
.phpunit.cache/
|
||||||
|
|
||||||
# ── IDE / OS ──
|
# Release builds
|
||||||
.idea/
|
/release/
|
||||||
.vscode/
|
|
||||||
*.swp
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# ── Local tooling ──
|
# Misc
|
||||||
.gsd
|
*.bak
|
||||||
.gsd-id
|
*.orig
|
||||||
.mcp.json
|
|
||||||
.bg-shell/
|
|
||||||
|
|
|
||||||
1006
moduldoku.md
Normal file
1006
moduldoku.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -18,10 +18,9 @@ APP_MAINTENANCE_DRIVER=file
|
||||||
BCRYPT_ROUNDS=12
|
BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
LOG_CHANNEL=stack
|
LOG_CHANNEL=stack
|
||||||
LOG_STACK=daily
|
LOG_STACK=single
|
||||||
LOG_DEPRECATIONS_CHANNEL=null
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
LOG_LEVEL=warning
|
LOG_LEVEL=debug
|
||||||
LOG_DAILY_DAYS=14
|
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=mysql
|
||||||
DB_HOST=127.0.0.1
|
DB_HOST=127.0.0.1
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
1.0.5
|
1.0.4
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ namespace App\Http\Controllers\Admin;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Services\Extension\DashboardWidgetRegistry;
|
use App\Services\Extension\DashboardWidgetRegistry;
|
||||||
use App\Services\Extension\FilterRegistry;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
|
@ -18,10 +17,9 @@ class DashboardController extends Controller
|
||||||
return redirect()->route('admin.dashboard');
|
return redirect()->route('admin.dashboard');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index(DashboardWidgetRegistry $widgetRegistry, FilterRegistry $filterRegistry): View
|
public function index(DashboardWidgetRegistry $widgetRegistry): View
|
||||||
{
|
{
|
||||||
$widgets = $widgetRegistry->getWidgets();
|
$widgets = $widgetRegistry->getWidgets();
|
||||||
$widgets = $filterRegistry->apply('schneespur.dashboard.kpis', $widgets);
|
|
||||||
|
|
||||||
return view('admin.dashboard', compact('widgets'));
|
return view('admin.dashboard', compact('widgets'));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Controllers\DsgvoOnboardingController;
|
|
||||||
use App\Models\DsgvoConfirmation;
|
use App\Models\DsgvoConfirmation;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
@ -19,7 +18,7 @@ class DsgvoAdminController extends Controller
|
||||||
$version = (int) Setting::get('dsgvo_template_version', 1);
|
$version = (int) Setting::get('dsgvo_template_version', 1);
|
||||||
|
|
||||||
if ($markdown === null) {
|
if ($markdown === null) {
|
||||||
$markdown = view(DsgvoOnboardingController::resolveDefaultTemplateView())->render();
|
$markdown = view('dsgvo.default-template')->render();
|
||||||
}
|
}
|
||||||
|
|
||||||
$previewHtml = Str::markdown($this->replacePlaceholders($markdown), ['html_input' => 'strip']);
|
$previewHtml = Str::markdown($this->replacePlaceholders($markdown), ['html_input' => 'strip']);
|
||||||
|
|
@ -83,7 +82,25 @@ class DsgvoAdminController extends Controller
|
||||||
|
|
||||||
private function replacePlaceholders(string $text): string
|
private function replacePlaceholders(string $text): string
|
||||||
{
|
{
|
||||||
return dsgvo_apply_company_placeholders($text);
|
$companyName = Setting::get('company_name', '');
|
||||||
|
$street = Setting::get('company_street', '');
|
||||||
|
$zip = Setting::get('company_zip', '');
|
||||||
|
$city = Setting::get('company_city', '');
|
||||||
|
$email = Setting::get('company_email', '');
|
||||||
|
$dpo = Setting::get('dpo_contact', '');
|
||||||
|
$dpoEmail = Setting::get('dpo_email', '');
|
||||||
|
|
||||||
|
$address = trim("$street, $zip $city", ', ');
|
||||||
|
|
||||||
|
$replacements = [
|
||||||
|
'[Firmenname eintragen]' => $companyName ?: '[Firmenname eintragen]',
|
||||||
|
'[Adresse eintragen]' => $address ?: '[Adresse eintragen]',
|
||||||
|
'[E-Mail-Adresse eintragen]' => $email ?: '[E-Mail-Adresse eintragen]',
|
||||||
|
'[DPO-E-Mail-Adresse eintragen]' => $dpoEmail ?: '[DPO-E-Mail-Adresse eintragen]',
|
||||||
|
'[Datenschutzbeauftragter / Ansprechpartner eintragen]' => $dpo ?: '[Datenschutzbeauftragter / Ansprechpartner eintragen]',
|
||||||
|
];
|
||||||
|
|
||||||
|
return str_replace(array_keys($replacements), array_values($replacements), $text);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function showConfirmation(int $id): View
|
public function showConfirmation(int $id): View
|
||||||
|
|
|
||||||
|
|
@ -66,22 +66,32 @@ class DsgvoOnboardingController extends Controller
|
||||||
$version = (int) Setting::get('dsgvo_template_version', 1);
|
$version = (int) Setting::get('dsgvo_template_version', 1);
|
||||||
|
|
||||||
if ($text === null) {
|
if ($text === null) {
|
||||||
$text = view(self::resolveDefaultTemplateView())->render();
|
$text = view('dsgvo.default-template')->render();
|
||||||
}
|
}
|
||||||
|
|
||||||
return [$text, $version];
|
return [$text, $version];
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function resolveDefaultTemplateView(): string
|
|
||||||
{
|
|
||||||
$locale = app()->getLocale();
|
|
||||||
$localized = "dsgvo.default-template-{$locale}";
|
|
||||||
|
|
||||||
return view()->exists($localized) ? $localized : 'dsgvo.default-template';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function replacePlaceholders(string $text): string
|
private function replacePlaceholders(string $text): string
|
||||||
{
|
{
|
||||||
return dsgvo_apply_company_placeholders($text);
|
$companyName = Setting::get('company_name', '');
|
||||||
|
$street = Setting::get('company_street', '');
|
||||||
|
$zip = Setting::get('company_zip', '');
|
||||||
|
$city = Setting::get('company_city', '');
|
||||||
|
$email = Setting::get('company_email', '');
|
||||||
|
$dpo = Setting::get('dpo_contact', '');
|
||||||
|
$dpoEmail = Setting::get('dpo_email', '');
|
||||||
|
|
||||||
|
$address = trim("$street, $zip $city", ', ');
|
||||||
|
|
||||||
|
$replacements = [
|
||||||
|
'[Firmenname eintragen]' => $companyName ?: '[Firmenname eintragen]',
|
||||||
|
'[Adresse eintragen]' => $address ?: '[Adresse eintragen]',
|
||||||
|
'[E-Mail-Adresse eintragen]' => $email ?: '[E-Mail-Adresse eintragen]',
|
||||||
|
'[DPO-E-Mail-Adresse eintragen]' => $dpoEmail ?: '[DPO-E-Mail-Adresse eintragen]',
|
||||||
|
'[Datenschutzbeauftragter / Ansprechpartner eintragen]' => $dpo ?: '[Datenschutzbeauftragter / Ansprechpartner eintragen]',
|
||||||
|
];
|
||||||
|
|
||||||
|
return str_replace(array_keys($replacements), array_values($replacements), $text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ class InstallerController extends Controller
|
||||||
{
|
{
|
||||||
if ($this->preflightChecker->hasCriticalFailures()) {
|
if ($this->preflightChecker->hasCriticalFailures()) {
|
||||||
return redirect()->route('install.preflight')
|
return redirect()->route('install.preflight')
|
||||||
->withErrors(['preflight' => __('install.preflight_has_failures')]);
|
->withErrors(['preflight' => 'Kritische Voraussetzungen nicht erfüllt.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect()->route('install.database');
|
return redirect()->route('install.database');
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ namespace App\Listeners;
|
||||||
|
|
||||||
use App\Events\JobCompleted;
|
use App\Events\JobCompleted;
|
||||||
use App\Mail\JobCompletedMail;
|
use App\Mail\JobCompletedMail;
|
||||||
use App\Services\Extension\FilterRegistry;
|
|
||||||
use App\Services\NotificationLogService;
|
use App\Services\NotificationLogService;
|
||||||
use App\Services\PdfReportService;
|
use App\Services\PdfReportService;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
|
@ -16,7 +15,6 @@ class SendJobCompletedNotification implements ShouldQueue
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private NotificationLogService $notificationLogService,
|
private NotificationLogService $notificationLogService,
|
||||||
private PdfReportService $pdfReportService,
|
private PdfReportService $pdfReportService,
|
||||||
private FilterRegistry $filterRegistry,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function handle(JobCompleted $event): void
|
public function handle(JobCompleted $event): void
|
||||||
|
|
@ -37,7 +35,6 @@ class SendJobCompletedNotification implements ShouldQueue
|
||||||
}
|
}
|
||||||
|
|
||||||
$recipients = $this->resolveRecipients($object, $customer);
|
$recipients = $this->resolveRecipients($object, $customer);
|
||||||
$recipients = $this->filterRegistry->apply('schneespur.job.notification.recipients', $recipients, $job);
|
|
||||||
|
|
||||||
if (empty($recipients)) {
|
if (empty($recipients)) {
|
||||||
$this->notificationLogService->logSkipped($job, $notificationType, 'skipped_no_email');
|
$this->notificationLogService->logSkipped($job, $notificationType, 'skipped_no_email');
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,12 @@ use App\Services\Diagnostic\DiagnosticManager;
|
||||||
use App\Services\Diagnostic\DiagnosticPayloadSanitizer;
|
use App\Services\Diagnostic\DiagnosticPayloadSanitizer;
|
||||||
use App\Services\Diagnostic\DiagnosticReporterRegistry;
|
use App\Services\Diagnostic\DiagnosticReporterRegistry;
|
||||||
use App\Services\Extension\DashboardWidgetRegistry;
|
use App\Services\Extension\DashboardWidgetRegistry;
|
||||||
use App\Services\Extension\FilterRegistry;
|
|
||||||
use App\Services\Extension\NavigationRegistry;
|
use App\Services\Extension\NavigationRegistry;
|
||||||
use App\Services\ForecastService;
|
use App\Services\ForecastService;
|
||||||
use App\Services\ModuleManager;
|
use App\Services\ModuleManager;
|
||||||
use App\Services\RetentionService;
|
use App\Services\RetentionService;
|
||||||
use App\Services\SchneespurUpdater;
|
use App\Services\SchneespurUpdater;
|
||||||
use App\Services\SeasonService;
|
use App\Services\SeasonService;
|
||||||
use App\Services\Translation\BrandedTranslator;
|
|
||||||
use App\Services\Weather\BrightSkyProvider;
|
use App\Services\Weather\BrightSkyProvider;
|
||||||
use App\Services\Weather\MetNorwayProvider;
|
use App\Services\Weather\MetNorwayProvider;
|
||||||
use App\Services\Weather\OpenMeteoApiProvider;
|
use App\Services\Weather\OpenMeteoApiProvider;
|
||||||
|
|
@ -48,7 +46,6 @@ class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
$this->app->singleton(AlertService::class);
|
$this->app->singleton(AlertService::class);
|
||||||
$this->app->singleton(DashboardWidgetRegistry::class);
|
$this->app->singleton(DashboardWidgetRegistry::class);
|
||||||
$this->app->singleton(FilterRegistry::class);
|
|
||||||
$this->app->singleton(NavigationRegistry::class);
|
$this->app->singleton(NavigationRegistry::class);
|
||||||
$this->app->singleton(DiagnosticPayloadSanitizer::class);
|
$this->app->singleton(DiagnosticPayloadSanitizer::class);
|
||||||
$this->app->singleton(DiagnosticReporterRegistry::class, fn ($app) => new DiagnosticReporterRegistry($app));
|
$this->app->singleton(DiagnosticReporterRegistry::class, fn ($app) => new DiagnosticReporterRegistry($app));
|
||||||
|
|
@ -67,17 +64,6 @@ class AppServiceProvider extends ServiceProvider
|
||||||
if (empty(config('app.key'))) {
|
if (empty(config('app.key'))) {
|
||||||
config(['app.key' => 'base64:'.base64_encode(random_bytes(32))]);
|
config(['app.key' => 'base64:'.base64_encode(random_bytes(32))]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->app->extend('translator', function ($translator, $app) {
|
|
||||||
if ($translator instanceof BrandedTranslator) {
|
|
||||||
return $translator;
|
|
||||||
}
|
|
||||||
$branded = new BrandedTranslator($translator->getLoader(), $translator->getLocale());
|
|
||||||
if ($fallback = $translator->getFallback()) {
|
|
||||||
$branded->setFallback($fallback);
|
|
||||||
}
|
|
||||||
return $branded;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services\Extension;
|
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
class FilterRegistry
|
|
||||||
{
|
|
||||||
private array $hooks = [];
|
|
||||||
|
|
||||||
private int $insertionCounter = 0;
|
|
||||||
|
|
||||||
public function register(string $hook, callable $callback, int $priority = 100): void
|
|
||||||
{
|
|
||||||
$this->hooks[$hook][] = [$priority, $this->insertionCounter++, $callback];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function apply(string $hook, mixed $value, mixed ...$context): mixed
|
|
||||||
{
|
|
||||||
if (empty($this->hooks[$hook])) {
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
$callbacks = $this->hooks[$hook];
|
|
||||||
usort($callbacks, fn (array $a, array $b) => $a[0] <=> $b[0] ?: $a[1] <=> $b[1]);
|
|
||||||
|
|
||||||
foreach ($callbacks as $entry) {
|
|
||||||
$previousValue = $value;
|
|
||||||
try {
|
|
||||||
$value = $entry[2]($value, ...$context);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Log::warning('FilterRegistry: callback failed', [
|
|
||||||
'hook' => $hook,
|
|
||||||
'index' => $entry[1],
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
$value = $previousValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,10 +8,6 @@ class NavigationRegistry extends ExtensionRegistry
|
||||||
{
|
{
|
||||||
protected array $groups = [];
|
protected array $groups = [];
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly FilterRegistry $filterRegistry,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function addGroup(string $key, string $label, int $order = 100): void
|
public function addGroup(string $key, string $label, int $order = 100): void
|
||||||
{
|
{
|
||||||
$this->groups[$key] = ['key' => $key, 'label' => $label, 'order' => $order];
|
$this->groups[$key] = ['key' => $key, 'label' => $label, 'order' => $order];
|
||||||
|
|
@ -77,6 +73,6 @@ class NavigationRegistry extends ExtensionRegistry
|
||||||
usort($groupItems, fn (array $a, array $b) => $a['order'] <=> $b['order']);
|
usort($groupItems, fn (array $a, array $b) => $a['order'] <=> $b['order']);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->filterRegistry->apply('schneespur.navigation.items', $grouped);
|
return $grouped;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ class ModuleManager
|
||||||
|
|
||||||
$this->modules[$slug] = $manifest;
|
$this->modules[$slug] = $manifest;
|
||||||
|
|
||||||
Log::debug('ModuleManager: module discovered', [
|
Log::info('ModuleManager: module discovered', [
|
||||||
'slug' => $slug,
|
'slug' => $slug,
|
||||||
'version' => $manifest['version'] ?? 'unknown',
|
'version' => $manifest['version'] ?? 'unknown',
|
||||||
]);
|
]);
|
||||||
|
|
@ -93,13 +93,6 @@ class ModuleManager
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reference example module — opt-in only via env var. Ensures the
|
|
||||||
// bundled dev demo never auto-loads on customer installs even if the
|
|
||||||
// old folder is still present after upgrading from older releases.
|
|
||||||
if ($slug === 'example' && ! env('EXAMPLE_MODULE_ENABLED', false)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$namespace = $manifest['namespace'] ?? null;
|
$namespace = $manifest['namespace'] ?? null;
|
||||||
$srcPath = ($manifest['path'] ?? '') . '/src';
|
$srcPath = ($manifest['path'] ?? '') . '/src';
|
||||||
|
|
||||||
|
|
@ -135,7 +128,7 @@ class ModuleManager
|
||||||
$provider->register();
|
$provider->register();
|
||||||
$provider->boot();
|
$provider->boot();
|
||||||
|
|
||||||
Log::debug('ModuleManager: module booted', [
|
Log::info('ModuleManager: module booted', [
|
||||||
'slug' => $slug,
|
'slug' => $slug,
|
||||||
'version' => $manifest['version'] ?? 'unknown',
|
'version' => $manifest['version'] ?? 'unknown',
|
||||||
]);
|
]);
|
||||||
|
|
@ -172,7 +165,7 @@ class ModuleManager
|
||||||
$this->disabledModules[] = $slug;
|
$this->disabledModules[] = $slug;
|
||||||
}
|
}
|
||||||
|
|
||||||
Log::debug('ModuleManager: module disabled', ['slug' => $slug]);
|
Log::info('ModuleManager: module disabled', ['slug' => $slug]);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services\Translation;
|
|
||||||
|
|
||||||
use Illuminate\Translation\Translator;
|
|
||||||
|
|
||||||
class BrandedTranslator extends Translator
|
|
||||||
{
|
|
||||||
public function get($key, array $replace = [], $locale = null, $fallback = true)
|
|
||||||
{
|
|
||||||
if (! array_key_exists('app_name', $replace)) {
|
|
||||||
$replace['app_name'] = $this->resolveAppName();
|
|
||||||
}
|
|
||||||
|
|
||||||
return parent::get($key, $replace, $locale, $fallback);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveAppName(): string
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
return brand();
|
|
||||||
} catch (\Throwable) {
|
|
||||||
return (string) config('app.name', 'Schneespur');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -23,46 +23,3 @@ function brand(): string
|
||||||
default => 'Schneespur',
|
default => 'Schneespur',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace company/DPO placeholders in DSGVO/GDPR template markdown.
|
|
||||||
*
|
|
||||||
* Knows both German and English placeholder strings so the same Settings
|
|
||||||
* values feed into either template language. Missing settings leave the
|
|
||||||
* placeholder visible so admins notice gaps.
|
|
||||||
*/
|
|
||||||
function dsgvo_apply_company_placeholders(string $text): string
|
|
||||||
{
|
|
||||||
$companyName = Setting::get('company_name', '');
|
|
||||||
$street = Setting::get('company_street', '');
|
|
||||||
$zip = Setting::get('company_zip', '');
|
|
||||||
$city = Setting::get('company_city', '');
|
|
||||||
$email = Setting::get('company_email', '');
|
|
||||||
$dpo = Setting::get('dpo_contact', '');
|
|
||||||
$dpoEmail = Setting::get('dpo_email', '');
|
|
||||||
|
|
||||||
$address = trim("$street, $zip $city", ', ');
|
|
||||||
|
|
||||||
$map = [
|
|
||||||
// German placeholders (default-template.blade.php)
|
|
||||||
'[Firmenname eintragen]' => $companyName,
|
|
||||||
'[Adresse eintragen]' => $address,
|
|
||||||
'[E-Mail-Adresse eintragen]' => $email,
|
|
||||||
'[DPO-E-Mail-Adresse eintragen]' => $dpoEmail,
|
|
||||||
'[Datenschutzbeauftragter / Ansprechpartner eintragen]' => $dpo,
|
|
||||||
// English placeholders (default-template-en.blade.php)
|
|
||||||
'[Company name]' => $companyName,
|
|
||||||
'[Address]' => $address,
|
|
||||||
'[Email]' => $email,
|
|
||||||
'[DPO email]' => $dpoEmail,
|
|
||||||
'[Data Protection Officer / Contact]' => $dpo,
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($map as $token => $value) {
|
|
||||||
if ($value !== '') {
|
|
||||||
$text = str_replace($token, $value, $text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $text;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -54,21 +54,21 @@ return [
|
||||||
|
|
||||||
'stack' => [
|
'stack' => [
|
||||||
'driver' => 'stack',
|
'driver' => 'stack',
|
||||||
'channels' => explode(',', (string) env('LOG_STACK', 'daily')),
|
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
|
||||||
'ignore_exceptions' => false,
|
'ignore_exceptions' => false,
|
||||||
],
|
],
|
||||||
|
|
||||||
'single' => [
|
'single' => [
|
||||||
'driver' => 'single',
|
'driver' => 'single',
|
||||||
'path' => storage_path('logs/laravel.log'),
|
'path' => storage_path('logs/laravel.log'),
|
||||||
'level' => env('LOG_LEVEL', 'warning'),
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
'replace_placeholders' => true,
|
'replace_placeholders' => true,
|
||||||
],
|
],
|
||||||
|
|
||||||
'daily' => [
|
'daily' => [
|
||||||
'driver' => 'daily',
|
'driver' => 'daily',
|
||||||
'path' => storage_path('logs/laravel.log'),
|
'path' => storage_path('logs/laravel.log'),
|
||||||
'level' => env('LOG_LEVEL', 'warning'),
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
'days' => env('LOG_DAILY_DAYS', 14),
|
'days' => env('LOG_DAILY_DAYS', 14),
|
||||||
'replace_placeholders' => true,
|
'replace_placeholders' => true,
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,7 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"namespace": "Schneespur\\Module\\Example",
|
"namespace": "Schneespur\\Module\\Example",
|
||||||
"service_provider": "Schneespur\\Module\\Example\\ExampleServiceProvider",
|
"service_provider": "Schneespur\\Module\\Example\\ExampleServiceProvider",
|
||||||
"description": "Reference module demonstrating all extension points (nav, widget, event, settings, route). Dev-only — opt in via EXAMPLE_MODULE_ENABLED=true in .env.",
|
"description": "Reference module demonstrating all extension points (nav, widget, event, settings, route).",
|
||||||
"min_schneespur_version": "1.0.0",
|
"min_schneespur_version": "1.0.0",
|
||||||
"requires_permissions": [],
|
"requires_permissions": []
|
||||||
"default_enabled": false
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ namespace Schneespur\Module\Example;
|
||||||
|
|
||||||
use App\Events\JobCompleted;
|
use App\Events\JobCompleted;
|
||||||
use App\Services\Extension\DashboardWidgetRegistry;
|
use App\Services\Extension\DashboardWidgetRegistry;
|
||||||
use App\Services\Extension\FilterRegistry;
|
|
||||||
use App\Services\Extension\NavigationRegistry;
|
use App\Services\Extension\NavigationRegistry;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
@ -19,28 +18,14 @@ class ExampleServiceProvider extends ServiceProvider
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
if (! $this->shouldBoot()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'example-module');
|
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'example-module');
|
||||||
|
|
||||||
$this->registerNavigation();
|
$this->registerNavigation();
|
||||||
$this->registerWidget();
|
$this->registerWidget();
|
||||||
$this->registerFilters();
|
|
||||||
$this->registerEventListeners();
|
$this->registerEventListeners();
|
||||||
$this->registerRoutes();
|
$this->registerRoutes();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Reference module — only loads when explicitly enabled.
|
|
||||||
* Devs can enable for local exploration via .env: EXAMPLE_MODULE_ENABLED=true
|
|
||||||
*/
|
|
||||||
protected function shouldBoot(): bool
|
|
||||||
{
|
|
||||||
return (bool) env('EXAMPLE_MODULE_ENABLED', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function registerNavigation(): void
|
protected function registerNavigation(): void
|
||||||
{
|
{
|
||||||
$nav = $this->app->make(NavigationRegistry::class);
|
$nav = $this->app->make(NavigationRegistry::class);
|
||||||
|
|
@ -67,49 +52,6 @@ class ExampleServiceProvider extends ServiceProvider
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function registerFilters(): void
|
|
||||||
{
|
|
||||||
$filters = $this->app->make(FilterRegistry::class);
|
|
||||||
|
|
||||||
$filters->register('schneespur.navigation.items', function (array $grouped): array {
|
|
||||||
$grouped['modules'][] = [
|
|
||||||
'group' => 'modules',
|
|
||||||
'slug' => 'example-filter',
|
|
||||||
'label' => 'Example Filter',
|
|
||||||
'route' => 'admin.example.settings',
|
|
||||||
'icon' => 'heroicon-o-funnel',
|
|
||||||
'order' => 250,
|
|
||||||
'permission' => null,
|
|
||||||
'route_check' => null,
|
|
||||||
'active_pattern' => 'admin.example.settings',
|
|
||||||
'badge' => null,
|
|
||||||
];
|
|
||||||
|
|
||||||
return $grouped;
|
|
||||||
}, 150);
|
|
||||||
|
|
||||||
$filters->register('schneespur.dashboard.kpis', function (array $widgets): array {
|
|
||||||
$widgets[] = [
|
|
||||||
'key' => 'example-filter-widget',
|
|
||||||
'label' => 'Filter Demo',
|
|
||||||
'view' => 'example-module::widgets.example-card',
|
|
||||||
'order' => 250,
|
|
||||||
'size' => 'half',
|
|
||||||
];
|
|
||||||
|
|
||||||
return $widgets;
|
|
||||||
}, 150);
|
|
||||||
|
|
||||||
$filters->register('schneespur.job.notification.recipients', function (array $recipients, $job): array {
|
|
||||||
Log::info('ExampleModule: notification recipients filter', [
|
|
||||||
'job_id' => $job->id ?? null,
|
|
||||||
'recipient_count' => count($recipients),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $recipients;
|
|
||||||
}, 150);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function registerEventListeners(): void
|
protected function registerEventListeners(): void
|
||||||
{
|
{
|
||||||
$this->app['events']->listen(JobCompleted::class, function (JobCompleted $event) {
|
$this->app['events']->listen(JobCompleted::class, function (JobCompleted $event) {
|
||||||
|
|
|
||||||
7283
schneespur/package-lock.json
generated
7283
schneespur/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,27 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
|
||||||
bootstrap="vendor/autoload.php"
|
|
||||||
colors="true"
|
|
||||||
cacheDirectory=".phpunit.cache"
|
|
||||||
>
|
|
||||||
<testsuites>
|
|
||||||
<testsuite name="Feature">
|
|
||||||
<directory>tests/Feature</directory>
|
|
||||||
</testsuite>
|
|
||||||
</testsuites>
|
|
||||||
<source>
|
|
||||||
<include>
|
|
||||||
<directory>app</directory>
|
|
||||||
</include>
|
|
||||||
</source>
|
|
||||||
<php>
|
|
||||||
<env name="APP_ENV" value="testing"/>
|
|
||||||
<env name="APP_KEY" value="base64:dGVzdGluZ2tleXRlc3RpbmdrZXl0ZXN0aW5na2V5cw=="/>
|
|
||||||
<env name="DB_CONNECTION" value="sqlite"/>
|
|
||||||
<env name="DB_DATABASE" value=":memory:"/>
|
|
||||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
|
||||||
<env name="MAIL_MAILER" value="array"/>
|
|
||||||
<env name="CACHE_STORE" value="array"/>
|
|
||||||
</php>
|
|
||||||
</phpunit>
|
|
||||||
1
schneespur/public/build/assets/app-DTM5xC6O.css
Normal file
1
schneespur/public/build/assets/app-DTM5xC6O.css
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -12,7 +12,7 @@
|
||||||
"src": "node_modules/leaflet/dist/images/marker-icon.png"
|
"src": "node_modules/leaflet/dist/images/marker-icon.png"
|
||||||
},
|
},
|
||||||
"resources/css/app.css": {
|
"resources/css/app.css": {
|
||||||
"file": "assets/app-Gkl9XGUK.css",
|
"file": "assets/app-DTM5xC6O.css",
|
||||||
"src": "resources/css/app.css",
|
"src": "resources/css/app.css",
|
||||||
"isEntry": true,
|
"isEntry": true,
|
||||||
"name": "app",
|
"name": "app",
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
if(!self.define){let e,s={};const n=(n,r)=>(n=new URL(n+".js",r).href,s[n]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=s,document.head.appendChild(e)}else e=n,importScripts(n),s()}).then(()=>{let e=s[n];if(!e)throw new Error(`Module ${n} didn’t register its module`);return e}));self.define=(r,i)=>{const t=e||("document"in self?document.currentScript.src:"")||location.href;if(s[t])return;let o={};const l=e=>n(e,t),c={module:{uri:t},exports:o,require:l};s[t]=Promise.all(r.map(e=>c[e]||l(e))).then(e=>(i(...e),o))}}define(["./workbox-d7f7d914"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"registerSW.js",revision:"2d094791c49e920331981a2d203b8cdb"},{url:"assets/marker-icon-hN30_KVU.png",revision:null},{url:"assets/layers-BWBAp2CZ.png",revision:null},{url:"assets/layers-2x-Bpkbi35X.png",revision:null},{url:"assets/app-Gkl9XGUK.css",revision:null},{url:"assets/app-GNqTWY09.js",revision:null},{url:"manifest.webmanifest",revision:"d9cbc35793758c64f87f24da203c23b4"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(/\.(?:woff2?|ttf|eot|otf)$/,new e.CacheFirst({cacheName:"fonts-cache",plugins:[new e.ExpirationPlugin({maxEntries:30,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/\/driver\/.*/,new e.NetworkFirst({cacheName:"driver-pages-cache",plugins:[new e.ExpirationPlugin({maxEntries:50,maxAgeSeconds:86400})]}),"GET")});
|
if(!self.define){let e,s={};const n=(n,r)=>(n=new URL(n+".js",r).href,s[n]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=s,document.head.appendChild(e)}else e=n,importScripts(n),s()}).then(()=>{let e=s[n];if(!e)throw new Error(`Module ${n} didn’t register its module`);return e}));self.define=(r,i)=>{const t=e||("document"in self?document.currentScript.src:"")||location.href;if(s[t])return;let o={};const l=e=>n(e,t),c={module:{uri:t},exports:o,require:l};s[t]=Promise.all(r.map(e=>c[e]||l(e))).then(e=>(i(...e),o))}}define(["./workbox-d7f7d914"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"registerSW.js",revision:"2d094791c49e920331981a2d203b8cdb"},{url:"assets/marker-icon-hN30_KVU.png",revision:null},{url:"assets/layers-BWBAp2CZ.png",revision:null},{url:"assets/layers-2x-Bpkbi35X.png",revision:null},{url:"assets/app-GNqTWY09.js",revision:null},{url:"assets/app-DTM5xC6O.css",revision:null},{url:"manifest.webmanifest",revision:"d9cbc35793758c64f87f24da203c23b4"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(/\.(?:woff2?|ttf|eot|otf)$/,new e.CacheFirst({cacheName:"fonts-cache",plugins:[new e.ExpirationPlugin({maxEntries:30,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/\/driver\/.*/,new e.NetworkFirst({cacheName:"driver-pages-cache",plugins:[new e.ExpirationPlugin({maxEntries:50,maxAgeSeconds:86400})]}),"GET")});
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,9 @@
|
||||||
@endphp
|
@endphp
|
||||||
<form method="POST" action="{{ route('admin.jobs.manual.store') }}" class="mt-6"
|
<form method="POST" action="{{ route('admin.jobs.manual.store') }}" class="mt-6"
|
||||||
x-data="{
|
x-data="{
|
||||||
selectedCustomerId: {{ old('customer_id') ? (int) old('customer_id') : 'null' }},
|
selectedCustomerId: {{ old('customer_id', 'null') }},
|
||||||
selectedObjectId: {{ old('customer_object_id') ? (int) old('customer_object_id') : 'null' }},
|
selectedObjectId: {{ old('customer_object_id', 'null') }},
|
||||||
allObjects: {{ json_encode($allObjects) }},
|
allObjects: @json($allObjects),
|
||||||
get objects() { return this.allObjects.filter(o => o.customer_id === this.selectedCustomerId); },
|
get objects() { return this.allObjects.filter(o => o.customer_id === this.selectedCustomerId); },
|
||||||
onCustomerChange() {
|
onCustomerChange() {
|
||||||
const objs = this.objects;
|
const objs = this.objects;
|
||||||
|
|
|
||||||
|
|
@ -1,74 +1,47 @@
|
||||||
<x-admin-layout>
|
<x-admin-layout>
|
||||||
<x-slot name="header">{{ __('admin.page_settings') }} <x-help-icon topic="settings" /></x-slot>
|
<x-slot name="header">{{ __('admin.page_settings') }} <x-help-icon topic="settings" /></x-slot>
|
||||||
|
|
||||||
@php
|
|
||||||
$cards = [
|
|
||||||
[
|
|
||||||
'route' => 'admin.settings.branding',
|
|
||||||
'title' => __('ui.branding_title'),
|
|
||||||
'desc' => __('ui.branding_description'),
|
|
||||||
'icon' => 'M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.395m3.42 3.42a15.995 15.995 0 004.764-4.648l3.876-5.814a1.151 1.151 0 00-1.597-1.597L14.146 6.32a15.996 15.996 0 00-4.649 4.763m3.42 3.42a6.776 6.776 0 00-3.42-3.42',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'route' => 'admin.settings.email',
|
|
||||||
'title' => __('notification.settings_card_email'),
|
|
||||||
'desc' => __('notification.settings_card_email_desc'),
|
|
||||||
'icon' => 'M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'route' => 'admin.settings.notification-log',
|
|
||||||
'title' => __('notification.settings_card_log'),
|
|
||||||
'desc' => __('notification.settings_card_log_desc'),
|
|
||||||
'icon' => 'M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'route' => 'admin.settings.company',
|
|
||||||
'title' => __('settings.company_title'),
|
|
||||||
'desc' => __('settings.company_description'),
|
|
||||||
'icon' => 'M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008z',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'route' => 'admin.settings.retention',
|
|
||||||
'title' => __('settings.retention_title'),
|
|
||||||
'desc' => __('settings.retention_description'),
|
|
||||||
'icon' => 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'route' => 'admin.settings.weather',
|
|
||||||
'title' => __('weather.settings_title'),
|
|
||||||
'desc' => __('weather.settings_description'),
|
|
||||||
'icon' => 'M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'route' => 'admin.settings.update',
|
|
||||||
'title' => __('update.settings_title'),
|
|
||||||
'desc' => __('update.settings_description'),
|
|
||||||
'icon' => 'M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'route' => 'admin.settings.modules.index',
|
|
||||||
'title' => __('modules.settings_card_title'),
|
|
||||||
'desc' => __('modules.settings_card_description'),
|
|
||||||
'icon' => 'm21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<div class="max-w-2xl space-y-4">
|
<div class="max-w-2xl space-y-4">
|
||||||
@foreach ($cards as $card)
|
<a href="{{ route('admin.settings.branding') }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors">
|
||||||
<a href="{{ route($card['route']) }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors">
|
<h3 class="text-sm font-medium text-gray-900">{{ __('ui.branding_title') }}</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">{{ __('ui.branding_description') }}</p>
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('admin.settings.email') }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors">
|
||||||
|
<h3 class="text-sm font-medium text-gray-900">{{ __('notification.settings_card_email') }}</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">{{ __('notification.settings_card_email_desc') }}</p>
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('admin.settings.notification-log') }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors">
|
||||||
|
<h3 class="text-sm font-medium text-gray-900">{{ __('notification.settings_card_log') }}</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">{{ __('notification.settings_card_log_desc') }}</p>
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('admin.settings.company') }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors">
|
||||||
|
<h3 class="text-sm font-medium text-gray-900">{{ __('settings.company_title') }}</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">{{ __('settings.company_description') }}</p>
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('admin.settings.retention') }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors">
|
||||||
|
<h3 class="text-sm font-medium text-gray-900">{{ __('settings.retention_title') }}</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">{{ __('settings.retention_description') }}</p>
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('admin.settings.weather') }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors">
|
||||||
|
<h3 class="text-sm font-medium text-gray-900">{{ __('weather.settings_title') }}</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">{{ __('weather.settings_description') }}</p>
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('admin.settings.update') }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors">
|
||||||
|
<h3 class="text-sm font-medium text-gray-900">{{ __('update.settings_title') }}</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">{{ __('update.settings_description') }}</p>
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('admin.settings.modules.index') }}" class="block bg-white shadow-sm rounded-lg p-6 hover:bg-gray-50 transition-colors">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600">
|
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600">
|
||||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="{{ $card['icon'] }}" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-medium text-gray-900">{{ $card['title'] }}</h3>
|
<h3 class="text-sm font-medium text-gray-900">{{ __('modules.settings_card_title') }}</h3>
|
||||||
<p class="mt-1 text-sm text-gray-500">{{ $card['desc'] }}</p>
|
<p class="mt-1 text-sm text-gray-500">{{ __('modules.settings_card_description') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@endforeach
|
|
||||||
</div>
|
</div>
|
||||||
</x-admin-layout>
|
</x-admin-layout>
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,9 @@
|
||||||
@endphp
|
@endphp
|
||||||
<form method="POST" action="{{ route('driver.job.manual.store') }}" class="space-y-5"
|
<form method="POST" action="{{ route('driver.job.manual.store') }}" class="space-y-5"
|
||||||
x-data="{
|
x-data="{
|
||||||
selectedCustomerId: {{ old('customer_id') ? (int) old('customer_id') : 'null' }},
|
selectedCustomerId: {{ old('customer_id', 'null') }},
|
||||||
selectedObjectId: {{ old('customer_object_id') ? (int) old('customer_object_id') : 'null' }},
|
selectedObjectId: {{ old('customer_object_id', 'null') }},
|
||||||
allObjects: {{ json_encode($allObjects) }},
|
allObjects: @json($allObjects),
|
||||||
get objects() { return this.allObjects.filter(o => o.customer_id === this.selectedCustomerId); },
|
get objects() { return this.allObjects.filter(o => o.customer_id === this.selectedCustomerId); },
|
||||||
onCustomerChange() {
|
onCustomerChange() {
|
||||||
const objs = this.objects;
|
const objs = this.objects;
|
||||||
|
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
# Data Protection Information for Winter Maintenance Staff
|
|
||||||
|
|
||||||
## 1. Controller
|
|
||||||
|
|
||||||
**[Company name]**
|
|
||||||
[Address]
|
|
||||||
Email: [Email]
|
|
||||||
|
|
||||||
## 2. Purpose of processing
|
|
||||||
|
|
||||||
In the course of your work in winter maintenance, the following personal data is collected and processed:
|
|
||||||
|
|
||||||
- **GPS location data** during operational hours, to document the gritting and snow-clearance services performed
|
|
||||||
- **Operational times** (start and end) for working-time records
|
|
||||||
- **Vehicle assignment** to enable traceability of the vehicles used
|
|
||||||
- **Photographs and notes** as evidence of the work carried out
|
|
||||||
|
|
||||||
## 3. Legal basis
|
|
||||||
|
|
||||||
Processing is carried out on the basis of **Article 6(1)(f) UK GDPR / EU GDPR** (legitimate interests). The legitimate interest of the employer lies in fulfilling the duty to maintain safe traffic conditions and in providing evidence that winter maintenance operations have been carried out properly.
|
|
||||||
|
|
||||||
## 4. Collection of GPS data
|
|
||||||
|
|
||||||
GPS data is collected via the **OwnTracks** app installed on your work device. Location data is recorded only during active operational hours and transmitted to the employer's server. The app does not track you outside of recorded shifts.
|
|
||||||
|
|
||||||
## 5. Retention period
|
|
||||||
|
|
||||||
The data collected is retained for the duration of statutory retention obligations (typically three years, in line with limitation periods for traffic-safety liability claims). After this period the data is deleted or anonymised.
|
|
||||||
|
|
||||||
## 6. Your rights
|
|
||||||
|
|
||||||
Under the UK GDPR / EU GDPR you have the following rights:
|
|
||||||
|
|
||||||
- **Right of access** (Article 15): you may request information about the personal data we hold about you.
|
|
||||||
- **Right to rectification** (Article 16): you may request correction of inaccurate data.
|
|
||||||
- **Right to erasure** (Article 17): you may request deletion of your data, subject to overriding legal retention obligations.
|
|
||||||
- **Right to restriction of processing** (Article 18)
|
|
||||||
- **Right to data portability** (Article 20)
|
|
||||||
- **Right to object** (Article 21)
|
|
||||||
- **Right to lodge a complaint** with the competent supervisory authority (ICO in the UK; the national DPA in other jurisdictions).
|
|
||||||
|
|
||||||
## 7. Contact for data protection enquiries
|
|
||||||
|
|
||||||
For any questions regarding data protection please contact:
|
|
||||||
|
|
||||||
**[Data Protection Officer / Contact]**
|
|
||||||
Email: [DPO email]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*This is a template — review and adapt the wording to your organisation and jurisdiction before publishing to staff.*
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests;
|
|
||||||
|
|
||||||
use Illuminate\Contracts\Console\Kernel;
|
|
||||||
|
|
||||||
trait CreatesApplication
|
|
||||||
{
|
|
||||||
public function createApplication(): \Illuminate\Foundation\Application
|
|
||||||
{
|
|
||||||
$app = require __DIR__.'/../bootstrap/app.php';
|
|
||||||
|
|
||||||
$app->make(Kernel::class)->bootstrap();
|
|
||||||
|
|
||||||
return $app;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,202 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature;
|
|
||||||
|
|
||||||
use App\Services\Extension\FilterRegistry;
|
|
||||||
use App\Services\Extension\NavigationRegistry;
|
|
||||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class FilterRegistryTest extends TestCase
|
|
||||||
{
|
|
||||||
use LazilyRefreshDatabase;
|
|
||||||
|
|
||||||
private FilterRegistry $registry;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
parent::setUp();
|
|
||||||
$this->registry = new FilterRegistry;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_apply_returns_original_value_when_no_filters_registered(): void
|
|
||||||
{
|
|
||||||
$result = $this->registry->apply('schneespur.nonexistent.hook', ['a', 'b']);
|
|
||||||
|
|
||||||
$this->assertSame(['a', 'b'], $result);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_filters_execute_in_priority_order(): void
|
|
||||||
{
|
|
||||||
$order = [];
|
|
||||||
|
|
||||||
$this->registry->register('test.hook', function ($value) use (&$order) {
|
|
||||||
$order[] = 'priority-200';
|
|
||||||
return $value;
|
|
||||||
}, 200);
|
|
||||||
|
|
||||||
$this->registry->register('test.hook', function ($value) use (&$order) {
|
|
||||||
$order[] = 'priority-50';
|
|
||||||
return $value;
|
|
||||||
}, 50);
|
|
||||||
|
|
||||||
$this->registry->register('test.hook', function ($value) use (&$order) {
|
|
||||||
$order[] = 'priority-100';
|
|
||||||
return $value;
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
$this->registry->apply('test.hook', 'start');
|
|
||||||
|
|
||||||
$this->assertSame(['priority-50', 'priority-100', 'priority-200'], $order);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_equal_priority_preserves_registration_order(): void
|
|
||||||
{
|
|
||||||
$order = [];
|
|
||||||
|
|
||||||
$this->registry->register('test.hook', function ($value) use (&$order) {
|
|
||||||
$order[] = 'first';
|
|
||||||
return $value;
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
$this->registry->register('test.hook', function ($value) use (&$order) {
|
|
||||||
$order[] = 'second';
|
|
||||||
return $value;
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
$this->registry->register('test.hook', function ($value) use (&$order) {
|
|
||||||
$order[] = 'third';
|
|
||||||
return $value;
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
$this->registry->apply('test.hook', 'start');
|
|
||||||
|
|
||||||
$this->assertSame(['first', 'second', 'third'], $order);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_apply_passes_context_to_callbacks(): void
|
|
||||||
{
|
|
||||||
$receivedContext = [];
|
|
||||||
|
|
||||||
$this->registry->register('test.hook', function ($value, $ctxA, $ctxB) use (&$receivedContext) {
|
|
||||||
$receivedContext = [$ctxA, $ctxB];
|
|
||||||
return $value;
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->registry->apply('test.hook', 'value', 'context-a', 'context-b');
|
|
||||||
|
|
||||||
$this->assertSame(['context-a', 'context-b'], $receivedContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_throwing_callback_logs_warning_and_preserves_previous_value(): void
|
|
||||||
{
|
|
||||||
Log::shouldReceive('warning')
|
|
||||||
->once()
|
|
||||||
->withArgs(function ($message, $context) {
|
|
||||||
return $message === 'FilterRegistry: callback failed'
|
|
||||||
&& $context['hook'] === 'test.hook'
|
|
||||||
&& str_contains($context['error'], 'Intentional test exception');
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->registry->register('test.hook', function (array $value) {
|
|
||||||
$value[] = 'before-error';
|
|
||||||
return $value;
|
|
||||||
}, 10);
|
|
||||||
|
|
||||||
$this->registry->register('test.hook', function ($value) {
|
|
||||||
throw new \RuntimeException('Intentional test exception');
|
|
||||||
}, 20);
|
|
||||||
|
|
||||||
$this->registry->register('test.hook', function (array $value) {
|
|
||||||
$value[] = 'after-error';
|
|
||||||
return $value;
|
|
||||||
}, 30);
|
|
||||||
|
|
||||||
$result = $this->registry->apply('test.hook', []);
|
|
||||||
|
|
||||||
$this->assertSame(['before-error', 'after-error'], $result);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_callbacks_can_transform_value_through_chain(): void
|
|
||||||
{
|
|
||||||
$this->registry->register('test.hook', function (int $value) {
|
|
||||||
return $value + 10;
|
|
||||||
}, 10);
|
|
||||||
|
|
||||||
$this->registry->register('test.hook', function (int $value) {
|
|
||||||
return $value * 2;
|
|
||||||
}, 20);
|
|
||||||
|
|
||||||
$result = $this->registry->apply('test.hook', 5);
|
|
||||||
|
|
||||||
$this->assertSame(30, $result);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_single_callback_executes(): void
|
|
||||||
{
|
|
||||||
$this->registry->register('test.hook', function (array $items) {
|
|
||||||
$items[] = 'added';
|
|
||||||
return $items;
|
|
||||||
});
|
|
||||||
|
|
||||||
$result = $this->registry->apply('test.hook', ['original']);
|
|
||||||
|
|
||||||
$this->assertSame(['original', 'added'], $result);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_navigation_items_hook_is_applied(): void
|
|
||||||
{
|
|
||||||
$this->bootExampleModule();
|
|
||||||
|
|
||||||
$nav = $this->app->make(NavigationRegistry::class);
|
|
||||||
$nav->addGroup('modules', 'Modules', 100);
|
|
||||||
|
|
||||||
$items = $nav->getItems();
|
|
||||||
|
|
||||||
$allSlugs = [];
|
|
||||||
foreach ($items as $groupItems) {
|
|
||||||
foreach ($groupItems as $item) {
|
|
||||||
$allSlugs[] = $item['slug'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->assertContains('example-filter', $allSlugs);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_dashboard_kpis_hook_is_applied(): void
|
|
||||||
{
|
|
||||||
$this->bootExampleModule();
|
|
||||||
|
|
||||||
$filterRegistry = $this->app->make(FilterRegistry::class);
|
|
||||||
|
|
||||||
$widgets = $filterRegistry->apply('schneespur.dashboard.kpis', [
|
|
||||||
['key' => 'original-widget', 'label' => 'Original'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$keys = array_column($widgets, 'key');
|
|
||||||
|
|
||||||
$this->assertContains('example-filter-widget', $keys);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function bootExampleModule(): void
|
|
||||||
{
|
|
||||||
$modulePath = base_path('modules/example/src');
|
|
||||||
spl_autoload_register(function (string $class) use ($modulePath) {
|
|
||||||
$prefix = 'Schneespur\\Module\\Example\\';
|
|
||||||
if (! str_starts_with($class, $prefix)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$relative = substr($class, strlen($prefix));
|
|
||||||
$file = $modulePath . '/' . str_replace('\\', '/', $relative) . '.php';
|
|
||||||
if (file_exists($file)) {
|
|
||||||
require_once $file;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
putenv('EXAMPLE_MODULE_ENABLED=true');
|
|
||||||
$_ENV['EXAMPLE_MODULE_ENABLED'] = true;
|
|
||||||
|
|
||||||
$this->app->register(\Schneespur\Module\Example\ExampleServiceProvider::class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
|
||||||
|
|
||||||
abstract class TestCase extends BaseTestCase
|
|
||||||
{
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
parent::setUp();
|
|
||||||
$this->withoutVite();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue