From 4b3201ca1c753f69141cfe3cde60d7563b5d31df Mon Sep 17 00:00:00 2001 From: Vasyka Date: Thu, 7 May 2026 17:16:09 +0000 Subject: [PATCH] Batch 2: Workload heatmap + Site PSauto + VIN search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ═══ Workload heatmap (Încărcare STO) ═══ - /app/workload custom Page (group Analiză) - Săptămână (Lu-Du) × posturi → matrice ore programate - Heatmap colorat: verde→galben→roșu pe ratio capacity (10h/zi) - Navigare săpt anterior/curent/următor - Programări fără pod → row '— fără pod —' separat ═══ Site PSauto (landing public) ═══ - / pe tenant subdomain → resources/views/site/landing.blade.php - Hero cu logo + nume + slogan; gradient theme color - Servicii (din settings.services) — grid card-uri - Locație/contact + program lucru standardizat - Mărci suportate (din settings.cars) - CTA: phone + email - Footer cu tenant name + powered by AutoCRM ═══ VIN search ═══ - VinDecoder service: WMI hardcoded (24 producători EU/Asia/USA) + year codes (2001-2026) — pure offline, fără API extern - /app/vin-search Page (group Depozit) cu: • Input VIN cu uppercase + monospace • Decode → producător/țară/an/serial • Match VIN-uri din baza Vehicles • Search piese din catalog (live debounce 300ms) - Rezultatele linkează la editor Vehicle/Part Total tenant routes: 102. --- app/Filament/Tenant/Pages/VinSearch.php | 56 +++++++++ app/Filament/Tenant/Pages/Workload.php | 94 ++++++++++++++ app/Services/VinDecoder.php | 59 +++++++++ .../tenant/pages/vin-search.blade.php | 102 ++++++++++++++++ .../filament/tenant/pages/workload.blade.php | 94 ++++++++++++++ resources/views/site/landing.blade.php | 115 ++++++++++++++++++ routes/web.php | 17 ++- 7 files changed, 534 insertions(+), 3 deletions(-) create mode 100644 app/Filament/Tenant/Pages/VinSearch.php create mode 100644 app/Filament/Tenant/Pages/Workload.php create mode 100644 app/Services/VinDecoder.php create mode 100644 resources/views/filament/tenant/pages/vin-search.blade.php create mode 100644 resources/views/filament/tenant/pages/workload.blade.php create mode 100644 resources/views/site/landing.blade.php diff --git a/app/Filament/Tenant/Pages/VinSearch.php b/app/Filament/Tenant/Pages/VinSearch.php new file mode 100644 index 0000000..b333243 --- /dev/null +++ b/app/Filament/Tenant/Pages/VinSearch.php @@ -0,0 +1,56 @@ +vin) return; + $this->decoded = app(VinDecoder::class)->decode($this->vin); + } + + public function vehicleMatches(): \Illuminate\Support\Collection + { + if (! $this->vin) return collect(); + return Vehicle::with('client') + ->where('vin', 'like', '%' . $this->vin . '%') + ->limit(10)->get(); + } + + public function partsResults(): \Illuminate\Support\Collection + { + $q = trim($this->partsQuery); + if ($q === '') return collect(); + return Part::where('is_active', true) + ->where(function ($query) use ($q) { + $query->where('name', 'like', "%{$q}%") + ->orWhere('article', 'like', "%{$q}%") + ->orWhere('brand', 'like', "%{$q}%"); + }) + ->limit(20)->get(); + } +} diff --git a/app/Filament/Tenant/Pages/Workload.php b/app/Filament/Tenant/Pages/Workload.php new file mode 100644 index 0000000..f0ee84c --- /dev/null +++ b/app/Filament/Tenant/Pages/Workload.php @@ -0,0 +1,94 @@ +weekStart = Carbon::now()->startOfWeek()->toDateString(); + } + + public function setWeek(int $delta): void + { + $this->weekStart = Carbon::parse($this->weekStart)->addWeeks($delta)->toDateString(); + } + + public function data(): array + { + $start = Carbon::parse($this->weekStart); + $end = (clone $start)->addDays(6); + + $posts = Post::where('is_active', true)->orderBy('sort_order')->get(); + $days = collect(range(0, 6))->map(fn ($i) => $start->copy()->addDays($i)); + + // Appointments grouped by post + day. + $appts = Appointment::whereBetween('date', [$start, $end]) + ->whereNotIn('status', ['cancelled', 'no_show']) + ->get() + ->groupBy(fn ($a) => $a->post_id . '|' . $a->date->format('Y-m-d')); + + // Compute total hours per cell. + $matrix = []; + foreach ($posts as $post) { + $matrix[$post->id] = ['post' => $post, 'days' => []]; + foreach ($days as $d) { + $key = $post->id . '|' . $d->format('Y-m-d'); + $items = $appts->get($key, collect()); + $hours = $items->sum(function ($a) { + $start = strtotime("1970-01-01 {$a->time_start}"); + $end = strtotime("1970-01-01 {$a->time_end}"); + return max(0, ($end - $start) / 3600); + }); + $matrix[$post->id]['days'][$d->format('Y-m-d')] = [ + 'date' => $d, + 'hours' => $hours, + 'count' => $items->count(), + ]; + } + } + + // Same for appointments without post (drop them on a virtual "—" row). + $unposted = $appts->keys()->filter(fn ($k) => str_starts_with($k, '|')); + if ($unposted->isNotEmpty()) { + $matrix[0] = ['post' => (object) ['id' => 0, 'name' => '— fără pod —', 'color' => '#9ca3af'], 'days' => []]; + foreach ($days as $d) { + $key = '|' . $d->format('Y-m-d'); + $items = $appts->get($key, collect()); + $hours = $items->sum(function ($a) { + $start = strtotime("1970-01-01 {$a->time_start}"); + $end = strtotime("1970-01-01 {$a->time_end}"); + return max(0, ($end - $start) / 3600); + }); + $matrix[0]['days'][$d->format('Y-m-d')] = ['date' => $d, 'hours' => $hours, 'count' => $items->count()]; + } + } + + return [ + 'matrix' => $matrix, + 'days' => $days, + 'weekLabel' => $start->format('d.m') . ' – ' . $end->format('d.m.Y'), + // Working day = 10h. Color scale: 0..10h → light to red. + 'capacity' => 10, + ]; + } +} diff --git a/app/Services/VinDecoder.php b/app/Services/VinDecoder.php new file mode 100644 index 0000000..41ef404 --- /dev/null +++ b/app/Services/VinDecoder.php @@ -0,0 +1,59 @@ + ['BMW', 'Germany'], 'WBS' => ['BMW M', 'Germany'], + 'WAU' => ['Audi', 'Germany'], 'WA1' => ['Audi SUV', 'Germany'], + 'WVW' => ['Volkswagen', 'Germany'], 'WV1' => ['VW Comm', 'Germany'], 'WV2' => ['VW Bus', 'Germany'], + 'WP0' => ['Porsche', 'Germany'], 'WP1' => ['Porsche SUV', 'Germany'], + 'WDB' => ['Mercedes-Benz', 'Germany'], 'WDC' => ['Mercedes SUV', 'Germany'], 'WDD' => ['Mercedes AMG', 'Germany'], + 'TMB' => ['Skoda', 'Czechia'], + 'JF1' => ['Subaru', 'Japan'], 'JS3' => ['Suzuki', 'Japan'], + 'JT1' => ['Toyota', 'Japan'], 'JTD' => ['Toyota Hybrid', 'Japan'], + 'JHM' => ['Honda', 'Japan'], '1HG' => ['Honda US', 'USA'], + 'KMH' => ['Hyundai', 'Korea'], 'KNA' => ['Kia', 'Korea'], + 'VF1' => ['Renault', 'France'], 'VF3' => ['Peugeot', 'France'], 'VF7' => ['Citroen', 'France'], + 'ZFA' => ['Fiat', 'Italy'], 'ZAR' => ['Alfa Romeo', 'Italy'], + 'WF0' => ['Ford EU', 'Germany'], '1FA' => ['Ford US', 'USA'], + 'YV1' => ['Volvo', 'Sweden'], + 'W0L' => ['Opel', 'Germany'], + ]; + + /** 10th VIN char (model year). */ + public const YEAR_CODES = [ + 'A' => 2010, 'B' => 2011, 'C' => 2012, 'D' => 2013, 'E' => 2014, 'F' => 2015, + 'G' => 2016, 'H' => 2017, 'J' => 2018, 'K' => 2019, 'L' => 2020, 'M' => 2021, + 'N' => 2022, 'P' => 2023, 'R' => 2024, 'S' => 2025, 'T' => 2026, + '1' => 2001, '2' => 2002, '3' => 2003, '4' => 2004, '5' => 2005, + '6' => 2006, '7' => 2007, '8' => 2008, '9' => 2009, + ]; + + public function decode(string $vin): array + { + $vin = strtoupper(trim($vin)); + if (strlen($vin) < 11) { + return ['valid' => false, 'error' => 'VIN trebuie să aibă cel puțin 11 caractere.']; + } + + $wmi = substr($vin, 0, 3); + $manufacturer = self::WMI[$wmi] ?? null; + $yearChar = $vin[9] ?? null; + $year = self::YEAR_CODES[$yearChar] ?? null; + + // Heuristic: 70s/80s/90s vs 2000s+ — model year alone is ambiguous, + // we just return what we can. + return [ + 'valid' => $manufacturer !== null, + 'vin' => $vin, + 'wmi' => $wmi, + 'manufacturer' => $manufacturer[0] ?? '?', + 'country' => $manufacturer[1] ?? '?', + 'serial' => substr($vin, 11), + 'year' => $year, + ]; + } +} diff --git a/resources/views/filament/tenant/pages/vin-search.blade.php b/resources/views/filament/tenant/pages/vin-search.blade.php new file mode 100644 index 0000000..2581372 --- /dev/null +++ b/resources/views/filament/tenant/pages/vin-search.blade.php @@ -0,0 +1,102 @@ + + + +
+

🔎 Decode VIN

+
+ + +
+
+ + @if ($decoded) +
+ @if ($decoded['valid']) +

✅ VIN decodificat

+
+
Producător
{{ $decoded['manufacturer'] }}
+
Țară
{{ $decoded['country'] }}
+
An model (estimat)
{{ $decoded['year'] ?? '—' }}
+
WMI
{{ $decoded['wmi'] }}
+
Serial
{{ $decoded['serial'] }}
+
+ @else +
⚠ {{ $decoded['error'] ?? 'WMI necunoscut — producătorul nu e în baza de date locală.' }}
+ @endif +
+ + @php $matches = $this->vehicleMatches(); @endphp + @if ($matches->isNotEmpty()) +
+

🚗 Mașini cu acest VIN în CRM

+ + + + @foreach ($matches as $v) + + + + + + + + @endforeach + +
VINMarca/ModelPlateClientKm
{{ $v->vin }}{{ $v->make }} {{ $v->model }} {{ $v->year }}{{ $v->plate ?? '—' }}{{ $v->client?->name ?? '—' }}{{ number_format($v->mileage, 0, '.', ' ') }}
+
+ @endif + @endif + +
+

📦 Caută piesă în catalog

+ + + @php $parts = $this->partsResults(); @endphp + @if ($partsQuery) +
+ @if ($parts->isEmpty()) +
Niciun rezultat pentru „{{ $partsQuery }}"
+ @else + + + + @foreach ($parts as $p) + + + + + + + + @endforeach + +
DenumireCodBrandStocPreț
+ {{ $p->name }} + {{ $p->article ?? '—' }}{{ $p->brand ?? '—' }} + {{ $p->qty }} {{ $p->unit }} + {{ number_format($p->sell_price, 2, '.', ' ') }} MDL
+ @endif +
+ @endif +
+
diff --git a/resources/views/filament/tenant/pages/workload.blade.php b/resources/views/filament/tenant/pages/workload.blade.php new file mode 100644 index 0000000..18ecb2f --- /dev/null +++ b/resources/views/filament/tenant/pages/workload.blade.php @@ -0,0 +1,94 @@ + + @php $data = $this->data(); @endphp + + + +
+ + {{ $data['weekLabel'] }} + + capacitate ref: {{ $data['capacity'] }}h/zi +
+ +
+ + + + + @foreach ($data['days'] as $d) + + @endforeach + + + + @foreach ($data['matrix'] as $row) + + + @foreach ($row['days'] as $cell) + @php + $h = (float) $cell['hours']; + $ratio = min(1, $h / max(0.001, $data['capacity'])); + // Heatmap color: green → yellow → red as ratio grows. + if ($h <= 0) { + $bg = '#f3f4f6'; $fg = '#9ca3af'; + } else { + $r = (int) (255 * $ratio); + $g = (int) (200 * (1 - $ratio)); + $bg = "rgba({$r},{$g},80,0.18)"; + $fg = "rgba({$r},{$g},80,1)"; + } + @endphp + + @endforeach + + @endforeach + +
{{ $d->translatedFormat('D') }}
{{ $d->format('d.m') }}
+
+ + {{ $row['post']->name }} +
+
+
+ @if ($h > 0) + {{ number_format($h, 1) }}h + {{ $cell['count'] }} progr. + @else + + @endif +
+
+
+ Legendă: + liber + ușor + mediu + plin (peste capacitate) +
+
+
diff --git a/resources/views/site/landing.blade.php b/resources/views/site/landing.blade.php new file mode 100644 index 0000000..231aa27 --- /dev/null +++ b/resources/views/site/landing.blade.php @@ -0,0 +1,115 @@ + + + + + +{{ $name }} — autoservice {{ $city ?? '' }} +@if ($faviconUrl) + +@endif + + + + +
+ @if ($logoUrl) + logo + @endif +

{{ $name }}

+

Autoservice profesional{{ $city ? ' — ' . $city : '' }}. Diagnostic, reparații, piese, ITP.

+
+ +@if (! empty($services)) +
+

Servicii oferite

+
+ @foreach ($services as $s) +
+
🔧
+

{{ $s }}

+
+ @endforeach +
+
+@endif + +
+
+
+

📍 Locație & contact

+ @if ($city)

Oraș: {{ $city }}

@endif + @if ($phone)

Telefon: {{ $phone }}

@endif + @if ($email)

Email: {{ $email }}

@endif +
+
+

🕒 Program de lucru

+

Luni – Vineri: 08:00 – 18:00

+

Sâmbătă: 09:00 – 14:00

+

Duminică: închis

+
+ @if (! empty($cars)) +
+

🚗 Mărci suportate

+

{{ implode(', ', array_slice($cars, 0, 12)) }}@if (count($cars) > 12), …@endif

+
+ @endif +
+
+ +
+

Rezervă o programare

+ @if ($phone) + 📞 {{ $phone }} + @endif + @if ($email) + ✉ {{ $email }} + @endif +
+ + + + + diff --git a/routes/web.php b/routes/web.php index b194127..bc7dcef 100644 --- a/routes/web.php +++ b/routes/web.php @@ -5,9 +5,20 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; Route::get('/', function () { - // On a tenant subdomain → redirect to the tenant panel. - if (app(TenantManager::class)->isResolved()) { - return redirect('/app'); + // On a tenant subdomain → public landing page. + $tenant = app(TenantManager::class)->current(); + if ($tenant) { + return view('site.landing', [ + 'name' => $tenant->display_name ?? $tenant->name, + 'city' => $tenant->city, + 'phone' => $tenant->phone, + 'email' => $tenant->email, + 'themeColor' => $tenant->settings['theme_color'] ?? '#3B82F6', + 'services' => (array) ($tenant->settings['services'] ?? []), + 'cars' => (array) ($tenant->settings['cars'] ?? []), + 'logoUrl' => $tenant->getLogoUrl(), + 'faviconUrl' => $tenant->getFaviconUrl(), + ]); } // On the central domain → redirect to admin. return redirect('/admin');