Files
CABM-IT/resources/views/components/organizations/⚡dashboard.blade.php
T
mrKamooandClaude Sonnet 5 74c0b01a2e Permet de créer une tâche directement depuis le tableau de bord
Ajoute un formulaire rapide dans la carte "Tâches ouvertes" du dashboard, évitant de passer par la page ToDo dédiée pour les créations simples.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 09:14:28 +02:00

520 lines
27 KiB
PHP

<?php
use App\Models\Organization;
use App\Models\Todo;
use App\Models\User;
use App\Services\Glpi\GlpiClient;
use Livewire\Component;
new class extends Component
{
public Organization $organization;
public string $newLogCategory = 'info';
public string $newLogNote = '';
public bool $showTodoForm = false;
public string $newTodoTitle = '';
public string $newTodoCategory = 'infra';
public string $newTodoCriticality = 'medium';
public ?string $newTodoDueDate = null;
public ?int $newTodoAssignedTo = null;
public int $activityPage = 1;
const ACTIVITY_PER_PAGE = 4;
public function addLogEntry(): void
{
$this->validate([
'newLogCategory' => 'required|in:'.implode(',', array_keys(\App\Models\OrganizationLogEntry::CATEGORIES)),
'newLogNote' => 'required|string',
]);
$this->organization->logEntries()->create([
'category' => $this->newLogCategory,
'note' => $this->newLogNote,
'created_by' => auth()->id(),
]);
$this->newLogNote = '';
}
public function deleteLogEntry(int $entryId): void
{
$this->organization->logEntries()->findOrFail($entryId)->delete();
}
public function toggleTodoDone(int $todoId): void
{
$todo = $this->organization->todos()->findOrFail($todoId);
$todo->update([
'is_done' => ! $todo->is_done,
'completed_at' => $todo->is_done ? null : now(),
]);
}
public function toggleTodoForm(): void
{
$this->showTodoForm = ! $this->showTodoForm;
}
public function addTodo(): void
{
$validated = $this->validate([
'newTodoTitle' => 'required|string|max:255',
'newTodoCategory' => 'required|in:'.implode(',', array_keys(Todo::CATEGORIES)),
'newTodoCriticality' => 'required|in:'.implode(',', array_keys(Todo::CRITICALITIES)),
'newTodoDueDate' => 'nullable|date',
'newTodoAssignedTo' => 'nullable|exists:users,id',
]);
$this->organization->todos()->create([
'title' => $validated['newTodoTitle'],
'category' => $validated['newTodoCategory'],
'criticality' => $validated['newTodoCriticality'],
'due_date' => $validated['newTodoDueDate'],
'assigned_to' => $validated['newTodoAssignedTo'],
'created_by' => auth()->id(),
]);
$this->reset(['newTodoTitle', 'newTodoCategory', 'newTodoCriticality', 'newTodoDueDate', 'newTodoAssignedTo', 'showTodoForm']);
}
public function previousActivityPage(): void
{
$this->activityPage = max(1, $this->activityPage - 1);
}
public function nextActivityPage(): void
{
$this->activityPage++;
}
public function with(): array
{
$expiringDomains = $this->organization->domains()
->get()
->filter(fn ($domain) => in_array($domain->expiryStatus(), ['soon', 'expired'], true))
->sortBy(fn ($domain) => $domain->expiresInDays());
$recentDocuments = $this->organization->documents()
->with('creator')
->where('is_folder', false)
->orderByDesc('updated_at')
->limit(20)
->get()
->map(fn ($doc) => [
'icon' => $doc->isOffice() ? ($doc->office_extension === 'xlsx' ? '📊' : '📘') : ($doc->isMarkdown() ? '📝' : '📄'),
'label' => $doc->name,
'kind' => 'Document',
'url' => route('organizations.documents.edit', [$this->organization, $doc]),
'creator' => $doc->creator?->name,
'created' => $doc->created_at->equalTo($doc->updated_at),
'at' => $doc->updated_at,
]);
$recentAssets = $this->organization->flexibleAssets()
->with(['type', 'creator'])
->orderByDesc('updated_at')
->limit(20)
->get()
->map(fn ($asset) => [
'icon' => $asset->type?->icon ?: '🗂️',
'label' => $asset->name,
'kind' => $asset->type?->name ?? 'Actif',
'url' => route('organizations.assets.edit', [$this->organization, $asset->type, $asset]),
'creator' => $asset->creator?->name,
'created' => $asset->created_at->equalTo($asset->updated_at),
'at' => $asset->updated_at,
]);
$recentVirtualMachines = $this->organization->virtualMachines()
->with('creator')
->orderByDesc('updated_at')
->limit(20)
->get()
->map(fn ($vm) => [
'icon' => '🖥️',
'label' => $vm->name,
'kind' => 'Machine virtuelle',
'url' => route('organizations.virtual-machines.edit', [$this->organization, $vm]),
'creator' => $vm->creator?->name,
'created' => $vm->created_at->equalTo($vm->updated_at),
'at' => $vm->updated_at,
]);
$allActivity = $recentDocuments
->concat($recentAssets)
->concat($recentVirtualMachines)
->sortByDesc('at')
->values();
$activityPageCount = max(1, (int) ceil($allActivity->count() / self::ACTIVITY_PER_PAGE));
$this->activityPage = min($this->activityPage, $activityPageCount);
$recentActivity = $allActivity->forPage($this->activityPage, self::ACTIVITY_PER_PAGE)->values();
$openTodos = $this->organization->todos()
->with('assignee')
->where('is_done', false)
->orderByRaw("CASE criticality WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END")
->orderByRaw('due_date IS NULL')
->orderBy('due_date')
->limit(8)
->get();
$glpi = ['tickets' => [], 'total' => 0, 'error' => null];
$glpiNewTicketUrl = null;
if ($this->organization->glpi_entity_id !== null) {
$glpiClient = app(GlpiClient::class);
$glpi = $glpiClient->recentTicketsForEntity($this->organization->glpi_entity_id);
$glpiNewTicketUrl = $glpiClient->frontUrl().'/front/ticket.form.php';
}
return [
'glpiTickets' => $glpi['tickets'],
'glpiTicketsTotal' => $glpi['total'],
'glpiError' => $glpi['error'],
'glpiNewTicketUrl' => $glpiNewTicketUrl,
'counts' => [
'contacts' => $this->organization->contacts()->count(),
'locations' => $this->organization->locations()->count(),
'domains' => $this->organization->domains()->count(),
'documents' => $this->organization->documents()->where('is_folder', false)->count(),
'virtualMachines' => $this->organization->virtualMachines()->count(),
'assets' => $this->organization->flexibleAssets()->count(),
'openTodos' => $this->organization->todos()->where('is_done', false)->count(),
],
'expiringDomains' => $expiringDomains,
'recentActivity' => $recentActivity,
'activityPageCount' => $activityPageCount,
'openTodos' => $openTodos,
'assignableUsers' => User::role(['Administrateur', 'Technicien'])->orderBy('name')->get(),
'logEntries' => $this->organization->logEntries()->with('creator')->limit(20)->get(),
'categories' => \App\Models\OrganizationLogEntry::CATEGORIES,
];
}
};
?>
<div>
<div class="mb-6 flex items-start justify-between">
<div>
<p class="text-sm text-slate-500">Tableau de bord</p>
<h1 class="text-2xl font-semibold text-slate-900">{{ $organization->name }}</h1>
</div>
<img src="{{ $organization->logo_url }}" alt="Logo {{ $organization->name }}"
class="h-20 w-20 shrink-0 rounded border border-slate-200 bg-white object-contain p-1">
</div>
<div class="mb-6 flex divide-x divide-slate-200 overflow-x-auto rounded border border-slate-200 bg-white">
<a href="{{ route('organizations.contacts.index', $organization) }}" class="flex flex-1 items-baseline gap-2 whitespace-nowrap px-4 py-3 hover:bg-slate-50">
<span class="text-lg font-semibold text-slate-900">{{ $counts['contacts'] }}</span>
<span class="text-xs text-slate-500">Contacts</span>
</a>
<a href="{{ route('organizations.locations.index', $organization) }}" class="flex flex-1 items-baseline gap-2 whitespace-nowrap px-4 py-3 hover:bg-slate-50">
<span class="text-lg font-semibold text-slate-900">{{ $counts['locations'] }}</span>
<span class="text-xs text-slate-500">Sites</span>
</a>
<a href="{{ route('organizations.domains.index', $organization) }}" class="flex flex-1 items-baseline gap-2 whitespace-nowrap px-4 py-3 hover:bg-slate-50">
<span class="text-lg font-semibold text-slate-900">{{ $counts['domains'] }}</span>
<span class="text-xs text-slate-500">Domaines</span>
</a>
<a href="{{ route('organizations.documents.index', $organization) }}" class="flex flex-1 items-baseline gap-2 whitespace-nowrap px-4 py-3 hover:bg-slate-50">
<span class="text-lg font-semibold text-slate-900">{{ $counts['documents'] }}</span>
<span class="text-xs text-slate-500">Documents</span>
</a>
<a href="{{ route('organizations.virtual-machines.index', $organization) }}" class="flex flex-1 items-baseline gap-2 whitespace-nowrap px-4 py-3 hover:bg-slate-50">
<span class="text-lg font-semibold text-slate-900">{{ $counts['virtualMachines'] }}</span>
<span class="text-xs text-slate-500">Machines virtuelles</span>
</a>
<a href="{{ route('organizations.assets.picker', $organization) }}" class="flex flex-1 items-baseline gap-2 whitespace-nowrap px-4 py-3 hover:bg-slate-50">
<span class="text-lg font-semibold text-slate-900">{{ $counts['assets'] }}</span>
<span class="text-xs text-slate-500">Actifs</span>
</a>
<a href="{{ route('organizations.todos.index', $organization) }}" class="flex flex-1 items-baseline gap-2 whitespace-nowrap px-4 py-3 hover:bg-slate-50">
<span class="text-lg font-semibold text-slate-900">{{ $counts['openTodos'] }}</span>
<span class="text-xs text-slate-500">Tâches ouvertes</span>
</a>
</div>
<div class="mb-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
<div class="rounded border border-slate-200 bg-white p-4">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">Domaines à surveiller</h2>
@if ($expiringDomains->isEmpty())
<p class="text-sm text-slate-400">Aucun domaine à surveiller.</p>
@else
<ul class="space-y-2">
@foreach ($expiringDomains as $domain)
<li class="flex items-center justify-between text-sm">
<a href="{{ route('organizations.domains.edit', [$organization, $domain]) }}" class="text-slate-900 hover:underline">
{{ $domain->name }}
</a>
<span class="rounded-full px-2 py-0.5 text-xs font-medium
{{ $domain->expiryStatus() === 'expired' ? 'bg-red-100 text-red-700' : 'bg-amber-100 text-amber-700' }}">
{{ $domain->expiryStatus() === 'expired' ? 'Expiré' : 'Expire dans '.$domain->expiresInDays().' j' }}
</span>
</li>
@endforeach
</ul>
@endif
</div>
<div class="rounded border border-slate-200 bg-white p-4">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">Activité récente</h2>
<ul class="space-y-2.5 text-sm">
@forelse ($recentActivity as $activity)
<li>
<div class="flex items-center justify-between">
<a href="{{ $activity['url'] }}" class="text-slate-900 hover:underline">
{{ $activity['icon'] }} {{ $activity['label'] }}
</a>
<span class="shrink-0 text-slate-400">{{ $activity['at']->diffForHumans() }}</span>
</div>
<p class="text-xs text-slate-500">
{{ $activity['kind'] }} {{ $activity['created'] ? 'créé' : 'modifié' }}
@if ($activity['creator'])
par {{ $activity['creator'] }}
@endif
</p>
</li>
@empty
<li class="text-slate-400">Aucune activité récente.</li>
@endforelse
</ul>
@if ($activityPageCount > 1)
<div class="mt-3 flex items-center justify-between border-t border-slate-100 pt-3 text-xs text-slate-500">
<button type="button" wire:click="previousActivityPage" @disabled($activityPage <= 1)
class="cursor-pointer text-slate-600 hover:text-slate-900 disabled:cursor-not-allowed disabled:text-slate-300 disabled:hover:text-slate-300">
&larr; Précédent
</button>
<span>Page {{ $activityPage }} / {{ $activityPageCount }}</span>
<button type="button" wire:click="nextActivityPage" @disabled($activityPage >= $activityPageCount)
class="cursor-pointer text-slate-600 hover:text-slate-900 disabled:cursor-not-allowed disabled:text-slate-300 disabled:hover:text-slate-300">
Suivant &rarr;
</button>
</div>
@endif
</div>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div class="rounded border border-slate-200 bg-white p-4">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Tâches ouvertes</h2>
<div class="flex items-center gap-3">
<button type="button" wire:click="toggleTodoForm"
class="cursor-pointer text-xs text-slate-500 hover:text-slate-900">
{{ $showTodoForm ? 'Annuler' : '+ Nouvelle tâche' }}
</button>
<a href="{{ route('organizations.todos.index', $organization) }}" class="text-xs text-slate-500 hover:text-slate-900">
Voir toutes les tâches &rarr;
</a>
</div>
</div>
@if ($showTodoForm)
<form wire:submit="addTodo" class="mb-4 space-y-2 rounded border border-slate-100 bg-slate-50 p-3">
<div>
<input type="text" wire:model="newTodoTitle" placeholder="Titre de la tâche"
class="w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('newTodoTitle') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
<select wire:model="newTodoCategory"
class="rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@foreach (Todo::CATEGORIES as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
<select wire:model="newTodoCriticality"
class="rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@foreach (Todo::CRITICALITIES as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
<input type="date" wire:model="newTodoDueDate"
class="rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<select wire:model="newTodoAssignedTo"
class="rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<option value="">Assigné à —</option>
@foreach ($assignableUsers as $user)
<option value="{{ $user->id }}">{{ $user->name }}</option>
@endforeach
</select>
</div>
<div class="flex items-center gap-3 pt-1">
<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 la tâche
</button>
</div>
</form>
@endif
<ul class="divide-y divide-slate-100">
@forelse ($openTodos as $todo)
<li wire:key="dash-todo-{{ $todo->id }}" class="flex items-center justify-between gap-4 py-2 text-sm">
<div class="flex items-center gap-3">
<input type="checkbox" wire:click="toggleTodoDone({{ $todo->id }})"
class="rounded border-slate-300">
<div>
<a href="{{ route('organizations.todos.edit', [$organization, $todo]) }}" class="font-medium text-slate-900 hover:underline">
{{ $todo->title }}
</a>
<p class="text-xs text-slate-500">
{{ $todo->categoryLabel() }}
@if ($todo->assignee)
&middot; {{ $todo->assignee->name }}
@endif
@if ($todo->due_date)
&middot; <span class="{{ $todo->isOverdue() ? 'font-medium text-red-600' : '' }}">{{ $todo->due_date->format('d/m/Y') }}</span>
@endif
</p>
</div>
</div>
<span class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium
{{ match($todo->criticality) {
'urgent' => 'bg-red-100 text-red-700',
'high' => 'bg-amber-100 text-amber-700',
'medium' => 'bg-blue-100 text-blue-700',
default => 'bg-slate-100 text-slate-600',
} }}">
{{ $todo->criticalityLabel() }}
</span>
</li>
@empty
<li class="py-2 text-sm text-slate-400">Aucune tâche ouverte.</li>
@endforelse
</ul>
</div>
<div class="rounded border border-slate-200 bg-white p-4">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">Journal de bord</h2>
<form wire:submit="addLogEntry" class="mb-4 space-y-2 rounded border border-slate-100 bg-slate-50 p-3">
<div class="flex items-start gap-2">
<select wire:model="newLogCategory" class="rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@foreach ($categories as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
<textarea wire:model="newLogNote" rows="2" placeholder="Projet en cours, point d'attention, intervention prévue..."
class="flex-1 rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500"></textarea>
<button type="submit" class="cursor-pointer rounded bg-slate-900 px-3 py-1.5 text-sm text-white hover:bg-slate-700">Ajouter</button>
</div>
@error('newLogNote') <p class="text-sm text-red-600">{{ $message }}</p> @enderror
</form>
<div class="space-y-3">
@forelse ($logEntries as $entry)
<div wire:key="log-{{ $entry->id }}" class="flex items-start justify-between gap-4 border-b border-slate-100 pb-3 last:border-0 last:pb-0">
<div>
<span class="mb-1 inline-block rounded-full px-2 py-0.5 text-xs font-medium
{{ match($entry->category) {
'project' => 'bg-blue-100 text-blue-700',
'attention' => 'bg-amber-100 text-amber-700',
'intervention' => 'bg-purple-100 text-purple-700',
default => 'bg-slate-100 text-slate-600',
} }}">
{{ $entry->categoryLabel() }}
</span>
<p class="whitespace-pre-line text-sm text-slate-900">{{ $entry->note }}</p>
<p class="mt-1 text-xs text-slate-500">{{ $entry->creator?->name ?? 'système' }} &middot; {{ $entry->created_at->diffForHumans() }}</p>
</div>
<button type="button" wire:click="deleteLogEntry({{ $entry->id }})"
wire:confirm="Supprimer cette entrée ?"
class="cursor-pointer shrink-0 text-xs text-red-500 hover:text-red-700">
Supprimer
</button>
</div>
@empty
<p class="text-sm text-slate-400">Aucune entrée dans le journal pour le moment.</p>
@endforelse
</div>
</div>
</div>
@if ($organization->glpi_entity_id !== null)
<div class="mt-6 rounded border border-slate-200 bg-white p-4">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Tickets GLPI</h2>
<a href="{{ $glpiNewTicketUrl }}" target="_blank" rel="noopener"
class="text-xs text-slate-500 hover:text-slate-900">
Nouveau ticket &rarr;
</a>
</div>
@if ($glpiError)
<p class="text-sm text-amber-600">{{ $glpiError }}</p>
@elseif (empty($glpiTickets))
<p class="text-sm text-slate-400">Aucun ticket non clos pour le moment.</p>
@else
<ul class="divide-y divide-slate-100">
@foreach ($glpiTickets as $ticket)
<li class="py-2.5 text-sm {{ $ticket['awaiting_technician'] ? '-mx-4 border-l-4 border-amber-400 bg-amber-50 px-4' : '' }}">
<div class="flex items-start justify-between gap-4">
<a href="{{ $ticket['url'] }}" target="_blank" rel="noopener"
class="min-w-0 font-medium text-slate-900 hover:underline">
@if ($ticket['awaiting_technician'])
<span aria-hidden="true" title="En attente d'une réponse du technicien">⚠️</span>
@endif
#{{ $ticket['id'] }} — {{ $ticket['title'] }}
</a>
<div class="flex shrink-0 items-center gap-1.5">
<span class="rounded-full px-2 py-0.5 text-xs font-medium
{{ match(true) {
$ticket['priority'] >= 5 => 'bg-red-100 text-red-700',
$ticket['priority'] === 4 => 'bg-amber-100 text-amber-700',
default => 'bg-slate-100 text-slate-600',
} }}">
{{ $ticket['priority_label'] }}
</span>
<span class="rounded-full px-2 py-0.5 text-xs font-medium
{{ match(true) {
$ticket['status'] === 5 => 'bg-emerald-100 text-emerald-700',
$ticket['status'] === 4 => 'bg-amber-100 text-amber-700',
default => 'bg-blue-100 text-blue-700',
} }}">
{{ $ticket['status_label'] }}
</span>
</div>
</div>
@if ($ticket['excerpt'])
<p class="mt-1 text-xs text-slate-600">{{ $ticket['excerpt'] }}</p>
@endif
<p class="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-slate-400">
<span>{{ \Illuminate\Support\Carbon::parse($ticket['date_mod'])->diffForHumans() }}</span>
@if ($ticket['category'])
<span aria-hidden="true">&middot;</span>
<span>{{ $ticket['category'] }}</span>
@endif
@if ($ticket['technician'])
<span aria-hidden="true">&middot;</span>
<span class="inline-flex items-center gap-1" title="Technicien responsable">
<span aria-hidden="true">👤</span> {{ $ticket['technician'] }}
</span>
@endif
<span aria-hidden="true">&middot;</span>
<span class="inline-flex items-center gap-1" title="Échanges entre le technicien et le demandeur (suivis + tâches)">
<span aria-hidden="true">💬</span> {{ $ticket['interactions_count'] }}
</span>
</p>
</li>
@endforeach
</ul>
@if ($glpiTicketsTotal > count($glpiTickets))
<p class="mt-3 border-t border-slate-100 pt-3 text-xs text-slate-500">
{{ $glpiTicketsTotal }} tickets non clos au total pour cette entité.
</p>
@endif
@endif
</div>
@endif
</div>