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:
jeremy bayse
2026-08-16 13:28:12 +02:00
commit 8f22dca9c8
209 changed files with 23324 additions and 0 deletions
@@ -0,0 +1,3 @@
<x-layouts.app title="Nouveau type d'actif">
<livewire:asset-types.form />
</x-layouts.app>
@@ -0,0 +1,3 @@
<x-layouts.app title="Modifier le type d'actif">
<livewire:asset-types.form :type="$type" />
</x-layouts.app>
@@ -0,0 +1,3 @@
<x-layouts.app title="Types d'actifs">
<livewire:asset-types.index />
</x-layouts.app>
+3
View File
@@ -0,0 +1,3 @@
<x-layouts.app title="Nouveau {{ $type->name }}">
<livewire:assets.form :organization="$organization" :type="$type" />
</x-layouts.app>
+3
View File
@@ -0,0 +1,3 @@
<x-layouts.app title="Modifier {{ $type->name }}">
<livewire:assets.form :organization="$organization" :type="$type" :asset="$asset" />
</x-layouts.app>
+3
View File
@@ -0,0 +1,3 @@
<x-layouts.app title="{{ $type->name }}">
<livewire:assets.index :organization="$organization" :type="$type" />
</x-layouts.app>
+21
View File
@@ -0,0 +1,21 @@
<x-layouts.app title="Actifs flexibles">
<div>
<p class="text-sm text-slate-500">{{ $organization->name }}</p>
<h1 class="mb-6 text-2xl font-semibold text-slate-900">Actifs flexibles</h1>
<div class="grid grid-cols-3 gap-4">
@forelse ($types as $type)
<a href="{{ route('organizations.assets.index', [$organization, $type]) }}"
class="rounded border border-slate-200 bg-white p-4 hover:border-slate-400">
<div class="font-medium text-slate-900">{{ $type->name }}</div>
<div class="mt-1 text-sm text-slate-500">{{ $type->description ?? 'Aucune description' }}</div>
</a>
@empty
<p class="col-span-3 text-slate-400">
Aucun type d'actif défini pour le moment.
<a href="{{ route('asset-types.create') }}" class="text-slate-700 hover:underline">En créer un &rarr;</a>
</p>
@endforelse
</div>
</div>
</x-layouts.app>
@@ -0,0 +1,32 @@
<x-layouts.guest title="Mot de passe oublié">
@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
@if ($errors->any())
<div class="mb-4 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
<ul class="list-inside list-disc">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<form method="POST" action="{{ route('password.email') }}" class="space-y-4">
@csrf
<div>
<label class="block text-sm font-medium text-slate-700" for="email">E-mail</label>
<input id="email" type="email" name="email" value="{{ old('email') }}" required autofocus
class="mt-1 w-full rounded border-slate-300 shadow-sm focus:border-slate-500 focus:ring-slate-500">
</div>
<button type="submit"
class="w-full rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
Envoyer le lien de réinitialisation
</button>
</form>
</x-layouts.guest>
+41
View File
@@ -0,0 +1,41 @@
<x-layouts.guest title="Connexion">
@if ($errors->any())
<div class="mb-4 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
<ul class="list-inside list-disc">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<form method="POST" action="{{ route('login') }}" class="space-y-4">
@csrf
<div>
<label class="block text-sm font-medium text-slate-700" for="email">E-mail</label>
<input id="email" type="email" name="email" value="{{ old('email') }}" required autofocus
class="mt-1 w-full rounded border-slate-300 shadow-sm focus:border-slate-500 focus:ring-slate-500">
</div>
<div>
<label class="block text-sm font-medium text-slate-700" for="password">Mot de passe</label>
<input id="password" type="password" name="password" required
class="mt-1 w-full rounded border-slate-300 shadow-sm focus:border-slate-500 focus:ring-slate-500">
</div>
<label class="flex items-center gap-2 text-sm text-slate-600">
<input type="checkbox" name="remember" class="rounded border-slate-300">
Se souvenir de moi
</label>
<button type="submit"
class="w-full rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
Connexion
</button>
<div class="flex items-center justify-between text-sm">
<a href="{{ route('password.request') }}" class="text-slate-500 hover:text-slate-900">Mot de passe oublié ?</a>
</div>
</form>
</x-layouts.guest>
@@ -0,0 +1,40 @@
<x-layouts.guest title="Réinitialiser le mot de passe">
@if ($errors->any())
<div class="mb-4 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
<ul class="list-inside list-disc">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<form method="POST" action="{{ route('password.update') }}" class="space-y-4">
@csrf
<input type="hidden" name="token" value="{{ $request->route('token') }}">
<div>
<label class="block text-sm font-medium text-slate-700" for="email">E-mail</label>
<input id="email" type="email" name="email" value="{{ old('email', $request->email) }}" required autofocus
class="mt-1 w-full rounded border-slate-300 shadow-sm focus:border-slate-500 focus:ring-slate-500">
</div>
<div>
<label class="block text-sm font-medium text-slate-700" for="password">Nouveau mot de passe</label>
<input id="password" type="password" name="password" required
class="mt-1 w-full rounded border-slate-300 shadow-sm focus:border-slate-500 focus:ring-slate-500">
</div>
<div>
<label class="block text-sm font-medium text-slate-700" for="password_confirmation">Confirmer le mot de passe</label>
<input id="password_confirmation" type="password" name="password_confirmation" required
class="mt-1 w-full rounded border-slate-300 shadow-sm focus:border-slate-500 focus:ring-slate-500">
</div>
<button type="submit"
class="w-full rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
Réinitialiser le mot de passe
</button>
</form>
</x-layouts.guest>
@@ -0,0 +1,36 @@
<x-layouts.guest title="Authentification à deux facteurs">
@if ($errors->any())
<div class="mb-4 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
<ul class="list-inside list-disc">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<p class="mb-4 text-sm text-slate-600">
Entrez le code de votre application d'authentification, ou l'un de vos codes de récupération.
</p>
<form method="POST" action="{{ route('two-factor.login') }}" class="space-y-4">
@csrf
<div>
<label class="block text-sm font-medium text-slate-700" for="code">Code d'authentification</label>
<input id="code" type="text" name="code" inputmode="numeric" autofocus
class="mt-1 w-full rounded border-slate-300 shadow-sm focus:border-slate-500 focus:ring-slate-500">
</div>
<div>
<label class="block text-sm font-medium text-slate-700" for="recovery_code">Code de récupération</label>
<input id="recovery_code" type="text" name="recovery_code"
class="mt-1 w-full rounded border-slate-300 shadow-sm focus:border-slate-500 focus:ring-slate-500">
</div>
<button type="submit"
class="w-full rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
Vérifier
</button>
</form>
</x-layouts.guest>
@@ -0,0 +1,236 @@
<?php
use App\Models\FlexibleAssetField;
use App\Models\FlexibleAssetType;
use Illuminate\Support\Str;
use Livewire\Component;
new class extends Component
{
public ?FlexibleAssetType $type = null;
public string $name = '';
public string $description = '';
/** @var array<int, array{id: ?int, label: string, field_type: string, is_required: bool, show_in_list: bool, options: string, related_target: string}> */
public array $fields = [];
public function mount(?FlexibleAssetType $type = null): void
{
if ($type?->exists) {
$this->type = $type;
$this->name = $type->name;
$this->description = $type->description ?? '';
$this->fields = $type->fields->map(fn (FlexibleAssetField $field) => [
'id' => $field->id,
'label' => $field->label,
'field_type' => $field->field_type,
'is_required' => $field->is_required,
'show_in_list' => $field->show_in_list,
'options' => implode(', ', $field->options['choices'] ?? []),
'related_target' => $field->options['target'] ?? '',
])->all();
}
if (empty($this->fields)) {
$this->addField();
}
}
public function addField(): void
{
$this->fields[] = [
'id' => null,
'label' => '',
'field_type' => 'text',
'is_required' => false,
'show_in_list' => false,
'options' => '',
'related_target' => '',
];
}
public function removeField(int $index): void
{
unset($this->fields[$index]);
$this->fields = array_values($this->fields);
}
public function moveFieldUp(int $index): void
{
if ($index === 0) {
return;
}
[$this->fields[$index - 1], $this->fields[$index]] = [$this->fields[$index], $this->fields[$index - 1]];
}
public function moveFieldDown(int $index): void
{
if ($index >= count($this->fields) - 1) {
return;
}
[$this->fields[$index + 1], $this->fields[$index]] = [$this->fields[$index], $this->fields[$index + 1]];
}
public function save(): void
{
$this->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'fields' => 'required|array|min:1',
'fields.*.label' => 'required|string|max:255',
'fields.*.field_type' => 'required|in:'.implode(',', FlexibleAssetField::FIELD_TYPES),
]);
$type = $this->type?->exists
? tap($this->type)->update(['name' => $this->name, 'description' => $this->description])
: FlexibleAssetType::create(['name' => $this->name, 'description' => $this->description]);
$keptFieldIds = [];
foreach ($this->fields as $index => $field) {
if ($field['field_type'] === 'related_asset') {
$fieldOptions = $field['related_target'] ? ['target' => $field['related_target']] : null;
} else {
$choices = array_filter(array_map('trim', explode(',', $field['options'])));
$fieldOptions = $choices ? ['choices' => array_values($choices)] : null;
}
$attributes = [
'label' => $field['label'],
'name' => Str::snake($field['label']),
'field_type' => $field['field_type'],
'is_required' => (bool) $field['is_required'],
'show_in_list' => (bool) $field['show_in_list'],
'options' => $fieldOptions,
'sort_order' => $index,
];
$saved = $field['id']
? tap($type->fields()->findOrFail($field['id']))->update($attributes)
: $type->fields()->create($attributes);
$keptFieldIds[] = $saved->id;
}
$type->fields()->whereNotIn('id', $keptFieldIds)->delete();
session()->flash('status', "Type d'actif \"{$type->name}\" enregistré.");
$this->redirectRoute('asset-types.index', navigate: true);
}
};
?>
<div class="max-w-4xl">
<h1 class="mb-6 text-2xl font-semibold text-slate-900">
{{ $type?->exists ? 'Modifier le type d\'actif' : 'Nouveau type d\'actif' }}
</h1>
<form wire:submit="save" class="space-y-6">
<div class="rounded border border-slate-200 bg-white p-6">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700">Nom</label>
<input type="text" wire:model="name" placeholder="ex. Réseau sans fil"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('name') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Description</label>
<input type="text" wire:model="description"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
</div>
</div>
</div>
<div class="rounded border border-slate-200 bg-white p-6">
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-500">Champs</h2>
@error('fields') <p class="mb-3 text-sm text-red-600">{{ $message }}</p> @enderror
<div class="space-y-3">
@foreach ($fields as $index => $field)
<div wire:key="field-{{ $index }}" class="flex items-start gap-2 rounded border border-slate-100 bg-slate-50 p-3">
<div class="flex flex-col gap-1 pt-1">
<button type="button" wire:click="moveFieldUp({{ $index }})" class="text-xs text-slate-400 hover:text-slate-700">&uarr;</button>
<button type="button" wire:click="moveFieldDown({{ $index }})" class="text-xs text-slate-400 hover:text-slate-700">&darr;</button>
</div>
<div class="grid flex-1 grid-cols-12 gap-2">
<div class="col-span-3">
<input type="text" wire:model="fields.{{ $index }}.label" placeholder="Libellé du champ"
class="w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error("fields.{$index}.label") <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
<div class="col-span-2">
<select wire:model="fields.{{ $index }}.field_type"
class="w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@foreach (\App\Models\FlexibleAssetField::FIELD_TYPES as $fieldType)
<option value="{{ $fieldType }}">{{ ucfirst(str_replace('_', ' ', $fieldType)) }}</option>
@endforeach
</select>
</div>
<div class="col-span-4">
@if (in_array($field['field_type'], ['select', 'tag']))
<input type="text" wire:model="fields.{{ $index }}.options" placeholder="Options, séparées par des virgules"
class="w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@elseif ($field['field_type'] === 'related_asset')
<select wire:model="fields.{{ $index }}.related_target"
class="w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<option value="">Choisir une liste...</option>
<option value="contact">Contacts</option>
<option value="location">Sites</option>
<option value="domain">Domaines</option>
@foreach (\App\Models\FlexibleAssetType::orderBy('name')->get() as $otherType)
<option value="flexible_asset:{{ $otherType->id }}">{{ $otherType->name }}</option>
@endforeach
</select>
@endif
</div>
<div class="col-span-1 flex items-center justify-center" title="Obligatoire">
<label class="flex items-center gap-1 text-xs text-slate-600">
<input type="checkbox" wire:model="fields.{{ $index }}.is_required" class="rounded border-slate-300">
Obl.
</label>
</div>
<div class="col-span-1 flex items-center justify-center" title="Afficher dans la liste">
<label class="flex items-center gap-1 text-xs text-slate-600">
<input type="checkbox" wire:model="fields.{{ $index }}.show_in_list" class="rounded border-slate-300">
Liste
</label>
</div>
<div class="col-span-1 flex items-center justify-center">
<button type="button" wire:click="removeField({{ $index }})"
class="text-xs text-red-500 hover:text-red-700">
Supprimer
</button>
</div>
</div>
</div>
@endforeach
</div>
<button type="button" wire:click="addField"
class="mt-4 text-sm text-slate-600 hover:text-slate-900">
+ Ajouter un champ
</button>
</div>
<div class="flex items-center gap-3">
<button type="submit"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
Enregistrer
</button>
<a href="{{ route('asset-types.index') }}" class="text-sm text-slate-500 hover:text-slate-900">Annuler</a>
</div>
</form>
</div>
@@ -0,0 +1,87 @@
<?php
use App\Models\FlexibleAssetType;
use Livewire\Component;
new class extends Component
{
public function delete(int $typeId): void
{
$type = FlexibleAssetType::withCount('assets')->findOrFail($typeId);
if ($type->assets_count > 0) {
session()->flash('error', "Impossible de supprimer \"{$type->name}\" — {$type->assets_count} actif(s) utilisent encore ce type.");
return;
}
$type->delete();
session()->flash('status', "Type d'actif \"{$type->name}\" supprimé.");
}
public function with(): array
{
return [
'types' => FlexibleAssetType::withCount(['fields', 'assets'])->orderBy('name')->get(),
];
}
};
?>
<div>
<div class="mb-6 flex items-center justify-between">
<div>
<p class="text-sm text-slate-500">Suivez n'importe quel actif</p>
<h1 class="text-2xl font-semibold text-slate-900">Types d'actifs flexibles</h1>
</div>
<a href="{{ route('asset-types.create') }}"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
+ Nouveau type d'actif
</a>
</div>
@if (session('error'))
<div class="mb-4 rounded border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">
{{ session('error') }}
</div>
@endif
<div class="overflow-hidden rounded border border-slate-200 bg-white">
<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">Champs</th>
<th class="px-4 py-3">Actifs</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@forelse ($types as $type)
<tr wire:key="type-{{ $type->id }}">
<td class="px-4 py-3">
<a href="{{ route('asset-types.edit', $type) }}" class="font-medium text-slate-900 hover:underline">
{{ $type->name }}
</a>
</td>
<td class="px-4 py-3 text-slate-600">{{ $type->fields_count }}</td>
<td class="px-4 py-3 text-slate-600">{{ $type->assets_count }}</td>
<td class="px-4 py-3 text-right">
<a href="{{ route('asset-types.edit', $type) }}" class="text-slate-500 hover:text-slate-900">Modifier</a>
<button type="button"
wire:click="delete({{ $type->id }})"
wire:confirm="Supprimer ce type d'actif ? 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">Aucun type d'actif pour le moment.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
@@ -0,0 +1,280 @@
<?php
use App\Models\FlexibleAsset;
use App\Models\FlexibleAssetType;
use App\Models\Organization;
use Livewire\Component;
new class extends Component
{
public Organization $organization;
public FlexibleAssetType $type;
public ?FlexibleAsset $asset = null;
/** @var array<string, mixed> */
public array $data = [];
public string $newLogNote = '';
public function mount(Organization $organization, FlexibleAssetType $type, ?FlexibleAsset $asset = null): void
{
$this->organization = $organization;
$this->type = $type;
if ($asset?->exists) {
$this->asset = $asset;
}
foreach ($type->fields as $field) {
$existing = $this->asset?->data[$field->name] ?? null;
$this->data[$field->name] = match ($field->field_type) {
'checkbox' => (bool) $existing,
'tag' => $existing ?? [],
default => $existing ?? '',
};
}
}
public function save(): void
{
$rules = [];
foreach ($this->type->fields as $field) {
$rule = $field->is_required ? 'required' : 'nullable';
if ($field->field_type === 'checkbox') {
$rule = 'boolean';
}
$rules["data.{$field->name}"] = $rule;
}
$this->validate($rules);
$primaryField = $this->type->fields->firstWhere('field_type', 'text') ?? $this->type->fields->first();
$name = $primaryField ? (string) ($this->data[$primaryField->name] ?? '') : $this->type->name;
if ($this->asset?->exists) {
$previousData = $this->asset->data ?? [];
$this->asset->update(['name' => $name ?: $this->asset->name, 'data' => $this->data]);
$this->logFieldChanges($previousData, $this->data);
session()->flash('status', "\"{$this->asset->name}\" mis à jour.");
} else {
$this->organization->flexibleAssets()->create([
'flexible_asset_type_id' => $this->type->id,
'name' => $name ?: $this->type->name,
'data' => $this->data,
'created_by' => auth()->id(),
]);
session()->flash('status', "\"{$name}\" créé.");
}
$this->redirectRoute('organizations.assets.index', [$this->organization, $this->type], navigate: true);
}
public function addLogEntry(): void
{
$this->validate(['newLogNote' => 'required|string']);
$this->asset->logs()->create([
'note' => $this->newLogNote,
'created_by' => auth()->id(),
]);
$this->newLogNote = '';
}
/**
* @param array<string, mixed> $previousData
* @param array<string, mixed> $newData
*/
private function logFieldChanges(array $previousData, array $newData): void
{
$changes = [];
foreach ($this->type->fields as $field) {
$before = $previousData[$field->name] ?? null;
$after = $newData[$field->name] ?? null;
if ($this->normalizeFieldValue($before) === $this->normalizeFieldValue($after)) {
continue;
}
if ($field->field_type === 'password') {
$changes[] = "{$field->label} modifié";
continue;
}
$changes[] = "{$field->label} : « ".$this->formatFieldValue($field, $before)." » → « ".$this->formatFieldValue($field, $after)." »";
}
if (empty($changes)) {
return;
}
$this->asset->logs()->create([
'note' => "Modification automatique :\n".implode("\n", $changes),
'created_by' => auth()->id(),
]);
}
private function normalizeFieldValue(mixed $value): mixed
{
if (is_array($value)) {
sort($value);
return $value;
}
return blank($value) ? null : $value;
}
private function formatFieldValue(\App\Models\FlexibleAssetField $field, mixed $value): string
{
if (blank($value) && $value !== false) {
return '—';
}
return match ($field->field_type) {
'checkbox' => $value ? 'Oui' : 'Non',
'tag' => is_array($value) ? implode(', ', $value) : (string) $value,
'related_asset' => $field->relatedOptions($this->organization)->firstWhere('id', (int) $value)['label'] ?? '—',
default => (string) $value,
};
}
};
?>
<div class="max-w-6xl">
<p class="text-sm text-slate-500">{{ $organization->name }} &middot; {{ $type->name }}</p>
<h1 class="mb-6 text-2xl font-semibold text-slate-900">
{{ $asset?->exists ? 'Modifier l\'enregistrement' : 'Nouvel enregistrement' }}
</h1>
<div class="grid grid-cols-1 gap-6 {{ $asset?->exists ? 'lg:grid-cols-2' : '' }}">
<div>
<form wire:submit="save" class="space-y-4 rounded border border-slate-200 bg-white p-6">
@foreach ($type->fields as $field)
<div>
<label class="block text-sm font-medium text-slate-700">
{{ $field->label }}
@if ($field->is_required) <span class="text-red-500">*</span> @endif
</label>
@switch($field->field_type)
@case('textarea')
<textarea wire:model="data.{{ $field->name }}" rows="3"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500"></textarea>
@break
@case('password')
<input type="password" wire:model="data.{{ $field->name }}" autocomplete="new-password"
class="mt-1 w-full rounded border-slate-300 font-mono text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@break
@case('checkbox')
<div class="mt-1">
<input type="checkbox" wire:model="data.{{ $field->name }}" class="rounded border-slate-300">
</div>
@break
@case('date')
<input type="date" wire:model="data.{{ $field->name }}"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@break
@case('select')
<select wire:model="data.{{ $field->name }}"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<option value="">—</option>
@foreach ($field->options['choices'] ?? [] as $choice)
<option value="{{ $choice }}">{{ $choice }}</option>
@endforeach
</select>
@break
@case('tag')
<select wire:model="data.{{ $field->name }}" multiple
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@foreach ($field->options['choices'] ?? [] as $choice)
<option value="{{ $choice }}">{{ $choice }}</option>
@endforeach
</select>
@break
@case('related_asset')
<select wire:model="data.{{ $field->name }}"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<option value="">—</option>
@foreach ($field->relatedOptions($organization) as $option)
<option value="{{ $option['id'] }}">{{ $option['label'] }}</option>
@endforeach
</select>
@break
@default
<input type="text" wire:model="data.{{ $field->name }}"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@endswitch
@error("data.{$field->name}") <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
@endforeach
<div class="flex items-center gap-3 pt-2">
<button type="submit"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
Enregistrer
</button>
<a href="{{ route('organizations.assets.index', [$organization, $type]) }}" class="text-sm text-slate-500 hover:text-slate-900">Annuler</a>
</div>
</form>
</div>
@if ($asset?->exists)
<div>
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">Journal MCO</h2>
<form wire:submit="addLogEntry" class="mb-4 flex items-start gap-2 rounded border border-slate-200 bg-white p-3">
<textarea wire:model="newLogNote" rows="2" placeholder="Ajouter une entrée au journal (maintenance, incident, mise à jour...)"
class="flex-1 rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500"></textarea>
<button type="submit" class="rounded bg-slate-900 px-3 py-1.5 text-sm text-white hover:bg-slate-700">Ajouter</button>
</form>
@error('newLogNote') <p class="mb-4 text-sm text-red-600">{{ $message }}</p> @enderror
<div class="overflow-hidden rounded border border-slate-200 bg-white">
<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-2">Note</th>
<th class="px-4 py-2">Par</th>
<th class="px-4 py-2">Quand</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@forelse ($asset->logs as $log)
<tr wire:key="log-{{ $log->id }}">
<td class="px-4 py-2 whitespace-pre-line text-slate-900">{{ $log->note }}</td>
<td class="px-4 py-2 text-slate-600">{{ $log->creator?->name ?? 'système' }}</td>
<td class="px-4 py-2 text-slate-500">{{ $log->created_at->diffForHumans() }}</td>
</tr>
@empty
<tr>
<td colspan="3" class="px-4 py-6 text-center text-slate-400">Aucune entrée pour le moment.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-8">
<livewire:related-items.panel :organization="$organization" :item="$asset" :key="'related-'.$asset->id" />
</div>
</div>
@endif
</div>
</div>
@@ -0,0 +1,117 @@
<?php
use App\Models\FlexibleAsset;
use App\Models\FlexibleAssetType;
use App\Models\Organization;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
new class extends Component
{
use WithPagination;
public Organization $organization;
public FlexibleAssetType $type;
#[Url]
public string $search = '';
public function delete(int $assetId): void
{
$asset = $this->organization->flexibleAssets()->findOrFail($assetId);
$asset->delete();
session()->flash('status', "\"{$asset->name}\" supprimé.");
}
public function with(): array
{
$listFields = $this->type->fields()->where('show_in_list', true)->get();
return [
'listFields' => $listFields,
'assets' => $this->organization->flexibleAssets()
->where('flexible_asset_type_id', $this->type->id)
->when($this->search, fn ($query) => $query->where('name', 'like', "%{$this->search}%"))
->orderBy('name')
->paginate(15),
];
}
};
?>
<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">{{ $type->name }}</h1>
</div>
<a href="{{ route('organizations.assets.create', [$organization, $type]) }}"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
+ Nouveau {{ $type->name }}
</a>
</div>
<input type="text" wire:model.live.debounce.300ms="search" placeholder="Filtrer par nom..."
class="mb-4 w-full max-w-sm rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<div class="overflow-x-auto rounded border border-slate-200 bg-white">
<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>
@foreach ($listFields as $field)
<th class="px-4 py-3">{{ $field->label }}</th>
@endforeach
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@forelse ($assets as $asset)
<tr wire:key="asset-{{ $asset->id }}">
<td class="px-4 py-3">
<a href="{{ route('organizations.assets.edit', [$organization, $type, $asset]) }}"
class="font-medium text-slate-900 hover:underline">
{{ $asset->name }}
</a>
</td>
@foreach ($listFields as $field)
<td class="px-4 py-3 text-slate-600">
@php $value = $asset->data[$field->name] ?? null; @endphp
@if ($field->field_type === 'password')
<span class="tracking-widest text-slate-400">••••••••</span>
@elseif ($field->field_type === 'checkbox')
{{ $value ? 'Oui' : 'Non' }}
@elseif ($field->field_type === 'related_asset')
{{ $value ? ($field->relatedOptions($organization)->firstWhere('id', (int) $value)['label'] ?? '—') : '—' }}
@elseif (is_array($value))
{{ implode(', ', $value) }}
@else
{{ $value ?? '—' }}
@endif
</td>
@endforeach
<td class="px-4 py-3 text-right whitespace-nowrap">
<a href="{{ route('organizations.assets.edit', [$organization, $type, $asset]) }}" class="text-slate-500 hover:text-slate-900">Modifier</a>
<button type="button"
wire:click="delete({{ $asset->id }})"
wire:confirm="Supprimer cet actif ? Cette action est irréversible."
class="ml-3 text-red-500 hover:text-red-700">
Supprimer
</button>
</td>
</tr>
@empty
<tr>
<td colspan="{{ $listFields->count() + 2 }}" class="px-4 py-6 text-center text-slate-400">Aucun enregistrement pour le moment.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">
{{ $assets->links() }}
</div>
</div>
@@ -0,0 +1,153 @@
<?php
use App\Models\Contact;
use App\Models\Organization;
use Livewire\Component;
new class extends Component
{
public Organization $organization;
public ?Contact $contact = null;
public string $first_name = '';
public string $last_name = '';
public string $title = '';
public string $email = '';
public string $phone = '';
public string $mobile = '';
public ?int $location_id = null;
public bool $is_important = false;
public string $notes = '';
public function mount(Organization $organization, ?Contact $contact = null): void
{
$this->organization = $organization;
if ($contact?->exists) {
$this->contact = $contact;
$this->first_name = $contact->first_name;
$this->last_name = $contact->last_name;
$this->title = $contact->title ?? '';
$this->email = $contact->email ?? '';
$this->phone = $contact->phone ?? '';
$this->mobile = $contact->mobile ?? '';
$this->location_id = $contact->location_id;
$this->is_important = $contact->is_important;
$this->notes = $contact->notes ?? '';
}
}
public function save(): void
{
$validated = $this->validate([
'first_name' => 'required|string|max:255',
'last_name' => 'required|string|max:255',
'title' => 'nullable|string|max:255',
'email' => 'nullable|email|max:255',
'phone' => 'nullable|string|max:255',
'mobile' => 'nullable|string|max:255',
'location_id' => 'nullable|exists:locations,id',
'is_important' => 'boolean',
'notes' => 'nullable|string',
]);
if ($this->contact?->exists) {
$this->contact->update($validated);
session()->flash('status', "Contact \"{$this->contact->full_name}\" mis à jour.");
} else {
$this->organization->contacts()->create($validated);
session()->flash('status', "Contact \"{$this->first_name} {$this->last_name}\" créé.");
}
$this->redirectRoute('organizations.contacts.index', $this->organization, navigate: true);
}
public function with(): array
{
return [
'locations' => $this->organization->locations()->orderBy('name')->get(),
];
}
};
?>
<div class="max-w-2xl">
<p class="text-sm text-slate-500">{{ $organization->name }}</p>
<h1 class="mb-6 text-2xl font-semibold text-slate-900">
{{ $contact?->exists ? 'Modifier le contact' : 'Nouveau contact' }}
</h1>
<form wire:submit="save" class="space-y-4 rounded border border-slate-200 bg-white p-6">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700">Prénom</label>
<input type="text" wire:model="first_name"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('first_name') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Nom</label>
<input type="text" wire:model="last_name"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('last_name') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700">Titre</label>
<input type="text" wire:model="title"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Site</label>
<select wire:model="location_id"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<option value="">—</option>
@foreach ($locations as $location)
<option value="{{ $location->id }}">{{ $location->name }}</option>
@endforeach
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Email</label>
<input type="email" wire:model="email"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('email') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700">Téléphone</label>
<input type="text" wire:model="phone"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Mobile</label>
<input type="text" wire:model="mobile"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Notes</label>
<textarea wire:model="notes" rows="3"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500"></textarea>
</div>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" wire:model="is_important" class="rounded border-slate-300">
Contact important
</label>
<div class="flex items-center gap-3 pt-2">
<button type="submit"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
Enregistrer
</button>
<a href="{{ route('organizations.contacts.index', $organization) }}" class="text-sm text-slate-500 hover:text-slate-900">Annuler</a>
</div>
</form>
</div>
@@ -0,0 +1,108 @@
<?php
use App\Models\Organization;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
new class extends Component
{
use WithPagination;
public Organization $organization;
#[Url]
public string $search = '';
public function delete(int $contactId): void
{
$contact = $this->organization->contacts()->findOrFail($contactId);
$contact->delete();
session()->flash('status', "Contact \"{$contact->full_name}\" supprimé.");
}
public function with(): array
{
return [
'contacts' => $this->organization->contacts()
->with('location')
->when($this->search, fn ($query) => $query->where(function ($q) {
$q->where('first_name', 'like', "%{$this->search}%")
->orWhere('last_name', 'like', "%{$this->search}%")
->orWhere('email', 'like', "%{$this->search}%");
}))
->orderByDesc('is_important')
->orderBy('last_name')
->paginate(15),
];
}
};
?>
<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">Contacts</h1>
</div>
<a href="{{ route('organizations.contacts.create', $organization) }}"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
+ Nouveau contact
</a>
</div>
<input type="text" wire:model.live.debounce.300ms="search" placeholder="Filtrer par nom ou email..."
class="mb-4 w-full max-w-sm rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<div class="overflow-x-auto rounded border border-slate-200 bg-white">
<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">Titre</th>
<th class="px-4 py-3">Email</th>
<th class="px-4 py-3">Téléphone</th>
<th class="px-4 py-3">Site</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@forelse ($contacts as $contact)
<tr wire:key="contact-{{ $contact->id }}">
<td class="px-4 py-3">
<a href="{{ route('organizations.contacts.edit', [$organization, $contact]) }}"
class="font-medium text-slate-900 hover:underline">
{{ $contact->full_name }}
</a>
@if ($contact->is_important)
<span class="ml-2 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700">Important</span>
@endif
</td>
<td class="px-4 py-3 text-slate-600">{{ $contact->title ?? '—' }}</td>
<td class="px-4 py-3 text-slate-600">{{ $contact->email ?? '—' }}</td>
<td class="px-4 py-3 text-slate-600">{{ $contact->phone ?? '—' }}</td>
<td class="px-4 py-3 text-slate-600">{{ $contact->location?->name ?? '—' }}</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
<a href="{{ route('organizations.contacts.edit', [$organization, $contact]) }}" class="text-slate-500 hover:text-slate-900">Modifier</a>
<button type="button"
wire:click="delete({{ $contact->id }})"
wire:confirm="Supprimer ce contact ? Cette action est irréversible."
class="ml-3 text-red-500 hover:text-red-700">
Supprimer
</button>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-4 py-6 text-center text-slate-400">Aucun contact pour le moment.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">
{{ $contacts->links() }}
</div>
</div>
@@ -0,0 +1,25 @@
@props(['document'])
<div class="mt-8">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">Historique des versions</h2>
<div class="overflow-hidden rounded border border-slate-200 bg-white">
<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-2">Version</th>
<th class="px-4 py-2">Par</th>
<th class="px-4 py-2">Quand</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@foreach ($document->versions as $version)
<tr>
<td class="px-4 py-2 text-slate-900">v{{ $version->version }}</td>
<td class="px-4 py-2 text-slate-600">{{ $version->creator?->name ?? 'système' }}</td>
<td class="px-4 py-2 text-slate-500">{{ $version->created_at->diffForHumans() }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@@ -0,0 +1,109 @@
<?php
use App\Models\Document;
use App\Models\Organization;
use Livewire\Component;
new class extends Component
{
public Organization $organization;
public ?Document $document = null;
public string $name = '';
public string $content = '';
public ?int $folder_id = null;
public function mount(Organization $organization, ?Document $document = null, ?int $folder = null): void
{
$this->organization = $organization;
if ($document?->exists) {
$this->document = $document;
$this->name = $document->name;
$this->content = $document->content ?? '';
$this->folder_id = $document->folder_id;
} else {
$this->folder_id = $folder;
}
}
public function save(): void
{
$validated = $this->validate([
'name' => 'required|string|max:255',
'content' => 'nullable|string',
]);
if ($this->document?->exists) {
$contentChanged = $this->document->content !== $this->content;
$this->document->update($validated);
if ($contentChanged) {
$this->document->increment('current_version');
$this->document->versions()->create([
'version' => $this->document->current_version,
'content' => $this->content,
'created_by' => auth()->id(),
]);
}
session()->flash('status', "\"{$this->document->name}\" mis à jour.");
} else {
$created = $this->organization->documents()->create([
...$validated,
'folder_id' => $this->folder_id,
'created_by' => auth()->id(),
]);
$created->versions()->create([
'version' => 1,
'content' => $this->content,
'created_by' => auth()->id(),
]);
session()->flash('status', "\"{$this->name}\" créé.");
}
$this->redirectRoute('organizations.documents.index', [$this->organization, 'folder' => $this->folder_id], navigate: true);
}
};
?>
<div class="max-w-3xl">
<p class="text-sm text-slate-500">{{ $organization->name }}</p>
<h1 class="mb-6 text-2xl font-semibold text-slate-900">
{{ $document?->exists ? 'Modifier le document' : 'Nouveau document' }}
</h1>
<form wire:submit="save" class="space-y-4 rounded border border-slate-200 bg-white p-6">
<div>
<label class="block text-sm font-medium text-slate-700">Nom</label>
<input type="text" wire:model="name"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('name') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Contenu</label>
<textarea wire:model="content" rows="14"
class="mt-1 w-full rounded border-slate-300 font-mono text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500"></textarea>
</div>
<div class="flex items-center gap-3 pt-2">
<button type="submit"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
Enregistrer
</button>
<a href="{{ route('organizations.documents.index', [$organization, 'folder' => $folder_id]) }}" class="text-sm text-slate-500 hover:text-slate-900">Annuler</a>
</div>
</form>
@if ($document?->exists)
<x-documents.version-history :document="$document" />
<div class="mt-8">
<livewire:related-items.panel :organization="$organization" :item="$document" :key="'related-'.$document->id" />
</div>
@endif
</div>
@@ -0,0 +1,384 @@
<?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>
@@ -0,0 +1,153 @@
<?php
use App\Models\Document;
use App\Models\Organization;
use Livewire\Attributes\Url;
use Livewire\Component;
new class extends Component
{
public Organization $organization;
public Document $document;
#[Url]
public bool $edit = false;
public string $name = '';
public string $content = '';
public bool $showPreview = false;
public function mount(Organization $organization, Document $document): void
{
abort_unless($document->isMarkdown(), 404);
$this->organization = $organization;
$this->document = $document;
$this->name = $document->name;
$this->content = $document->content ?? '';
}
public function save(): void
{
$validated = $this->validate([
'name' => 'required|string|max:255',
'content' => 'nullable|string',
]);
$contentChanged = $this->document->content !== $this->content;
$this->document->update($validated);
if ($contentChanged) {
$this->document->increment('current_version');
$this->document->versions()->create([
'version' => $this->document->current_version,
'content' => $this->content,
'created_by' => auth()->id(),
]);
}
session()->flash('status', "\"{$this->document->name}\" mis à jour.");
$this->edit = false;
}
public function renderedPreview(): string
{
return (new League\CommonMark\CommonMarkConverter())->convert($this->content ?? '')->getContent();
}
};
?>
<div class="max-w-5xl">
<p class="text-sm text-slate-500">{{ $organization->name }}</p>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-semibold text-slate-900">{{ $document->name }}</h1>
<div class="flex items-center gap-4">
@unless ($edit)
<button type="button" wire:click="$set('edit', true)"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
Modifier
</button>
@endunless
<a href="{{ route('organizations.documents.index', [$organization, 'folder' => $document->folder_id]) }}"
class="text-sm text-slate-500 hover:text-slate-900">Retour</a>
</div>
</div>
@if (! $edit)
<div class="prose prose-sm max-w-none rounded border border-slate-200 bg-white p-6">
@if (trim($document->content ?? '') === '')
<p class="text-sm text-slate-400 italic">Ce document est vide.</p>
@else
{!! $this->renderedPreview() !!}
@endif
</div>
@else
<form wire:submit="save" class="space-y-4 rounded border border-slate-200 bg-white p-6">
<div>
<label class="block text-sm font-medium text-slate-700">Nom</label>
<input type="text" wire:model="name"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('name') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<div class="mb-1 flex items-center justify-between">
<label class="block text-sm font-medium text-slate-700">Contenu (Markdown)</label>
<button type="button" wire:click="$toggle('showPreview')" class="text-sm text-slate-500 hover:text-slate-900">
{{ $showPreview ? 'Masquer l\'aperçu' : 'Afficher l\'aperçu' }}
</button>
</div>
<details class="mb-3 rounded border border-slate-200 bg-slate-50 text-sm">
<summary class="cursor-pointer select-none px-3 py-2 font-medium text-slate-600 hover:text-slate-900">
Aide-mémoire syntaxe Markdown
</summary>
<div class="grid grid-cols-1 gap-x-6 gap-y-1 border-t border-slate-200 px-3 py-3 font-mono text-xs text-slate-600 sm:grid-cols-2">
<div># Titre 1</div>
<div>## Titre 2</div>
<div>**gras**</div>
<div>*italique*</div>
<div>~~barré~~</div>
<div>`code`</div>
<div>[texte](https://exemple.com)</div>
<div>![alt](image.png)</div>
<div>- élément de liste</div>
<div>1. élément numéroté</div>
<div>&gt; citation</div>
<div>--- (ligne horizontale)</div>
<div>```<br>bloc de code<br>```</div>
<div>| A | B |<br>|---|---|<br>| 1 | 2 |</div>
</div>
</details>
<div class="grid gap-4 {{ $showPreview ? 'grid-cols-2' : 'grid-cols-1' }}">
<textarea wire:model.live.debounce.500ms="content" rows="20"
class="w-full rounded border-slate-300 font-mono text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500"></textarea>
@if ($showPreview)
<div class="prose prose-sm max-w-none rounded border border-slate-200 bg-slate-50 p-4">
{!! $this->renderedPreview() !!}
</div>
@endif
</div>
</div>
<div class="flex items-center gap-3 pt-2">
<button type="submit"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
Enregistrer
</button>
<button type="button" wire:click="$set('edit', false)" class="text-sm text-slate-500 hover:text-slate-900">Annuler</button>
</div>
</form>
@endif
<x-documents.version-history :document="$document" />
<div class="mt-8">
<livewire:related-items.panel :organization="$organization" :item="$document" :key="'related-'.$document->id" />
</div>
</div>
@@ -0,0 +1,118 @@
<?php
use App\Models\Document;
use App\Models\Organization;
use App\Services\OnlyOffice\DocumentConfigBuilder;
use Livewire\Attributes\Url;
use Livewire\Component;
new class extends Component
{
public Organization $organization;
public Document $document;
#[Url]
public bool $edit = false;
public array $editorConfig = [];
public bool $renaming = false;
public string $name = '';
public function mount(Organization $organization, Document $document, DocumentConfigBuilder $configBuilder): void
{
abort_unless($document->isOffice(), 404);
$this->organization = $organization;
$this->document = $document;
$this->name = $document->name;
$this->editorConfig = $configBuilder->build($document, auth()->user(), editable: $this->edit);
}
public function rename(): void
{
$validated = $this->validate(['name' => 'required|string|max:255']);
$this->document->update($validated);
$this->renaming = false;
}
};
?>
<div>
<p class="text-sm text-slate-500">{{ $organization->name }}</p>
<div class="mb-6 flex items-center justify-between">
@if ($renaming)
<form wire:submit="rename" class="flex items-center gap-2">
<input type="text" wire:model="name" autofocus
class="rounded border-slate-300 text-2xl font-semibold 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">Enregistrer</button>
<button type="button" wire:click="$set('renaming', false)" class="text-sm text-slate-500 hover:text-slate-900">Annuler</button>
@error('name') <p class="text-sm text-red-600">{{ $message }}</p> @enderror
</form>
@else
<button type="button" wire:click="$set('renaming', true)" class="group flex items-center gap-2">
<h1 class="text-2xl font-semibold text-slate-900">{{ $document->name }}</h1>
<span class="text-sm text-slate-400 opacity-0 group-hover:opacity-100">Renommer</span>
</button>
@endif
<div class="flex items-center gap-4">
@unless ($edit)
<a href="{{ route('organizations.documents.edit', [$organization, $document, 'edit' => true]) }}"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
Modifier
</a>
@else
<span class="rounded bg-amber-100 px-2 py-1 text-xs font-medium text-amber-700">Modification en cours</span>
@endunless
<a href="{{ route('organizations.documents.index', [$organization, 'folder' => $document->folder_id]) }}"
class="text-sm text-slate-500 hover:text-slate-900">Retour</a>
</div>
</div>
<div
wire:ignore
x-data="onlyOfficeEditor(@js($editorConfig), @js(config('onlyoffice.document_server_url')))"
x-init="init()"
class="w-full"
style="height: 85vh;"
>
<div id="onlyoffice-editor-{{ $document->id }}" style="width: 100%; height: 100%;"></div>
</div>
<div class="max-w-5xl">
<x-documents.version-history :document="$document" />
<div class="mt-8">
<livewire:related-items.panel :organization="$organization" :item="$document" :key="'related-'.$document->id" />
</div>
</div>
</div>
@once
<script>
function onlyOfficeEditor(config, documentServerUrl) {
return {
editor: null,
init() {
this.loadApi(documentServerUrl).then(() => {
this.editor = new DocsAPI.DocEditor(this.$el.querySelector('div').id, config);
});
},
loadApi(baseUrl) {
if (window.DocsAPI) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = baseUrl.replace(/\/$/, '') + '/web-apps/apps/api/documents/api.js';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
},
};
}
</script>
@endonce
@@ -0,0 +1,82 @@
<?php
use App\Models\Document;
use App\Models\Organization;
use Livewire\Component;
new class extends Component
{
public Organization $organization;
public Document $document;
public bool $renaming = false;
public string $name = '';
public function mount(Organization $organization, Document $document): void
{
abort_unless($document->isUpload(), 404);
$this->organization = $organization;
$this->document = $document;
$this->name = $document->name;
}
public function rename(): void
{
$validated = $this->validate(['name' => 'required|string|max:255']);
$this->document->update($validated);
$this->renaming = false;
}
};
?>
<div>
<p class="text-sm text-slate-500">{{ $organization->name }}</p>
<div class="mb-6 flex items-center justify-between">
@if ($renaming)
<form wire:submit="rename" class="flex items-center gap-2">
<input type="text" wire:model="name" autofocus
class="rounded border-slate-300 text-2xl font-semibold 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">Enregistrer</button>
<button type="button" wire:click="$set('renaming', false)" class="text-sm text-slate-500 hover:text-slate-900">Annuler</button>
@error('name') <p class="text-sm text-red-600">{{ $message }}</p> @enderror
</form>
@else
<button type="button" wire:click="$set('renaming', true)" class="group flex items-center gap-2">
<h1 class="text-2xl font-semibold text-slate-900">📎 {{ $document->name }}</h1>
<span class="text-sm text-slate-400 opacity-0 group-hover:opacity-100">Renommer</span>
</button>
@endif
<div class="flex items-center gap-4">
<a href="{{ route('organizations.documents.download', [$organization, $document]) }}"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
Télécharger
</a>
<a href="{{ route('organizations.documents.index', [$organization, 'folder' => $document->folder_id]) }}"
class="text-sm text-slate-500 hover:text-slate-900">Retour</a>
</div>
</div>
<div class="rounded border border-slate-200 bg-white p-4">
<p class="text-sm text-slate-500">
Fichier importé le {{ $document->created_at->format('d/m/Y à H:i') }}
@if ($document->creator)
par {{ $document->creator->name }}
@endif
</p>
</div>
@if (str_ends_with(strtolower($document->file_path ?? ''), '.pdf'))
<div class="mt-4 w-full overflow-hidden rounded border border-slate-200 bg-white">
<iframe src="{{ route('organizations.documents.download', [$organization, $document, 'inline' => 1]) }}"
class="h-[80vh] w-full" title="Aperçu du document"></iframe>
</div>
@endif
<x-documents.version-history :document="$document" />
<div class="mt-8">
<livewire:related-items.panel :organization="$organization" :item="$document" :key="'related-'.$document->id" />
</div>
</div>
@@ -0,0 +1,142 @@
<?php
use App\Models\Domain;
use App\Models\Organization;
use Illuminate\Validation\Rule;
use Livewire\Component;
new class extends Component
{
public Organization $organization;
public ?Domain $domain = null;
public string $name = '';
public string $registrar = '';
public string $nameservers = '';
public ?string $expires_at = null;
public bool $auto_renew = false;
public string $notes = '';
public function mount(Organization $organization, ?Domain $domain = null): void
{
$this->organization = $organization;
if ($domain?->exists) {
$this->domain = $domain;
$this->name = $domain->name;
$this->registrar = $domain->registrar ?? '';
$this->nameservers = $domain->nameservers ?? '';
$this->expires_at = $domain->expires_at?->format('Y-m-d');
$this->auto_renew = (bool) $domain->auto_renew;
$this->notes = $domain->notes ?? '';
}
}
public function save(): void
{
$validated = $this->validate([
'name' => [
'required', 'string', 'max:255',
Rule::unique('domains', 'name')->ignore($this->domain?->id),
],
'registrar' => 'nullable|string|max:255',
'nameservers' => 'nullable|string',
'expires_at' => 'nullable|date',
'auto_renew' => 'boolean',
'notes' => 'nullable|string',
]);
if ($this->domain?->exists) {
$this->domain->update($validated);
session()->flash('status', "Domaine \"{$this->domain->name}\" mis à jour.");
} else {
$this->organization->domains()->create($validated);
session()->flash('status', "Domaine \"{$this->name}\" créé.");
}
$this->redirectRoute('organizations.domains.index', $this->organization, navigate: true);
}
};
?>
<div class="max-w-2xl">
<p class="text-sm text-slate-500">{{ $organization->name }}</p>
<h1 class="mb-6 text-2xl font-semibold text-slate-900">
{{ $domain?->exists ? 'Modifier le domaine' : 'Nouveau domaine' }}
</h1>
<form wire:submit="save" class="space-y-4 rounded border border-slate-200 bg-white p-6">
<div>
<label class="block text-sm font-medium text-slate-700">Nom de domaine</label>
<input type="text" wire:model="name" placeholder="example.com"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('name') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700">Bureau d'enregistrement</label>
<input type="text" wire:model="registrar" placeholder="ex. OVH, Gandi..."
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Expire le</label>
<input type="date" wire:model="expires_at"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('expires_at') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Serveurs DNS</label>
<textarea wire:model="nameservers" rows="3" placeholder="ns1.example.com&#10;ns2.example.com"
class="mt-1 w-full rounded border-slate-300 font-mono text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500"></textarea>
<p class="mt-1 text-xs text-slate-400">Un par ligne.</p>
</div>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" wire:model="auto_renew" class="rounded border-slate-300">
Renouvellement automatique activé
</label>
<div>
<label class="block text-sm font-medium text-slate-700">Notes</label>
<textarea wire:model="notes" rows="4"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500"></textarea>
</div>
<div class="flex items-center gap-3 pt-2">
<button type="submit"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
Enregistrer
</button>
<a href="{{ route('organizations.domains.index', $organization) }}" class="text-sm text-slate-500 hover:text-slate-900">Annuler</a>
</div>
</form>
@if ($domain?->exists && $domain->sslCertificates->isNotEmpty())
<div class="mt-8">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">Certificats SSL</h2>
<div class="overflow-hidden rounded border border-slate-200 bg-white">
<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-2">Nom</th>
<th class="px-4 py-2">Émetteur</th>
<th class="px-4 py-2">Expiration</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@foreach ($domain->sslCertificates as $cert)
<tr>
<td class="px-4 py-2 text-slate-900">{{ $cert->name }}</td>
<td class="px-4 py-2 text-slate-600">{{ $cert->issuer ?? '—' }}</td>
<td class="px-4 py-2 text-slate-600">{{ $cert->expires_at?->format('Y-m-d') ?? '—' }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
</div>
@@ -0,0 +1,120 @@
<?php
use App\Models\Organization;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
new class extends Component
{
use WithPagination;
public Organization $organization;
#[Url]
public string $search = '';
public function delete(int $domainId): void
{
$domain = $this->organization->domains()->findOrFail($domainId);
$domain->delete();
session()->flash('status', "Domaine \"{$domain->name}\" supprimé.");
}
public function with(): array
{
return [
'domains' => $this->organization->domains()
->when($this->search, fn ($query) => $query->where(function ($q) {
$q->where('name', 'like', "%{$this->search}%")
->orWhere('registrar', 'like', "%{$this->search}%");
}))
->orderBy('expires_at')
->paginate(15),
];
}
};
?>
<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">Domaines</h1>
</div>
<a href="{{ route('organizations.domains.create', $organization) }}"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
+ Nouveau domaine
</a>
</div>
<input type="text" wire:model.live.debounce.300ms="search" placeholder="Filtrer par nom ou bureau d'enregistrement..."
class="mb-4 w-full max-w-sm rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<div class="overflow-x-auto rounded border border-slate-200 bg-white">
<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">Domaine</th>
<th class="px-4 py-3">TLD</th>
<th class="px-4 py-3">Bureau d'enregistrement</th>
<th class="px-4 py-3">Expiration</th>
<th class="px-4 py-3">Renouvellement auto.</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@forelse ($domains as $domain)
@php $status = $domain->expiryStatus(); @endphp
<tr wire:key="domain-{{ $domain->id }}">
<td class="px-4 py-3">
<a href="{{ route('organizations.domains.edit', [$organization, $domain]) }}"
class="font-medium text-slate-900 hover:underline">
{{ $domain->name }}
</a>
</td>
<td class="px-4 py-3 text-slate-600">{{ $domain->tld() ?? '—' }}</td>
<td class="px-4 py-3 text-slate-600">{{ $domain->registrar ?? '—' }}</td>
<td class="px-4 py-3">
@if ($domain->expires_at)
<span @class([
'text-red-600 font-medium' => $status === 'expired',
'text-amber-600 font-medium' => $status === 'soon',
'text-slate-600' => $status === 'ok',
])>
{{ $domain->expires_at->format('Y-m-d') }}
</span>
@if ($status === 'expired')
<span class="ml-2 rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">Expiré</span>
@elseif ($status === 'soon')
<span class="ml-2 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700">{{ $domain->expiresInDays() }}j restants</span>
@endif
@else
<span class="text-slate-400">—</span>
@endif
</td>
<td class="px-4 py-3 text-slate-600">{{ $domain->auto_renew ? 'Oui' : 'Non' }}</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
<a href="{{ route('organizations.domains.edit', [$organization, $domain]) }}" class="text-slate-500 hover:text-slate-900">Modifier</a>
<button type="button"
wire:click="delete({{ $domain->id }})"
wire:confirm="Supprimer ce domaine ? Cette action est irréversible."
class="ml-3 text-red-500 hover:text-red-700">
Supprimer
</button>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-4 py-6 text-center text-slate-400">Aucun domaine pour le moment.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">
{{ $domains->links() }}
</div>
</div>
@@ -0,0 +1,166 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ $title ?? config('app.name') }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
</head>
<body class="bg-slate-50 text-slate-900 antialiased">
<div class="flex min-h-screen">
@php
$currentOrganization = request()->route('organization');
@endphp
<aside class="w-60 shrink-0 bg-slate-900 text-slate-200">
<div class="px-4 py-4 text-lg font-semibold text-white">{{ config('app.name') }}</div>
<nav class="mt-2 space-y-1 px-2 text-sm">
<a href="{{ route('organizations.index') }}"
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.*') ? 'bg-slate-800 text-white' : '' }}">
Organisations
</a>
@if ($currentOrganization)
@php
$assetTypes = \App\Models\FlexibleAssetType::whereHas('assets', fn ($q) => $q->where('organization_id', $currentOrganization->id))
->orderBy('name')
->get();
$shortcuts = \App\Models\Shortcut::where('organization_id', $currentOrganization->id)
->orWhereNull('organization_id')
->orderBy('label')
->get();
@endphp
<div class="mt-3 border-t border-slate-800 pt-3">
<p class="px-3 pb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">{{ $currentOrganization->name }}</p>
<a href="{{ route('organizations.dashboard', $currentOrganization) }}"
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.dashboard') ? 'bg-slate-800 text-white' : '' }}">
Tableau de bord
</a>
<a href="{{ route('organizations.todos.index', $currentOrganization) }}"
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.todos.*') ? 'bg-slate-800 text-white' : '' }}">
ToDo
</a>
<a href="{{ route('organizations.passwords.index', $currentOrganization) }}"
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.passwords.*') ? 'bg-slate-800 text-white' : '' }}">
Mots de passe
</a>
<a href="{{ route('organizations.contacts.index', $currentOrganization) }}"
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.contacts.*') ? 'bg-slate-800 text-white' : '' }}">
Contacts
</a>
<a href="{{ route('organizations.locations.index', $currentOrganization) }}"
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.locations.*') ? 'bg-slate-800 text-white' : '' }}">
Sites
</a>
<a href="{{ route('organizations.domains.index', $currentOrganization) }}"
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.domains.*') ? 'bg-slate-800 text-white' : '' }}">
Domaines
</a>
<a href="{{ route('organizations.documents.index', $currentOrganization) }}"
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.documents.*') ? 'bg-slate-800 text-white' : '' }}">
Documents
</a>
<a href="{{ route('organizations.virtual-machines.index', $currentOrganization) }}"
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.virtual-machines.*') ? 'bg-slate-800 text-white' : '' }}">
Machines virtuelles
</a>
<div x-data="{ open: localStorage.getItem('nav-assets-open') === '1' }" x-init="$watch('open', value => localStorage.setItem('nav-assets-open', value ? '1' : '0'))">
<div class="flex items-center rounded hover:bg-slate-800 {{ request()->routeIs('organizations.assets.picker') ? 'bg-slate-800 text-white' : '' }}">
<a href="{{ route('organizations.assets.picker', $currentOrganization) }}"
class="block flex-1 px-3 py-2">
Actifs
</a>
@if ($assetTypes->isNotEmpty())
<button type="button" x-on:click="open = !open" class="px-2 py-2 text-slate-400 hover:text-white" :aria-expanded="open" aria-label="Replier/déplier les actifs">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-3.5 w-3.5 transition-transform" :class="open ? 'rotate-90' : ''">
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
</svg>
</button>
@endif
</div>
@if ($assetTypes->isNotEmpty())
<div x-show="open" class="ml-4 space-y-0.5 border-l border-slate-700 pl-2">
@foreach ($assetTypes as $assetType)
<a href="{{ route('organizations.assets.index', [$currentOrganization, $assetType]) }}"
class="block rounded px-3 py-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-100 {{ request()->routeIs('organizations.assets.*') && request()->route('type')?->is($assetType) ? 'bg-slate-800 text-white' : '' }}">
{{ $assetType->icon }} {{ \Illuminate\Support\Str::ucfirst(\Illuminate\Support\Str::lower($assetType->name)) }}
</a>
@endforeach
</div>
@endif
</div>
<div x-data="{ open: localStorage.getItem('nav-shortcuts-open') === '1' }" x-init="$watch('open', value => localStorage.setItem('nav-shortcuts-open', value ? '1' : '0'))">
<div class="flex items-center rounded hover:bg-slate-800 {{ request()->routeIs('organizations.shortcuts.*') ? 'bg-slate-800 text-white' : '' }}">
<a href="{{ route('organizations.shortcuts.index', $currentOrganization) }}"
class="block flex-1 px-3 py-2">
Raccourcis
</a>
@if ($shortcuts->isNotEmpty())
<button type="button" x-on:click="open = !open" class="px-2 py-2 text-slate-400 hover:text-white" :aria-expanded="open" aria-label="Replier/déplier les raccourcis">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-3.5 w-3.5 transition-transform" :class="open ? 'rotate-90' : ''">
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" />
</svg>
</button>
@endif
</div>
@if ($shortcuts->isNotEmpty())
<div x-show="open" class="ml-4 space-y-0.5 border-l border-slate-700 pl-2">
@foreach ($shortcuts as $shortcut)
<a href="{{ $shortcut->url }}" target="_blank" rel="noopener"
class="flex items-center gap-1 rounded px-3 py-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-100">
<span class="truncate">{{ $shortcut->icon ?: '🔗' }} {{ $shortcut->label }}</span>
</a>
@endforeach
</div>
@endif
</div>
</div>
@endif
@role('Administrateur')
<div class="mt-3 border-t border-slate-800 pt-3">
<p class="px-3 pb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Administration</p>
<a href="{{ route('asset-types.index') }}"
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('asset-types.*') ? 'bg-slate-800 text-white' : '' }}">
Types d'actifs
</a>
<a href="{{ route('users.index') }}"
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('users.*') ? 'bg-slate-800 text-white' : '' }}">
Utilisateurs
</a>
</div>
@endrole
</nav>
</aside>
<div class="flex-1">
<header class="flex items-center justify-between border-b border-slate-200 bg-white px-6 py-3">
<div class="text-sm text-slate-500">{{ $header ?? '' }}</div>
@auth
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="text-sm text-slate-500 hover:text-slate-900">
{{ auth()->user()->name }} &middot; Déconnexion
</button>
</form>
@endauth
</header>
<main class="p-6">
@if (session('status'))
<div class="mb-4 rounded border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm text-emerald-700">
{{ session('status') }}
</div>
@endif
{{ $slot }}
</main>
</div>
</div>
@livewireScripts
</body>
</html>
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ $title ?? config('app.name') }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="flex min-h-screen items-center justify-center bg-slate-100">
<div class="w-full max-w-sm rounded-lg bg-white p-8 shadow">
<div class="mb-6 text-center text-xl font-semibold text-slate-900">{{ config('app.name') }}</div>
{{ $slot }}
</div>
</body>
</html>
@@ -0,0 +1,143 @@
<?php
use App\Models\Location;
use App\Models\Organization;
use Livewire\Component;
new class extends Component
{
public Organization $organization;
public ?Location $location = null;
public string $name = '';
public string $address_line_1 = '';
public string $address_line_2 = '';
public string $city = '';
public string $region = '';
public string $postal_code = '';
public string $country = '';
public string $phone = '';
public bool $is_primary = false;
public function mount(Organization $organization, ?Location $location = null): void
{
$this->organization = $organization;
if ($location?->exists) {
$this->location = $location;
$this->name = $location->name;
$this->address_line_1 = $location->address_line_1 ?? '';
$this->address_line_2 = $location->address_line_2 ?? '';
$this->city = $location->city ?? '';
$this->region = $location->region ?? '';
$this->postal_code = $location->postal_code ?? '';
$this->country = $location->country ?? '';
$this->phone = $location->phone ?? '';
$this->is_primary = $location->is_primary;
}
}
public function save(): void
{
$validated = $this->validate([
'name' => 'required|string|max:255',
'address_line_1' => 'nullable|string|max:255',
'address_line_2' => 'nullable|string|max:255',
'city' => 'nullable|string|max:255',
'region' => 'nullable|string|max:255',
'postal_code' => 'nullable|string|max:255',
'country' => 'nullable|string|max:255',
'phone' => 'nullable|string|max:255',
'is_primary' => 'boolean',
]);
if ($validated['is_primary']) {
$this->organization->locations()
->when($this->location?->exists, fn ($query) => $query->whereKeyNot($this->location->id))
->update(['is_primary' => false]);
}
if ($this->location?->exists) {
$this->location->update($validated);
session()->flash('status', "Site \"{$this->location->name}\" mis à jour.");
} else {
$this->organization->locations()->create($validated);
session()->flash('status', "Site \"{$this->name}\" créé.");
}
$this->redirectRoute('organizations.locations.index', $this->organization, navigate: true);
}
};
?>
<div class="max-w-2xl">
<p class="text-sm text-slate-500">{{ $organization->name }}</p>
<h1 class="mb-6 text-2xl font-semibold text-slate-900">
{{ $location?->exists ? 'Modifier le site' : 'Nouveau site' }}
</h1>
<form wire:submit="save" class="space-y-4 rounded border border-slate-200 bg-white p-6">
<div>
<label class="block text-sm font-medium text-slate-700">Nom</label>
<input type="text" wire:model="name"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('name') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Adresse ligne 1</label>
<input type="text" wire:model="address_line_1"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Adresse ligne 2</label>
<input type="text" wire:model="address_line_2"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
</div>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700">Ville</label>
<input type="text" wire:model="city"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Région</label>
<input type="text" wire:model="region"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Code postal</label>
<input type="text" wire:model="postal_code"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700">Pays</label>
<input type="text" wire:model="country"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Téléphone</label>
<input type="text" wire:model="phone"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
</div>
</div>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" wire:model="is_primary" class="rounded border-slate-300">
Site principal
</label>
<div class="flex items-center gap-3 pt-2">
<button type="submit"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
Enregistrer
</button>
<a href="{{ route('organizations.locations.index', $organization) }}" class="text-sm text-slate-500 hover:text-slate-900">Annuler</a>
</div>
</form>
</div>
@@ -0,0 +1,104 @@
<?php
use App\Models\Organization;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
new class extends Component
{
use WithPagination;
public Organization $organization;
#[Url]
public string $search = '';
public function delete(int $locationId): void
{
$location = $this->organization->locations()->findOrFail($locationId);
$location->delete();
session()->flash('status', "Site \"{$location->name}\" supprimé.");
}
public function with(): array
{
return [
'locations' => $this->organization->locations()
->when($this->search, fn ($query) => $query->where(function ($q) {
$q->where('name', 'like', "%{$this->search}%")
->orWhere('city', 'like', "%{$this->search}%");
}))
->orderByDesc('is_primary')
->orderBy('name')
->paginate(15),
];
}
};
?>
<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">Sites</h1>
</div>
<a href="{{ route('organizations.locations.create', $organization) }}"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
+ Nouveau site
</a>
</div>
<input type="text" wire:model.live.debounce.300ms="search" placeholder="Filtrer par nom ou ville..."
class="mb-4 w-full max-w-sm rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<div class="overflow-x-auto rounded border border-slate-200 bg-white">
<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">Ville</th>
<th class="px-4 py-3">Pays</th>
<th class="px-4 py-3">Téléphone</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@forelse ($locations as $location)
<tr wire:key="loc-{{ $location->id }}">
<td class="px-4 py-3">
<a href="{{ route('organizations.locations.edit', [$organization, $location]) }}"
class="font-medium text-slate-900 hover:underline">
{{ $location->name }}
</a>
@if ($location->is_primary)
<span class="ml-2 rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">Principal</span>
@endif
</td>
<td class="px-4 py-3 text-slate-600">{{ $location->city ?? '—' }}</td>
<td class="px-4 py-3 text-slate-600">{{ $location->country ?? '—' }}</td>
<td class="px-4 py-3 text-slate-600">{{ $location->phone ?? '—' }}</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
<a href="{{ route('organizations.locations.edit', [$organization, $location]) }}" class="text-slate-500 hover:text-slate-900">Modifier</a>
<button type="button"
wire:click="delete({{ $location->id }})"
wire:confirm="Supprimer ce site ? Cette action est irréversible."
class="ml-3 text-red-500 hover:text-red-700">
Supprimer
</button>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-4 py-6 text-center text-slate-400">Aucun site pour le moment.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">
{{ $locations->links() }}
</div>
</div>
@@ -0,0 +1,344 @@
<?php
use App\Models\Organization;
use Livewire\Component;
new class extends Component
{
public Organization $organization;
public string $newLogCategory = 'info';
public string $newLogNote = '';
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 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();
return [
'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,
'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="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="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>
<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>
<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="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="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>
</div>
@@ -0,0 +1,338 @@
<?php
use App\Models\Organization;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Livewire\Component;
use Livewire\WithFileUploads;
new class extends Component
{
use WithFileUploads;
public ?Organization $organization = null;
public string $name = '';
public string $org_type = 'client';
public string $status = 'active';
public string $description = '';
public string $allowed_ip_ranges = '';
public $logo = null;
public function mount(?Organization $organization = null): void
{
if ($organization?->exists) {
$this->organization = $organization;
$this->name = $organization->name;
$this->org_type = $organization->org_type ?? 'client';
$this->status = $organization->status;
$this->description = $organization->description ?? '';
$this->allowed_ip_ranges = implode("\n", $organization->allowed_ip_ranges ?? []);
}
}
public function save(): void
{
$validated = $this->validate([
'name' => 'required|string|max:255',
'org_type' => 'required|in:client,internal,vendor',
'status' => 'required|in:active,inactive',
'description' => 'nullable|string',
'logo' => 'nullable|image|max:2048',
]);
unset($validated['logo']);
$ranges = collect(preg_split('/\r?\n/', $this->allowed_ip_ranges))
->map(fn ($line) => trim($line))
->filter()
->values()
->all();
$validated['allowed_ip_ranges'] = $ranges ?: null;
if ($this->organization?->exists) {
$this->organization->update($validated);
} else {
$validated['slug'] = Str::slug($this->name).'-'.Str::random(6);
$this->organization = Organization::create($validated);
}
if ($this->logo) {
if ($this->organization->logo_path) {
Storage::disk('public')->delete($this->organization->logo_path);
}
$path = $this->logo->store("organizations/{$this->organization->id}", 'public');
$this->organization->update(['logo_path' => $path]);
}
session()->flash('status', "Organisation « {$this->organization->name} » enregistrée.");
$this->redirectRoute('organizations.index', navigate: true);
}
};
?>
<div class="max-w-2xl">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-semibold text-slate-900">
{{ $organization?->exists ? 'Modifier l\'organisation' : 'Nouvelle organisation' }}
</h1>
@if ($organization?->exists)
<div class="flex flex-wrap gap-4 text-sm text-slate-500">
<a href="{{ route('organizations.dashboard', $organization) }}" class="hover:text-slate-900">
Voir le tableau de bord &rarr;
</a>
<a href="{{ route('organizations.passwords.index', $organization) }}" class="hover:text-slate-900">
Voir les mots de passe &rarr;
</a>
<a href="{{ route('organizations.assets.picker', $organization) }}" class="hover:text-slate-900">
Voir les actifs &rarr;
</a>
<a href="{{ route('organizations.contacts.index', $organization) }}" class="hover:text-slate-900">
Voir les contacts &rarr;
</a>
<a href="{{ route('organizations.locations.index', $organization) }}" class="hover:text-slate-900">
Voir les sites &rarr;
</a>
<a href="{{ route('organizations.documents.index', $organization) }}" class="hover:text-slate-900">
Voir les documents &rarr;
</a>
</div>
@endif
</div>
<form wire:submit="save" class="space-y-4 rounded border border-slate-200 bg-white p-6">
<div x-data="logoCropper($wire)">
<label class="block text-sm font-medium text-slate-700">Logo</label>
<div class="mt-1 flex items-center gap-4">
<img src="{{ $logo ? $logo->temporaryUrl() : ($organization?->logo_url ?? asset('images/default-organization-logo.svg')) }}"
class="h-16 w-16 rounded border border-slate-200 object-contain p-1" alt="Logo">
<div>
<input type="file" name="logo_file_picker" accept="image/*" x-ref="fileInput" x-on:change="onFileSelected($event)"
class="block text-sm text-slate-600 file:mr-3 file:rounded file:border-0 file:bg-slate-100 file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-slate-700 hover:file:bg-slate-200">
<p class="mt-1 text-xs text-slate-500">L'image pourra être recadrée avant l'envoi.</p>
</div>
</div>
@error('logo') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
<div x-show="cropping" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-md rounded bg-white p-4 shadow-xl">
<h3 class="mb-3 text-sm font-semibold text-slate-900">Recadrer le logo</h3>
<div x-ref="viewport"
class="relative mx-auto h-64 w-64 cursor-move select-none overflow-hidden rounded border border-slate-300 bg-slate-100"
x-on:mousedown="startDrag($event)"
x-on:touchstart="startDrag($event)">
<img x-ref="cropImage" x-show="imageSrc" :src="imageSrc"
class="pointer-events-none absolute max-w-none origin-top-left"
draggable="false">
</div>
<div class="mt-3 flex items-center gap-2">
<span class="text-xs text-slate-500">Zoom</span>
<input type="range" :min="minZoom" :max="minZoom * 4" step="0.001" x-model.number="zoom" x-on:input="applyTransform()" class="flex-1">
</div>
<div class="mt-4 flex items-center justify-end gap-3">
<button type="button" x-on:click="cancelCrop()" class="text-sm text-slate-500 hover:text-slate-900">Annuler</button>
<button type="button" x-on:click="confirmCrop()"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
Valider le recadrage
</button>
</div>
</div>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Nom</label>
<input type="text" wire:model="name"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('name') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700">Type</label>
<select wire:model="org_type"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<option value="client">Client</option>
<option value="internal">Interne</option>
<option value="vendor">Fournisseur</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Statut</label>
<select wire:model="status"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<option value="active">Actif</option>
<option value="inactive">Inactif</option>
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Description</label>
<textarea wire:model="description" rows="3"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Plages IP autorisées (une par ligne, CIDR)</label>
<textarea wire:model="allowed_ip_ranges" rows="3" placeholder="203.0.113.0/24&#10;198.51.100.42"
class="mt-1 w-full rounded border-slate-300 font-mono text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500"></textarea>
<p class="mt-1 text-xs text-slate-500">Laisser vide pour autoriser l'accès depuis n'importe quelle IP.</p>
</div>
<div class="flex items-center gap-3 pt-2">
<button type="submit"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
Enregistrer
</button>
<a href="{{ route('organizations.index') }}" class="text-sm text-slate-500 hover:text-slate-900">Annuler</a>
</div>
</form>
</div>
@once
<script>
function logoCropper(wire) {
return {
cropping: false,
imageSrc: null,
zoom: 1,
minZoom: 1,
naturalWidth: 0,
naturalHeight: 0,
offsetX: 0,
offsetY: 0,
dragging: false,
dragStartX: 0,
dragStartY: 0,
dragOffsetStartX: 0,
dragOffsetStartY: 0,
fileName: 'logo.png',
fileType: 'image/png',
onFileSelected(event) {
const file = event.target.files[0];
if (!file) return;
this.fileName = file.name;
this.fileType = file.type || 'image/png';
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
this.naturalWidth = img.naturalWidth;
this.naturalHeight = img.naturalHeight;
this.imageSrc = e.target.result;
this.cropping = true;
this.$nextTick(() => this.resetTransform());
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
},
resetTransform() {
const viewport = 256; // px, matches the w-64/h-64 crop viewport
this.minZoom = Math.max(viewport / this.naturalWidth, viewport / this.naturalHeight);
this.zoom = this.minZoom;
this.offsetX = (viewport - this.naturalWidth * this.zoom) / 2;
this.offsetY = (viewport - this.naturalHeight * this.zoom) / 2;
this.applyTransform();
},
applyTransform() {
this.zoom = Math.max(this.zoom, this.minZoom);
this.clampOffset();
const img = this.$refs.cropImage;
if (img) {
img.style.width = (this.naturalWidth * this.zoom) + 'px';
img.style.height = (this.naturalHeight * this.zoom) + 'px';
img.style.transform = `translate(${this.offsetX}px, ${this.offsetY}px)`;
}
},
clampOffset() {
const viewport = 256;
const width = this.naturalWidth * this.zoom;
const height = this.naturalHeight * this.zoom;
this.offsetX = Math.min(0, Math.max(viewport - width, this.offsetX));
this.offsetY = Math.min(0, Math.max(viewport - height, this.offsetY));
},
startDrag(event) {
this.dragging = true;
const point = event.touches ? event.touches[0] : event;
this.dragStartX = point.clientX;
this.dragStartY = point.clientY;
this.dragOffsetStartX = this.offsetX;
this.dragOffsetStartY = this.offsetY;
const move = (e) => {
if (!this.dragging) return;
const p = e.touches ? e.touches[0] : e;
this.offsetX = this.dragOffsetStartX + (p.clientX - this.dragStartX);
this.offsetY = this.dragOffsetStartY + (p.clientY - this.dragStartY);
this.applyTransform();
};
const stop = () => {
this.dragging = false;
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', stop);
window.removeEventListener('touchmove', move);
window.removeEventListener('touchend', stop);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', stop);
window.addEventListener('touchmove', move);
window.addEventListener('touchend', stop);
},
cancelCrop() {
this.cropping = false;
this.imageSrc = null;
this.$refs.fileInput.value = '';
},
confirmCrop() {
const viewport = 256;
const canvas = document.createElement('canvas');
canvas.width = viewport;
canvas.height = viewport;
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = () => {
ctx.drawImage(
img,
-this.offsetX / this.zoom,
-this.offsetY / this.zoom,
viewport / this.zoom,
viewport / this.zoom,
0, 0, viewport, viewport
);
canvas.toBlob((blob) => {
const croppedFile = new File([blob], this.fileName, { type: this.fileType });
wire.$upload('logo', croppedFile, () => {
this.cropping = false;
this.imageSrc = null;
});
}, this.fileType);
};
img.src = this.imageSrc;
},
};
}
</script>
@endonce
@@ -0,0 +1,99 @@
<?php
use App\Models\Organization;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
new class extends Component
{
use WithPagination;
#[Url]
public string $search = '';
public function delete(int $organizationId): void
{
$organization = Organization::findOrFail($organizationId);
$organization->delete();
session()->flash('status', "Organisation « {$organization->name} » supprimée.");
}
public function with(): array
{
return [
'organizations' => Organization::query()
->when($this->search, fn ($query) => $query->where('name', 'like', "%{$this->search}%"))
->orderBy('name')
->paginate(15),
];
}
};
?>
<div>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-semibold text-slate-900">Organisations</h1>
<a href="{{ route('organizations.create') }}"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
+ Nouvelle organisation
</a>
</div>
<input type="text" wire:model.live.debounce.300ms="search" placeholder="Filtrer par nom..."
class="mb-4 w-full max-w-sm rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<div class="overflow-hidden rounded border border-slate-200 bg-white">
<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"></th>
<th class="px-4 py-3">Nom</th>
<th class="px-4 py-3">Type</th>
<th class="px-4 py-3">Statut</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@forelse ($organizations as $organization)
<tr wire:key="org-{{ $organization->id }}">
<td class="px-4 py-3">
<img src="{{ $organization->logo_url }}" alt="Logo {{ $organization->name }}"
class="h-8 w-8 rounded border border-slate-200 bg-white object-contain p-0.5">
</td>
<td class="px-4 py-3">
<a href="{{ route('organizations.dashboard', $organization) }}" class="font-medium text-slate-900 hover:underline">
{{ $organization->name }}
</a>
</td>
<td class="px-4 py-3 text-slate-600">{{ $organization->org_type ?? '—' }}</td>
<td class="px-4 py-3">
<span class="rounded-full px-2 py-0.5 text-xs font-medium
{{ $organization->status === 'active' ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-600' }}">
{{ $organization->status === 'active' ? 'Actif' : 'Inactif' }}
</span>
</td>
<td class="px-4 py-3 text-right">
<a href="{{ route('organizations.edit', $organization) }}" class="text-slate-500 hover:text-slate-900">Modifier</a>
<button type="button"
wire:click="delete({{ $organization->id }})"
wire:confirm="Supprimer cette organisation ? Cette action est irréversible."
class="ml-3 text-red-500 hover:text-red-700">
Supprimer
</button>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-4 py-6 text-center text-slate-400">Aucune organisation pour le moment.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">
{{ $organizations->links() }}
</div>
</div>
@@ -0,0 +1,168 @@
<?php
use App\Models\Organization;
use App\Models\Password;
use Livewire\Component;
new class extends Component
{
public Organization $organization;
public ?Password $password = null;
public string $name = '';
public string $username = '';
public string $password_ciphertext = '';
public string $url = '';
public string $notes = '';
public bool $is_favorite = false;
public function mount(Organization $organization, ?Password $password = null): void
{
$this->organization = $organization;
if ($password?->exists) {
$this->password = $password;
$this->name = $password->name;
$this->username = $password->username ?? '';
// password_ciphertext intentionally left blank: never pre-fill a decrypted secret into the form.
$this->url = $password->url ?? '';
$this->notes = $password->notes ?? '';
$this->is_favorite = $password->is_favorite;
$password->logAction('viewed');
}
}
public function save(): void
{
$validated = $this->validate([
'name' => 'required|string|max:255',
'username' => 'nullable|string|max:255',
'password_ciphertext' => $this->password?->exists ? 'nullable|string' : 'required|string',
'url' => 'nullable|url|max:255',
'notes' => 'nullable|string',
'is_favorite' => 'boolean',
]);
if (blank($validated['password_ciphertext'])) {
unset($validated['password_ciphertext']);
} else {
$validated['password_updated_at'] = now();
}
if ($this->password?->exists) {
$isRotation = isset($validated['password_ciphertext']);
$this->password->update($validated);
$this->password->logAction($isRotation ? 'rotated' : 'updated');
session()->flash('status', "Mot de passe « {$this->password->name} » mis à jour.");
} else {
$validated['created_by'] = auth()->id();
$validated['password_updated_at'] = now();
$created = $this->organization->passwords()->create($validated);
$created->logAction('created');
session()->flash('status', "Mot de passe « {$this->name} » créé.");
}
$this->redirectRoute('organizations.passwords.index', $this->organization, navigate: true);
}
};
?>
<div class="max-w-2xl">
<p class="text-sm text-slate-500">{{ $organization->name }}</p>
<h1 class="mb-6 text-2xl font-semibold text-slate-900">
{{ $password?->exists ? 'Modifier le mot de passe' : 'Nouveau mot de passe' }}
</h1>
<form wire:submit="save" class="space-y-4 rounded border border-slate-200 bg-white p-6">
<div>
<label class="block text-sm font-medium text-slate-700">Nom</label>
<input type="text" wire:model="name"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('name') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700">Nom d'utilisateur</label>
<input type="text" wire:model="username"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">
Mot de passe
@if ($password?->exists) <span class="text-xs font-normal text-slate-400">(laisser vide pour conserver l'actuel)</span> @endif
</label>
<input type="password" wire:model="password_ciphertext" autocomplete="new-password"
class="mt-1 w-full rounded border-slate-300 font-mono text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('password_ciphertext') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">URL</label>
<input type="text" wire:model="url" placeholder="https://..."
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('url') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Notes</label>
<textarea wire:model="notes" rows="3"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500"></textarea>
</div>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" wire:model="is_favorite" class="rounded border-slate-300">
Favori
</label>
<div class="flex items-center gap-3 pt-2">
<button type="submit"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
Enregistrer
</button>
<a href="{{ route('organizations.passwords.index', $organization) }}" class="text-sm text-slate-500 hover:text-slate-900">Annuler</a>
</div>
</form>
@if ($password?->exists)
<div class="mt-8">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">Journal d'audit</h2>
<div class="overflow-hidden rounded border border-slate-200 bg-white">
<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-2">Action</th>
<th class="px-4 py-2">Utilisateur</th>
<th class="px-4 py-2">IP</th>
<th class="px-4 py-2">Quand</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@forelse ($password->auditLogs()->with('user')->latest()->limit(20)->get() as $log)
<tr>
<td class="px-4 py-2 capitalize text-slate-900">{{ $log->action }}</td>
<td class="px-4 py-2 text-slate-600">{{ $log->user?->name ?? 'système' }}</td>
<td class="px-4 py-2 text-slate-600">{{ $log->ip_address ?? '—' }}</td>
<td class="px-4 py-2 text-slate-500">{{ $log->created_at->diffForHumans() }}</td>
</tr>
@empty
<tr>
<td colspan="4" class="px-4 py-4 text-center text-slate-400">Aucune activité pour le moment.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="mt-8">
<livewire:related-items.panel :organization="$organization" :item="$password" :key="'related-'.$password->id" />
</div>
@endif
</div>
@@ -0,0 +1,144 @@
<?php
use App\Models\Organization;
use App\Models\Password;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
new class extends Component
{
use WithPagination;
public Organization $organization;
#[Url]
public string $search = '';
public array $revealed = [];
public function reveal(int $passwordId): void
{
$password = $this->organization->passwords()->findOrFail($passwordId);
$password->logAction('revealed');
$this->revealed[$passwordId] = $password->password_ciphertext;
}
public function hide(int $passwordId): void
{
unset($this->revealed[$passwordId]);
}
public function copy(int $passwordId): void
{
$password = $this->organization->passwords()->findOrFail($passwordId);
$password->logAction('copied');
$this->dispatch('password-copy', value: $password->password_ciphertext);
}
public function delete(int $passwordId): void
{
$password = $this->organization->passwords()->findOrFail($passwordId);
$password->logAction('deleted');
$password->delete();
unset($this->revealed[$passwordId]);
session()->flash('status', "Mot de passe « {$password->name} » supprimé.");
}
public function with(): array
{
return [
'passwords' => $this->organization->passwords()
->when($this->search, fn ($query) => $query->where(function ($q) {
$q->where('name', 'like', "%{$this->search}%")
->orWhere('username', 'like', "%{$this->search}%")
->orWhere('url', 'like', "%{$this->search}%");
}))
->orderBy('name')
->paginate(15),
];
}
};
?>
<div x-data x-on:password-copy.window="navigator.clipboard.writeText($event.detail.value)">
<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">Mots de passe</h1>
</div>
<a href="{{ route('organizations.passwords.create', $organization) }}"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
+ Nouveau mot de passe
</a>
</div>
<input type="text" wire:model.live.debounce.300ms="search" placeholder="Filtrer par nom, nom d'utilisateur, url..."
class="mb-4 w-full max-w-sm rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<div class="overflow-x-auto rounded border border-slate-200 bg-white">
<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">Nom d'utilisateur</th>
<th class="px-4 py-3">Mot de passe</th>
<th class="px-4 py-3">URL</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@forelse ($passwords as $password)
<tr wire:key="pw-{{ $password->id }}">
<td class="px-4 py-3">
<a href="{{ route('organizations.passwords.edit', [$organization, $password]) }}"
class="font-medium text-slate-900 hover:underline">
{{ $password->name }}
</a>
</td>
<td class="px-4 py-3 text-slate-600">{{ $password->username ?? '—' }}</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2 font-mono">
@if (isset($revealed[$password->id]))
<span class="text-slate-900">{{ $revealed[$password->id] }}</span>
<button type="button" wire:click="hide({{ $password->id }})"
class="text-xs text-slate-400 hover:text-slate-700">masquer</button>
@else
<span class="tracking-widest text-slate-400">••••••••</span>
<button type="button" wire:click="reveal({{ $password->id }})"
class="text-xs text-slate-500 hover:text-slate-900">afficher</button>
@endif
<button type="button" wire:click="copy({{ $password->id }})"
class="text-xs text-slate-500 hover:text-slate-900">copier</button>
</div>
</td>
<td class="px-4 py-3 text-slate-600">{{ $password->url ?? '—' }}</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
<a href="{{ route('organizations.passwords.edit', [$organization, $password]) }}" class="text-slate-500 hover:text-slate-900">Modifier</a>
<button type="button"
wire:click="delete({{ $password->id }})"
wire:confirm="Supprimer ce mot de passe ? Cette action est irréversible."
class="ml-3 text-red-500 hover:text-red-700">
Supprimer
</button>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-4 py-6 text-center text-slate-400">Aucun mot de passe pour le moment.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">
{{ $passwords->links() }}
</div>
</div>
@@ -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>
@@ -0,0 +1,106 @@
<?php
use App\Models\Organization;
use App\Models\Shortcut;
use Livewire\Component;
new class extends Component
{
public Organization $organization;
public ?Shortcut $shortcut = null;
public bool $global = false;
public string $label = '';
public string $url = '';
public string $icon = '';
public function mount(Organization $organization, ?Shortcut $shortcut = null, bool $global = false): void
{
$this->organization = $organization;
if ($shortcut?->exists) {
abort_unless($shortcut->organization_id === $organization->id || $shortcut->isGlobal(), 404);
if ($shortcut->isGlobal()) {
abort_unless(auth()->user()->hasRole('Administrateur'), 403);
}
$this->shortcut = $shortcut;
$this->global = $shortcut->isGlobal();
$this->label = $shortcut->label;
$this->url = $shortcut->url;
$this->icon = $shortcut->icon ?? '';
} else {
abort_if($global && ! auth()->user()->hasRole('Administrateur'), 403);
$this->global = $global;
}
}
public function save(): void
{
$validated = $this->validate([
'label' => 'required|string|max:255',
'url' => 'required|url|max:2048',
'icon' => 'nullable|string|max:10',
]);
if ($this->shortcut?->exists) {
$this->shortcut->update($validated);
session()->flash('status', "Raccourci « {$this->shortcut->label} » mis à jour.");
} else {
Shortcut::create([
...$validated,
'organization_id' => $this->global ? null : $this->organization->id,
'created_by' => auth()->id(),
]);
session()->flash('status', "Raccourci « {$this->label} » créé.");
}
$this->redirectRoute('organizations.shortcuts.index', $this->organization, navigate: true);
}
};
?>
<div class="max-w-lg">
<p class="text-sm text-slate-500">{{ $organization->name }}</p>
<h1 class="mb-6 text-2xl font-semibold text-slate-900">
{{ $shortcut?->exists ? 'Modifier le raccourci' : 'Nouveau raccourci' }}
</h1>
@if ($global)
<p class="mb-4 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
Ce raccourci sera visible depuis toutes les organisations.
</p>
@endif
<form wire:submit="save" class="space-y-4 rounded border border-slate-200 bg-white p-6">
<div class="grid grid-cols-4 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700">Icône</label>
<input type="text" wire:model="icon" placeholder="🔗" maxlength="10"
class="mt-1 w-full rounded border-slate-300 text-center text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
</div>
<div class="col-span-3">
<label class="block text-sm font-medium text-slate-700">Libellé</label>
<input type="text" wire:model="label" placeholder="ex. Portail client"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('label') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">URL</label>
<input type="url" wire:model="url" placeholder="https://exemple.com"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('url') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div class="flex items-center gap-3 pt-2">
<button type="submit"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
Enregistrer
</button>
<a href="{{ route('organizations.shortcuts.index', $organization) }}" class="text-sm text-slate-500 hover:text-slate-900">Annuler</a>
</div>
</form>
</div>
@@ -0,0 +1,111 @@
<?php
use App\Models\Organization;
use App\Models\Shortcut;
use Livewire\Component;
new class extends Component
{
public Organization $organization;
public function delete(int $shortcutId): void
{
$shortcut = Shortcut::findOrFail($shortcutId);
abort_unless($shortcut->organization_id === $this->organization->id || $shortcut->isGlobal(), 404);
if ($shortcut->isGlobal()) {
abort_unless(auth()->user()->hasRole('Administrateur'), 403);
}
$shortcut->delete();
session()->flash('status', "Raccourci « {$shortcut->label} » supprimé.");
}
public function with(): array
{
return [
'globalShortcuts' => Shortcut::whereNull('organization_id')->orderBy('label')->get(),
'orgShortcuts' => $this->organization->shortcuts()->orderBy('label')->get(),
];
}
};
?>
<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">Raccourcis</h1>
</div>
<div class="flex gap-2">
@role('Administrateur')
<a href="{{ route('organizations.shortcuts.create', [$organization, 'global' => true]) }}"
class="rounded border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
+ Raccourci global
</a>
@endrole
<a href="{{ route('organizations.shortcuts.create', $organization) }}"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
+ Raccourci de l'organisation
</a>
</div>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div>
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">Globaux (toutes organisations)</h2>
<div class="overflow-hidden rounded border border-slate-200 bg-white">
<ul class="divide-y divide-slate-100">
@forelse ($globalShortcuts as $shortcut)
<li class="flex items-center justify-between gap-3 px-4 py-3 text-sm">
<a href="{{ $shortcut->url }}" target="_blank" rel="noopener" class="flex min-w-0 items-center gap-2 text-slate-900 hover:underline">
<span>{{ $shortcut->icon ?: '🔗' }}</span>
<span class="truncate">{{ $shortcut->label }}</span>
</a>
@role('Administrateur')
<div class="flex shrink-0 items-center gap-3 text-xs">
<a href="{{ route('organizations.shortcuts.edit', [$organization, $shortcut]) }}" class="text-slate-500 hover:text-slate-900">Modifier</a>
<button type="button" wire:click="delete({{ $shortcut->id }})"
wire:confirm="Supprimer ce raccourci global ? Il disparaîtra pour toutes les organisations."
class="text-red-500 hover:text-red-700">
Supprimer
</button>
</div>
@endrole
</li>
@empty
<li class="px-4 py-6 text-center text-sm text-slate-400">Aucun raccourci global.</li>
@endforelse
</ul>
</div>
</div>
<div>
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">Propres à {{ $organization->name }}</h2>
<div class="overflow-hidden rounded border border-slate-200 bg-white">
<ul class="divide-y divide-slate-100">
@forelse ($orgShortcuts as $shortcut)
<li class="flex items-center justify-between gap-3 px-4 py-3 text-sm">
<a href="{{ $shortcut->url }}" target="_blank" rel="noopener" class="flex min-w-0 items-center gap-2 text-slate-900 hover:underline">
<span>{{ $shortcut->icon ?: '🔗' }}</span>
<span class="truncate">{{ $shortcut->label }}</span>
</a>
<div class="flex shrink-0 items-center gap-3 text-xs">
<a href="{{ route('organizations.shortcuts.edit', [$organization, $shortcut]) }}" class="text-slate-500 hover:text-slate-900">Modifier</a>
<button type="button" wire:click="delete({{ $shortcut->id }})"
wire:confirm="Supprimer ce raccourci ?"
class="text-red-500 hover:text-red-700">
Supprimer
</button>
</div>
</li>
@empty
<li class="px-4 py-6 text-center text-sm text-slate-400">Aucun raccourci pour cette organisation.</li>
@endforelse
</ul>
</div>
</div>
</div>
</div>
@@ -0,0 +1,143 @@
<?php
use App\Models\Organization;
use App\Models\Todo;
use App\Models\User;
use Livewire\Component;
new class extends Component
{
public Organization $organization;
public ?Todo $todo = null;
public string $title = '';
public string $description = '';
public string $category = 'infra';
public string $criticality = 'medium';
public ?string $due_date = null;
public ?int $assigned_to = null;
public function mount(Organization $organization, ?Todo $todo = null): void
{
$this->organization = $organization;
if ($todo?->exists) {
$this->todo = $todo;
$this->title = $todo->title;
$this->description = $todo->description ?? '';
$this->category = $todo->category;
$this->criticality = $todo->criticality;
$this->due_date = $todo->due_date?->format('Y-m-d');
$this->assigned_to = $todo->assigned_to;
}
}
public function save(): void
{
$validated = $this->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'category' => 'required|in:'.implode(',', array_keys(Todo::CATEGORIES)),
'criticality' => 'required|in:'.implode(',', array_keys(Todo::CRITICALITIES)),
'due_date' => 'nullable|date',
'assigned_to' => 'nullable|exists:users,id',
]);
if ($this->todo?->exists) {
$this->todo->update($validated);
session()->flash('status', "Tâche \"{$this->todo->title}\" mise à jour.");
} else {
$this->organization->todos()->create([
...$validated,
'created_by' => auth()->id(),
]);
session()->flash('status', "Tâche \"{$this->title}\" créée.");
}
$this->redirectRoute('organizations.todos.index', $this->organization, navigate: true);
}
public function with(): array
{
return [
'assignableUsers' => User::role(['Administrateur', 'Technicien'])->orderBy('name')->get(),
];
}
};
?>
<div class="max-w-2xl">
<p class="text-sm text-slate-500">{{ $organization->name }}</p>
<h1 class="mb-6 text-2xl font-semibold text-slate-900">
{{ $todo?->exists ? 'Modifier la tâche' : 'Nouvelle tâche' }}
</h1>
<form wire:submit="save" class="space-y-4 rounded border border-slate-200 bg-white p-6">
<div>
<label class="block text-sm font-medium text-slate-700">Titre</label>
<input type="text" wire:model="title"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('title') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Description</label>
<textarea wire:model="description" rows="3"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500"></textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700">Catégorie</label>
<select wire:model="category"
class="mt-1 w-full 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>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Criticité</label>
<select wire:model="criticality"
class="mt-1 w-full 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>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700">Date d'échéance</label>
<input type="date" wire:model="due_date"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('due_date') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Assigné à</label>
<select wire:model="assigned_to"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<option value="">—</option>
@foreach ($assignableUsers as $user)
<option value="{{ $user->id }}">{{ $user->name }}</option>
@endforeach
</select>
</div>
</div>
<div class="flex items-center gap-3 pt-2">
<button type="submit"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
Enregistrer
</button>
<a href="{{ route('organizations.todos.index', $organization) }}" class="text-sm text-slate-500 hover:text-slate-900">Annuler</a>
</div>
</form>
@if ($todo?->exists)
<div class="mt-8">
<livewire:related-items.panel :organization="$organization" :item="$todo" :key="'related-'.$todo->id" />
</div>
@endif
</div>
@@ -0,0 +1,158 @@
<?php
use App\Models\Organization;
use App\Models\Todo;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
new class extends Component
{
use WithPagination;
public Organization $organization;
#[Url]
public string $search = '';
#[Url]
public string $category = '';
#[Url]
public string $status = 'open';
public function toggleDone(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 delete(int $todoId): void
{
$todo = $this->organization->todos()->findOrFail($todoId);
$todo->delete();
session()->flash('status', "Tâche \"{$todo->title}\" supprimée.");
}
public function with(): array
{
return [
'todos' => $this->organization->todos()
->with(['assignee', 'creator'])
->when($this->search, fn ($q) => $q->where('title', 'like', "%{$this->search}%"))
->when($this->category, fn ($q) => $q->where('category', $this->category))
->when($this->status === 'open', fn ($q) => $q->where('is_done', false))
->when($this->status === 'done', fn ($q) => $q->where('is_done', true))
->orderBy('is_done')
->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')
->paginate(15),
'categories' => Todo::CATEGORIES,
];
}
};
?>
<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">ToDo</h1>
</div>
<a href="{{ route('organizations.todos.create', $organization) }}"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
+ Nouvelle tâche
</a>
</div>
<div class="mb-4 flex flex-wrap items-center gap-2">
<input type="text" wire:model.live.debounce.300ms="search" placeholder="Filtrer par titre..."
class="w-full max-w-sm rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<select wire:model.live="category" class="rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<option value="">Toutes catégories</option>
@foreach ($categories as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
<select wire:model.live="status" class="rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<option value="open">À faire</option>
<option value="done">Terminées</option>
<option value="">Toutes</option>
</select>
</div>
<div class="overflow-x-auto rounded border border-slate-200 bg-white">
<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"></th>
<th class="px-4 py-3">Titre</th>
<th class="px-4 py-3">Catégorie</th>
<th class="px-4 py-3">Criticité</th>
<th class="px-4 py-3">Échéance</th>
<th class="px-4 py-3">Assigné à</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@forelse ($todos as $todo)
<tr wire:key="todo-{{ $todo->id }}" class="{{ $todo->is_done ? 'opacity-50' : '' }}">
<td class="px-4 py-3">
<input type="checkbox" wire:click="toggleDone({{ $todo->id }})" @checked($todo->is_done)
class="rounded border-slate-300">
</td>
<td class="px-4 py-3">
<a href="{{ route('organizations.todos.edit', [$organization, $todo]) }}"
class="font-medium text-slate-900 hover:underline {{ $todo->is_done ? 'line-through' : '' }}">
{{ $todo->title }}
</a>
</td>
<td class="px-4 py-3 text-slate-600">{{ $todo->categoryLabel() }}</td>
<td class="px-4 py-3">
<span class="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>
</td>
<td class="px-4 py-3 {{ $todo->isOverdue() ? 'font-medium text-red-600' : 'text-slate-600' }}">
{{ $todo->due_date?->format('d/m/Y') ?? '—' }}
@if ($todo->isOverdue())
<span class="ml-1 text-xs">(en retard)</span>
@endif
</td>
<td class="px-4 py-3 text-slate-600">{{ $todo->assignee?->name ?? '—' }}</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
<a href="{{ route('organizations.todos.edit', [$organization, $todo]) }}" class="text-slate-500 hover:text-slate-900">Modifier</a>
<button type="button"
wire:click="delete({{ $todo->id }})"
wire:confirm="Supprimer cette tâche ? Cette action est irréversible."
class="ml-3 text-red-500 hover:text-red-700">
Supprimer
</button>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-4 py-6 text-center text-slate-400">Aucune tâche pour le moment.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">
{{ $todos->links() }}
</div>
</div>
@@ -0,0 +1,159 @@
<?php
use App\Models\Organization;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Livewire\Component;
use Spatie\Permission\Models\Role;
new class extends Component
{
public ?User $user = null;
public string $name = '';
public string $email = '';
public string $password = '';
public string $password_confirmation = '';
public string $role = '';
public bool $is_msp_staff = false;
/** @var array<int, int> */
public array $organization_ids = [];
public function mount(?User $user = null): void
{
if ($user?->exists) {
$this->user = $user;
$this->name = $user->name;
$this->email = $user->email;
$this->role = $user->getRoleNames()->first() ?? '';
$this->is_msp_staff = (bool) $user->is_msp_staff;
$this->organization_ids = $user->organizations->pluck('id')->all();
}
}
public function save(): void
{
$validated = $this->validate([
'name' => 'required|string|max:255',
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($this->user?->id)],
'password' => [$this->user?->exists ? 'nullable' : 'required', 'string', Password::default(), 'confirmed'],
'role' => 'required|exists:roles,name',
'is_msp_staff' => 'boolean',
'organization_ids' => 'array',
'organization_ids.*' => 'exists:organizations,id',
]);
$attributes = [
'name' => $validated['name'],
'email' => $validated['email'],
'is_msp_staff' => $validated['is_msp_staff'],
];
if (! empty($validated['password'])) {
$attributes['password'] = Hash::make($validated['password']);
}
if ($this->user?->exists) {
$this->user->update($attributes);
} else {
$this->user = User::create($attributes);
}
$this->user->syncRoles([$this->role]);
$this->user->organizations()->sync($this->organization_ids);
session()->flash('status', "Utilisateur « {$this->user->name} » enregistré.");
$this->redirectRoute('users.index', navigate: true);
}
public function with(): array
{
return [
'roles' => Role::orderBy('name')->get(),
'organizations' => Organization::orderBy('name')->get(),
];
}
};
?>
<div class="max-w-2xl">
<p class="text-sm text-slate-500">Gestion des utilisateurs</p>
<h1 class="mb-6 text-2xl font-semibold text-slate-900">
{{ $user?->exists ? 'Modifier l\'utilisateur' : 'Nouvel utilisateur' }}
</h1>
<form wire:submit="save" class="space-y-4 rounded border border-slate-200 bg-white p-6">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700">Nom</label>
<input type="text" wire:model="name"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('name') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Email</label>
<input type="email" wire:model="email"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('email') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700">Mot de passe {{ $user?->exists ? '(laisser vide pour ne pas changer)' : '' }}</label>
<input type="password" wire:model="password" autocomplete="new-password"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('password') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Confirmation</label>
<input type="password" wire:model="password_confirmation" autocomplete="new-password"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Rôle</label>
<select wire:model="role"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<option value="">—</option>
@foreach ($roles as $roleOption)
<option value="{{ $roleOption->name }}">{{ $roleOption->name }}</option>
@endforeach
</select>
@error('role') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" wire:model.live="is_msp_staff" class="rounded border-slate-300">
Membre de l'équipe MSP (accès à toutes les organisations)
</label>
@unless ($is_msp_staff)
<div>
<label class="block text-sm font-medium text-slate-700">Organisations accessibles</label>
<div class="mt-2 max-h-48 space-y-1 overflow-y-auto rounded border border-slate-200 p-3">
@forelse ($organizations as $organization)
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" wire:model="organization_ids" value="{{ $organization->id }}" class="rounded border-slate-300">
{{ $organization->name }}
</label>
@empty
<p class="text-sm text-slate-400">Aucune organisation.</p>
@endforelse
</div>
</div>
@endunless
<div class="flex items-center gap-3 pt-2">
<button type="submit"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
Enregistrer
</button>
<a href="{{ route('users.index') }}" class="text-sm text-slate-500 hover:text-slate-900">Annuler</a>
</div>
</form>
</div>
@@ -0,0 +1,111 @@
<?php
use App\Models\User;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
new class extends Component
{
use WithPagination;
#[Url]
public string $search = '';
public function delete(int $userId): void
{
if ($userId === auth()->id()) {
session()->flash('error', 'Vous ne pouvez pas supprimer votre propre compte.');
return;
}
$user = User::findOrFail($userId);
$user->delete();
session()->flash('status', "Utilisateur « {$user->name} » supprimé.");
}
public function with(): array
{
return [
'users' => User::query()
->when($this->search, fn ($query) => $query->where(function ($q) {
$q->where('name', 'like', "%{$this->search}%")
->orWhere('email', 'like', "%{$this->search}%");
}))
->orderBy('name')
->paginate(15),
];
}
};
?>
<div>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-semibold text-slate-900">Utilisateurs</h1>
<a href="{{ route('users.create') }}"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
+ Nouvel utilisateur
</a>
</div>
@if (session('error'))
<div class="mb-4 rounded border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">
{{ session('error') }}
</div>
@endif
<input type="text" wire:model.live.debounce.300ms="search" placeholder="Filtrer par nom ou email..."
class="mb-4 w-full max-w-sm rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<div class="overflow-x-auto rounded border border-slate-200 bg-white">
<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">Email</th>
<th class="px-4 py-3">Rôle</th>
<th class="px-4 py-3">Organisations</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@forelse ($users as $user)
<tr wire:key="user-{{ $user->id }}">
<td class="px-4 py-3">
<a href="{{ route('users.edit', $user) }}" class="font-medium text-slate-900 hover:underline">
{{ $user->name }}
</a>
@if ($user->id === auth()->id())
<span class="ml-2 rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">Vous</span>
@endif
</td>
<td class="px-4 py-3 text-slate-600">{{ $user->email }}</td>
<td class="px-4 py-3 text-slate-600">{{ $user->getRoleNames()->implode(', ') ?: '—' }}</td>
<td class="px-4 py-3 text-slate-600">{{ $user->is_msp_staff ? 'Toutes (staff)' : $user->organizations->count() }}</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
<a href="{{ route('users.edit', $user) }}" class="text-slate-500 hover:text-slate-900">Modifier</a>
@unless ($user->id === auth()->id())
<button type="button"
wire:click="delete({{ $user->id }})"
wire:confirm="Supprimer cet utilisateur ? Cette action est irréversible."
class="ml-3 text-red-500 hover:text-red-700">
Supprimer
</button>
@endunless
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-4 py-6 text-center text-slate-400">Aucun utilisateur pour le moment.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">
{{ $users->links() }}
</div>
</div>
@@ -0,0 +1,176 @@
<?php
use App\Models\Organization;
use App\Models\VirtualMachine;
use Livewire\Component;
new class extends Component
{
public Organization $organization;
public ?VirtualMachine $virtualMachine = null;
public string $name = '';
public string $role = '';
public ?int $memory_gb = null;
public ?int $cpu_count = null;
public string $operating_system = '';
public string $notes = '';
public string $newLogNote = '';
public function mount(Organization $organization, ?VirtualMachine $virtualMachine = null): void
{
$this->organization = $organization;
if ($virtualMachine?->exists) {
$this->virtualMachine = $virtualMachine;
$this->name = $virtualMachine->name;
$this->role = $virtualMachine->role ?? '';
$this->memory_gb = $virtualMachine->memory_gb;
$this->cpu_count = $virtualMachine->cpu_count;
$this->operating_system = $virtualMachine->operating_system ?? '';
$this->notes = $virtualMachine->notes ?? '';
}
}
public function save(): void
{
$validated = $this->validate([
'name' => 'required|string|max:255',
'role' => 'nullable|string|max:255',
'memory_gb' => 'nullable|integer|min:0',
'cpu_count' => 'nullable|integer|min:0',
'operating_system' => 'nullable|string|max:255',
'notes' => 'nullable|string',
]);
if ($this->virtualMachine?->exists) {
$this->virtualMachine->update($validated);
session()->flash('status', "Machine virtuelle \"{$this->virtualMachine->name}\" mise à jour.");
} else {
$this->organization->virtualMachines()->create([
...$validated,
'created_by' => auth()->id(),
]);
session()->flash('status', "Machine virtuelle \"{$this->name}\" créée.");
}
$this->redirectRoute('organizations.virtual-machines.index', $this->organization, navigate: true);
}
public function addLogEntry(): void
{
$this->validate(['newLogNote' => 'required|string']);
$this->virtualMachine->logs()->create([
'note' => $this->newLogNote,
'created_by' => auth()->id(),
]);
$this->newLogNote = '';
}
};
?>
<div class="max-w-6xl">
<p class="text-sm text-slate-500">{{ $organization->name }}</p>
<h1 class="mb-6 text-2xl font-semibold text-slate-900">
{{ $virtualMachine?->exists ? 'Modifier la machine virtuelle' : 'Nouvelle machine virtuelle' }}
</h1>
<div class="grid grid-cols-1 gap-6 {{ $virtualMachine?->exists ? 'lg:grid-cols-2' : '' }}">
<div>
<form wire:submit="save" class="space-y-4 rounded border border-slate-200 bg-white p-6">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700">Nom</label>
<input type="text" wire:model="name"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('name') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Rôle</label>
<input type="text" wire:model="role" placeholder="ex. Contrôleur de domaine"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
</div>
</div>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700">Mémoire (Go)</label>
<input type="number" min="0" wire:model="memory_gb"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('memory_gb') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Processeurs (vCPU)</label>
<input type="number" min="0" wire:model="cpu_count"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('cpu_count') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Système d'exploitation</label>
<input type="text" wire:model="operating_system" placeholder="ex. Ubuntu 22.04"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Notes</label>
<textarea wire:model="notes" rows="3"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500"></textarea>
</div>
<div class="flex items-center gap-3 pt-2">
<button type="submit"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
Enregistrer
</button>
<a href="{{ route('organizations.virtual-machines.index', $organization) }}" class="text-sm text-slate-500 hover:text-slate-900">Annuler</a>
</div>
</form>
</div>
@if ($virtualMachine?->exists)
<div>
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">Journal MCO</h2>
<form wire:submit="addLogEntry" class="mb-4 flex items-start gap-2 rounded border border-slate-200 bg-white p-3">
<textarea wire:model="newLogNote" rows="2" placeholder="Ajouter une entrée au journal (maintenance, incident, mise à jour...)"
class="flex-1 rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500"></textarea>
<button type="submit" class="rounded bg-slate-900 px-3 py-1.5 text-sm text-white hover:bg-slate-700">Ajouter</button>
</form>
@error('newLogNote') <p class="mb-4 text-sm text-red-600">{{ $message }}</p> @enderror
<div class="overflow-hidden rounded border border-slate-200 bg-white">
<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-2">Note</th>
<th class="px-4 py-2">Par</th>
<th class="px-4 py-2">Quand</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@forelse ($virtualMachine->logs as $log)
<tr wire:key="log-{{ $log->id }}">
<td class="px-4 py-2 whitespace-pre-line text-slate-900">{{ $log->note }}</td>
<td class="px-4 py-2 text-slate-600">{{ $log->creator?->name ?? 'système' }}</td>
<td class="px-4 py-2 text-slate-500">{{ $log->created_at->diffForHumans() }}</td>
</tr>
@empty
<tr>
<td colspan="3" class="px-4 py-6 text-center text-slate-400">Aucune entrée pour le moment.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-8">
<livewire:related-items.panel :organization="$organization" :item="$virtualMachine" :key="'related-'.$virtualMachine->id" />
</div>
</div>
@endif
</div>
</div>
@@ -0,0 +1,103 @@
<?php
use App\Models\Organization;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
new class extends Component
{
use WithPagination;
public Organization $organization;
#[Url]
public string $search = '';
public function delete(int $virtualMachineId): void
{
$virtualMachine = $this->organization->virtualMachines()->findOrFail($virtualMachineId);
$virtualMachine->delete();
session()->flash('status', "Machine virtuelle \"{$virtualMachine->name}\" supprimée.");
}
public function with(): array
{
return [
'virtualMachines' => $this->organization->virtualMachines()
->when($this->search, fn ($query) => $query->where(function ($q) {
$q->where('name', 'like', "%{$this->search}%")
->orWhere('role', 'like', "%{$this->search}%")
->orWhere('operating_system', 'like', "%{$this->search}%");
}))
->orderBy('name')
->paginate(15),
];
}
};
?>
<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">Machines virtuelles</h1>
</div>
<a href="{{ route('organizations.virtual-machines.create', $organization) }}"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
+ Nouvelle machine virtuelle
</a>
</div>
<input type="text" wire:model.live.debounce.300ms="search" placeholder="Filtrer par nom, rôle ou OS..."
class="mb-4 w-full max-w-sm rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<div class="overflow-x-auto rounded border border-slate-200 bg-white">
<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">Rôle</th>
<th class="px-4 py-3">Mémoire</th>
<th class="px-4 py-3">CPU</th>
<th class="px-4 py-3">OS</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@forelse ($virtualMachines as $vm)
<tr wire:key="vm-{{ $vm->id }}">
<td class="px-4 py-3">
<a href="{{ route('organizations.virtual-machines.edit', [$organization, $vm]) }}"
class="font-medium text-slate-900 hover:underline">
🖥️ {{ $vm->name }}
</a>
</td>
<td class="px-4 py-3 text-slate-600">{{ $vm->role ?? '—' }}</td>
<td class="px-4 py-3 text-slate-600">{{ $vm->memory_gb ? $vm->memory_gb.' Go' : '—' }}</td>
<td class="px-4 py-3 text-slate-600">{{ $vm->cpu_count ? $vm->cpu_count.' vCPU' : '—' }}</td>
<td class="px-4 py-3 text-slate-600">{{ $vm->operating_system ?? '—' }}</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
<a href="{{ route('organizations.virtual-machines.edit', [$organization, $vm]) }}" class="text-slate-500 hover:text-slate-900">Modifier</a>
<button type="button"
wire:click="delete({{ $vm->id }})"
wire:confirm="Supprimer cette machine virtuelle ? Cette action est irréversible."
class="ml-3 text-red-500 hover:text-red-700">
Supprimer
</button>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-4 py-6 text-center text-slate-400">Aucune machine virtuelle pour le moment.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">
{{ $virtualMachines->links() }}
</div>
</div>
@@ -0,0 +1,3 @@
<x-layouts.app title="Nouveau contact">
<livewire:contacts.form :organization="$organization" />
</x-layouts.app>
+3
View File
@@ -0,0 +1,3 @@
<x-layouts.app title="Modifier le contact">
<livewire:contacts.form :organization="$organization" :contact="$contact" />
</x-layouts.app>
+3
View File
@@ -0,0 +1,3 @@
<x-layouts.app title="Contacts">
<livewire:contacts.index :organization="$organization" />
</x-layouts.app>
@@ -0,0 +1,3 @@
<x-layouts.app title="Nouveau document">
<livewire:documents.form :organization="$organization" :folder="$folder ?? null" />
</x-layouts.app>
+11
View File
@@ -0,0 +1,11 @@
<x-layouts.app title="Modifier le document">
@if ($document->isOffice())
<livewire:documents.office-editor :organization="$organization" :document="$document" />
@elseif ($document->isMarkdown())
<livewire:documents.markdown-editor :organization="$organization" :document="$document" />
@elseif ($document->isUpload())
<livewire:documents.upload-viewer :organization="$organization" :document="$document" />
@else
<livewire:documents.form :organization="$organization" :document="$document" />
@endif
</x-layouts.app>
@@ -0,0 +1,3 @@
<x-layouts.app title="Documents">
<livewire:documents.index :organization="$organization" />
</x-layouts.app>
+3
View File
@@ -0,0 +1,3 @@
<x-layouts.app title="Nouveau domaine">
<livewire:domains.form :organization="$organization" />
</x-layouts.app>
+3
View File
@@ -0,0 +1,3 @@
<x-layouts.app title="Modifier le domaine">
<livewire:domains.form :organization="$organization" :domain="$domain" />
</x-layouts.app>
+3
View File
@@ -0,0 +1,3 @@
<x-layouts.app title="Domaines">
<livewire:domains.index :organization="$organization" />
</x-layouts.app>
@@ -0,0 +1,3 @@
<x-layouts.app title="Nouveau site">
<livewire:locations.form :organization="$organization" />
</x-layouts.app>
+3
View File
@@ -0,0 +1,3 @@
<x-layouts.app title="Modifier le site">
<livewire:locations.form :organization="$organization" :location="$location" />
</x-layouts.app>
@@ -0,0 +1,3 @@
<x-layouts.app title="Sites">
<livewire:locations.index :organization="$organization" />
</x-layouts.app>
@@ -0,0 +1,3 @@
<x-layouts.app title="Nouvelle organisation">
<livewire:organizations.form />
</x-layouts.app>
@@ -0,0 +1,3 @@
<x-layouts.app :title="$organization->name">
<livewire:organizations.dashboard :organization="$organization" />
</x-layouts.app>
@@ -0,0 +1,3 @@
<x-layouts.app title="Modifier l'organisation">
<livewire:organizations.form :organization="$organization" />
</x-layouts.app>
@@ -0,0 +1,3 @@
<x-layouts.app title="Organisations">
<livewire:organizations.index />
</x-layouts.app>
@@ -0,0 +1,3 @@
<x-layouts.app title="Nouveau mot de passe">
<livewire:passwords.form :organization="$organization" />
</x-layouts.app>
+3
View File
@@ -0,0 +1,3 @@
<x-layouts.app title="Modifier le mot de passe">
<livewire:passwords.form :organization="$organization" :password="$password" />
</x-layouts.app>
@@ -0,0 +1,3 @@
<x-layouts.app title="Mots de passe">
<livewire:passwords.index :organization="$organization" />
</x-layouts.app>
@@ -0,0 +1,3 @@
<x-layouts.app title="Nouveau raccourci">
<livewire:shortcuts.form :organization="$organization" :global="$global ?? false" />
</x-layouts.app>
+3
View File
@@ -0,0 +1,3 @@
<x-layouts.app title="Modifier le raccourci">
<livewire:shortcuts.form :organization="$organization" :shortcut="$shortcut" />
</x-layouts.app>
@@ -0,0 +1,3 @@
<x-layouts.app title="Raccourcis">
<livewire:shortcuts.index :organization="$organization" />
</x-layouts.app>
+3
View File
@@ -0,0 +1,3 @@
<x-layouts.app title="Nouvelle tâche">
<livewire:todos.form :organization="$organization" />
</x-layouts.app>
+3
View File
@@ -0,0 +1,3 @@
<x-layouts.app title="Modifier la tâche">
<livewire:todos.form :organization="$organization" :todo="$todo" />
</x-layouts.app>
+3
View File
@@ -0,0 +1,3 @@
<x-layouts.app title="ToDo">
<livewire:todos.index :organization="$organization" />
</x-layouts.app>
+3
View File
@@ -0,0 +1,3 @@
<x-layouts.app title="Nouvel utilisateur">
<livewire:users.form />
</x-layouts.app>
+3
View File
@@ -0,0 +1,3 @@
<x-layouts.app title="Modifier l'utilisateur">
<livewire:users.form :user="$user" />
</x-layouts.app>
+3
View File
@@ -0,0 +1,3 @@
<x-layouts.app title="Utilisateurs">
<livewire:users.index />
</x-layouts.app>
@@ -0,0 +1,3 @@
<x-layouts.app title="Nouvelle machine virtuelle">
<livewire:virtual-machines.form :organization="$organization" />
</x-layouts.app>
@@ -0,0 +1,3 @@
<x-layouts.app title="Modifier la machine virtuelle">
<livewire:virtual-machines.form :organization="$organization" :virtual-machine="$virtualMachine" />
</x-layouts.app>
@@ -0,0 +1,3 @@
<x-layouts.app title="Machines virtuelles">
<livewire:virtual-machines.index :organization="$organization" />
</x-layouts.app>
File diff suppressed because one or more lines are too long