From edcdba9d535840ccc8659266f3bd346e8a2f080b Mon Sep 17 00:00:00 2001 From: Vasyka Date: Wed, 27 May 2026 19:21:23 +0000 Subject: [PATCH] =?UTF-8?q?Stage=203=20=E2=80=94=20WO=20photos=20+=20ETA?= =?UTF-8?q?=20+=20QR=20+=20public=20tracking=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HasMedia (Spatie) on WorkOrder with `photos` collection - eta_at + tracking_token columns; token auto-generated on create - Public /t/{token} page — tenant-scoped via subdomain, white-label themed - QR code SVG via chillerlan/php-qrcode (inline modal + download) - Filament: SpatieMediaLibraryFileUpload + ETA picker + tracking section - EditWorkOrder header action "Link client (QR)" modal - Fix: Auditable::dontSubmitEmptyLogs() → dontLogEmptyChanges() (removed in activitylog) - Tests: TrackingPageTest (4 pass) covering token gen + cross-tenant isolation Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Tenant/Resources/WorkOrderResource.php | 29 +++ .../WorkOrderResource/Pages/EditWorkOrder.php | 10 + app/Http/Controllers/TrackingController.php | 66 +++++++ app/Models/Concerns/Auditable.php | 2 +- app/Models/Tenant/WorkOrder.php | 24 ++- ...00_add_eta_and_tracking_to_work_orders.php | 35 ++++ phpunit.xml | 1 + .../filament/tenant/tracking-qr.blade.php | 48 +++++ resources/views/tracking/show.blade.php | 176 ++++++++++++++++++ routes/web.php | 8 + tests/Feature/TrackingPageTest.php | 91 +++++++++ 11 files changed, 487 insertions(+), 3 deletions(-) create mode 100644 app/Http/Controllers/TrackingController.php create mode 100644 database/migrations/2026_05_27_120000_add_eta_and_tracking_to_work_orders.php create mode 100644 resources/views/filament/tenant/tracking-qr.blade.php create mode 100644 resources/views/tracking/show.blade.php create mode 100644 tests/Feature/TrackingPageTest.php diff --git a/app/Filament/Tenant/Resources/WorkOrderResource.php b/app/Filament/Tenant/Resources/WorkOrderResource.php index 8055bad..76bad9c 100644 --- a/app/Filament/Tenant/Resources/WorkOrderResource.php +++ b/app/Filament/Tenant/Resources/WorkOrderResource.php @@ -101,6 +101,35 @@ class WorkOrderResource extends Resource Forms\Components\Textarea::make('diagnosis')->label('Diagnostic')->rows(3)->columnSpanFull(), Forms\Components\Textarea::make('recommendations')->label('Recomandări')->rows(2)->columnSpanFull(), ]), + Schemas\Components\Section::make('Foto') + ->collapsible() + ->schema([ + \Filament\Forms\Components\SpatieMediaLibraryFileUpload::make('photos') + ->label('Fotografii') + ->collection('photos') + ->multiple() + ->reorderable() + ->image() + ->imageEditor() + ->maxFiles(20) + ->columnSpanFull(), + ]), + Schemas\Components\Section::make('Tracking & ETA') + ->columns(3) + ->collapsible() + ->schema([ + Forms\Components\DateTimePicker::make('eta_at') + ->label('Gata estimat (ETA)') + ->seconds(false), + Forms\Components\TextInput::make('tracking_token') + ->label('Token public') + ->disabled() + ->dehydrated(false) + ->columnSpan(2) + ->helperText(fn (?WorkOrder $record) => $record?->tracking_token + ? 'Link client: ' . $record->trackingUrl() + : 'Se generează la salvare'), + ]), Schemas\Components\Section::make('Plată & total') ->columns(3) ->schema([ diff --git a/app/Filament/Tenant/Resources/WorkOrderResource/Pages/EditWorkOrder.php b/app/Filament/Tenant/Resources/WorkOrderResource/Pages/EditWorkOrder.php index 63cc074..1615a20 100644 --- a/app/Filament/Tenant/Resources/WorkOrderResource/Pages/EditWorkOrder.php +++ b/app/Filament/Tenant/Resources/WorkOrderResource/Pages/EditWorkOrder.php @@ -15,6 +15,16 @@ class EditWorkOrder extends EditRecord protected function getHeaderActions(): array { return [ + Actions\Action::make('tracking') + ->label('Link client (QR)') + ->icon('heroicon-m-qr-code') + ->color('primary') + ->modalHeading(fn () => 'Tracking client — WO #' . $this->record->number) + ->modalSubmitAction(false) + ->modalCancelActionLabel('Închide') + ->modalContent(fn () => view('filament.tenant.tracking-qr', [ + 'wo' => $this->record, + ])), Actions\Action::make('pdf') ->label('Descarcă PDF') ->icon('heroicon-m-document-arrow-down') diff --git a/app/Http/Controllers/TrackingController.php b/app/Http/Controllers/TrackingController.php new file mode 100644 index 0000000..8a3bf93 --- /dev/null +++ b/app/Http/Controllers/TrackingController.php @@ -0,0 +1,66 @@ +current(); + if (! $tenant) { + throw new NotFoundHttpException('Tracking only available on tenant subdomain.'); + } + + $wo = WorkOrder::with(['client', 'vehicle', 'master', 'media']) + ->where('tracking_token', $token) + ->first(); + + if (! $wo) { + throw new NotFoundHttpException('Fișa nu a fost găsită.'); + } + + return view('tracking.show', [ + 'wo' => $wo, + 'tenant' => $tenant, + 'photos' => $wo->getMedia('photos'), + ]); + } + + public function qr(Request $request, string $token) + { + $tenant = app(TenantManager::class)->current(); + if (! $tenant) { + throw new NotFoundHttpException(); + } + $wo = WorkOrder::where('tracking_token', $token)->first(); + if (! $wo) { + throw new NotFoundHttpException(); + } + + $options = new \chillerlan\QRCode\QROptions([ + 'outputType' => \chillerlan\QRCode\QRCode::OUTPUT_MARKUP_SVG, + 'eccLevel' => \chillerlan\QRCode\QRCode::ECC_M, + 'scale' => 6, + 'imageBase64' => false, + 'svgViewBoxSize' => 200, + 'addQuietzone' => true, + ]); + + $svg = (new \chillerlan\QRCode\QRCode($options))->render($wo->trackingUrl()); + + return response($svg, 200, [ + 'Content-Type' => 'image/svg+xml', + 'Cache-Control' => 'public, max-age=3600', + ]); + } +} diff --git a/app/Models/Concerns/Auditable.php b/app/Models/Concerns/Auditable.php index c45cd58..c7da127 100644 --- a/app/Models/Concerns/Auditable.php +++ b/app/Models/Concerns/Auditable.php @@ -22,7 +22,7 @@ trait Auditable return LogOptions::defaults() ->logFillable() ->logOnlyDirty() - ->dontSubmitEmptyLogs() + ->dontLogEmptyChanges() ->setDescriptionForEvent(fn (string $event) => match ($event) { 'created' => 'creat', 'updated' => 'modificat', diff --git a/app/Models/Tenant/WorkOrder.php b/app/Models/Tenant/WorkOrder.php index 186782b..e8d9fe1 100644 --- a/app/Models/Tenant/WorkOrder.php +++ b/app/Models/Tenant/WorkOrder.php @@ -8,10 +8,12 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Spatie\MediaLibrary\HasMedia; +use Spatie\MediaLibrary\InteractsWithMedia; -class WorkOrder extends Model +class WorkOrder extends Model implements HasMedia { - use Auditable, BelongsToTenant, SoftDeletes; + use Auditable, BelongsToTenant, InteractsWithMedia, SoftDeletes; public const STATUSES = [ 'new' => 'Nou', @@ -38,17 +40,29 @@ class WorkOrder extends Model 'complaint', 'diagnosis', 'recommendations', 'status', 'pay_status', 'approved', 'approved_at', 'discount_pct', 'total', + 'eta_at', 'tracking_token', ]; protected $casts = [ 'opened_at' => 'date', 'closed_at' => 'date', 'approved_at' => 'datetime', + 'eta_at' => 'datetime', 'approved' => 'boolean', 'discount_pct' => 'decimal:2', 'total' => 'decimal:2', ]; + public function registerMediaCollections(): void + { + $this->addMediaCollection('photos'); + } + + public function trackingUrl(): string + { + return url('/t/' . $this->tracking_token); + } + public function client(): BelongsTo { return $this->belongsTo(Client::class); @@ -112,6 +126,12 @@ class WorkOrder extends Model /** Auto-send 'ready' email + broadcast WS event on status change. */ protected static function booted(): void { + static::creating(function (self $wo) { + if (empty($wo->tracking_token)) { + $wo->tracking_token = \Illuminate\Support\Str::random(24); + } + }); + static::updated(function (self $wo) { if ( $wo->wasChanged('status') diff --git a/database/migrations/2026_05_27_120000_add_eta_and_tracking_to_work_orders.php b/database/migrations/2026_05_27_120000_add_eta_and_tracking_to_work_orders.php new file mode 100644 index 0000000..1fa9f9a --- /dev/null +++ b/database/migrations/2026_05_27_120000_add_eta_and_tracking_to_work_orders.php @@ -0,0 +1,35 @@ +dateTime('eta_at')->nullable()->after('closed_at'); + $t->string('tracking_token', 32)->nullable()->after('eta_at'); + $t->unique('tracking_token'); + }); + + WorkOrder::withoutGlobalScopes() + ->whereNull('tracking_token') + ->cursor() + ->each(function (WorkOrder $wo) { + $wo->tracking_token = Str::random(24); + $wo->saveQuietly(); + }); + } + + public function down(): void + { + Schema::table('work_orders', function (Blueprint $t) { + $t->dropUnique(['tracking_token']); + $t->dropColumn(['eta_at', 'tracking_token']); + }); + } +}; diff --git a/phpunit.xml b/phpunit.xml index e7f0a48..10790e1 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -32,5 +32,6 @@ + diff --git a/resources/views/filament/tenant/tracking-qr.blade.php b/resources/views/filament/tenant/tracking-qr.blade.php new file mode 100644 index 0000000..5256ee8 --- /dev/null +++ b/resources/views/filament/tenant/tracking-qr.blade.php @@ -0,0 +1,48 @@ +@php + $url = $wo->trackingUrl(); + $qrSvg = (new \chillerlan\QRCode\QRCode(new \chillerlan\QRCode\QROptions([ + 'outputType' => \chillerlan\QRCode\QRCode::OUTPUT_MARKUP_SVG, + 'eccLevel' => \chillerlan\QRCode\QRCode::ECC_M, + 'scale' => 8, + 'imageBase64' => false, + 'addQuietzone' => true, + ])))->render($url); +@endphp +
+

+ Trimite acest link clientului — vede statusul fișei, ETA și fotografiile fără să se logheze. +

+ +
+
{!! $qrSvg !!}
+
+ +
+ + +
+ + +
diff --git a/resources/views/tracking/show.blade.php b/resources/views/tracking/show.blade.php new file mode 100644 index 0000000..d87b27c --- /dev/null +++ b/resources/views/tracking/show.blade.php @@ -0,0 +1,176 @@ + + + + + +#{{ $wo->number }} — {{ $tenant->display_name ?? $tenant->name }} +@php + $themeColor = $tenant->settings['theme_color'] ?? '#3B82F6'; + $logoUrl = method_exists($tenant, 'getLogoUrl') ? $tenant->getLogoUrl() : null; + $faviconUrl = method_exists($tenant, 'getFaviconUrl') ? $tenant->getFaviconUrl() : null; + $statuses = App\Models\Tenant\WorkOrder::STATUSES; + $flow = ['new', 'diagnosis', 'agreement', 'approved', 'in_work', 'awaiting_parts', 'ready', 'done']; + $currentIdx = array_search($wo->status, $flow, true); +@endphp +@if ($faviconUrl) + +@endif + + + +
+ @if ($logoUrl)@endif +

{{ $tenant->display_name ?? $tenant->name }}

+
Fișa #{{ $wo->number }}
+
+ +
+ +
+ {{ $statuses[$wo->status] ?? $wo->status }} + @if ($wo->eta_at && in_array($wo->status, ['in_work', 'awaiting_parts', 'approved', 'diagnosis'], true)) +

+ Gata estimat: {{ $wo->eta_at->isoFormat('D MMM YYYY, HH:mm') }} +

+ @endif +
+ +
+

Detalii

+ @if ($wo->vehicle) +
+ Auto + {{ trim($wo->vehicle->make . ' ' . $wo->vehicle->model) }} + @if ($wo->vehicle->plate) · {{ $wo->vehicle->plate }} @endif + +
+ @endif + @if ($wo->mileage_in) +
Kilometraj{{ number_format($wo->mileage_in, 0, '.', ' ') }} km
+ @endif +
Deschis{{ $wo->opened_at?->isoFormat('D MMM YYYY') }}
+ @if ($wo->master) +
Maistru{{ $wo->master->name }}
+ @endif +
+ +
+

Etape

+
    + @foreach ($flow as $i => $st) + @php + $cls = ''; + if ($currentIdx !== false && $i < $currentIdx) $cls = 'done'; + elseif ($currentIdx !== false && $i === $currentIdx) $cls = 'current'; + @endphp +
  • {{ $statuses[$st] }}
  • + @endforeach +
+
+ + @if ($wo->complaint) +
+

Ce ne-ai cerut

+

{{ $wo->complaint }}

+
+ @endif + + @if ($wo->recommendations) +
+

Recomandări

+
{{ $wo->recommendations }}
+
+ @endif + + @if ($photos->count()) +
+

Fotografii

+
+ @foreach ($photos as $p) + + + + @endforeach +
+
+ @endif + + @if ((float) $wo->total > 0) +
+
+ Total + {{ number_format((float) $wo->total, 2, '.', ' ') }} {{ $tenant->settings['currency'] ?? 'MDL' }} +
+
+ @endif + +
+ Powered by AutoCRM +
+
+ + diff --git a/routes/web.php b/routes/web.php index 3ec033e..27c678e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -57,6 +57,14 @@ Route::get('/login', function (Request $request) { return redirect($tenant ? '/app/login' : '/admin/login'); })->name('login'); +// ─── Public WO tracking (no auth, tenant-scoped via subdomain) ────── +Route::get('/t/{token}', [\App\Http\Controllers\TrackingController::class, 'show']) + ->where('token', '[A-Za-z0-9]{16,32}') + ->name('tracking.show'); +Route::get('/t/{token}/qr.svg', [\App\Http\Controllers\TrackingController::class, 'qr']) + ->where('token', '[A-Za-z0-9]{16,32}') + ->name('tracking.qr'); + // Locale switch — POST /locale/{lang} sets session and persists to user. Route::post('/locale/{lang}', function (Request $request, string $lang) { if (! in_array($lang, ['ro', 'ru', 'en'], true)) { diff --git a/tests/Feature/TrackingPageTest.php b/tests/Feature/TrackingPageTest.php new file mode 100644 index 0000000..ee3bad0 --- /dev/null +++ b/tests/Feature/TrackingPageTest.php @@ -0,0 +1,91 @@ +makeWorkOrder('alpha'); + + $response = $this->get('http://alpha.service.mir.md/t/' . $wo->tracking_token); + $response->assertStatus(200); + $response->assertSee('#' . $wo->number); + } + + public function test_tracking_page_404_on_unknown_token(): void + { + $this->makeWorkOrder('beta'); + + $response = $this->get('http://beta.service.mir.md/t/NotARealTokenZZZZZ'); + $response->assertStatus(404); + } + + public function test_tracking_token_cannot_be_read_from_other_tenant(): void + { + $woA = $this->makeWorkOrder('gamma'); + + // Try to access tenant A's WO token from tenant B's subdomain. + $this->makeCompany('delta'); + + $response = $this->get('http://delta.service.mir.md/t/' . $woA->tracking_token); + $response->assertStatus(404); + } + + public function test_token_is_generated_on_create(): void + { + $wo = $this->makeWorkOrder('epsilon'); + $this->assertNotEmpty($wo->tracking_token); + $this->assertGreaterThanOrEqual(16, strlen($wo->tracking_token)); + } + + private function makeWorkOrder(string $slug): WorkOrder + { + $company = $this->makeCompany($slug); + app(TenantManager::class)->setCurrent($company); + + $client = Client::create([ + 'name' => 'Test Client', 'phone' => '+37399' . random_int(100000, 999999), + 'type' => 'individual', 'status' => 'active', + ]); + + $vehicle = Vehicle::create([ + 'client_id' => $client->id, + 'make' => 'VW', 'model' => 'Golf', 'plate' => 'XYZ' . random_int(100, 999), + ]); + + return WorkOrder::create([ + 'number' => WorkOrder::generateNumber($company->id), + 'client_id' => $client->id, + 'vehicle_id' => $vehicle->id, + 'opened_at' => now(), + 'complaint' => 'Test', + 'status' => 'in_work', + ]); + } + + private function makeCompany(string $slug): Company + { + $plan = Plan::firstOrCreate(['slug' => 'test'], [ + 'name' => 'Test', 'price' => 0, 'features' => [], + ]); + + return Company::create([ + 'plan_id' => $plan->id, + 'slug' => $slug, + 'name' => ucfirst($slug) . ' Service', + 'status' => 'active', + ]); + } +}