dbac930523
Switched render hook from PAGE_HEADER_ACTIONS_BEFORE to PAGE_HEADER_HEADING_AFTER: - Previous hook lived inside the action buttons container which is skipped on pages without header actions (most Admin pages: Settings, Integrations, API Tokens, Backup, Activity log). - Previous hook also pushed the icon to the right side on pages that did have actions (Clienți, Mașini, Calendar list), which looked disconnected from the title. New hook renders "?" immediately after the page heading text on every page, always visible. Also fixed one URL prefix mismatch: app/pipeline → app/pipeline-board (actual Filament page slug). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
348 lines
19 KiB
PHP
348 lines
19 KiB
PHP
<?php
|
|
|
|
namespace App\Providers\Filament;
|
|
|
|
use App\Http\Middleware\CheckTenantStatus;
|
|
use App\Http\Middleware\ResolveTenant;
|
|
use Filament\Http\Middleware\Authenticate;
|
|
use Filament\Http\Middleware\AuthenticateSession;
|
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
|
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
|
use Filament\Pages\Dashboard;
|
|
use Filament\Panel;
|
|
use Filament\PanelProvider;
|
|
use Filament\Support\Colors\Color;
|
|
use Filament\View\PanelsRenderHook;
|
|
use Illuminate\Support\Facades\Blade;
|
|
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
|
use Illuminate\Cookie\Middleware\EncryptCookies;
|
|
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
|
use Illuminate\Routing\Middleware\SubstituteBindings;
|
|
use Illuminate\Session\Middleware\StartSession;
|
|
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
|
|
|
/**
|
|
* Tenant panel — served on every <slug>.service.mir.md.
|
|
* ResolveTenant middleware loads the current Company before any auth check.
|
|
*/
|
|
class TenantPanelProvider extends PanelProvider
|
|
{
|
|
public function panel(Panel $panel): Panel
|
|
{
|
|
return $panel
|
|
->id('tenant')
|
|
->path('app')
|
|
->login()
|
|
->sidebarCollapsibleOnDesktop() // permite îngustarea coloanei stânga cu un click
|
|
->maxContentWidth(\Filament\Support\Enums\Width::Full)
|
|
->brandName(fn () => app(\App\Tenancy\TenantManager::class)->current()?->display_name
|
|
?? app(\App\Tenancy\TenantManager::class)->current()?->name
|
|
?? 'AutoCRM')
|
|
->brandLogo(function () {
|
|
$t = app(\App\Tenancy\TenantManager::class)->current();
|
|
if (! $t) return null;
|
|
$logo = $t->getLogoUrl();
|
|
$name = e($t->display_name ?? $t->name ?? 'AutoCRM');
|
|
if ($logo) {
|
|
return new \Illuminate\Support\HtmlString(
|
|
'<div style="display:flex;align-items:center;gap:8px;">'
|
|
. '<img src="' . e($logo) . '" alt="logo" style="height:2.25rem;max-width:120px;object-fit:contain;">'
|
|
. '<span style="font-weight:600;font-size:14px;color:inherit;">' . $name . '</span>'
|
|
. '</div>'
|
|
);
|
|
}
|
|
return null;
|
|
})
|
|
->brandLogoHeight('2.5rem')
|
|
->favicon(fn () => app(\App\Tenancy\TenantManager::class)->current()?->getFaviconUrl()
|
|
?: app(\App\Tenancy\TenantManager::class)->current()?->getLogoUrl()
|
|
?: null)
|
|
->colors([
|
|
'primary' => Color::Blue,
|
|
])
|
|
->authGuard('web')
|
|
->databaseNotifications()
|
|
->databaseNotificationsPolling('30s')
|
|
->globalSearch()
|
|
->globalSearchKeyBindings(['mod+k'])
|
|
->globalSearchFieldKeyBindingSuffix()
|
|
->globalSearchDebounce('500ms')
|
|
->multiFactorAuthentication([
|
|
\Filament\Auth\MultiFactor\App\AppAuthentication::make(),
|
|
\Filament\Auth\MultiFactor\Email\EmailAuthentication::make(),
|
|
])
|
|
->profile()
|
|
->discoverResources(in: app_path('Filament/Tenant/Resources'), for: 'App\\Filament\\Tenant\\Resources')
|
|
->discoverPages(in: app_path('Filament/Tenant/Pages'), for: 'App\\Filament\\Tenant\\Pages')
|
|
->pages([
|
|
Dashboard::class,
|
|
])
|
|
->discoverWidgets(in: app_path('Filament/Tenant/Widgets'), for: 'App\\Filament\\Tenant\\Widgets')
|
|
->widgets([
|
|
\App\Filament\Tenant\Widgets\StatsOverview::class,
|
|
\App\Filament\Tenant\Widgets\FinanceOverview::class,
|
|
\App\Filament\Tenant\Widgets\LowStockTable::class,
|
|
])
|
|
->middleware([
|
|
// CRITICAL: tenant resolution must run BEFORE Filament's
|
|
// Authenticate middleware (which is inserted by authMiddleware
|
|
// between ShareErrorsFromSession and AuthenticateSession).
|
|
// Otherwise User::find() during auth check has no tenant
|
|
// context → TenantScope returns 0 rows → user appears
|
|
// unauthenticated → endless redirect to /app/login.
|
|
ResolveTenant::class,
|
|
CheckTenantStatus::class,
|
|
EncryptCookies::class,
|
|
AddQueuedCookiesToResponse::class,
|
|
StartSession::class,
|
|
\App\Http\Middleware\SetLocale::class,
|
|
\App\Http\Middleware\RequireOnboarding::class,
|
|
AuthenticateSession::class,
|
|
ShareErrorsFromSession::class,
|
|
VerifyCsrfToken::class,
|
|
SubstituteBindings::class,
|
|
DisableBladeIconComponents::class,
|
|
DispatchServingFilamentEvent::class,
|
|
])
|
|
->authMiddleware([
|
|
Authenticate::class,
|
|
])
|
|
// PWA + theming injection
|
|
->renderHook(
|
|
PanelsRenderHook::HEAD_END,
|
|
fn (): string => Blade::render(<<<'BLADE'
|
|
@php
|
|
$t = app(\App\Tenancy\TenantManager::class)->current();
|
|
$themeColor = $t?->settings['theme_color'] ?? '#3B82F6';
|
|
$name = $t?->display_name ?? $t?->name ?? 'AutoCRM';
|
|
// Generate primary color shades from theme_color hex.
|
|
$hex = ltrim($themeColor, '#');
|
|
if (strlen($hex) === 6) {
|
|
$r = hexdec(substr($hex, 0, 2));
|
|
$g = hexdec(substr($hex, 2, 2));
|
|
$b = hexdec(substr($hex, 4, 2));
|
|
} else { $r = 59; $g = 130; $b = 246; }
|
|
@endphp
|
|
<link rel="manifest" href="/manifest.json">
|
|
<meta name="theme-color" content="{{ $themeColor }}">
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
|
<meta name="apple-mobile-web-app-title" content="{{ $name }}">
|
|
<style>
|
|
:root {
|
|
--primary-50: {{ "rgb({$r} {$g} {$b} / 0.05)" }};
|
|
--primary-100: {{ "rgb({$r} {$g} {$b} / 0.10)" }};
|
|
--primary-200: {{ "rgb({$r} {$g} {$b} / 0.20)" }};
|
|
--primary-300: {{ "rgb({$r} {$g} {$b} / 0.35)" }};
|
|
--primary-400: {{ "rgb({$r} {$g} {$b} / 0.55)" }};
|
|
--primary-500: {{ "rgb({$r} {$g} {$b})" }};
|
|
--primary-600: {{ "rgb({$r} {$g} {$b})" }};
|
|
--primary-700: {{ "rgb(" . max(0,$r-20) . " " . max(0,$g-20) . " " . max(0,$b-20) . ")" }};
|
|
--primary-800: {{ "rgb(" . max(0,$r-40) . " " . max(0,$g-40) . " " . max(0,$b-40) . ")" }};
|
|
--primary-900: {{ "rgb(" . max(0,$r-60) . " " . max(0,$g-60) . " " . max(0,$b-60) . ")" }};
|
|
--primary-950: {{ "rgb(" . max(0,$r-80) . " " . max(0,$g-80) . " " . max(0,$b-80) . ")" }};
|
|
}
|
|
</style>
|
|
BLADE)
|
|
)
|
|
->userMenuItems([
|
|
'billing' => \Filament\Navigation\MenuItem::make()
|
|
->label('Facturile mele')
|
|
->icon('heroicon-o-credit-card')
|
|
->url('/billing')
|
|
->openUrlInNewTab(false),
|
|
])
|
|
->renderHook(
|
|
PanelsRenderHook::USER_MENU_BEFORE,
|
|
fn (): string => Blade::render(<<<'BLADE'
|
|
@php
|
|
$locale = app()->getLocale();
|
|
$langs = ['ro' => 'RO', 'ru' => 'RU', 'en' => 'EN'];
|
|
$csrf = csrf_token();
|
|
@endphp
|
|
<div class="al-locale" x-data="{ open: false }">
|
|
<button type="button" class="al-locale-btn" @click="open = !open" @click.away="open = false">
|
|
🌐 {{ $langs[$locale] ?? 'RO' }}
|
|
<span class="al-locale-caret">▾</span>
|
|
</button>
|
|
<div x-show="open" x-cloak class="al-locale-menu">
|
|
@foreach ($langs as $code => $label)
|
|
<form method="POST" action="/locale/{{ $code }}">
|
|
<input type="hidden" name="_token" value="{{ $csrf }}">
|
|
<button type="submit" class="al-locale-item {{ $locale === $code ? 'active' : '' }}">
|
|
{{ $label }} —
|
|
<span style="opacity:.7;font-size:11px;">
|
|
@switch($code)
|
|
@case('ro') Română @break
|
|
@case('ru') Русский @break
|
|
@case('en') English @break
|
|
@endswitch
|
|
</span>
|
|
</button>
|
|
</form>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
<style>
|
|
.al-locale { position: relative; margin-right: 8px; }
|
|
.al-locale-btn {
|
|
background: transparent; border: 1px solid rgba(0,0,0,.08);
|
|
border-radius: 8px; padding: 6px 10px; font-size: 12px;
|
|
cursor: pointer; color: inherit; display: flex; align-items: center; gap: 4px;
|
|
}
|
|
.dark .al-locale-btn { border-color: rgba(255,255,255,.1); }
|
|
.al-locale-btn:hover { background: rgba(0,0,0,.04); }
|
|
.dark .al-locale-btn:hover { background: rgba(255,255,255,.04); }
|
|
.al-locale-caret { font-size: 10px; opacity: .6; }
|
|
.al-locale-menu {
|
|
position: absolute; top: 100%; right: 0; margin-top: 4px;
|
|
background: #fff; border: 1px solid #e5e7eb; border-radius: 8px;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,.08);
|
|
min-width: 160px; z-index: 50; overflow: hidden;
|
|
}
|
|
.dark .al-locale-menu { background: #1f2937; border-color: #374151; }
|
|
.al-locale-menu form { margin: 0; }
|
|
.al-locale-item {
|
|
display: block; width: 100%; text-align: left;
|
|
background: transparent; border: none; padding: 8px 12px;
|
|
font-size: 13px; cursor: pointer; color: inherit;
|
|
}
|
|
.al-locale-item:hover { background: #f3f4f6; }
|
|
.dark .al-locale-item:hover { background: #374151; }
|
|
.al-locale-item.active { background: #eff6ff; color: #2563eb; font-weight: 600; }
|
|
.dark .al-locale-item.active { background: #1e3a8a; color: #93c5fd; }
|
|
</style>
|
|
BLADE)
|
|
)
|
|
// Small hint banner at the top of the main tenant dashboard.
|
|
->renderHook(
|
|
PanelsRenderHook::PAGE_START,
|
|
fn (): string => Blade::render(<<<'BLADE'
|
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;font-size:12px;color:#6b7280;">
|
|
<span>💡 {{ __('Fiecare card e clicabil — duce la lista relevantă.') }}</span>
|
|
<x-hint key="finante.dashboard.stats" />
|
|
<span style="margin-left:auto;font-size:11px;">
|
|
{{ __('Nu vezi „?" pe pagini?') }}
|
|
<a href="{{ url('/app/faq') }}" style="color:#2563eb;">{{ __('Activează sugestiile') }} →</a>
|
|
</span>
|
|
</div>
|
|
BLADE),
|
|
scopes: [Dashboard::class],
|
|
)
|
|
// Auto-inject "?" hint icon right after the page title on every
|
|
// main page whose URL matches an entry in Hints::forPath().
|
|
// PAGE_HEADER_HEADING_AFTER always renders (unlike the actions
|
|
// slot which is skipped on pages without header actions).
|
|
->renderHook(
|
|
PanelsRenderHook::PAGE_HEADER_HEADING_AFTER,
|
|
fn (): string => Blade::render(<<<'BLADE'
|
|
@php $__hintKey = \App\Support\Hints::forPath(request()->path()); @endphp
|
|
@if ($__hintKey)
|
|
<x-hint :key="$__hintKey" />
|
|
@endif
|
|
BLADE),
|
|
)
|
|
->renderHook(
|
|
PanelsRenderHook::BODY_END,
|
|
fn (): string => Blade::render(<<<'BLADE'
|
|
@php
|
|
$tenant = app(\App\Tenancy\TenantManager::class)->current();
|
|
$reverbKey = config('broadcasting.connections.reverb.key');
|
|
$reverbHost = config('broadcasting.connections.reverb.options.host');
|
|
$reverbPort = config('broadcasting.connections.reverb.options.port');
|
|
$reverbScheme = config('broadcasting.connections.reverb.options.scheme', 'https');
|
|
$broadcastEnabled = config('broadcasting.default') === 'reverb' && $reverbKey && $reverbHost;
|
|
$vapidPublic = config('webpush.vapid.public_key');
|
|
$csrf = csrf_token();
|
|
@endphp
|
|
<button id="autocrm-install" type="button" style="display:none;position:fixed;bottom:16px;right:16px;z-index:60;background:#3b82f6;color:#fff;border:0;border-radius:24px;padding:10px 18px;font-size:13px;font-weight:600;box-shadow:0 4px 12px rgba(0,0,0,.2);cursor:pointer;">
|
|
⤓ Instalează aplicația
|
|
</button>
|
|
<script>
|
|
// Service worker + Web Push subscription.
|
|
const AUTOCRM_VAPID = @json($vapidPublic);
|
|
function urlBase64ToUint8Array(b64) {
|
|
const pad = '='.repeat((4 - b64.length % 4) % 4);
|
|
const base64 = (b64 + pad).replace(/-/g, '+').replace(/_/g, '/');
|
|
const raw = atob(base64);
|
|
return Uint8Array.from([...raw].map(c => c.charCodeAt(0)));
|
|
}
|
|
if ('serviceWorker' in navigator) {
|
|
window.addEventListener('load', async () => {
|
|
try {
|
|
const reg = await navigator.serviceWorker.register('/sw.js');
|
|
if (AUTOCRM_VAPID && 'PushManager' in window) {
|
|
const perm = await Notification.requestPermission().catch(() => 'default');
|
|
if (perm === 'granted') {
|
|
let sub = await reg.pushManager.getSubscription();
|
|
if (!sub) {
|
|
sub = await reg.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey: urlBase64ToUint8Array(AUTOCRM_VAPID),
|
|
});
|
|
}
|
|
await fetch('/push/subscribe', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ $csrf }}' },
|
|
body: JSON.stringify(sub.toJSON()),
|
|
});
|
|
}
|
|
}
|
|
} catch (e) { /* push optional */ }
|
|
});
|
|
}
|
|
// PWA install prompt.
|
|
let deferredPrompt = null;
|
|
const installBtn = document.getElementById('autocrm-install');
|
|
window.addEventListener('beforeinstallprompt', (e) => {
|
|
e.preventDefault();
|
|
deferredPrompt = e;
|
|
if (installBtn) installBtn.style.display = 'block';
|
|
});
|
|
if (installBtn) {
|
|
installBtn.addEventListener('click', async () => {
|
|
if (!deferredPrompt) return;
|
|
deferredPrompt.prompt();
|
|
await deferredPrompt.userChoice;
|
|
deferredPrompt = null;
|
|
installBtn.style.display = 'none';
|
|
});
|
|
}
|
|
window.addEventListener('appinstalled', () => { if (installBtn) installBtn.style.display = 'none'; });
|
|
</script>
|
|
@if ($broadcastEnabled && $tenant)
|
|
<script src="https://js.pusher.com/8.4/pusher.min.js"></script>
|
|
<script>
|
|
(function() {
|
|
if (typeof Pusher === 'undefined') return;
|
|
Pusher.logToConsole = false;
|
|
window.AutoCRMEcho = new Pusher('{{ $reverbKey }}', {
|
|
wsHost: '{{ $reverbHost }}',
|
|
wsPort: {{ $reverbPort ?: ($reverbScheme === 'https' ? 443 : 80) }},
|
|
wssPort: {{ $reverbPort ?: 443 }},
|
|
forceTLS: {{ $reverbScheme === 'https' ? 'true' : 'false' }},
|
|
enabledTransports: ['ws', 'wss'],
|
|
cluster: 'mt1',
|
|
authEndpoint: '/broadcasting/auth',
|
|
auth: {
|
|
headers: {
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || ''
|
|
}
|
|
}
|
|
});
|
|
const ch = window.AutoCRMEcho.subscribe('private-tenant.{{ $tenant->slug }}');
|
|
ch.bind('work-order.updated', function(payload) {
|
|
window.dispatchEvent(new CustomEvent('autocrm:wo-updated', { detail: payload }));
|
|
// If we're on the kanban or a WO list page, refresh the Livewire component.
|
|
if (typeof Livewire !== 'undefined') {
|
|
Livewire.all().forEach(c => c.$refresh && c.$refresh());
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
@endif
|
|
BLADE)
|
|
);
|
|
}
|
|
}
|