Batch 2: Workload heatmap + Site PSauto + VIN search

═══ 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.
This commit is contained in:
2026-05-07 17:16:09 +00:00
parent 67da97178d
commit 4b3201ca1c
7 changed files with 534 additions and 3 deletions
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Filament\Tenant\Pages;
use App\Models\Tenant\Part;
use App\Models\Tenant\Vehicle;
use App\Services\VinDecoder;
use Filament\Pages\Page;
class VinSearch extends Page
{
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-magnifying-glass';
protected static ?string $navigationLabel = 'VIN-căutare';
protected static string|\UnitEnum|null $navigationGroup = 'Depozit';
protected static ?int $navigationSort = 41;
protected static ?string $title = 'VIN căutare & piese';
protected string $view = 'filament.tenant.pages.vin-search';
public string $vin = '';
public ?array $decoded = null;
public string $partsQuery = '';
public function decode(): void
{
if (! $this->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();
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
namespace App\Filament\Tenant\Pages;
use App\Models\Tenant\Appointment;
use App\Models\Tenant\Post;
use Carbon\Carbon;
use Filament\Pages\Page;
class Workload extends Page
{
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-fire';
protected static ?string $navigationLabel = 'Încărcare STO';
protected static string|\UnitEnum|null $navigationGroup = 'Analiză';
protected static ?int $navigationSort = 73;
protected static ?string $title = 'Încărcare service';
protected string $view = 'filament.tenant.pages.workload';
public string $weekStart;
public function mount(): void
{
$this->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,
];
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App\Services;
class VinDecoder
{
/** WMI (first 3 chars of VIN) → manufacturer/country (subset relevant for European cars). */
public const WMI = [
'WBA' => ['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,
];
}
}