33d1675182
Instead of a bare 404 when a work-order id doesn't resolve, redirect to the list with a persistent notification explaining why: either the WO doesn't exist, or it belongs to a different tenant. This is the most common 404 cause: user types /app/work-orders/2/dashboard but WO id 2 is in another tenant's slice — the tenant scope filters it out and find() returns null. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
276 lines
9.2 KiB
PHP
276 lines
9.2 KiB
PHP
<?php
|
|
|
|
namespace App\Filament\Tenant\Pages;
|
|
|
|
use App\Models\Tenant\WorkOrder;
|
|
use Filament\Pages\Page;
|
|
|
|
/**
|
|
* Mitchell1-style 3-column dashboard view for a single WorkOrder.
|
|
* MVP Phase 1: layout + tabs (Works/Parts/Diagnostic/Photos/Docs/Notes).
|
|
* Editing still goes through /app/work-orders/{id}/edit for now.
|
|
*/
|
|
class WorkOrderDashboard extends Page
|
|
{
|
|
protected static string|\BackedEnum|null $navigationIcon = null;
|
|
|
|
protected static bool $shouldRegisterNavigation = false;
|
|
|
|
protected string $view = 'filament.tenant.pages.work-order-dashboard';
|
|
|
|
public ?WorkOrder $record = null;
|
|
|
|
public string $activeTab = 'works';
|
|
|
|
public static function getSlug(?\Filament\Panel $panel = null): string
|
|
{
|
|
return 'work-orders/{record}/dashboard';
|
|
}
|
|
|
|
public static function getRoutePath(?\Filament\Panel $panel = null): string
|
|
{
|
|
return 'work-orders/{record}/dashboard';
|
|
}
|
|
|
|
public function mount(int|string $record): void
|
|
{
|
|
$id = (int) $record;
|
|
$wo = WorkOrder::with([
|
|
'client', 'vehicle', 'master',
|
|
'works.labor', 'works.master',
|
|
'parts.part',
|
|
'payments.user',
|
|
'subcontractJobs.subcontractor',
|
|
])->find($id);
|
|
|
|
if (! $wo) {
|
|
// Diagnose: does it exist in another tenant?
|
|
$existsGlobally = WorkOrder::withoutGlobalScopes()
|
|
->where('id', $id)->exists();
|
|
$tenant = app(\App\Tenancy\TenantManager::class)->current();
|
|
$tenantSlug = $tenant?->slug ?? '—';
|
|
$msg = $existsGlobally
|
|
? sprintf(__('Fișa #%d există, dar nu aparține tenantului „%s".'), $id, $tenantSlug)
|
|
: sprintf(__('Fișa #%d nu există. Verifică lista de fișe active.'), $id);
|
|
\Filament\Notifications\Notification::make()
|
|
->title(__('Fișă indisponibilă'))
|
|
->body($msg)
|
|
->danger()
|
|
->persistent()
|
|
->send();
|
|
redirect('/app/work-orders')->send();
|
|
exit;
|
|
}
|
|
$this->record = $wo;
|
|
}
|
|
|
|
public function setTab(string $tab): void
|
|
{
|
|
$this->activeTab = $tab;
|
|
}
|
|
|
|
public function getTitle(): string|\Illuminate\Contracts\Support\Htmlable
|
|
{
|
|
return __('Fișă') . ' ' . ($this->record->number ?? '');
|
|
}
|
|
|
|
public function getHeading(): string|\Illuminate\Contracts\Support\Htmlable
|
|
{
|
|
return '';
|
|
}
|
|
|
|
/** Latest 5 WOs for the vehicle (repair history sidebar). */
|
|
public function getRepairHistory(): \Illuminate\Support\Collection
|
|
{
|
|
if (! $this->record->vehicle_id) return collect();
|
|
return WorkOrder::where('vehicle_id', $this->record->vehicle_id)
|
|
->where('id', '!=', $this->record->id)
|
|
->orderByDesc('opened_at')
|
|
->limit(5)
|
|
->get(['id', 'number', 'opened_at', 'total', 'status']);
|
|
}
|
|
|
|
/**
|
|
* Timeline events for the right panel. Synthesises events from
|
|
* WO fields (created, opened, closed, paid, status transitions
|
|
* captured via activity_log if the trait is added) + notifications sent.
|
|
*/
|
|
public function getTimeline(): array
|
|
{
|
|
$wo = $this->record;
|
|
$events = [];
|
|
|
|
if ($wo->created_at) {
|
|
$events[] = [
|
|
'at' => $wo->created_at,
|
|
'kind' => 'created',
|
|
'label' => __('Fișă creată'),
|
|
'meta' => $wo->number,
|
|
'icon' => '📋',
|
|
];
|
|
}
|
|
if ($wo->opened_at && $wo->opened_at->ne($wo->created_at)) {
|
|
$events[] = [
|
|
'at' => $wo->opened_at,
|
|
'kind' => 'opened',
|
|
'label' => __('Auto primit în service'),
|
|
'meta' => null,
|
|
'icon' => '🚗',
|
|
];
|
|
}
|
|
if ($wo->approved_at) {
|
|
$events[] = [
|
|
'at' => $wo->approved_at,
|
|
'kind' => 'approved',
|
|
'label' => __('Aprobat de client'),
|
|
'meta' => null,
|
|
'icon' => '✓',
|
|
];
|
|
}
|
|
if ($wo->closed_at) {
|
|
$events[] = [
|
|
'at' => $wo->closed_at instanceof \Carbon\Carbon ? $wo->closed_at : \Carbon\Carbon::parse($wo->closed_at),
|
|
'kind' => 'closed',
|
|
'label' => __('Fișă închisă'),
|
|
'meta' => null,
|
|
'icon' => '🔒',
|
|
];
|
|
}
|
|
|
|
// Payments
|
|
foreach ($wo->payments as $p) {
|
|
$events[] = [
|
|
'at' => $p->paid_at,
|
|
'kind' => 'payment',
|
|
'label' => __('Plată primită'),
|
|
'meta' => number_format((float) $p->amount, 2, '.', ' ') . ' MDL · ' . __(\App\Models\Tenant\Payment::METHODS[$p->method] ?? $p->method),
|
|
'icon' => '💰',
|
|
];
|
|
}
|
|
|
|
// Activity log (spatie/activitylog) — best-effort read
|
|
try {
|
|
$activities = \DB::table('activity_log')
|
|
->where('subject_type', WorkOrder::class)
|
|
->where('subject_id', $wo->id)
|
|
->where('company_id', $wo->company_id)
|
|
->orderBy('created_at')
|
|
->get();
|
|
foreach ($activities as $a) {
|
|
$events[] = [
|
|
'at' => \Carbon\Carbon::parse($a->created_at),
|
|
'kind' => $a->event ?? 'log',
|
|
'label' => $a->description ?: ucfirst($a->event ?? 'log'),
|
|
'meta' => null,
|
|
'icon' => '📝',
|
|
];
|
|
}
|
|
} catch (\Throwable) { /* activity_log table may be empty on fresh DB */ }
|
|
|
|
// Sort chronologically desc so newest first
|
|
usort($events, fn ($a, $b) => $b['at']->timestamp <=> $a['at']->timestamp);
|
|
return $events;
|
|
}
|
|
|
|
/** Outbound notifications to the client (SMS/WhatsApp/Telegram/Email). */
|
|
public function getNotifications(): \Illuminate\Support\Collection
|
|
{
|
|
return \App\Models\Tenant\ClientNotificationLog::where('work_order_id', $this->record->id)
|
|
->orderByDesc('sent_at')
|
|
->limit(20)
|
|
->get();
|
|
}
|
|
|
|
public string $newMessage = '';
|
|
|
|
public function sendMessage(): void
|
|
{
|
|
$text = trim($this->newMessage);
|
|
if ($text === '' || ! $this->record->client) return;
|
|
|
|
$client = $this->record->client;
|
|
$company = app(\App\Tenancy\TenantManager::class)->current();
|
|
if (! $company) return;
|
|
|
|
$dispatcher = app(\App\Services\NotificationDispatcher::class);
|
|
$sent = false;
|
|
|
|
// Prefer Telegram if the client is linked; fall back to WhatsApp if a phone is set.
|
|
if (! empty($client->telegram_chat_id)) {
|
|
try {
|
|
$sent = app(\App\Services\Notifications\TelegramService::class)
|
|
->sendMessage($company, (string) $client->telegram_chat_id, $text);
|
|
} catch (\Throwable) { /* ignore */ }
|
|
}
|
|
|
|
// Log the message so it shows up in the chat pane regardless of delivery.
|
|
\App\Models\Tenant\ClientNotificationLog::create([
|
|
'company_id' => $company->id,
|
|
'work_order_id' => $this->record->id,
|
|
'client_id' => $client->id,
|
|
'channel' => ! empty($client->telegram_chat_id) ? 'telegram' : 'sms',
|
|
'template_key' => 'manual',
|
|
'message_text' => $text,
|
|
'status' => $sent ? 'sent' : 'failed',
|
|
'sent_at' => now(),
|
|
]);
|
|
|
|
$this->newMessage = '';
|
|
|
|
\Filament\Notifications\Notification::make()
|
|
->title($sent ? __('Mesaj trimis') : __('Trimitere eșuată — verifică integrarea'))
|
|
->{$sent ? 'success' : 'warning'}()
|
|
->send();
|
|
}
|
|
|
|
/** Adjacent WOs for the previous/next buttons in the bottom bar. */
|
|
public function getPrevWo(): ?WorkOrder
|
|
{
|
|
return WorkOrder::where('id', '<', $this->record->id)
|
|
->orderByDesc('id')
|
|
->first(['id', 'number']);
|
|
}
|
|
|
|
public function getNextWo(): ?WorkOrder
|
|
{
|
|
return WorkOrder::where('id', '>', $this->record->id)
|
|
->orderBy('id')
|
|
->first(['id', 'number']);
|
|
}
|
|
|
|
public function closeWorkOrder(): void
|
|
{
|
|
$wo = $this->record;
|
|
if ($wo->status === 'done') return;
|
|
$wo->forceFill([
|
|
'status' => 'done',
|
|
'closed_at' => now(),
|
|
])->save();
|
|
$this->record->refresh();
|
|
\Filament\Notifications\Notification::make()
|
|
->title(__('Fișă închisă'))
|
|
->success()->send();
|
|
}
|
|
|
|
/** Aggregated finance data — used by right panel in future phases. */
|
|
public function getFinance(): array
|
|
{
|
|
$r = $this->record;
|
|
$worksSum = (float) $r->works->sum('total');
|
|
$partsSum = (float) $r->parts->sum('total');
|
|
$subtotal = $worksSum + $partsSum;
|
|
$discount = (float) $r->discount;
|
|
$total = max(0, $subtotal - $discount);
|
|
$paid = $r->paidAmount();
|
|
return [
|
|
'works_sum' => $worksSum,
|
|
'parts_sum' => $partsSum,
|
|
'subtotal' => $subtotal,
|
|
'discount' => $discount,
|
|
'total' => $total,
|
|
'paid' => $paid,
|
|
'balance' => max(0, $total - $paid),
|
|
];
|
|
}
|
|
}
|