Files
mrKamooandClaude Sonnet 5 2e614f2929 Permet de déplacer schémas et dossiers par glisser-déposer
Reprend le mécanisme du module Documents (drag & drop + menu déroulant
"Déplacer vers"), scopé aux documents de type diagram pour rester isolé
du navigateur Documents.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 08:18:57 +02:00

331 lines
15 KiB
PHP
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
use App\Models\Document;
use App\Models\Organization;
use Livewire\Attributes\Url;
use Livewire\Component;
new class extends Component
{
public Organization $organization;
#[Url]
public ?int $folderId = null;
#[Url]
public ?int $locationId = null;
public string $newDiagramName = '';
public bool $showNewDiagram = false;
public string $newFolderName = '';
public bool $showNewFolder = false;
public function createFolder(): void
{
$this->validate(['newFolderName' => 'required|string|max:255']);
$this->organization->documents()->create([
'folder_id' => $this->folderId,
'name' => $this->newFolderName,
'type' => 'diagram',
'is_folder' => true,
'created_by' => auth()->id(),
]);
$this->newFolderName = '';
$this->showNewFolder = false;
}
public function createDiagram(): void
{
$this->validate(['newDiagramName' => 'required|string|max:255']);
$emptyDiagram = <<<'XML'
<mxfile host="app.diagrams.net">
<diagram name="Page-1">
<mxGraphModel dx="800" dy="600" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
</root>
</mxGraphModel>
</diagram>
</mxfile>
XML;
$document = $this->organization->documents()->create([
'folder_id' => $this->folderId,
'name' => $this->newDiagramName,
'type' => 'diagram',
'content' => $emptyDiagram,
'current_version' => 1,
'created_by' => auth()->id(),
]);
$document->versions()->create([
'version' => 1,
'content' => $emptyDiagram,
'created_by' => auth()->id(),
]);
$this->redirectRoute('organizations.infrastructure.edit', [$this->organization, $document, 'folderId' => $this->folderId], navigate: true);
}
public function delete(int $documentId): void
{
$document = $this->organization->documents()->where('type', 'diagram')->findOrFail($documentId);
$document->delete();
session()->flash('status', "« {$document->name} » supprimé.");
}
public function moveDocument(int $documentId, ?int $targetFolderId): void
{
$document = $this->organization->documents()->where('type', 'diagram')->findOrFail($documentId);
if ($document->id === $targetFolderId) {
return;
}
if ($targetFolderId !== null) {
$targetFolder = $this->organization->documents()->where('type', 'diagram')->find($targetFolderId);
if (! $targetFolder || ! $targetFolder->isFolder()) {
return;
}
// Prevent moving a folder into itself or one of its own descendants.
if ($document->isFolder()) {
$walk = $targetFolder;
while ($walk) {
if ($walk->id === $document->id) {
return;
}
$walk = $walk->folder;
}
}
}
if ($document->folder_id === $targetFolderId) {
return;
}
$document->update(['folder_id' => $targetFolderId]);
session()->flash('status', "« {$document->name} » déplacé.");
}
private function folderTree(\Illuminate\Support\Collection $allFolders): \Illuminate\Support\Collection
{
$byParent = $allFolders->groupBy('folder_id');
$walk = function ($parentId, $depth) use (&$walk, $byParent) {
return ($byParent->get($parentId) ?? collect())
->sortBy('name')
->flatMap(fn ($folder) => collect([['folder' => $folder, 'depth' => $depth]])
->concat($walk($folder->id, $depth + 1)));
};
return $walk(null, 0);
}
public function with(): array
{
$currentFolder = $this->folderId
? $this->organization->documents()->where('type', 'diagram')->find($this->folderId)
: null;
$breadcrumbs = collect();
$walk = $currentFolder;
while ($walk) {
$breadcrumbs->prepend($walk);
$walk = $walk->folder;
}
$allFolders = $this->organization->documents()->where('type', 'diagram')->where('is_folder', true)->get();
return [
'currentFolder' => $currentFolder,
'breadcrumbs' => $breadcrumbs,
'folderTree' => $this->folderTree($allFolders),
'locations' => $this->organization->locations()->orderBy('name')->get(),
'items' => $this->organization->documents()
->where('type', 'diagram')
->where('folder_id', $this->folderId)
->when($this->locationId, fn ($query) => $query->where('location_id', $this->locationId))
->with('location')
->orderByDesc('is_folder')
->orderBy('name')
->get(),
];
}
};
?>
<div>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-semibold text-slate-900">Infrastructure</h1>
<div class="flex gap-2">
<button type="button" wire:click="$set('showNewFolder', true)" title="Nouveau dossier"
class="flex h-10 w-10 cursor-pointer items-center justify-center rounded border border-slate-300 text-lg hover:bg-slate-50">
📁
</button>
<button type="button" wire:click="$set('showNewDiagram', true)"
class="cursor-pointer rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
+ Nouveau schéma
</button>
</div>
</div>
@if (session('status'))
<div class="mb-4 rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
{{ session('status') }}
</div>
@endif
<div class="mb-4 flex items-center justify-between gap-4">
<div class="flex items-center gap-1 text-sm text-slate-500">
<a wire:click="$set('folderId', null)" href="#" class="hover:text-slate-900">Infrastructure</a>
@foreach ($breadcrumbs as $crumb)
<span>/</span>
<a wire:click="$set('folderId', {{ $crumb->id }})" href="#" class="hover:text-slate-900">{{ $crumb->name }}</a>
@endforeach
</div>
@if ($locations->isNotEmpty())
<select wire:model.live="locationId" class="rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<option value="">Tous les sites</option>
@foreach ($locations as $location)
<option value="{{ $location->id }}">{{ $location->name }}</option>
@endforeach
</select>
@endif
</div>
@if ($showNewFolder)
<form wire:submit="createFolder" class="mb-4 flex items-center gap-2 rounded border border-slate-200 bg-white p-3">
<input type="text" wire:model="newFolderName" placeholder="Nom du dossier" autofocus
class="flex-1 rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<button type="submit" class="cursor-pointer rounded bg-slate-900 px-3 py-1.5 text-sm text-white hover:bg-slate-700">Créer</button>
<button type="button" wire:click="$set('showNewFolder', false)" class="cursor-pointer text-sm text-slate-500 hover:text-slate-900">Annuler</button>
@error('newFolderName') <p class="text-sm text-red-600">{{ $message }}</p> @enderror
</form>
@endif
@if ($showNewDiagram)
<form wire:submit="createDiagram" class="mb-4 flex items-start gap-2 rounded border border-slate-200 bg-white p-3">
<div class="flex-1">
<input type="text" wire:model="newDiagramName" placeholder="Ex: Schéma réseau - Mairie, Schéma fonctionnel - SI RH"
class="w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('newDiagramName') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<button type="submit" class="cursor-pointer rounded bg-slate-900 px-3 py-1.5 text-sm text-white hover:bg-slate-700">
Créer
</button>
</form>
@endif
@if ($items->isNotEmpty())
<p class="mb-2 text-xs text-slate-400">Astuce : glissez-déposez un élément sur un dossier pour le déplacer.</p>
@endif
<div class="overflow-hidden rounded border border-slate-200 bg-white" x-data="{ draggingId: null, overId: null }">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
<tr>
<th class="px-4 py-3">Nom</th>
<th class="px-4 py-3">Site</th>
<th class="px-4 py-3">Mis à jour</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@forelse ($items as $item)
@php $isFolder = $item->isFolder(); @endphp
<tr wire:key="diagram-{{ $item->id }}"
draggable="true"
x-on:dragstart="draggingId = {{ $item->id }}"
x-on:dragend="draggingId = null; overId = null"
@if ($isFolder)
x-on:dragover.prevent="if (draggingId !== {{ $item->id }}) overId = {{ $item->id }}"
x-on:dragleave="if (overId === {{ $item->id }}) overId = null"
x-on:drop.prevent="if (draggingId && draggingId !== {{ $item->id }}) { $wire.moveDocument(draggingId, {{ $item->id }}); } draggingId = null; overId = null"
@endif
:class="draggingId === {{ $item->id }} ? 'opacity-40' : (overId === {{ $item->id }} ? 'bg-slate-100 ring-1 ring-inset ring-slate-400' : '')"
class="cursor-move">
<td class="px-4 py-3">
@if ($isFolder)
<a wire:click="$set('folderId', {{ $item->id }})" href="#" class="font-medium text-slate-900 hover:underline">
📁 {{ $item->name }}
</a>
@else
<a href="{{ route('organizations.infrastructure.edit', [$organization, $item, 'folderId' => $this->folderId]) }}" class="font-medium text-slate-900 hover:underline">
🗺️ {{ $item->name }}
</a>
@endif
</td>
<td class="px-4 py-3 text-slate-600">{{ $isFolder ? '—' : ($item->location?->name ?? '—') }}</td>
<td class="px-4 py-3 text-slate-600">{{ $item->updated_at->diffForHumans() }}</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
@unless ($isFolder)
<a href="{{ route('organizations.infrastructure.edit', [$organization, $item, 'folderId' => $this->folderId]) }}" class="text-slate-500 hover:text-slate-900">Ouvrir</a>
@endunless
@php
// Exclude the item's own subtree (if it's a folder) so it can't be moved into itself or a descendant.
$excludedIds = [$item->id, $item->folder_id];
if ($isFolder) {
$stack = [$item->id];
while ($stack) {
$id = array_pop($stack);
foreach ($folderTree as $node) {
if ($node['folder']->folder_id === $id) {
$excludedIds[] = $node['folder']->id;
$stack[] = $node['folder']->id;
}
}
}
}
$moveTargets = $folderTree->reject(fn ($node) => in_array($node['folder']->id, $excludedIds, true));
@endphp
@if ($moveTargets->isNotEmpty() || $item->folder_id !== null)
<select wire:change="moveDocument({{ $item->id }}, $event.target.value === '' ? null : $event.target.value)"
class="ml-3 rounded border-slate-300 text-xs shadow-sm focus:border-slate-500 focus:ring-slate-500"
onclick="event.stopPropagation()">
<option value="" selected disabled>Déplacer vers...</option>
@if ($item->folder_id !== null)
<option value="">🏠 Racine</option>
@endif
@foreach ($moveTargets as $node)
<option value="{{ $node['folder']->id }}">{{ str_repeat(' ', $node['depth']) }}{{ $node['depth'] > 0 ? '└ ' : '' }}📁 {{ $node['folder']->name }}</option>
@endforeach
</select>
@endif
<button type="button"
wire:click="delete({{ $item->id }})"
wire:confirm="Supprimer ce {{ $isFolder ? 'dossier' : 'schéma' }} ? Cette action est irréversible."
class="ml-3 cursor-pointer text-red-500 hover:text-red-700">
Supprimer
</button>
</td>
</tr>
@empty
<tr>
<td colspan="4" class="px-4 py-6 text-center text-slate-400">Dossier vide.</td>
</tr>
@endforelse
</tbody>
</table>
@if ($currentFolder)
<div class="border-t border-slate-200 px-4 py-3 text-sm text-slate-500"
x-on:dragover.prevent="overId = 'up'"
x-on:dragleave="if (overId === 'up') overId = null"
x-on:drop.prevent="if (draggingId) { $wire.moveDocument(draggingId, {{ $currentFolder->folder_id ?? 'null' }}); } draggingId = null; overId = null"
:class="overId === 'up' ? 'bg-slate-100 ring-1 ring-inset ring-slate-400' : ''">
⬆️ Glisser ici pour remonter au dossier parent
</div>
@endif
</div>
</div>