Initial commit: CABM-IT (ItGlue-style MSP documentation platform)
Laravel 13 + Livewire/Volt app for managing client organizations, documents, passwords, flexible assets, virtual machines, contacts, domains, todos, shortcuts, and role-based user administration.
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Document;
|
||||
use App\Models\FlexibleAsset;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Password;
|
||||
use App\Models\RelatedItem;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Livewire\Component;
|
||||
|
||||
new class extends Component
|
||||
{
|
||||
public Organization $organization;
|
||||
public Model $item;
|
||||
|
||||
public string $search = '';
|
||||
|
||||
public function link(string $type, int $id): void
|
||||
{
|
||||
$model = $this->resolveModel($type, $id);
|
||||
|
||||
if (! $model || $model->organization_id !== $this->organization->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$alreadyLinked = RelatedItem::forItem($this->item)->get()->contains(
|
||||
fn (RelatedItem $edge) => $this->sameModel($edge->otherItem($this->item), $model)
|
||||
);
|
||||
|
||||
if ($alreadyLinked || $this->sameModel($model, $this->item)) {
|
||||
return;
|
||||
}
|
||||
|
||||
RelatedItem::create([
|
||||
'item_type' => $this->item->getMorphClass(),
|
||||
'item_id' => $this->item->getKey(),
|
||||
'related_item_type' => $model->getMorphClass(),
|
||||
'related_item_id' => $model->getKey(),
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
$this->search = '';
|
||||
}
|
||||
|
||||
public function unlink(int $edgeId): void
|
||||
{
|
||||
RelatedItem::forItem($this->item)->whereKey($edgeId)->delete();
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$linkedEdges = RelatedItem::forItem($this->item)->with(['item', 'relatedItem'])->latest()->get();
|
||||
|
||||
return [
|
||||
'linked' => $linkedEdges->map(fn (RelatedItem $edge) => [
|
||||
'edge_id' => $edge->id,
|
||||
'model' => $edge->otherItem($this->item),
|
||||
])->filter(fn ($row) => $row['model'] !== null),
|
||||
'results' => $this->search !== '' ? $this->searchCandidates() : collect(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function searchCandidates()
|
||||
{
|
||||
$linkedKeys = RelatedItem::forItem($this->item)->get()
|
||||
->map(fn (RelatedItem $edge) => $this->modelKey($edge->otherItem($this->item)))
|
||||
->filter()
|
||||
->all();
|
||||
|
||||
$selfKey = $this->modelKey($this->item);
|
||||
|
||||
$results = collect();
|
||||
|
||||
$results = $results->merge(
|
||||
Password::where('organization_id', $this->organization->id)
|
||||
->where('name', 'like', "%{$this->search}%")
|
||||
->limit(5)->get()
|
||||
);
|
||||
|
||||
$results = $results->merge(
|
||||
FlexibleAsset::where('organization_id', $this->organization->id)
|
||||
->where('name', 'like', "%{$this->search}%")
|
||||
->limit(5)->get()
|
||||
);
|
||||
|
||||
$results = $results->merge(
|
||||
Document::where('organization_id', $this->organization->id)
|
||||
->whereNotNull('content')->where('content', '!=', '')
|
||||
->where('name', 'like', "%{$this->search}%")
|
||||
->limit(5)->get()
|
||||
);
|
||||
|
||||
return $results->reject(fn ($model) => in_array($this->modelKey($model), $linkedKeys, true) || $this->modelKey($model) === $selfKey)
|
||||
->values();
|
||||
}
|
||||
|
||||
protected function resolveModel(string $type, int $id): ?Model
|
||||
{
|
||||
return match ($type) {
|
||||
Password::class => Password::find($id),
|
||||
FlexibleAsset::class => FlexibleAsset::find($id),
|
||||
Document::class => Document::find($id),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
protected function modelKey(?Model $model): ?string
|
||||
{
|
||||
return $model ? $model->getMorphClass().':'.$model->getKey() : null;
|
||||
}
|
||||
|
||||
protected function sameModel(?Model $a, ?Model $b): bool
|
||||
{
|
||||
return $this->modelKey($a) !== null && $this->modelKey($a) === $this->modelKey($b);
|
||||
}
|
||||
};
|
||||
?>
|
||||
|
||||
<div>
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">Éléments liés</h2>
|
||||
|
||||
<div class="rounded border border-slate-200 bg-white p-4">
|
||||
<div class="relative">
|
||||
<input type="text" wire:model.live.debounce.300ms="search" placeholder="Rechercher pour lier un mot de passe, un actif ou un document..."
|
||||
class="w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
|
||||
|
||||
@if ($search !== '')
|
||||
<div class="absolute z-10 mt-1 w-full rounded border border-slate-200 bg-white shadow-lg">
|
||||
@forelse ($results as $result)
|
||||
<button type="button"
|
||||
wire:click="link(@js(get_class($result)), {{ $result->id }})"
|
||||
class="flex w-full items-center justify-between px-3 py-2 text-left text-sm hover:bg-slate-50">
|
||||
<span>{{ $result->linkableLabel() }}</span>
|
||||
<span class="text-xs text-slate-400">{{ $result->linkableKind() }}</span>
|
||||
</button>
|
||||
@empty
|
||||
<div class="px-3 py-2 text-sm text-slate-400">Aucun résultat.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<ul class="mt-4 divide-y divide-slate-100">
|
||||
@forelse ($linked as $row)
|
||||
<li class="flex items-center justify-between py-2 text-sm">
|
||||
<a href="{{ $row['model']->linkableUrl() }}" class="text-slate-900 hover:underline">
|
||||
{{ $row['model']->linkableLabel() }}
|
||||
</a>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600">{{ $row['model']->linkableKind() }}</span>
|
||||
<button type="button" wire:click="unlink({{ $row['edge_id'] }})" wire:confirm="Supprimer ce lien ?"
|
||||
class="text-xs text-red-500 hover:text-red-700">
|
||||
Délier
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
@empty
|
||||
<li class="py-2 text-sm text-slate-400">Aucun élément lié pour le moment.</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user