264 lines
10 KiB
PHP
264 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Livewire;
|
|
|
|
use App\Filament\Resources\InventoryItemResource;
|
|
use App\Models\InventoryItem;
|
|
use App\Models\Tenant;
|
|
use App\Services\Inventory\DependencyQueryService;
|
|
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
|
|
use App\Support\Enums\RelationshipType;
|
|
use App\Support\Filament\TablePaginationProfiles;
|
|
use Filament\Facades\Filament;
|
|
use Filament\Tables\Columns\TextColumn;
|
|
use Filament\Tables\Filters\SelectFilter;
|
|
use Filament\Tables\Table;
|
|
use Filament\Tables\TableComponent;
|
|
use Illuminate\Contracts\View\View;
|
|
use Illuminate\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Str;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
|
class InventoryItemDependencyEdgesTable extends TableComponent
|
|
{
|
|
public int $inventoryItemId;
|
|
|
|
private ?InventoryItem $cachedInventoryItem = null;
|
|
|
|
public function mount(int $inventoryItemId): void
|
|
{
|
|
$this->inventoryItemId = $inventoryItemId;
|
|
|
|
$this->resolveInventoryItem();
|
|
}
|
|
|
|
public function table(Table $table): Table
|
|
{
|
|
return $table
|
|
->queryStringIdentifier('inventoryItemDependencyEdges'.Str::studly((string) $this->inventoryItemId))
|
|
->defaultSort('relationship_label')
|
|
->defaultPaginationPageOption(10)
|
|
->paginated(TablePaginationProfiles::picker())
|
|
->striped()
|
|
->deferLoading(! app()->runningUnitTests())
|
|
->records(function (
|
|
?string $sortColumn,
|
|
?string $sortDirection,
|
|
?string $search,
|
|
array $filters,
|
|
int $page,
|
|
int $recordsPerPage
|
|
): LengthAwarePaginator {
|
|
$rows = $this->dependencyRows(
|
|
direction: (string) ($filters['direction']['value'] ?? 'all'),
|
|
relationshipType: $this->normalizeRelationshipType($filters['relationship_type']['value'] ?? null),
|
|
);
|
|
|
|
$rows = $this->sortRows($rows, $sortColumn, $sortDirection);
|
|
|
|
return $this->paginateRows($rows, $page, $recordsPerPage);
|
|
})
|
|
->filters([
|
|
SelectFilter::make('direction')
|
|
->label('Direction')
|
|
->default('all')
|
|
->options([
|
|
'all' => 'All',
|
|
'inbound' => 'Inbound',
|
|
'outbound' => 'Outbound',
|
|
]),
|
|
SelectFilter::make('relationship_type')
|
|
->label('Relationship')
|
|
->options([
|
|
'all' => 'All',
|
|
...RelationshipType::options(),
|
|
])
|
|
->default('all')
|
|
->searchable(),
|
|
])
|
|
->columns([
|
|
TextColumn::make('relationship_label')
|
|
->label('Relationship')
|
|
->badge()
|
|
->sortable(),
|
|
TextColumn::make('target_label')
|
|
->label('Target')
|
|
->badge()
|
|
->url(fn (array $record): ?string => is_string($record['target_url'] ?? null) ? $record['target_url'] : null)
|
|
->tooltip(fn (array $record): ?string => is_string($record['target_tooltip'] ?? null) ? $record['target_tooltip'] : null)
|
|
->wrap(),
|
|
TextColumn::make('missing_state')
|
|
->label('Status')
|
|
->badge()
|
|
->placeholder('—')
|
|
->color(fn (?string $state): string => $state === 'Missing' ? 'danger' : 'gray')
|
|
->icon(fn (?string $state): ?string => $state === 'Missing' ? 'heroicon-m-exclamation-triangle' : null)
|
|
->description(fn (array $record): ?string => is_string($record['missing_hint'] ?? null) ? $record['missing_hint'] : null)
|
|
->wrap(),
|
|
])
|
|
->actions([])
|
|
->bulkActions([])
|
|
->emptyStateHeading('No dependencies found')
|
|
->emptyStateDescription('Change direction or relationship filters to review a different dependency scope.');
|
|
}
|
|
|
|
public function render(): View
|
|
{
|
|
return view('livewire.inventory-item-dependency-edges-table');
|
|
}
|
|
|
|
/**
|
|
* @return Collection<string, array<string, mixed>>
|
|
*/
|
|
private function dependencyRows(string $direction, ?string $relationshipType): Collection
|
|
{
|
|
$inventoryItem = $this->resolveInventoryItem();
|
|
$tenant = $this->resolveCurrentTenant();
|
|
$service = app(DependencyQueryService::class);
|
|
$resolver = app(DependencyTargetResolver::class);
|
|
|
|
$edges = collect();
|
|
|
|
if ($direction === 'inbound' || $direction === 'all') {
|
|
$edges = $edges->merge($service->getInboundEdges($inventoryItem, $relationshipType));
|
|
}
|
|
|
|
if ($direction === 'outbound' || $direction === 'all') {
|
|
$edges = $edges->merge($service->getOutboundEdges($inventoryItem, $relationshipType));
|
|
}
|
|
|
|
return $resolver->attachRenderedTargets($edges->take(100), $tenant)
|
|
->map(function (array $edge): array {
|
|
$targetId = $edge['target_id'] ?? null;
|
|
$renderedTarget = is_array($edge['rendered_target'] ?? null) ? $edge['rendered_target'] : [];
|
|
$badgeText = is_string($renderedTarget['badge_text'] ?? null) ? $renderedTarget['badge_text'] : null;
|
|
$linkUrl = is_string($renderedTarget['link_url'] ?? null) ? $renderedTarget['link_url'] : null;
|
|
$lastKnownName = is_string(data_get($edge, 'metadata.last_known_name')) ? data_get($edge, 'metadata.last_known_name') : null;
|
|
$isMissing = ($edge['target_type'] ?? null) === 'missing';
|
|
|
|
$missingHint = null;
|
|
|
|
if ($isMissing) {
|
|
$missingHint = 'Missing target';
|
|
|
|
if (filled($lastKnownName)) {
|
|
$missingHint .= ". Last known: {$lastKnownName}";
|
|
}
|
|
|
|
$rawRef = data_get($edge, 'metadata.raw_ref');
|
|
$encodedRef = $rawRef !== null ? json_encode($rawRef) : null;
|
|
|
|
if (is_string($encodedRef) && $encodedRef !== '') {
|
|
$missingHint .= '. Ref: '.Str::limit($encodedRef, 200);
|
|
}
|
|
}
|
|
|
|
$fallbackLabel = null;
|
|
|
|
if (filled($lastKnownName)) {
|
|
$fallbackLabel = $lastKnownName;
|
|
} elseif (is_string($targetId) && $targetId !== '') {
|
|
$fallbackLabel = 'ID: '.substr($targetId, 0, 6).'…';
|
|
} else {
|
|
$fallbackLabel = 'External reference';
|
|
}
|
|
|
|
$relationshipType = (string) ($edge['relationship_type'] ?? 'unknown');
|
|
|
|
return [
|
|
'id' => (string) ($edge['id'] ?? Str::uuid()->toString()),
|
|
'relationship_label' => RelationshipType::options()[$relationshipType] ?? Str::headline($relationshipType),
|
|
'target_label' => $badgeText ?? $fallbackLabel,
|
|
'target_url' => $linkUrl,
|
|
'target_tooltip' => is_string($targetId) ? $targetId : null,
|
|
'missing_state' => $isMissing ? 'Missing' : null,
|
|
'missing_hint' => $missingHint,
|
|
];
|
|
})
|
|
->mapWithKeys(fn (array $row): array => [$row['id'] => $row]);
|
|
}
|
|
|
|
/**
|
|
* @param Collection<string, array<string, mixed>> $rows
|
|
* @return Collection<string, array<string, mixed>>
|
|
*/
|
|
private function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
|
|
{
|
|
$sortColumn = in_array($sortColumn, ['relationship_label', 'target_label', 'missing_state'], true)
|
|
? $sortColumn
|
|
: 'relationship_label';
|
|
$descending = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc';
|
|
|
|
$records = $rows->all();
|
|
|
|
uasort($records, static function (array $left, array $right) use ($sortColumn, $descending): int {
|
|
$comparison = strnatcasecmp(
|
|
(string) ($left[$sortColumn] ?? ''),
|
|
(string) ($right[$sortColumn] ?? ''),
|
|
);
|
|
|
|
if ($comparison === 0) {
|
|
$comparison = strnatcasecmp(
|
|
(string) ($left['target_label'] ?? ''),
|
|
(string) ($right['target_label'] ?? ''),
|
|
);
|
|
}
|
|
|
|
return $descending ? ($comparison * -1) : $comparison;
|
|
});
|
|
|
|
return collect($records);
|
|
}
|
|
|
|
/**
|
|
* @param Collection<string, array<string, mixed>> $rows
|
|
*/
|
|
private function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
|
|
{
|
|
return new LengthAwarePaginator(
|
|
items: $rows->forPage($page, $recordsPerPage),
|
|
total: $rows->count(),
|
|
perPage: $recordsPerPage,
|
|
currentPage: $page,
|
|
);
|
|
}
|
|
|
|
private function resolveInventoryItem(): InventoryItem
|
|
{
|
|
if ($this->cachedInventoryItem instanceof InventoryItem) {
|
|
return $this->cachedInventoryItem;
|
|
}
|
|
|
|
$inventoryItem = InventoryItem::query()->findOrFail($this->inventoryItemId);
|
|
$tenant = $this->resolveCurrentTenant();
|
|
|
|
if ((int) $inventoryItem->tenant_id !== (int) $tenant->getKey() || ! InventoryItemResource::canView($inventoryItem)) {
|
|
throw new NotFoundHttpException;
|
|
}
|
|
|
|
return $this->cachedInventoryItem = $inventoryItem;
|
|
}
|
|
|
|
private function resolveCurrentTenant(): Tenant
|
|
{
|
|
$tenant = Filament::getTenant();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
throw new NotFoundHttpException;
|
|
}
|
|
|
|
return $tenant;
|
|
}
|
|
|
|
private function normalizeRelationshipType(mixed $value): ?string
|
|
{
|
|
if (! is_string($value) || $value === '' || $value === 'all') {
|
|
return null;
|
|
}
|
|
|
|
return RelationshipType::tryFrom($value)?->value;
|
|
}
|
|
} |