From 3c87ced1e48d04afe12ccba91424cc8d3d486d93 Mon Sep 17 00:00:00 2001 From: mrKamoo Date: Fri, 21 Aug 2026 15:55:23 +0200 Subject: [PATCH] =?UTF-8?q?Ajoute=20fiche=20d'identit=C3=A9=20informatique?= =?UTF-8?q?,=20catalogue=20prestataires=20et=20sch=C3=A9mas=20draw.io?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fiche d'identité par organisation (technicien référent, FAI, IP publiques, prestataires liés) accessible depuis un nouveau menu. - Nouveau module Prestataires dédié (catalogue léger réutilisable entre communes, avec journal de suivi des échanges) plutôt que de détourner les organisations de type "Fournisseur". - Nouveau menu Infrastructure par organisation avec schémas réseau/fonctionnel édités via draw.io embarqué (URL configurable, cf. réglages admin). Co-Authored-By: Claude Sonnet 5 --- app/Models/Document.php | 5 + app/Models/Organization.php | 15 +- app/Models/Provider.php | 34 +++ app/Models/ProviderLogEntry.php | 34 +++ app/Services/SettingsRegistry.php | 3 + config/drawio.php | 9 + ...ity_card_fields_to_organizations_table.php | 25 ++ ...26_08_21_090001_create_providers_table.php | 28 +++ ...002_create_organization_provider_table.php | 26 +++ ...0003_create_provider_log_entries_table.php | 25 ++ .../documents/⚡diagram-editor.blade.php | 130 +++++++++++ .../infrastructure/⚡index.blade.php | 135 +++++++++++ .../views/components/layouts/app.blade.php | 12 + .../organizations/⚡identity-card.blade.php | 198 ++++++++++++++++ .../components/providers/⚡form.blade.php | 217 ++++++++++++++++++ .../components/providers/⚡index.blade.php | 100 ++++++++ resources/views/documents/edit.blade.php | 2 + resources/views/infrastructure/edit.blade.php | 3 + .../views/infrastructure/index.blade.php | 3 + .../organizations/identity-card.blade.php | 3 + resources/views/providers/create.blade.php | 3 + resources/views/providers/edit.blade.php | 3 + resources/views/providers/index.blade.php | 3 + routes/web.php | 16 ++ tests/Feature/InfrastructureTest.php | 75 ++++++ .../Feature/OrganizationIdentityCardTest.php | 157 +++++++++++++ tests/Feature/ProviderTest.php | 76 ++++++ tests/Feature/SettingsEnvPageTest.php | 26 +++ 28 files changed, 1365 insertions(+), 1 deletion(-) create mode 100644 app/Models/Provider.php create mode 100644 app/Models/ProviderLogEntry.php create mode 100644 config/drawio.php create mode 100644 database/migrations/2026_08_21_090000_add_identity_card_fields_to_organizations_table.php create mode 100644 database/migrations/2026_08_21_090001_create_providers_table.php create mode 100644 database/migrations/2026_08_21_090002_create_organization_provider_table.php create mode 100644 database/migrations/2026_08_21_090003_create_provider_log_entries_table.php create mode 100644 resources/views/components/documents/⚡diagram-editor.blade.php create mode 100644 resources/views/components/infrastructure/⚡index.blade.php create mode 100644 resources/views/components/organizations/⚡identity-card.blade.php create mode 100644 resources/views/components/providers/⚡form.blade.php create mode 100644 resources/views/components/providers/⚡index.blade.php create mode 100644 resources/views/infrastructure/edit.blade.php create mode 100644 resources/views/infrastructure/index.blade.php create mode 100644 resources/views/organizations/identity-card.blade.php create mode 100644 resources/views/providers/create.blade.php create mode 100644 resources/views/providers/edit.blade.php create mode 100644 resources/views/providers/index.blade.php create mode 100644 tests/Feature/InfrastructureTest.php create mode 100644 tests/Feature/OrganizationIdentityCardTest.php create mode 100644 tests/Feature/ProviderTest.php create mode 100644 tests/Feature/SettingsEnvPageTest.php 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(), + ]); + } + } +}; +?> + +
+

{{ $organization->name }}

+
+ @if ($renaming) +
+ + + + @error('name')

{{ $message }}

@enderror +
+ @else + + @endif + Retour +
+ +
+ +
+ +

+ +
+ + +
+ +
+
+
+ +@once + +@endonce diff --git a/resources/views/components/infrastructure/⚡index.blade.php b/resources/views/components/infrastructure/⚡index.blade.php new file mode 100644 index 0000000..56f35b5 --- /dev/null +++ b/resources/views/components/infrastructure/⚡index.blade.php @@ -0,0 +1,135 @@ +validate(['newDiagramName' => 'required|string|max:255']); + + $emptyDiagram = <<<'XML' + + + + + + + + + + + XML; + + $document = $this->organization->documents()->create([ + 'name' => $this->newDiagramName, + 'type' => 'diagram', + 'content' => $emptyDiagram, + 'current_version' => 1, + 'created_by' => auth()->id(), + ]); + + $document->versions()->create([ + 'version' => 1, + 'content' => $emptyDiagram, + 'created_by' => auth()->id(), + ]); + + $this->redirectRoute('organizations.infrastructure.edit', [$this->organization, $document], navigate: true); + } + + public function delete(int $documentId): void + { + $document = $this->organization->documents()->where('type', 'diagram')->findOrFail($documentId); + $document->delete(); + + session()->flash('status', "Schéma « {$document->name} » supprimé."); + } + + public function with(): array + { + return [ + 'diagrams' => $this->organization->documents() + ->where('type', 'diagram') + ->with('creator') + ->orderBy('name') + ->get(), + ]; + } +}; +?> + +
+
+

Infrastructure

+ +
+ + @if (session('status')) +
+ {{ session('status') }} +
+ @endif + + @if ($showNewDiagram) +
+
+ + @error('newDiagramName')

{{ $message }}

@enderror +
+ +
+ @endif + +
+ + + + + + + + + + + @forelse ($diagrams as $diagram) + + + + + + + @empty + + + + @endforelse + +
NomCréé parMis à jour
+ + 🗺️ {{ $diagram->name }} + + {{ $diagram->creator?->name ?? '—' }}{{ $diagram->updated_at->diffForHumans() }} + Ouvrir + +
Aucun schéma pour le moment.
+
+
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 + + Prestataires + @if ($currentOrganization) @php @@ -38,6 +42,10 @@ class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.dashboard') ? 'bg-slate-800 text-white' : '' }}"> Tableau de bord + + Fiche d'identité + ToDo @@ -62,6 +70,10 @@ class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.documents.*') ? 'bg-slate-800 text-white' : '' }}"> Documents + + Infrastructure + 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 + +
+
+ + + @error('technicianUserId')

{{ $message }}

@enderror +
+ +
+ + + @error('ispName')

{{ $message }}

@enderror +
+ +
+ + + @error('publicIpsText')

{{ $message }}

@enderror +
+ +
+ +
+
+ +
+

Prestataires

+ +
+ +
+
+ + @error('newProviderId')

{{ $message }}

@enderror +
+
+ +
+ +
+ + @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 + +
+
+ +
+ Logo + +
+ @error('logo')

{{ $message }}

@enderror +
+ +
+ + + @error('name')

{{ $message }}

@enderror +
+ +
+
+ + +
+
+ + + @error('email')

{{ $message }}

@enderror +
+
+ +
+ + +
+ +
+ + +
+ +
+ + Annuler +
+
+ + @if ($provider?->exists) +
+

Journal de suivi

+ +
+
+ + + +
+ @error('newLogNote')

{{ $message }}

@enderror +
+ +
+ @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), + ]; + } +}; +?> + +
+
+

Prestataires

+ + + Nouveau prestataire + +
+ + @if (session('status')) +
+ {{ session('status') }} +
+ @endif + + + +
+ + + + + + + + + + + + @forelse ($providers as $provider) + + + + + + + + @empty + + + + @endforelse + +
NomTéléphoneEmail
+ Logo {{ $provider->name }} + + + {{ $provider->name }} + + {{ $provider->phone ?? '—' }}{{ $provider->email ?? '—' }} + Modifier + +
Aucun prestataire pour le moment.
+
+ +
+ {{ $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'); + } +}