Files
CABM-IT/resources/views/components/documents/⚡index.blade.php
T
jeremy bayse 8f22dca9c8 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.
2026-08-16 13:28:12 +02:00

385 lines
17 KiB
PHP
Raw 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 Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithFileUploads;
new class extends Component
{
use WithFileUploads;
const OFFICE_EXTENSIONS = ['docx', 'xlsx'];
const UPLOAD_EXTENSIONS = ['pdf', 'txt', 'md'];
public Organization $organization;
#[Url]
public ?int $folderId = null;
public string $newFolderName = '';
public bool $showNewFolder = false;
public $uploadedFile = null;
public function createFolder(): void
{
$this->validate(['newFolderName' => 'required|string|max:255']);
$this->organization->documents()->create([
'folder_id' => $this->folderId,
'name' => $this->newFolderName,
'is_folder' => true,
'created_by' => auth()->id(),
]);
$this->newFolderName = '';
$this->showNewFolder = false;
}
public function createMarkdownDocument(): void
{
$document = $this->organization->documents()->create([
'folder_id' => $this->folderId,
'name' => 'Document sans titre',
'type' => 'markdown',
'content' => '',
'created_by' => auth()->id(),
]);
$document->versions()->create([
'version' => 1,
'content' => '',
'created_by' => auth()->id(),
]);
$this->redirectRoute('organizations.documents.edit', [$this->organization, $document, 'edit' => true], navigate: true);
}
public function createOfficeDocument(string $extension): void
{
abort_unless(in_array($extension, ['docx', 'xlsx'], true), 400);
$name = $extension === 'xlsx' ? 'Feuille de calcul sans titre' : 'Document sans titre';
$document = $this->organization->documents()->create([
'folder_id' => $this->folderId,
'name' => $name,
'type' => 'office',
'office_extension' => $extension,
'office_key' => Str::random(20),
'created_by' => auth()->id(),
]);
$document->file_path = "documents/office/{$this->organization->id}/{$document->id}.{$extension}";
Storage::put($document->file_path, file_get_contents(resource_path("office-templates/blank.{$extension}")));
$document->save();
$document->versions()->create([
'version' => 1,
'created_by' => auth()->id(),
]);
$this->redirectRoute('organizations.documents.edit', [$this->organization, $document, 'edit' => true], navigate: true);
}
public function updatedUploadedFile(): void
{
$this->validate([
'uploadedFile' => 'required|file|max:51200|mimes:'.implode(',', [...self::OFFICE_EXTENSIONS, ...self::UPLOAD_EXTENSIONS]),
]);
$extension = strtolower($this->uploadedFile->getClientOriginalExtension());
$originalName = $this->uploadedFile->getClientOriginalName();
$isOffice = in_array($extension, self::OFFICE_EXTENSIONS, true);
$document = $this->organization->documents()->create([
'folder_id' => $this->folderId,
'name' => $originalName,
'type' => $isOffice ? 'office' : 'upload',
'office_extension' => $isOffice ? $extension : null,
'office_key' => $isOffice ? Str::random(20) : null,
'created_by' => auth()->id(),
]);
$storedPath = $isOffice
? "documents/office/{$this->organization->id}/{$document->id}.{$extension}"
: "documents/uploads/{$this->organization->id}/{$document->id}.{$extension}";
Storage::put($storedPath, file_get_contents($this->uploadedFile->getRealPath()));
$document->update(['file_path' => $storedPath]);
$document->versions()->create([
'version' => 1,
'created_by' => auth()->id(),
]);
$this->uploadedFile = null;
session()->flash('status', "\"{$originalName}\" importé.");
}
public function delete(int $documentId): void
{
$document = $this->organization->documents()->findOrFail($documentId);
$document->delete();
session()->flash('status', "\"{$document->name}\" supprimé.");
}
public function moveDocument(int $documentId, ?int $targetFolderId): void
{
$document = $this->organization->documents()->findOrFail($documentId);
if ($document->id === $targetFolderId) {
return;
}
if ($targetFolderId !== null) {
$targetFolder = $this->organization->documents()->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é.");
}
/**
* All folders as a flat list ordered depth-first (parent immediately
* followed by its children), each annotated with its nesting depth so
* the "move to" dropdown can render indentation reflecting the tree.
*
* @return \Illuminate\Support\Collection<int, array{folder: Document, depth: int}>
*/
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()->find($this->folderId) : null;
$breadcrumbs = collect();
$walk = $currentFolder;
while ($walk) {
$breadcrumbs->prepend($walk);
$walk = $walk->folder;
}
$allFolders = $this->organization->documents()->where('is_folder', true)->get();
return [
'currentFolder' => $currentFolder,
'breadcrumbs' => $breadcrumbs,
'items' => $this->organization->documents()
->where('folder_id', $this->folderId)
->orderByDesc('is_folder')
->orderBy('name')
->get(),
'folderTree' => $this->folderTree($allFolders),
];
}
};
?>
<div>
<div class="mb-6 flex items-center justify-between">
<div>
<p class="text-sm text-slate-500">{{ $organization->name }}</p>
<h1 class="text-2xl font-semibold text-slate-900">Documents</h1>
</div>
<div class="flex gap-2">
<button type="button" wire:click="$set('showNewFolder', true)" title="Nouveau dossier"
class="flex h-10 w-10 items-center justify-center rounded border border-slate-300 text-lg hover:bg-slate-50">
📁
</button>
<a href="{{ route('organizations.documents.create', [$organization, 'folder' => $folderId]) }}" title="Nouveau document texte"
class="flex h-10 w-10 items-center justify-center rounded border border-slate-300 text-lg hover:bg-slate-50">
📄
</a>
<button type="button" wire:click="createMarkdownDocument" title="Nouveau document Markdown"
class="flex h-10 w-10 items-center justify-center rounded border border-slate-300 text-lg hover:bg-slate-50">
📝
</button>
<button type="button" wire:click="createOfficeDocument('docx')" title="Nouveau document Word"
class="flex h-10 w-10 items-center justify-center rounded bg-slate-900 text-lg hover:bg-slate-700">
📘
</button>
<button type="button" wire:click="createOfficeDocument('xlsx')" title="Nouvelle feuille de calcul"
class="flex h-10 w-10 items-center justify-center rounded bg-emerald-700 text-lg hover:bg-emerald-800">
📊
</button>
<label title="Importer un fichier (xlsx, docx, pdf, txt, md)"
class="flex h-10 w-10 cursor-pointer items-center justify-center rounded border border-slate-300 text-lg hover:bg-slate-50">
📤
<input type="file" wire:model="uploadedFile" accept=".xlsx,.docx,.pdf,.txt,.md" class="hidden">
</label>
</div>
</div>
<div wire:loading wire:target="uploadedFile" class="mb-4 text-sm text-slate-500">
Import en cours...
</div>
@error('uploadedFile') <div class="mb-4 rounded border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">{{ $message }}</div> @enderror
<div class="mb-4 flex items-center gap-1 text-sm text-slate-500">
<a wire:click="$set('folderId', null)" href="#" class="hover:text-slate-900">Documents</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 ($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="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="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 ($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">Version</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="doc-{{ $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.documents.edit', [$organization, $item]) }}" class="font-medium text-slate-900 hover:underline">
{{ match(true) {
$item->office_extension === 'xlsx' => '📊',
$item->type === 'markdown' => '📝',
$item->type === 'upload' => '📎',
default => '📄',
} }} {{ $item->name }}
</a>
@endif
</td>
<td class="px-4 py-3 text-slate-600">{{ $isFolder ? '—' : 'v'.$item->current_version }}</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.documents.edit', [$organization, $item, 'edit' => true]) }}" class="text-slate-500 hover:text-slate-900">
{{ $item->type === 'upload' ? 'Ouvrir' : 'Modifier' }}
</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' : 'document' }} ? Cette action est irréversible."
class="ml-3 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>