diff --git a/app/Models/Document.php b/app/Models/Document.php
index b80bba1..4fced43 100644
--- a/app/Models/Document.php
+++ b/app/Models/Document.php
@@ -35,6 +35,11 @@ class Document extends Model implements Linkable
return $this->type === 'upload';
}
+ public function isDiagram(): bool
+ {
+ return $this->type === 'diagram';
+ }
+
public function renderedMarkdown(): string
{
return (new CommonMarkConverter())->convert($this->content ?? '')->getContent();
diff --git a/app/Models/Organization.php b/app/Models/Organization.php
index 8433cb8..ad4e755 100644
--- a/app/Models/Organization.php
+++ b/app/Models/Organization.php
@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;
-#[Fillable(['name', 'slug', 'org_type', 'status', 'description', 'allowed_ip_ranges', 'logo_path', 'glpi_entity_id'])]
+#[Fillable(['name', 'slug', 'org_type', 'status', 'description', 'allowed_ip_ranges', 'logo_path', 'glpi_entity_id', 'technician_user_id', 'isp_name', 'public_ips'])]
class Organization extends Model
{
use SoftDeletes;
@@ -17,6 +17,7 @@ class Organization extends Model
{
return [
'allowed_ip_ranges' => 'array',
+ 'public_ips' => 'array',
];
}
@@ -86,4 +87,16 @@ class Organization extends Model
{
return $this->hasMany(Shortcut::class);
}
+
+ public function technician()
+ {
+ return $this->belongsTo(User::class, 'technician_user_id');
+ }
+
+ public function providers()
+ {
+ return $this->belongsToMany(Provider::class, 'organization_provider')
+ ->withPivot('id', 'label')
+ ->withTimestamps();
+ }
}
diff --git a/app/Models/Provider.php b/app/Models/Provider.php
new file mode 100644
index 0000000..a6a883c
--- /dev/null
+++ b/app/Models/Provider.php
@@ -0,0 +1,34 @@
+ $this->logo_path ? Storage::disk('public')->url($this->logo_path) : asset('images/default-organization-logo.svg')
+ );
+ }
+
+ public function organizations()
+ {
+ return $this->belongsToMany(Organization::class, 'organization_provider')
+ ->withPivot('id', 'label')
+ ->withTimestamps();
+ }
+
+ public function logEntries()
+ {
+ return $this->hasMany(ProviderLogEntry::class)->orderByDesc('created_at');
+ }
+}
diff --git a/app/Models/ProviderLogEntry.php b/app/Models/ProviderLogEntry.php
new file mode 100644
index 0000000..a1c2425
--- /dev/null
+++ b/app/Models/ProviderLogEntry.php
@@ -0,0 +1,34 @@
+ 'Échange (mail / appel)',
+ 'observation' => 'Observation',
+ 'incident' => 'Incident',
+ 'info' => 'Information générale',
+ ];
+
+ protected $guarded = [];
+
+ public function provider()
+ {
+ return $this->belongsTo(Provider::class);
+ }
+
+ public function creator()
+ {
+ return $this->belongsTo(User::class, 'created_by');
+ }
+
+ public function categoryLabel(): string
+ {
+ return self::CATEGORIES[$this->category] ?? $this->category;
+ }
+}
diff --git a/app/Services/SettingsRegistry.php b/app/Services/SettingsRegistry.php
index f62b47f..ad262c3 100644
--- a/app/Services/SettingsRegistry.php
+++ b/app/Services/SettingsRegistry.php
@@ -36,6 +36,9 @@ class SettingsRegistry
'ONLYOFFICE_CALLBACK_BASE_URL' => ['config' => 'onlyoffice.callback_base_url', 'label' => 'URL de rappel (callback)'],
'ONLYOFFICE_JWT_SECRET' => ['config' => 'onlyoffice.jwt_secret', 'label' => 'Secret JWT'],
],
+ 'Draw.io' => [
+ 'DRAWIO_URL' => ['config' => 'drawio.url', 'label' => "URL de l'éditeur draw.io"],
+ ],
'GLPI' => [
'GLPI_API_URL' => ['config' => 'glpi.api_url', 'label' => "URL de l'API"],
'GLPI_APP_TOKEN' => ['config' => 'glpi.app_token', 'label' => 'Jeton application'],
diff --git a/config/drawio.php b/config/drawio.php
new file mode 100644
index 0000000..acf0003
--- /dev/null
+++ b/config/drawio.php
@@ -0,0 +1,9 @@
+ env('DRAWIO_URL', 'https://embed.diagrams.net'),
+];
diff --git a/database/migrations/2026_08_21_090000_add_identity_card_fields_to_organizations_table.php b/database/migrations/2026_08_21_090000_add_identity_card_fields_to_organizations_table.php
new file mode 100644
index 0000000..b66c649
--- /dev/null
+++ b/database/migrations/2026_08_21_090000_add_identity_card_fields_to_organizations_table.php
@@ -0,0 +1,25 @@
+foreignId('technician_user_id')->nullable()->after('glpi_entity_id')->constrained('users')->nullOnDelete();
+ $table->string('isp_name')->nullable()->after('technician_user_id');
+ $table->json('public_ips')->nullable()->after('isp_name');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('organizations', function (Blueprint $table) {
+ $table->dropConstrainedForeignId('technician_user_id');
+ $table->dropColumn(['isp_name', 'public_ips']);
+ });
+ }
+};
diff --git a/database/migrations/2026_08_21_090001_create_providers_table.php b/database/migrations/2026_08_21_090001_create_providers_table.php
new file mode 100644
index 0000000..9cff5fc
--- /dev/null
+++ b/database/migrations/2026_08_21_090001_create_providers_table.php
@@ -0,0 +1,28 @@
+id();
+ $table->string('name');
+ $table->string('logo_path')->nullable();
+ $table->string('phone')->nullable();
+ $table->string('email')->nullable();
+ $table->string('website')->nullable();
+ $table->text('notes')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('providers');
+ }
+};
diff --git a/database/migrations/2026_08_21_090002_create_organization_provider_table.php b/database/migrations/2026_08_21_090002_create_organization_provider_table.php
new file mode 100644
index 0000000..b5867c4
--- /dev/null
+++ b/database/migrations/2026_08_21_090002_create_organization_provider_table.php
@@ -0,0 +1,26 @@
+id();
+ $table->foreignId('organization_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('provider_id')->constrained()->cascadeOnDelete();
+ $table->string('label')->nullable();
+ $table->timestamps();
+
+ $table->unique(['organization_id', 'provider_id', 'label'], 'organization_provider_unique');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('organization_provider');
+ }
+};
diff --git a/database/migrations/2026_08_21_090003_create_provider_log_entries_table.php b/database/migrations/2026_08_21_090003_create_provider_log_entries_table.php
new file mode 100644
index 0000000..8e70926
--- /dev/null
+++ b/database/migrations/2026_08_21_090003_create_provider_log_entries_table.php
@@ -0,0 +1,25 @@
+id();
+ $table->foreignId('provider_id')->constrained()->cascadeOnDelete();
+ $table->string('category');
+ $table->text('note');
+ $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamp('created_at')->useCurrent();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('provider_log_entries');
+ }
+};
diff --git a/resources/views/components/documents/⚡diagram-editor.blade.php b/resources/views/components/documents/⚡diagram-editor.blade.php
new file mode 100644
index 0000000..2daf51d
--- /dev/null
+++ b/resources/views/components/documents/⚡diagram-editor.blade.php
@@ -0,0 +1,130 @@
+isDiagram(), 404);
+
+ $this->organization = $organization;
+ $this->document = $document;
+ $this->backRoute = $backRoute;
+ $this->name = $document->name;
+ }
+
+ public function rename(): void
+ {
+ $validated = $this->validate(['name' => 'required|string|max:255']);
+
+ $this->document->update($validated);
+ $this->renaming = false;
+ }
+
+ public function saveDiagram(string $xml): void
+ {
+ $contentChanged = $this->document->content !== $xml;
+
+ $this->document->update(['content' => $xml]);
+
+ if ($contentChanged) {
+ $this->document->increment('current_version');
+ $this->document->versions()->create([
+ 'version' => $this->document->current_version,
+ 'content' => $xml,
+ 'created_by' => auth()->id(),
+ ]);
+ }
+ }
+};
+?>
+
+
+
+
Infrastructure
+
+
+
+ @if (session('status'))
+
+ {{ session('status') }}
+
+ @endif
+
+ @if ($showNewDiagram)
+
+ @endif
+
+
+
+
+
+ | Nom |
+ Créé par |
+ Mis à jour |
+ |
+
+
+
+ @forelse ($diagrams as $diagram)
+
+ |
+
+ 🗺️ {{ $diagram->name }}
+
+ |
+ {{ $diagram->creator?->name ?? '—' }} |
+ {{ $diagram->updated_at->diffForHumans() }} |
+
+ Ouvrir
+
+ |
+
+ @empty
+
+ | Aucun schéma pour le moment. |
+
+ @endforelse
+
+
+
+
diff --git a/resources/views/components/layouts/app.blade.php b/resources/views/components/layouts/app.blade.php
index 065a29d..a3aad4f 100644
--- a/resources/views/components/layouts/app.blade.php
+++ b/resources/views/components/layouts/app.blade.php
@@ -19,6 +19,10 @@
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.*') ? 'bg-slate-800 text-white' : '' }}">
Organisations
+
Machines virtuelles
diff --git a/resources/views/components/organizations/⚡identity-card.blade.php b/resources/views/components/organizations/⚡identity-card.blade.php
new file mode 100644
index 0000000..fd52c39
--- /dev/null
+++ b/resources/views/components/organizations/⚡identity-card.blade.php
@@ -0,0 +1,198 @@
+organization = $organization;
+ $this->technicianUserId = $organization->technician_user_id;
+ $this->ispName = $organization->isp_name ?? '';
+ $this->publicIpsText = implode("\n", $organization->public_ips ?? []);
+ }
+
+ public function save(): void
+ {
+ $validated = $this->validate([
+ 'technicianUserId' => 'nullable|exists:users,id',
+ 'ispName' => 'nullable|string|max:255',
+ 'publicIpsText' => 'nullable|string',
+ ]);
+
+ $ips = collect(preg_split('/[\s,]+/', (string) $validated['publicIpsText'], -1, PREG_SPLIT_NO_EMPTY))
+ ->unique()
+ ->values();
+
+ if ($ips->contains(fn ($ip) => filter_var($ip, FILTER_VALIDATE_IP) === false)) {
+ $this->addError('publicIpsText', 'Une ou plusieurs adresses IP sont invalides.');
+
+ return;
+ }
+
+ $this->organization->update([
+ 'technician_user_id' => $validated['technicianUserId'],
+ 'isp_name' => $validated['ispName'] !== '' ? $validated['ispName'] : null,
+ 'public_ips' => $ips->all(),
+ ]);
+
+ session()->flash('status', "Fiche d'identité mise à jour.");
+ }
+
+ public function addProvider(): void
+ {
+ $validated = $this->validate([
+ 'newProviderId' => 'required|exists:providers,id',
+ 'newProviderLabel' => 'nullable|string|max:255',
+ ]);
+
+ $label = $validated['newProviderLabel'] ?: null;
+
+ $alreadyLinked = $this->organization->providers()
+ ->wherePivot('provider_id', $validated['newProviderId'])
+ ->wherePivot('label', '=', $label)
+ ->exists();
+
+ if ($alreadyLinked) {
+ $this->addError('newProviderId', 'Ce prestataire est déjà lié avec ce libellé.');
+
+ return;
+ }
+
+ $this->organization->providers()->attach($validated['newProviderId'], ['label' => $label]);
+
+ $this->reset(['newProviderId', 'newProviderLabel']);
+ }
+
+ public function removeProvider(int $pivotId): void
+ {
+ DB::table('organization_provider')
+ ->where('id', $pivotId)
+ ->where('organization_id', $this->organization->id)
+ ->delete();
+ }
+
+ public function with(): array
+ {
+ return [
+ 'technicians' => User::role(['Administrateur', 'Technicien'])->orderBy('name')->get(),
+ 'availableProviders' => Provider::orderBy('name')->get(),
+ 'providers' => $this->organization->providers()->orderBy('name')->get(),
+ ];
+ }
+};
+?>
+
+
+
{{ $organization->name }}
+
Fiche d'identité informatique
+
+ @if (session('status'))
+
+ {{ session('status') }}
+
+ @endif
+
+
+
+
+
Prestataires
+
+
+ @forelse ($providers as $provider)
+ -
+
+
+
+
+ @empty
+ - Aucun prestataire lié.
+ @endforelse
+
+
+
+
+ @if ($availableProviders->isEmpty())
+
+ Aucun prestataire n'existe encore.
+ En créer un.
+
+ @endif
+
+
diff --git a/resources/views/components/providers/⚡form.blade.php b/resources/views/components/providers/⚡form.blade.php
new file mode 100644
index 0000000..0d6ca00
--- /dev/null
+++ b/resources/views/components/providers/⚡form.blade.php
@@ -0,0 +1,217 @@
+exists) {
+ $this->provider = $provider;
+ $this->name = $provider->name;
+ $this->phone = $provider->phone ?? '';
+ $this->email = $provider->email ?? '';
+ $this->website = $provider->website ?? '';
+ $this->notes = $provider->notes ?? '';
+ }
+ }
+
+ public function save(): void
+ {
+ $validated = $this->validate([
+ 'name' => 'required|string|max:255',
+ 'phone' => 'nullable|string|max:50',
+ 'email' => 'nullable|email|max:255',
+ 'website' => 'nullable|string|max:255',
+ 'notes' => 'nullable|string',
+ 'logo' => 'nullable|image|max:2048',
+ ]);
+
+ unset($validated['logo']);
+
+ foreach (['phone', 'email', 'website', 'notes'] as $field) {
+ $validated[$field] = $validated[$field] !== '' ? $validated[$field] : null;
+ }
+
+ if ($this->provider?->exists) {
+ $this->provider->update($validated);
+ } else {
+ $this->provider = Provider::create($validated);
+ }
+
+ if ($this->logo) {
+ if ($this->provider->logo_path) {
+ Storage::disk('public')->delete($this->provider->logo_path);
+ }
+
+ $path = $this->logo->store("providers/{$this->provider->id}", 'public');
+ $this->provider->update(['logo_path' => $path]);
+ }
+
+ session()->flash('status', "Prestataire « {$this->provider->name} » enregistré.");
+
+ $this->redirectRoute('providers.index', navigate: true);
+ }
+
+ public function addLogEntry(): void
+ {
+ $this->validate([
+ 'newLogCategory' => 'required|in:'.implode(',', array_keys(ProviderLogEntry::CATEGORIES)),
+ 'newLogNote' => 'required|string',
+ ]);
+
+ $this->provider->logEntries()->create([
+ 'category' => $this->newLogCategory,
+ 'note' => $this->newLogNote,
+ 'created_by' => auth()->id(),
+ ]);
+
+ $this->newLogNote = '';
+ }
+
+ public function deleteLogEntry(int $entryId): void
+ {
+ $this->provider->logEntries()->findOrFail($entryId)->delete();
+ }
+
+ public function with(): array
+ {
+ return [
+ 'logEntries' => $this->provider?->exists ? $this->provider->logEntries()->with('creator')->limit(30)->get() : collect(),
+ 'categories' => ProviderLogEntry::CATEGORIES,
+ ];
+ }
+};
+?>
+
+
+
+ {{ $provider?->exists ? 'Modifier le prestataire' : 'Nouveau prestataire' }}
+
+
+ @if (session('status'))
+
+ {{ session('status') }}
+
+ @endif
+
+
+
+ @if ($provider?->exists)
+
+
Journal de suivi
+
+
+
+
+ @forelse ($logEntries as $entry)
+
+
+
+ {{ $entry->categoryLabel() }}
+
+
{{ $entry->note }}
+
{{ $entry->creator?->name ?? 'système' }} · {{ $entry->created_at->diffForHumans() }}
+
+
+
+ @empty
+
Aucune entrée dans le journal pour le moment.
+ @endforelse
+
+
+ @endif
+
diff --git a/resources/views/components/providers/⚡index.blade.php b/resources/views/components/providers/⚡index.blade.php
new file mode 100644
index 0000000..f6f59e3
--- /dev/null
+++ b/resources/views/components/providers/⚡index.blade.php
@@ -0,0 +1,100 @@
+delete();
+
+ session()->flash('status', "Prestataire « {$provider->name} » supprimé.");
+ }
+
+ public function with(): array
+ {
+ return [
+ 'providers' => Provider::query()
+ ->when($this->search, fn ($query) => $query->where('name', 'like', "%{$this->search}%"))
+ ->orderBy('name')
+ ->paginate(15),
+ ];
+ }
+};
+?>
+
+
+
+
+ @if (session('status'))
+
+ {{ session('status') }}
+
+ @endif
+
+
+
+
+
+
+
+ |
+ Nom |
+ Téléphone |
+ Email |
+ |
+
+
+
+ @forelse ($providers as $provider)
+
+
+
+ |
+
+
+ {{ $provider->name }}
+
+ |
+ {{ $provider->phone ?? '—' }} |
+ {{ $provider->email ?? '—' }} |
+
+ Modifier
+
+ |
+
+ @empty
+
+ | Aucun prestataire pour le moment. |
+
+ @endforelse
+
+
+
+
+
+ {{ $providers->links() }}
+
+
diff --git a/resources/views/documents/edit.blade.php b/resources/views/documents/edit.blade.php
index f148743..5901cef 100644
--- a/resources/views/documents/edit.blade.php
+++ b/resources/views/documents/edit.blade.php
@@ -5,6 +5,8 @@
@elseif ($document->isUpload())
+ @elseif ($document->isDiagram())
+
@else
@endif
diff --git a/resources/views/infrastructure/edit.blade.php b/resources/views/infrastructure/edit.blade.php
new file mode 100644
index 0000000..3faa34c
--- /dev/null
+++ b/resources/views/infrastructure/edit.blade.php
@@ -0,0 +1,3 @@
+
+
+
diff --git a/resources/views/infrastructure/index.blade.php b/resources/views/infrastructure/index.blade.php
new file mode 100644
index 0000000..6bf3f97
--- /dev/null
+++ b/resources/views/infrastructure/index.blade.php
@@ -0,0 +1,3 @@
+
+
+
diff --git a/resources/views/organizations/identity-card.blade.php b/resources/views/organizations/identity-card.blade.php
new file mode 100644
index 0000000..f99ac3e
--- /dev/null
+++ b/resources/views/organizations/identity-card.blade.php
@@ -0,0 +1,3 @@
+
+
+
diff --git a/resources/views/providers/create.blade.php b/resources/views/providers/create.blade.php
new file mode 100644
index 0000000..13767a0
--- /dev/null
+++ b/resources/views/providers/create.blade.php
@@ -0,0 +1,3 @@
+
+
+
diff --git a/resources/views/providers/edit.blade.php b/resources/views/providers/edit.blade.php
new file mode 100644
index 0000000..a3fb863
--- /dev/null
+++ b/resources/views/providers/edit.blade.php
@@ -0,0 +1,3 @@
+
+
+
diff --git a/resources/views/providers/index.blade.php b/resources/views/providers/index.blade.php
new file mode 100644
index 0000000..aba35da
--- /dev/null
+++ b/resources/views/providers/index.blade.php
@@ -0,0 +1,3 @@
+
+
+
diff --git a/routes/web.php b/routes/web.php
index 3ea56d3..e03f75e 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -8,6 +8,7 @@ use App\Models\FlexibleAssetType;
use App\Models\Location;
use App\Models\Organization;
use App\Models\Password;
+use App\Models\Provider;
use App\Models\Shortcut;
use App\Models\Todo;
use App\Models\User;
@@ -23,7 +24,14 @@ Route::middleware(['auth'])->group(function () {
Route::get('/organizations', fn () => view('organizations.index'))->name('organizations.index');
Route::get('/organizations/create', fn () => view('organizations.create'))->name('organizations.create');
Route::get('/organizations/{organization}/edit', fn (Organization $organization) => view('organizations.edit', ['organization' => $organization]))->name('organizations.edit');
+
+ Route::prefix('/providers')->name('providers.')->group(function () {
+ Route::get('/', fn () => view('providers.index'))->name('index');
+ Route::get('/create', fn () => view('providers.create'))->name('create');
+ Route::get('/{provider}/edit', fn (Provider $provider) => view('providers.edit', ['provider' => $provider]))->name('edit');
+ });
Route::get('/organizations/{organization}/dashboard', fn (Organization $organization) => view('organizations.dashboard', ['organization' => $organization]))->name('organizations.dashboard');
+ Route::get('/organizations/{organization}/identity-card', fn (Organization $organization) => view('organizations.identity-card', ['organization' => $organization]))->name('organizations.identity-card');
Route::prefix('/organizations/{organization}/passwords')->name('organizations.passwords.')->group(function () {
Route::get('/', fn (Organization $organization) => view('passwords.index', ['organization' => $organization]))->name('index');
@@ -74,6 +82,14 @@ Route::middleware(['auth'])->group(function () {
Route::get('/{document}/download', DocumentDownloadController::class)->name('download');
});
+ Route::prefix('/organizations/{organization}/infrastructure')->name('organizations.infrastructure.')->group(function () {
+ Route::get('/', fn (Organization $organization) => view('infrastructure.index', ['organization' => $organization]))->name('index');
+ Route::get('/{document}/edit', fn (Organization $organization, Document $document) => view('infrastructure.edit', [
+ 'organization' => $organization,
+ 'document' => $document,
+ ]))->name('edit');
+ });
+
Route::prefix('/organizations/{organization}/virtual-machines')->name('organizations.virtual-machines.')->group(function () {
Route::get('/', fn (Organization $organization) => view('virtual-machines.index', ['organization' => $organization]))->name('index');
Route::get('/create', fn (Organization $organization) => view('virtual-machines.create', ['organization' => $organization]))->name('create');
diff --git a/tests/Feature/InfrastructureTest.php b/tests/Feature/InfrastructureTest.php
new file mode 100644
index 0000000..8026d66
--- /dev/null
+++ b/tests/Feature/InfrastructureTest.php
@@ -0,0 +1,75 @@
+create();
+ $org = Organization::create(['name' => 'Test Commune', 'slug' => 'test-commune', 'org_type' => 'client']);
+
+ $response = $this->actingAs($user)->get(route('organizations.infrastructure.index', $org));
+
+ $response->assertOk();
+ $response->assertSee('Infrastructure');
+ }
+
+ public function test_can_create_a_diagram_and_it_opens_in_the_editor(): void
+ {
+ $user = User::factory()->create();
+ $org = Organization::create(['name' => 'Test Commune', 'slug' => 'test-commune', 'org_type' => 'client']);
+
+ $this->actingAs($user);
+
+ Livewire::test('infrastructure.index', ['organization' => $org])
+ ->set('newDiagramName', 'Schéma réseau - Mairie')
+ ->call('createDiagram')
+ ->assertHasNoErrors();
+
+ $this->assertDatabaseHas('documents', [
+ 'organization_id' => $org->id,
+ 'name' => 'Schéma réseau - Mairie',
+ 'type' => 'diagram',
+ ]);
+
+ $document = $org->documents()->where('type', 'diagram')->firstOrFail();
+
+ $response = $this->get(route('organizations.infrastructure.edit', [$org, $document]));
+ $response->assertOk();
+ $response->assertSee('Schéma réseau - Mairie');
+ }
+
+ public function test_saving_a_diagram_creates_a_new_version_only_when_content_changed(): void
+ {
+ $user = User::factory()->create();
+ $org = Organization::create(['name' => 'Test Commune', 'slug' => 'test-commune', 'org_type' => 'client']);
+
+ $document = $org->documents()->create([
+ 'name' => 'Schéma test',
+ 'type' => 'diagram',
+ 'content' => 'old',
+ 'current_version' => 1,
+ 'created_by' => $user->id,
+ ]);
+ $document->versions()->create(['version' => 1, 'content' => 'old', 'created_by' => $user->id]);
+
+ $this->actingAs($user);
+
+ Livewire::test('documents.diagram-editor', ['organization' => $org, 'document' => $document])
+ ->call('saveDiagram', 'new');
+
+ $document->refresh();
+ $this->assertSame('new', $document->content);
+ $this->assertSame(2, $document->current_version);
+ $this->assertCount(2, $document->versions);
+ }
+}
diff --git a/tests/Feature/OrganizationIdentityCardTest.php b/tests/Feature/OrganizationIdentityCardTest.php
new file mode 100644
index 0000000..b32a1e6
--- /dev/null
+++ b/tests/Feature/OrganizationIdentityCardTest.php
@@ -0,0 +1,157 @@
+seed(\Database\Seeders\RolesAndPermissionsSeeder::class);
+
+ $user = User::factory()->create();
+ $org = Organization::create(['name' => 'Test Commune', 'slug' => 'test-commune', 'org_type' => 'client']);
+
+ $response = $this->actingAs($user)->get(route('organizations.identity-card', $org));
+
+ $response->assertOk();
+ $response->assertSee("Fiche d'identité informatique");
+ }
+
+ public function test_can_save_identity_fields_and_link_a_provider(): void
+ {
+ $this->seed(\Database\Seeders\RolesAndPermissionsSeeder::class);
+
+ $user = User::factory()->create();
+ $technician = User::factory()->create();
+ $technician->assignRole('Technicien');
+
+ $org = Organization::create(['name' => 'Test Commune', 'slug' => 'test-commune', 'org_type' => 'client']);
+ $provider = Provider::create(['name' => 'Orange Pro']);
+
+ $this->actingAs($user);
+
+ Livewire::test('organizations.identity-card', ['organization' => $org])
+ ->set('technicianUserId', $technician->id)
+ ->set('ispName', 'Orange Pro')
+ ->set('publicIpsText', "203.0.113.10\n203.0.113.11")
+ ->call('save')
+ ->assertHasNoErrors();
+
+ $org->refresh();
+ $this->assertSame($technician->id, $org->technician_user_id);
+ $this->assertSame('Orange Pro', $org->isp_name);
+ $this->assertSame(['203.0.113.10', '203.0.113.11'], $org->public_ips);
+
+ Livewire::test('organizations.identity-card', ['organization' => $org])
+ ->set('newProviderId', $provider->id)
+ ->set('newProviderLabel', 'Téléphonie')
+ ->call('addProvider')
+ ->assertHasNoErrors();
+
+ $this->assertCount(1, $org->providers()->get());
+
+ $pivotId = $org->providers()->first()->pivot->id;
+
+ Livewire::test('organizations.identity-card', ['organization' => $org])
+ ->call('removeProvider', $pivotId);
+
+ $this->assertCount(0, $org->providers()->get());
+ }
+
+ public function test_clearing_the_technician_select_nulls_it_out(): void
+ {
+ $this->seed(\Database\Seeders\RolesAndPermissionsSeeder::class);
+
+ $user = User::factory()->create();
+ $technician = User::factory()->create();
+ $technician->assignRole('Technicien');
+
+ $org = Organization::create([
+ 'name' => 'Test Commune',
+ 'slug' => 'test-commune',
+ 'org_type' => 'client',
+ 'technician_user_id' => $technician->id,
+ ]);
+
+ $this->actingAs($user);
+
+ Livewire::test('organizations.identity-card', ['organization' => $org])
+ ->assertSet('technicianUserId', $technician->id)
+ ->set('technicianUserId', '')
+ ->call('save')
+ ->assertHasNoErrors();
+
+ $this->assertNull($org->refresh()->technician_user_id);
+ }
+
+ public function test_duplicate_provider_link_with_same_label_is_rejected(): void
+ {
+ $this->seed(\Database\Seeders\RolesAndPermissionsSeeder::class);
+
+ $user = User::factory()->create();
+ $org = Organization::create(['name' => 'Test Commune', 'slug' => 'test-commune', 'org_type' => 'client']);
+ $provider = Provider::create(['name' => 'Orange Pro']);
+
+ $this->actingAs($user);
+
+ $org->providers()->attach($provider->id, ['label' => 'Téléphonie']);
+
+ Livewire::test('organizations.identity-card', ['organization' => $org])
+ ->set('newProviderId', $provider->id)
+ ->set('newProviderLabel', 'Téléphonie')
+ ->call('addProvider')
+ ->assertHasErrors('newProviderId');
+
+ $this->assertCount(1, $org->providers()->get());
+ }
+
+ public function test_duplicate_provider_link_without_a_label_is_rejected(): void
+ {
+ $this->seed(\Database\Seeders\RolesAndPermissionsSeeder::class);
+
+ $user = User::factory()->create();
+ $org = Organization::create(['name' => 'Test Commune', 'slug' => 'test-commune', 'org_type' => 'client']);
+ $provider = Provider::create(['name' => 'Orange Pro']);
+
+ $this->actingAs($user);
+
+ $org->providers()->attach($provider->id, ['label' => null]);
+
+ Livewire::test('organizations.identity-card', ['organization' => $org])
+ ->set('newProviderId', $provider->id)
+ ->set('newProviderLabel', '')
+ ->call('addProvider')
+ ->assertHasErrors('newProviderId');
+
+ $this->assertCount(1, $org->providers()->get());
+ }
+
+ public function test_removing_a_provider_link_is_scoped_to_its_own_organization(): void
+ {
+ $this->seed(\Database\Seeders\RolesAndPermissionsSeeder::class);
+
+ $user = User::factory()->create();
+ $orgA = Organization::create(['name' => 'Commune A', 'slug' => 'commune-a', 'org_type' => 'client']);
+ $orgB = Organization::create(['name' => 'Commune B', 'slug' => 'commune-b', 'org_type' => 'client']);
+ $provider = Provider::create(['name' => 'Orange Pro']);
+
+ $orgA->providers()->attach($provider->id, ['label' => null]);
+ $pivotIdA = $orgA->providers()->first()->pivot->id;
+
+ $this->actingAs($user);
+
+ Livewire::test('organizations.identity-card', ['organization' => $orgB])
+ ->call('removeProvider', $pivotIdA);
+
+ $this->assertCount(1, $orgA->providers()->get());
+ }
+}
diff --git a/tests/Feature/ProviderTest.php b/tests/Feature/ProviderTest.php
new file mode 100644
index 0000000..1e86d74
--- /dev/null
+++ b/tests/Feature/ProviderTest.php
@@ -0,0 +1,76 @@
+create();
+
+ $response = $this->actingAs($user)->get(route('providers.index'));
+
+ $response->assertOk();
+ $response->assertSee('Prestataires');
+ }
+
+ public function test_can_create_a_provider(): void
+ {
+ $user = User::factory()->create();
+ $this->actingAs($user);
+
+ Livewire::test('providers.form')
+ ->set('name', 'Orange Pro')
+ ->set('phone', '09 70 00 00 00')
+ ->set('email', 'contact@orange-pro.fr')
+ ->call('save')
+ ->assertHasNoErrors();
+
+ $this->assertDatabaseHas('providers', ['name' => 'Orange Pro', 'email' => 'contact@orange-pro.fr']);
+ }
+
+ public function test_blank_optional_fields_are_stored_as_null_not_empty_strings(): void
+ {
+ $user = User::factory()->create();
+ $this->actingAs($user);
+
+ Livewire::test('providers.form')
+ ->set('name', 'Minimal Provider')
+ ->call('save')
+ ->assertHasNoErrors();
+
+ $provider = Provider::where('name', 'Minimal Provider')->firstOrFail();
+ $this->assertNull($provider->phone);
+ $this->assertNull($provider->email);
+ $this->assertNull($provider->website);
+ $this->assertNull($provider->notes);
+ }
+
+ public function test_can_add_a_log_entry_to_a_provider(): void
+ {
+ $user = User::factory()->create();
+ $provider = Provider::create(['name' => 'Orange Pro']);
+
+ $this->actingAs($user);
+
+ Livewire::test('providers.form', ['provider' => $provider])
+ ->set('newLogCategory', 'contact')
+ ->set('newLogNote', "Mail envoyé le 21/08 pour demander des précisions sur le contrat.")
+ ->call('addLogEntry')
+ ->assertHasNoErrors();
+
+ $this->assertDatabaseHas('provider_log_entries', [
+ 'provider_id' => $provider->id,
+ 'category' => 'contact',
+ 'created_by' => $user->id,
+ ]);
+ }
+}
diff --git a/tests/Feature/SettingsEnvPageTest.php b/tests/Feature/SettingsEnvPageTest.php
new file mode 100644
index 0000000..53c21c2
--- /dev/null
+++ b/tests/Feature/SettingsEnvPageTest.php
@@ -0,0 +1,26 @@
+seed(\Database\Seeders\RolesAndPermissionsSeeder::class);
+
+ $admin = User::factory()->create();
+ $admin->assignRole('Administrateur');
+
+ $response = $this->actingAs($admin)->get(route('settings.env'));
+
+ $response->assertOk();
+ $response->assertSee('Draw.io');
+ $response->assertSee('DRAWIO_URL');
+ }
+}