Files
Vasyka ac7d5b4733 fix: central company view page 404 + broken stats query
The /admin/companies/{id} view page (ViewCompany extends Page) 404'd because
the {record} route param could arrive as a JSON-encoded model (Livewire typed-
property hydration), so findOrFail() received a non-id and threw
ModelNotFoundException. Added resolveRecordKey() to normalize scalar id / model
/ JSON-string down to the integer key.

Also fixed getStats() referencing non-existent parts columns `stock` /
`low_stock_threshold` (real columns are `qty` / `min_qty`), which would 500 the
page once mount resolved.

Added CentralCompanyViewTest as regression (asserts 200 + company name). Full
suite: 100 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:24:08 +00:00

197 lines
8.2 KiB
PHP

<?php
namespace App\Filament\Central\Resources\CompanyResource\Pages;
use App\Filament\Central\Resources\CompanyResource;
use App\Models\Central\Company;
use App\Models\Central\Subscription;
use App\Models\Tenant\Client;
use App\Models\Tenant\Part;
use App\Models\Tenant\Payment;
use App\Models\Tenant\User as TenantUser;
use App\Models\Tenant\Vehicle;
use App\Models\Tenant\WorkOrder;
use App\Tenancy\TenantManager;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\Page;
class ViewCompany extends Page
{
protected static string $resource = CompanyResource::class;
protected string $view = 'filament.central.resources.company.view';
public Company $record;
public function mount(int|string $record): void
{
// The {record} route param may arrive as a scalar id, an Eloquent model,
// or (via Livewire's typed-property hydration) a JSON-encoded model.
// Normalize all of these down to the integer primary key.
$key = $this->resolveRecordKey($record);
$this->record = Company::with(['plan', 'subscriptions' => fn ($q) => $q->latest('period_end')->limit(10)])
->findOrFail($key);
}
private function resolveRecordKey(mixed $record): int|string
{
if ($record instanceof Company) {
return $record->getKey();
}
if (is_string($record) && str_starts_with(ltrim($record), '{')) {
$decoded = json_decode($record, true);
if (is_array($decoded) && isset($decoded['id'])) {
return $decoded['id'];
}
}
return $record;
}
public function getTitle(): string
{
return $this->record->display_name ?? $this->record->name;
}
public function getStats(): array
{
// Set tenant context to query scoped tables.
app(TenantManager::class)->setCurrent($this->record);
app(\Spatie\Permission\PermissionRegistrar::class)
->setPermissionsTeamId($this->record->id);
$stats = [
'users' => TenantUser::count(),
'clients' => Client::count(),
'vehicles' => Vehicle::count(),
'work_orders' => WorkOrder::count(),
'work_orders_open' => WorkOrder::whereNotIn('status', ['done', 'cancelled'])->count(),
'parts' => Part::count(),
'parts_low_stock' => Part::where('is_active', true)
->whereColumn('qty', '<=', 'min_qty')
->where('qty', '>', 0)
->count(),
'revenue_this_month' => (float) Payment::whereYear('paid_at', date('Y'))
->whereMonth('paid_at', date('m'))->sum('amount'),
'revenue_last_month' => (float) Payment::whereYear('paid_at', now()->subMonth()->year)
->whereMonth('paid_at', now()->subMonth()->month)->sum('amount'),
'last_login' => TenantUser::whereNotNull('last_login_at')->max('last_login_at'),
'storage_mb' => $this->calculateStorageMb(),
];
app(TenantManager::class)->setCurrent(null);
return $stats;
}
private function calculateStorageMb(): float
{
try {
$bytes = (int) \DB::table('media')
->where('model_type', \App\Models\Central\Company::class)
->where('model_id', $this->record->id)
->sum('size');
return round($bytes / 1024 / 1024, 2);
} catch (\Throwable) {
return 0;
}
}
public function getDaysUntilExpiry(): ?int
{
$until = $this->record->active_until ?? $this->record->trial_ends_at;
if (! $until) return null;
$diff = now()->diffInDays($until, false);
return (int) $diff;
}
protected function getHeaderActions(): array
{
return [
Actions\Action::make('view_as')
->label('Deschide tenantul')
->icon('heroicon-m-arrow-top-right-on-square')
->color('primary')
->url(fn () => $this->record->url('/app'))
->openUrlInNewTab(),
Actions\Action::make('issue_invoice')
->label('Generează factură')
->icon('heroicon-m-document-plus')
->color('success')
->visible(fn () => (bool) $this->record->plan_id)
->schema([
Forms\Components\Select::make('period')->options(\App\Models\Central\Subscription::PERIODS)->default('monthly')->required(),
])
->action(function (array $data) {
$plan = $this->record->plan;
if (! $plan) return;
$start = today();
$end = $data['period'] === 'yearly' ? $start->copy()->addYear() : $start->copy()->addMonth();
$sub = Subscription::create([
'company_id' => $this->record->id,
'plan_id' => $plan->id,
'period' => $data['period'],
'amount' => $data['period'] === 'yearly' ? $plan->price_yearly : $plan->price_monthly,
'currency' => $plan->currency,
'status' => 'pending',
'period_start' => $start,
'period_end' => $end,
'due_at' => $start->copy()->addDays(7),
'invoice_number' => Subscription::generateInvoiceNumber(),
]);
Notification::make()
->title('Factură generată: ' . $sub->invoice_number)
->body('Suma: ' . number_format($sub->amount, 2) . ' ' . $sub->currency)
->success()->send();
$this->dispatch('$refresh');
}),
Actions\Action::make('upload_logo')
->label('Logo')
->icon('heroicon-m-photo')
->color('gray')
->schema([
Forms\Components\FileUpload::make('logo')
->image()
->imageEditor()
->disk('public')
->directory('central-uploads')
->maxSize(2048)
->required(),
])
->action(function (array $data) {
if (empty($data['logo'])) return;
$abs = \Illuminate\Support\Facades\Storage::disk('public')->path($data['logo']);
if (file_exists($abs)) {
$this->record->clearMediaCollection('logo');
$this->record->addMedia($abs)->preservingOriginal()->toMediaCollection('logo');
@unlink($abs);
Notification::make()->title('Logo actualizat')->success()->send();
}
}),
Actions\Action::make('edit')
->label('Editează')
->icon('heroicon-m-pencil')
->url(fn () => CompanyResource::getUrl('edit', ['record' => $this->record])),
Actions\Action::make('suspend')
->label('Suspendă')->icon('heroicon-m-no-symbol')->color('danger')
->visible(fn () => in_array($this->record->status, ['active', 'trial']))
->requiresConfirmation()
->action(function () {
app(\App\Services\CompanyProvisioner::class)->suspend($this->record);
Notification::make()->title('Tenant suspendat.')->success()->send();
$this->dispatch('$refresh');
}),
Actions\Action::make('activate')
->label('Reactivează')->icon('heroicon-m-check-circle')->color('success')
->visible(fn () => in_array($this->record->status, ['suspended', 'expired']))
->requiresConfirmation()
->action(function () {
app(\App\Services\CompanyProvisioner::class)->reactivate($this->record);
Notification::make()->title('Tenant activat.')->success()->send();
$this->dispatch('$refresh');
}),
];
}
}