Initial commit: CABM-IT (ItGlue-style MSP documentation platform)
Laravel 13 + Livewire/Volt app for managing client organizations, documents, passwords, flexible assets, virtual machines, contacts, domains, todos, shortcuts, and role-based user administration.
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
//
|
||||
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
<x-layouts.app title="Nouveau {{ $type->name }}">
|
||||
<livewire:assets.form :organization="$organization" :type="$type" />
|
||||
</x-layouts.app>
|
||||
@@ -0,0 +1,3 @@
|
||||
<x-layouts.app title="Modifier {{ $type->name }}">
|
||||
<livewire:assets.form :organization="$organization" :type="$type" :asset="$asset" />
|
||||
</x-layouts.app>
|
||||
@@ -0,0 +1,3 @@
|
||||
<x-layouts.app title="{{ $type->name }}">
|
||||
<livewire:assets.index :organization="$organization" :type="$type" />
|
||||
</x-layouts.app>
|
||||
@@ -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 →</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>
|
||||
@@ -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">↑</button>
|
||||
<button type="button" wire:click="moveFieldDown({{ $index }})" class="text-xs text-slate-400 hover:text-slate-700">↓</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 }} · {{ $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></div>
|
||||
<div>- élément de liste</div>
|
||||
<div>1. élément numéroté</div>
|
||||
<div>> 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 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 }} · 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">
|
||||
← 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 →
|
||||
</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 →
|
||||
</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)
|
||||
· {{ $todo->assignee->name }}
|
||||
@endif
|
||||
@if ($todo->due_date)
|
||||
· <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' }} · {{ $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 →
|
||||
</a>
|
||||
<a href="{{ route('organizations.passwords.index', $organization) }}" class="hover:text-slate-900">
|
||||
Voir les mots de passe →
|
||||
</a>
|
||||
<a href="{{ route('organizations.assets.picker', $organization) }}" class="hover:text-slate-900">
|
||||
Voir les actifs →
|
||||
</a>
|
||||
<a href="{{ route('organizations.contacts.index', $organization) }}" class="hover:text-slate-900">
|
||||
Voir les contacts →
|
||||
</a>
|
||||
<a href="{{ route('organizations.locations.index', $organization) }}" class="hover:text-slate-900">
|
||||
Voir les sites →
|
||||
</a>
|
||||
<a href="{{ route('organizations.documents.index', $organization) }}" class="hover:text-slate-900">
|
||||
Voir les documents →
|
||||
</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 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>
|
||||
@@ -0,0 +1,3 @@
|
||||
<x-layouts.app title="Modifier le contact">
|
||||
<livewire:contacts.form :organization="$organization" :contact="$contact" />
|
||||
</x-layouts.app>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
<x-layouts.app title="Nouveau domaine">
|
||||
<livewire:domains.form :organization="$organization" />
|
||||
</x-layouts.app>
|
||||
@@ -0,0 +1,3 @@
|
||||
<x-layouts.app title="Modifier le domaine">
|
||||
<livewire:domains.form :organization="$organization" :domain="$domain" />
|
||||
</x-layouts.app>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
<x-layouts.app title="Nouvelle tâche">
|
||||
<livewire:todos.form :organization="$organization" />
|
||||
</x-layouts.app>
|
||||
@@ -0,0 +1,3 @@
|
||||
<x-layouts.app title="Modifier la tâche">
|
||||
<livewire:todos.form :organization="$organization" :todo="$todo" />
|
||||
</x-layouts.app>
|
||||
@@ -0,0 +1,3 @@
|
||||
<x-layouts.app title="ToDo">
|
||||
<livewire:todos.index :organization="$organization" />
|
||||
</x-layouts.app>
|
||||
@@ -0,0 +1,3 @@
|
||||
<x-layouts.app title="Nouvel utilisateur">
|
||||
<livewire:users.form />
|
||||
</x-layouts.app>
|
||||
@@ -0,0 +1,3 @@
|
||||
<x-layouts.app title="Modifier l'utilisateur">
|
||||
<livewire:users.form :user="$user" />
|
||||
</x-layouts.app>
|
||||
@@ -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
Reference in New Issue
Block a user