Compare commits

...
10 Commits
Author SHA1 Message Date
mrKamooandClaude Sonnet 5 2e614f2929 Permet de déplacer schémas et dossiers par glisser-déposer
Reprend le mécanisme du module Documents (drag & drop + menu déroulant
"Déplacer vers"), scopé aux documents de type diagram pour rester isolé
du navigateur Documents.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 08:18:57 +02:00
mrKamooandClaude Sonnet 5 c1c6d56d1e Ajoute dossiers et association de site aux schémas d'infrastructure
- Navigation par dossiers dans la section Infrastructure (créer, parcourir,
  fil d'ariane), isolée du navigateur Documents comme les schémas eux-mêmes.
- Un schéma peut être associé à un site de l'organisation, avec filtre par
  site dans la liste.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 07:57:45 +02:00
mrKamooandClaude Sonnet 5 3e5878192e Exclut les schémas draw.io du navigateur Documents
Les schémas créés depuis la section Infrastructure sont stockés comme des
Document (type=diagram) pour réutiliser le versioning existant, mais ne
doivent apparaître que dans leur propre section, pas mélangés aux fichiers
du module Documents.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 07:50:17 +02:00
mrKamooandClaude Sonnet 5 3c87ced1e4 Ajoute fiche d'identité informatique, catalogue prestataires et schémas draw.io
- 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 <noreply@anthropic.com>
2026-08-21 15:55:23 +02:00
mrKamooandClaude Sonnet 5 74c0b01a2e Permet de créer une tâche directement depuis le tableau de bord
Ajoute un formulaire rapide dans la carte "Tâches ouvertes" du dashboard, évitant de passer par la page ToDo dédiée pour les créations simples.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 09:14:28 +02:00
mrKamooandClaude Sonnet 5 c35833ed0b Remplace l'édition directe du .env par des réglages en base de données
L'édition de fichier nécessitait que le process web ait un accès en
écriture sur .env — problématique en prod (permissions, plusieurs
instances). À la place :

- Table `settings` (clé/valeur) + SettingsRegistry, une liste blanche
  explicite des réglages modifiables (mail, OnlyOffice, GLPI, nom/URL de
  l'app) mappés vers leur chemin de config Laravel.
- Volontairement exclu : tout ce qui touche à l'infrastructure (APP_KEY,
  DB_*, SESSION_DRIVER, CACHE_STORE, QUEUE_CONNECTION...) — ces valeurs
  sont nécessaires avant même qu'une connexion BDD existe, donc une
  surcharge en base ne serait pas fiable et reste réservée au .env.
- AppServiceProvider applique les surcharges au boot (cache 5 min,
  invalidé immédiatement à l'enregistrement) ; le reste du code continue
  de lire config() normalement, sans changement ailleurs.
- Tant qu'aucun réglage n'est enregistré, le comportement reste identique
  au .env actuel — transition sans rupture.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 09:21:15 +02:00
mrKamooandClaude Sonnet 5 da589d0627 Ajoute un menu admin pour éditer les variables .env de l'app
Nouvelle page "Configuration (.env)" réservée au rôle Administrateur :
- EnvFileManager lit/réécrit le .env ligne à ligne en préservant
  commentaires et mise en forme, avec écriture atomique (fichier
  temporaire + rename).
- Variables critiques (APP_KEY, DB_*, SESSION_DRIVER) en lecture seule
  pour éviter de casser la connexion base de données ou de bloquer
  l'accès admin — protection appliquée aussi côté serveur, pas
  seulement dans l'affichage.
- Valeurs sensibles (mots de passe, jetons, clés) masquées par défaut
  avec un bouton de révélation par champ.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 08:58:29 +02:00
mrKamooandClaude Sonnet 5 2f576ca286 Ajoute technicien, compteur d'interactions et alerte "en attente" aux tickets GLPI
- Affiche le technicien responsable (avec repli sur le technicien assigné)
  sur chaque ticket du tableau de bord.
- Compte les interactions technicien/demandeur en combinant suivis ET
  tâches GLPI — les techniciens répondent parfois via une tâche plutôt
  qu'un suivi, un premier passage qui ne comptait que les suivis sous-
  estimait donc les échanges et déclenchait de fausses alertes.
- Signale visuellement (bordure + fond ambre, pictogramme ⚠️) les tickets
  dont la dernière interaction vient du demandeur, en comparant la date du
  dernier suivi et de la dernière tâche pour déterminer qui a parlé en
  dernier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 17:58:11 +02:00
mrKamooandClaude Sonnet 5 2bf169ed88 Fait confiance aux reverse proxies pour la détection HTTPS
Sans TrustProxies, Laravel ignore X-Forwarded-Proto derrière un proxy qui
termine le TLS et génère les URLs d'assets en http://, provoquant des
erreurs de contenu mixte sur les pages servies en https://.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 17:18:05 +02:00
mrKamooandClaude Sonnet 5 01233928cc Ajoute géolocalisation des sites, duplication d'actifs et intégration GLPI
- Coordonnées GPS sur les sites (formulaire + carte Leaflet en direct sur
  la fiche et sur la liste des sites d'une organisation).
- Duplication d'un actif depuis la liste.
- Intégration GLPI en lecture seule : panneau "Tickets" sur le tableau de
  bord de l'organisation (tickets non clos, priorité, catégorie, extrait),
  organisations liées à leur entité GLPI via glpi_entity_id.
- Corrige la mise en cache des permissions Spatie lors du seed (silencée
  par WithoutModelEvents) et rend le seeder admin idempotent.
- Ajoute busy_timeout/WAL à la config SQLite pour éviter les erreurs de
  verrou sous écritures concurrentes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 08:45:49 +02:00
50 changed files with 2933 additions and 26 deletions
+11 -1
View File
@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use League\CommonMark\CommonMarkConverter; use League\CommonMark\CommonMarkConverter;
#[Fillable(['organization_id', 'folder_id', 'name', 'type', 'is_folder', 'content', 'file_path', 'office_extension', 'office_key', 'current_version', 'created_by'])] #[Fillable(['organization_id', 'folder_id', 'location_id', 'name', 'type', 'is_folder', 'content', 'file_path', 'office_extension', 'office_key', 'current_version', 'created_by'])]
class Document extends Model implements Linkable class Document extends Model implements Linkable
{ {
use SoftDeletes; use SoftDeletes;
@@ -35,6 +35,11 @@ class Document extends Model implements Linkable
return $this->type === 'upload'; return $this->type === 'upload';
} }
public function isDiagram(): bool
{
return $this->type === 'diagram';
}
public function renderedMarkdown(): string public function renderedMarkdown(): string
{ {
return (new CommonMarkConverter())->convert($this->content ?? '')->getContent(); return (new CommonMarkConverter())->convert($this->content ?? '')->getContent();
@@ -55,6 +60,11 @@ class Document extends Model implements Linkable
return $this->belongsTo(Document::class, 'folder_id'); return $this->belongsTo(Document::class, 'folder_id');
} }
public function location()
{
return $this->belongsTo(Location::class);
}
public function children() public function children()
{ {
return $this->hasMany(Document::class, 'folder_id'); return $this->hasMany(Document::class, 'folder_id');
+3
View File
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Model;
#[Fillable([ #[Fillable([
'organization_id', 'name', 'address_line_1', 'address_line_2', 'organization_id', 'name', 'address_line_1', 'address_line_2',
'city', 'region', 'postal_code', 'country', 'phone', 'is_primary', 'city', 'region', 'postal_code', 'country', 'phone', 'is_primary',
'latitude', 'longitude',
])] ])]
class Location extends Model class Location extends Model
{ {
@@ -15,6 +16,8 @@ class Location extends Model
{ {
return [ return [
'is_primary' => 'boolean', 'is_primary' => 'boolean',
'latitude' => 'float',
'longitude' => 'float',
]; ];
} }
+14 -1
View File
@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
#[Fillable(['name', 'slug', 'org_type', 'status', 'description', 'allowed_ip_ranges', 'logo_path'])] #[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 class Organization extends Model
{ {
use SoftDeletes; use SoftDeletes;
@@ -17,6 +17,7 @@ class Organization extends Model
{ {
return [ return [
'allowed_ip_ranges' => 'array', 'allowed_ip_ranges' => 'array',
'public_ips' => 'array',
]; ];
} }
@@ -86,4 +87,16 @@ class Organization extends Model
{ {
return $this->hasMany(Shortcut::class); 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();
}
} }
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;
#[Fillable(['name', 'logo_path', 'phone', 'email', 'website', 'notes'])]
class Provider extends Model
{
use SoftDeletes;
protected function logoUrl(): Attribute
{
return Attribute::get(
fn () => $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');
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ProviderLogEntry extends Model
{
const UPDATED_AT = null;
const CATEGORIES = [
'contact' => 'É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;
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
/**
* A key/value override for a whitelisted config path — see SettingsRegistry.
* Absence of a row (or a null value) means "use the .env/config default".
*/
#[Fillable(['key', 'value'])]
class Setting extends Model
{
}
+33 -1
View File
@@ -2,7 +2,11 @@
namespace App\Providers; namespace App\Providers;
use App\Models\Setting;
use App\Services\SettingsRegistry;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Throwable;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@@ -11,7 +15,35 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
// $this->applySettingOverrides();
}
/**
* Overlay database-stored settings (see SettingsRegistry) onto config,
* so the rest of the app keeps reading config('mail.from.address') etc.
* without knowing whether the value came from .env or the settings UI.
* Silently does nothing if the settings table doesn't exist yet (e.g.
* during the very first `migrate` on a fresh install).
*/
protected function applySettingOverrides(): void
{
try {
$overrides = Cache::remember(
SettingsRegistry::CACHE_KEY,
300,
fn () => Setting::query()->whereNotNull('value')->pluck('value', 'key')->all(),
);
} catch (Throwable) {
return;
}
$map = SettingsRegistry::configMap();
foreach ($overrides as $key => $value) {
if (isset($map[$key]) && $value !== '') {
config([$map[$key] => $value]);
}
}
} }
/** /**
+361
View File
@@ -0,0 +1,361 @@
<?php
namespace App\Services\Glpi;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
class GlpiClient
{
public const STATUSES = [
1 => 'Nouveau',
2 => 'En cours (assigné)',
3 => 'En cours (planifié)',
4 => 'En attente',
5 => 'Résolu',
6 => 'Clos',
];
public const STATUS_CLOSED = 6;
public const PRIORITIES = [
1 => 'Très basse',
2 => 'Basse',
3 => 'Moyenne',
4 => 'Haute',
5 => 'Très haute',
6 => 'Majeure',
];
protected string $baseUrl;
protected string $appToken;
protected string $userToken;
public function __construct()
{
$this->baseUrl = rtrim((string) config('glpi.api_url'), '/');
$this->appToken = (string) config('glpi.app_token');
$this->userToken = (string) config('glpi.user_token');
}
public function isConfigured(): bool
{
return $this->baseUrl !== '' && $this->appToken !== '' && $this->userToken !== '';
}
/**
* The GLPI front-end base URL (for building links to tickets), derived
* from the REST API URL by dropping the trailing "apirest.php".
*/
public function frontUrl(): string
{
return preg_replace('#/apirest\.php$#', '', $this->baseUrl);
}
public function ticketUrl(int $ticketId): string
{
return $this->frontUrl()."/front/ticket.form.php?id={$ticketId}";
}
/**
* Recent tickets for a GLPI entity, most recently modified first.
*
* @return array{tickets: array<int, array<string, mixed>>, total: int, error: ?string}
*/
public function recentTicketsForEntity(int $entityId, int $limit = 8): array
{
if (! $this->isConfigured()) {
return ['tickets' => [], 'total' => 0, 'error' => null];
}
return Cache::remember(
"glpi.tickets.entity.{$entityId}.{$limit}",
(int) config('glpi.cache_ttl_seconds', 60),
function () use ($entityId, $limit) {
try {
return $this->fetchRecentTickets($entityId, $limit);
} catch (Throwable $e) {
Log::warning('GLPI: échec de récupération des tickets', [
'entity_id' => $entityId,
'message' => $e->getMessage(),
]);
return ['tickets' => [], 'total' => 0, 'error' => "Impossible de joindre GLPI pour le moment."];
}
}
);
}
/**
* @return array{tickets: array<int, array<string, mixed>>, total: int, error: ?string}
*/
protected function fetchRecentTickets(int $entityId, int $limit): array
{
return $this->withSession(function (string $sessionToken) use ($entityId, $limit) {
$response = Http::withHeaders($this->headers($sessionToken))
->get("{$this->baseUrl}/search/Ticket", [
'criteria' => [
['field' => 80, 'searchtype' => 'equals', 'value' => $entityId],
// GLPI's "notequals" searchtype is silently ignored by the search
// engine; excluding a value only works via a negated AND link.
['link' => 'AND NOT', 'field' => 12, 'searchtype' => 'equals', 'value' => self::STATUS_CLOSED],
],
// 4 = demandeur, 27 = nombre de suivis, 28 = nombre de tâches (les
// techniciens répondent parfois via une tâche plutôt qu'un suivi —
// les deux comptent comme des interactions), 95 = technicien
// responsable, 5 = technicien assigné (repli si 95 n'est pas renseigné).
'forcedisplay' => [2, 1, 12, 3, 4, 7, 21, 27, 28, 95, 5, 19],
'sort' => 19,
'order' => 'DESC',
'range' => '0-'.($limit - 1),
]);
if ($response->failed()) {
throw new GlpiException("GLPI a répondu avec une erreur (HTTP {$response->status()}).");
}
$rows = $response->json('data') ?? [];
$technicianIds = collect($rows)
->flatMap(fn (array $row) => [
...Arr::wrap($row['95'] ?? null),
...Arr::wrap($row['5'] ?? null),
])
->filter()
->unique()
->values();
$technicianNames = $this->resolveUserNames($sessionToken, $technicianIds);
$lastInteractionAuthors = $this->fetchLastInteractionAuthors($sessionToken, $rows);
$tickets = collect($rows)->map(function (array $row) use ($technicianNames, $lastInteractionAuthors) {
$technicianIds = filled($row['95'] ?? null) ? Arr::wrap($row['95']) : Arr::wrap($row['5'] ?? null);
$followupsCount = (int) ($row['27'] ?? 0);
$tasksCount = (int) ($row['28'] ?? 0);
$requesterIds = array_map('strval', Arr::wrap($row['4'] ?? null));
// Nothing yet means the only thing on the ticket is the
// requester's own opening message — nobody from support has
// replied (via a followup or a task). Otherwise, check who
// wrote whichever of those came in last.
$lastAuthor = ($followupsCount === 0 && $tasksCount === 0)
? Arr::first($requesterIds)
: ($lastInteractionAuthors[(string) $row['2']] ?? null);
return [
'id' => $row['2'],
'title' => $this->decodeText((string) $row['1']),
'status' => (int) $row['12'],
'status_label' => self::STATUSES[(int) $row['12']] ?? 'Inconnu',
'category' => filled($row['7'] ?? null) ? $this->decodeText((string) $row['7']) : null,
'excerpt' => $this->excerpt($row['21'] ?? null),
'priority' => (int) $row['3'],
'priority_label' => self::PRIORITIES[(int) $row['3']] ?? 'Inconnue',
'interactions_count' => $followupsCount + $tasksCount,
'awaiting_technician' => $lastAuthor !== null && in_array((string) $lastAuthor, $requesterIds, true),
'technician' => collect($technicianIds)
->map(fn ($id) => $technicianNames[$id] ?? null)
->filter()
->implode(', ') ?: null,
'date_mod' => $row['19'],
'url' => $this->ticketUrl((int) $row['2']),
];
})->all();
return [
'tickets' => $tickets,
'total' => (int) ($response->json('totalcount') ?? count($tickets)),
'error' => null,
];
});
}
/**
* The search engine can count followups (option 27) and tasks (option 28)
* but doesn't expose who wrote the most recent one of either — and
* technicians in this GLPI instance sometimes reply via a task instead of
* a followup, so both have to be checked. For every ticket that has at
* least one of either, fetch its last followup AND its last task, then
* keep whichever is more recent. Run all those lookups concurrently
* (Http::pool) so an 8-ticket dashboard panel costs one round trip, not
* up to sixteen sequential ones.
*
* @param array<int, array<string, mixed>> $rows
* @return array<string, string> ticket id => author user id
*/
protected function fetchLastInteractionAuthors(string $sessionToken, array $rows): array
{
$ticketIds = collect($rows)
->filter(fn (array $row) => (int) ($row['27'] ?? 0) > 0 || (int) ($row['28'] ?? 0) > 0)
->pluck('2')
->all();
if (empty($ticketIds)) {
return [];
}
$subItemParams = ['sort' => 'date', 'order' => 'DESC', 'range' => '0-0'];
$responses = Http::pool(fn ($pool) => collect($ticketIds)->flatMap(fn ($ticketId) => [
$pool->as("followup-{$ticketId}")->withHeaders($this->headers($sessionToken))
->get("{$this->baseUrl}/Ticket/{$ticketId}/ITILFollowup", $subItemParams),
$pool->as("task-{$ticketId}")->withHeaders($this->headers($sessionToken))
->get("{$this->baseUrl}/Ticket/{$ticketId}/TicketTask", $subItemParams),
])->all());
$latestPerTicket = [];
foreach (['followup', 'task'] as $type) {
foreach ($ticketIds as $ticketId) {
$response = $responses["{$type}-{$ticketId}"] ?? null;
if (! $response instanceof \Illuminate\Http\Client\Response || $response->failed()) {
continue;
}
$entry = $response->json('0');
if (! isset($entry['users_id'], $entry['date'])) {
continue;
}
$ticketId = (string) $ticketId;
$existing = $latestPerTicket[$ticketId] ?? null;
if ($existing === null || $entry['date'] > $existing['date']) {
$latestPerTicket[$ticketId] = $entry;
}
}
}
return collect($latestPerTicket)->map(fn ($entry) => (string) $entry['users_id'])->all();
}
/**
* Ticket search results only carry raw GLPI user IDs for the technician
* fields (unlike single-value dropdowns such as category, these aren't
* auto-resolved to a display name) — batch-resolve them by name in one
* extra call within the same session.
*
* @param Collection<int, int|string> $userIds
* @return array<string, string> id => "Firstname Lastname"
*/
protected function resolveUserNames(string $sessionToken, Collection $userIds): array
{
if ($userIds->isEmpty()) {
return [];
}
$response = Http::withHeaders($this->headers($sessionToken))
->get("{$this->baseUrl}/search/User", [
'criteria' => $this->buildOrCriteria(2, $userIds->all()),
'forcedisplay' => [2, 9, 34],
'range' => '0-'.($userIds->count() - 1),
]);
if ($response->failed()) {
return [];
}
return collect($response->json('data') ?? [])
->mapWithKeys(function (array $row) {
$name = trim(($row['9'] ?? '').' '.($row['34'] ?? ''));
return [(string) $row['2'] => $name !== '' ? $name : null];
})
->filter()
->all();
}
/**
* Builds a GLPI search "criteria" array matching any of the given values
* against a single field, e.g. field=2 equals 5 OR field=2 equals 9.
*/
protected function buildOrCriteria(int $field, array $values): array
{
$criteria = [];
foreach (array_values($values) as $index => $value) {
$criteria[] = [
...($index > 0 ? ['link' => 'OR'] : []),
'field' => $field,
'searchtype' => 'equals',
'value' => $value,
];
}
return $criteria;
}
/**
* GLPI's ticket description comes back as HTML, itself HTML-entity-encoded
* (e.g. "&#60;p&#62;" for "<p>"). Strip markup and trim to a short
* plain-text preview.
*/
protected function excerpt(?string $html, int $length = 160): ?string
{
if (blank($html)) {
return null;
}
$text = strip_tags($this->decodeText($html));
$text = trim(preg_replace('/\s+/', ' ', $text));
return $text === '' ? null : \Illuminate\Support\Str::limit($text, $length);
}
/**
* Some GLPI fields (rich-text description, titles typed via a rich editor)
* are HTML-entity-encoded twice over: once because the stored content is
* itself escaped HTML (e.g. a literal "&amp;"), and again because the
* search API encodes the whole field for its JSON response. A single
* decode leaves artifacts like "&amp;" in the output, so decode twice —
* harmless no-op on text that was only encoded once.
*/
protected function decodeText(string $value): string
{
return html_entity_decode(html_entity_decode($value, ENT_QUOTES), ENT_QUOTES);
}
/**
* Opens a GLPI session, runs the callback with the session token, and
* always closes the session afterwards — GLPI has no stateless auth mode
* for the REST API, so every call pays this round trip.
*/
protected function withSession(\Closure $callback): mixed
{
$session = Http::withHeaders([
'App-Token' => $this->appToken,
'Authorization' => 'user_token '.$this->userToken,
])->get("{$this->baseUrl}/initSession");
if ($session->failed()) {
throw new GlpiException("Échec de connexion à GLPI (HTTP {$session->status()}).");
}
$sessionToken = $session->json('session_token');
try {
return $callback($sessionToken);
} finally {
Http::withHeaders($this->headers($sessionToken))->get("{$this->baseUrl}/killSession");
}
}
/**
* @return array<string, string>
*/
protected function headers(string $sessionToken): array
{
return [
'App-Token' => $this->appToken,
'Session-Token' => $sessionToken,
];
}
}
+9
View File
@@ -0,0 +1,9 @@
<?php
namespace App\Services\Glpi;
use RuntimeException;
class GlpiException extends RuntimeException
{
}
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace App\Services;
/**
* Whitelist of settings administrators can override from the database instead
* of editing .env directly. Deliberately excludes anything read at the very
* start of the boot process (APP_KEY, DB_*, SESSION_DRIVER, CACHE_STORE,
* QUEUE_CONNECTION, ...): those are resolved before a database connection —
* or before this override even runs — so changing them here wouldn't
* reliably take effect, and getting DB_* wrong here could lock everyone out.
* Only settings that are safe to read lazily, at point of use, belong here.
*/
class SettingsRegistry
{
/**
* @var array<string, array<string, array{config: string, label: string}>>
*/
public const SETTINGS = [
'Application' => [
'APP_NAME' => ['config' => 'app.name', 'label' => "Nom de l'application"],
'APP_URL' => ['config' => 'app.url', 'label' => 'URL de base'],
],
'Mail' => [
'MAIL_MAILER' => ['config' => 'mail.default', 'label' => 'Pilote (mailer)'],
'MAIL_HOST' => ['config' => 'mail.mailers.smtp.host', 'label' => 'Hôte SMTP'],
'MAIL_PORT' => ['config' => 'mail.mailers.smtp.port', 'label' => 'Port SMTP'],
'MAIL_SCHEME' => ['config' => 'mail.mailers.smtp.scheme', 'label' => 'Schéma (tls, ssl…)'],
'MAIL_USERNAME' => ['config' => 'mail.mailers.smtp.username', 'label' => 'Utilisateur SMTP'],
'MAIL_PASSWORD' => ['config' => 'mail.mailers.smtp.password', 'label' => 'Mot de passe SMTP'],
'MAIL_FROM_ADDRESS' => ['config' => 'mail.from.address', 'label' => 'Adresse expéditeur'],
'MAIL_FROM_NAME' => ['config' => 'mail.from.name', 'label' => 'Nom expéditeur'],
],
'OnlyOffice' => [
'ONLYOFFICE_DS_URL' => ['config' => 'onlyoffice.document_server_url', 'label' => 'URL du serveur de documents'],
'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'],
'GLPI_USER_TOKEN' => ['config' => 'glpi.user_token', 'label' => 'Jeton utilisateur'],
'GLPI_CACHE_TTL' => ['config' => 'glpi.cache_ttl_seconds', 'label' => 'Durée du cache (secondes)'],
],
];
public const CACHE_KEY = 'settings.overrides';
public static function isSensitive(string $key): bool
{
return (bool) preg_match('/PASSWORD|SECRET|TOKEN|KEY/i', $key);
}
/**
* Flat env-style key => config path map, for applying overrides at boot.
*
* @return array<string, string>
*/
public static function configMap(): array
{
$map = [];
foreach (self::SETTINGS as $section) {
foreach ($section as $envKey => $meta) {
$map[$envKey] = $meta['config'];
}
}
return $map;
}
}
+6
View File
@@ -12,6 +12,12 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
// The app runs behind a reverse proxy that terminates TLS and forwards
// plain HTTP — without this, Laravel doesn't know the original request
// was HTTPS and generates asset/URL links as http://, which browsers
// then block as mixed content on an https:// page.
$middleware->trustProxies(at: '*');
$middleware->alias([ $middleware->alias([
'org.ip_access' => \App\Http\Middleware\CheckOrganizationIpAccess::class, 'org.ip_access' => \App\Http\Middleware\CheckOrganizationIpAccess::class,
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, 'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
+2 -2
View File
@@ -38,8 +38,8 @@ return [
'database' => env('DB_DATABASE', database_path('database.sqlite')), 'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '', 'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null, 'busy_timeout' => env('DB_BUSY_TIMEOUT', 5000),
'journal_mode' => null, 'journal_mode' => env('DB_JOURNAL_MODE', 'wal'),
'synchronous' => null, 'synchronous' => null,
'transaction_mode' => 'DEFERRED', 'transaction_mode' => 'DEFERRED',
], ],
+9
View File
@@ -0,0 +1,9 @@
<?php
return [
// Base URL of the draw.io / diagrams.net editor to embed (embed mode).
// Defaults to the public diagrams.net service; point this to a self-hosted
// instance (e.g. the official jgraph/drawio Docker image) to keep diagram
// editing fully in-house.
'url' => env('DRAWIO_URL', 'https://embed.diagrams.net'),
];
+16
View File
@@ -0,0 +1,16 @@
<?php
return [
// Base REST API URL, e.g. https://hotline.example.com/glpi/apirest.php
'api_url' => env('GLPI_API_URL'),
// Identifies this application to GLPI (Setup > General > API > API clients).
'app_token' => env('GLPI_APP_TOKEN'),
// Authenticates as a GLPI account (ideally a dedicated, least-privilege
// service account) — generated from that user's "API tokens" tab.
'user_token' => env('GLPI_USER_TOKEN'),
// How long ticket listings are cached before hitting the GLPI API again.
'cache_ttl_seconds' => env('GLPI_CACHE_TTL', 60),
];
@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('locations', function (Blueprint $table) {
$table->decimal('latitude', 10, 7)->nullable()->after('country');
$table->decimal('longitude', 10, 7)->nullable()->after('latitude');
});
}
public function down(): void
{
Schema::table('locations', function (Blueprint $table) {
$table->dropColumn(['latitude', 'longitude']);
});
}
};
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('organizations', function (Blueprint $table) {
$table->unsignedInteger('glpi_entity_id')->nullable()->after('allowed_ip_ranges');
});
}
public function down(): void
{
Schema::table('organizations', function (Blueprint $table) {
$table->dropColumn('glpi_entity_id');
});
}
};
@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('settings', function (Blueprint $table) {
$table->id();
$table->string('key')->unique();
$table->text('value')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('settings');
}
};
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('organizations', function (Blueprint $table) {
$table->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']);
});
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('providers', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('organization_provider', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('provider_log_entries', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('documents', function (Blueprint $table) {
$table->foreignId('location_id')->nullable()->after('folder_id')->constrained()->nullOnDelete();
});
}
public function down(): void
{
Schema::table('documents', function (Blueprint $table) {
$table->dropConstrainedForeignId('location_id');
});
}
};
+9 -4
View File
@@ -5,6 +5,7 @@ namespace Database\Seeders;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class DatabaseSeeder extends Seeder class DatabaseSeeder extends Seeder
{ {
@@ -19,10 +20,14 @@ class DatabaseSeeder extends Seeder
$this->call(RolesAndPermissionsSeeder::class); $this->call(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create([ $admin = User::firstOrCreate(
'name' => 'Administrateur', ['email' => 'administrateur@cabmit.local'],
'email' => 'administrateur@cabmit.local', [
]); 'name' => 'Administrateur',
'email_verified_at' => now(),
'password' => Hash::make('password'),
],
);
$admin->assignRole('Administrateur'); $admin->assignRole('Administrateur');
$this->call(AssetTypeLookupSeeder::class); $this->call(AssetTypeLookupSeeder::class);
@@ -5,6 +5,7 @@ namespace Database\Seeders;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role; use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
class RolesAndPermissionsSeeder extends Seeder class RolesAndPermissionsSeeder extends Seeder
{ {
@@ -22,6 +23,12 @@ class RolesAndPermissionsSeeder extends Seeder
Permission::findOrCreate($permission); Permission::findOrCreate($permission);
} }
// DatabaseSeeder uses WithoutModelEvents, which silences the
// saved/deleted events Spatie relies on to invalidate its
// permission cache. Reset it manually so newly created
// permissions are visible below.
app(PermissionRegistrar::class)->forgetCachedPermissions();
$admin = Role::findOrCreate('Administrateur'); $admin = Role::findOrCreate('Administrateur');
$admin->syncPermissions($permissions); $admin->syncPermissions($permissions);
@@ -25,6 +25,22 @@ new class extends Component
session()->flash('status', "\"{$asset->name}\" supprimé."); session()->flash('status', "\"{$asset->name}\" supprimé.");
} }
public function duplicate(int $assetId): void
{
$asset = $this->organization->flexibleAssets()->findOrFail($assetId);
$copy = $this->organization->flexibleAssets()->create([
'flexible_asset_type_id' => $asset->flexible_asset_type_id,
'name' => "{$asset->name} (copie)",
'data' => $asset->data,
'created_by' => auth()->id(),
]);
session()->flash('status', "\"{$asset->name}\" dupliqué.");
$this->redirectRoute('organizations.assets.edit', [$this->organization, $this->type, $copy], navigate: true);
}
public function with(): array public function with(): array
{ {
$listFields = $this->type->fields()->where('show_in_list', true)->get(); $listFields = $this->type->fields()->where('show_in_list', true)->get();
@@ -94,6 +110,11 @@ new class extends Component
@endforeach @endforeach
<td class="px-4 py-3 text-right whitespace-nowrap"> <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> <a href="{{ route('organizations.assets.edit', [$organization, $type, $asset]) }}" class="text-slate-500 hover:text-slate-900">Modifier</a>
<button type="button"
wire:click="duplicate({{ $asset->id }})"
class="ml-3 text-slate-500 hover:text-slate-900">
Dupliquer
</button>
<button type="button" <button type="button"
wire:click="delete({{ $asset->id }})" wire:click="delete({{ $asset->id }})"
wire:confirm="Supprimer cet actif ? Cette action est irréversible." wire:confirm="Supprimer cet actif ? Cette action est irréversible."
@@ -0,0 +1,155 @@
<?php
use App\Models\Document;
use App\Models\Organization;
use Livewire\Component;
new class extends Component
{
public Organization $organization;
public Document $document;
public string $backRoute = 'organizations.documents.index';
public ?int $folderId = null;
public bool $renaming = false;
public string $name = '';
public function mount(Organization $organization, Document $document, string $backRoute = 'organizations.documents.index', ?int $folderId = null): void
{
abort_unless($document->isDiagram(), 404);
$this->organization = $organization;
$this->document = $document;
$this->backRoute = $backRoute;
$this->folderId = $folderId;
$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 updateLocation(?int $locationId): void
{
$this->document->update(['location_id' => $locationId]);
}
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(),
]);
}
}
public function with(): array
{
return [
'locations' => $this->organization->locations()->orderBy('name')->get(),
];
}
};
?>
<div>
<p class="text-sm text-slate-500">{{ $organization->name }}</p>
<div class="mb-4 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="cursor-pointer 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="cursor-pointer 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 cursor-pointer 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
<a href="{{ route($backRoute, [$organization, 'folderId' => $folderId]) }}" class="text-sm text-slate-500 hover:text-slate-900">Retour</a>
</div>
<div class="mb-4 flex items-center gap-2">
<label class="text-sm font-medium text-slate-700">Site associé</label>
<select wire:change="updateLocation($event.target.value === '' ? null : $event.target.value)"
class="rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<option value="">Aucun</option>
@foreach ($locations as $location)
<option value="{{ $location->id }}" @selected($document->location_id === $location->id)>{{ $location->name }}</option>
@endforeach
</select>
</div>
<div
wire:ignore
x-data="drawioEditor(@js($document->content ?? ''), @js(config('drawio.url')))"
x-init="init()"
class="w-full"
style="height: 78vh;"
>
<iframe x-ref="iframe" src="{{ config('drawio.url') }}?embed=1&proto=json&spin=1&noSaveBtn=0&saveAndExit=0&noExitBtn=1"
class="h-full w-full rounded border border-slate-200 bg-white"></iframe>
</div>
<p class="mt-2 text-xs text-slate-500" x-data="{ status: '' }" x-on:diagram-status.window="status = $event.detail" x-text="status"></p>
<div class="mt-8 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 drawioEditor(initialXml, baseUrl) {
return {
origin: null,
init() {
this.origin = new URL(baseUrl, window.location.href).origin;
window.addEventListener('message', (evt) => this.handleMessage(evt));
},
handleMessage(evt) {
if (evt.origin !== this.origin) return;
let msg;
try {
msg = JSON.parse(evt.data);
} catch (e) {
return;
}
if (msg.event === 'init') {
this.post({ action: 'load', xml: initialXml, autosave: 0 });
} else if (msg.event === 'save') {
this.$wire.saveDiagram(msg.xml).then(() => {
window.dispatchEvent(new CustomEvent('diagram-status', { detail: 'Enregistré à ' + new Date().toLocaleTimeString() }));
this.post({ action: 'status', message: 'Enregistré', modified: false });
});
} else if (msg.event === 'exit') {
this.post({ action: 'status', message: '', modified: false });
}
},
post(payload) {
this.$refs.iframe.contentWindow.postMessage(JSON.stringify(payload), this.origin);
},
};
}
</script>
@endonce
@@ -206,6 +206,7 @@ new class extends Component
'breadcrumbs' => $breadcrumbs, 'breadcrumbs' => $breadcrumbs,
'items' => $this->organization->documents() 'items' => $this->organization->documents()
->where('folder_id', $this->folderId) ->where('folder_id', $this->folderId)
->where('type', '!=', 'diagram')
->orderByDesc('is_folder') ->orderByDesc('is_folder')
->orderBy('name') ->orderBy('name')
->get(), ->get(),
@@ -0,0 +1,330 @@
<?php
use App\Models\Document;
use App\Models\Organization;
use Livewire\Attributes\Url;
use Livewire\Component;
new class extends Component
{
public Organization $organization;
#[Url]
public ?int $folderId = null;
#[Url]
public ?int $locationId = null;
public string $newDiagramName = '';
public bool $showNewDiagram = false;
public string $newFolderName = '';
public bool $showNewFolder = false;
public function createFolder(): void
{
$this->validate(['newFolderName' => 'required|string|max:255']);
$this->organization->documents()->create([
'folder_id' => $this->folderId,
'name' => $this->newFolderName,
'type' => 'diagram',
'is_folder' => true,
'created_by' => auth()->id(),
]);
$this->newFolderName = '';
$this->showNewFolder = false;
}
public function createDiagram(): void
{
$this->validate(['newDiagramName' => 'required|string|max:255']);
$emptyDiagram = <<<'XML'
<mxfile host="app.diagrams.net">
<diagram name="Page-1">
<mxGraphModel dx="800" dy="600" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
</root>
</mxGraphModel>
</diagram>
</mxfile>
XML;
$document = $this->organization->documents()->create([
'folder_id' => $this->folderId,
'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, 'folderId' => $this->folderId], navigate: true);
}
public function delete(int $documentId): void
{
$document = $this->organization->documents()->where('type', 'diagram')->findOrFail($documentId);
$document->delete();
session()->flash('status', "« {$document->name} » supprimé.");
}
public function moveDocument(int $documentId, ?int $targetFolderId): void
{
$document = $this->organization->documents()->where('type', 'diagram')->findOrFail($documentId);
if ($document->id === $targetFolderId) {
return;
}
if ($targetFolderId !== null) {
$targetFolder = $this->organization->documents()->where('type', 'diagram')->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é.");
}
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()->where('type', 'diagram')->find($this->folderId)
: null;
$breadcrumbs = collect();
$walk = $currentFolder;
while ($walk) {
$breadcrumbs->prepend($walk);
$walk = $walk->folder;
}
$allFolders = $this->organization->documents()->where('type', 'diagram')->where('is_folder', true)->get();
return [
'currentFolder' => $currentFolder,
'breadcrumbs' => $breadcrumbs,
'folderTree' => $this->folderTree($allFolders),
'locations' => $this->organization->locations()->orderBy('name')->get(),
'items' => $this->organization->documents()
->where('type', 'diagram')
->where('folder_id', $this->folderId)
->when($this->locationId, fn ($query) => $query->where('location_id', $this->locationId))
->with('location')
->orderByDesc('is_folder')
->orderBy('name')
->get(),
];
}
};
?>
<div>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-semibold text-slate-900">Infrastructure</h1>
<div class="flex gap-2">
<button type="button" wire:click="$set('showNewFolder', true)" title="Nouveau dossier"
class="flex h-10 w-10 cursor-pointer items-center justify-center rounded border border-slate-300 text-lg hover:bg-slate-50">
📁
</button>
<button type="button" wire:click="$set('showNewDiagram', true)"
class="cursor-pointer rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
+ Nouveau schéma
</button>
</div>
</div>
@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
<div class="mb-4 flex items-center justify-between gap-4">
<div class="flex items-center gap-1 text-sm text-slate-500">
<a wire:click="$set('folderId', null)" href="#" class="hover:text-slate-900">Infrastructure</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 ($locations->isNotEmpty())
<select wire:model.live="locationId" class="rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<option value="">Tous les sites</option>
@foreach ($locations as $location)
<option value="{{ $location->id }}">{{ $location->name }}</option>
@endforeach
</select>
@endif
</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="cursor-pointer 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="cursor-pointer 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 ($showNewDiagram)
<form wire:submit="createDiagram" class="mb-4 flex items-start gap-2 rounded border border-slate-200 bg-white p-3">
<div class="flex-1">
<input type="text" wire:model="newDiagramName" placeholder="Ex: Schéma réseau - Mairie, Schéma fonctionnel - SI RH"
class="w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('newDiagramName') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<button type="submit" class="cursor-pointer rounded bg-slate-900 px-3 py-1.5 text-sm text-white hover:bg-slate-700">
Créer
</button>
</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">Site</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="diagram-{{ $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.infrastructure.edit', [$organization, $item, 'folderId' => $this->folderId]) }}" class="font-medium text-slate-900 hover:underline">
🗺️ {{ $item->name }}
</a>
@endif
</td>
<td class="px-4 py-3 text-slate-600">{{ $isFolder ? '—' : ($item->location?->name ?? '—') }}</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.infrastructure.edit', [$organization, $item, 'folderId' => $this->folderId]) }}" class="text-slate-500 hover:text-slate-900">Ouvrir</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' : 'schéma' }} ? Cette action est irréversible."
class="ml-3 cursor-pointer 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>
@@ -19,6 +19,10 @@
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.*') ? 'bg-slate-800 text-white' : '' }}"> class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.*') ? 'bg-slate-800 text-white' : '' }}">
Organisations Organisations
</a> </a>
<a href="{{ route('providers.index') }}"
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('providers.*') ? 'bg-slate-800 text-white' : '' }}">
Prestataires
</a>
@if ($currentOrganization) @if ($currentOrganization)
@php @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' : '' }}"> class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.dashboard') ? 'bg-slate-800 text-white' : '' }}">
Tableau de bord Tableau de bord
</a> </a>
<a href="{{ route('organizations.identity-card', $currentOrganization) }}"
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.identity-card') ? 'bg-slate-800 text-white' : '' }}">
Fiche d'identité
</a>
<a href="{{ route('organizations.todos.index', $currentOrganization) }}" <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' : '' }}"> class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.todos.*') ? 'bg-slate-800 text-white' : '' }}">
ToDo 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' : '' }}"> class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.documents.*') ? 'bg-slate-800 text-white' : '' }}">
Documents Documents
</a> </a>
<a href="{{ route('organizations.infrastructure.index', $currentOrganization) }}"
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.infrastructure.*') ? 'bg-slate-800 text-white' : '' }}">
Infrastructure
</a>
<a href="{{ route('organizations.virtual-machines.index', $currentOrganization) }}" <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' : '' }}"> class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.virtual-machines.*') ? 'bg-slate-800 text-white' : '' }}">
Machines virtuelles Machines virtuelles
@@ -131,6 +143,10 @@
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('users.*') ? 'bg-slate-800 text-white' : '' }}"> class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('users.*') ? 'bg-slate-800 text-white' : '' }}">
Utilisateurs Utilisateurs
</a> </a>
<a href="{{ route('settings.env') }}"
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('settings.env') ? 'bg-slate-800 text-white' : '' }}">
Configuration
</a>
</div> </div>
@endrole @endrole
</nav> </nav>
@@ -18,6 +18,8 @@ new class extends Component
public string $country = ''; public string $country = '';
public string $phone = ''; public string $phone = '';
public bool $is_primary = false; public bool $is_primary = false;
public ?float $latitude = null;
public ?float $longitude = null;
public function mount(Organization $organization, ?Location $location = null): void public function mount(Organization $organization, ?Location $location = null): void
{ {
@@ -34,6 +36,8 @@ new class extends Component
$this->country = $location->country ?? ''; $this->country = $location->country ?? '';
$this->phone = $location->phone ?? ''; $this->phone = $location->phone ?? '';
$this->is_primary = $location->is_primary; $this->is_primary = $location->is_primary;
$this->latitude = $location->latitude;
$this->longitude = $location->longitude;
} }
} }
@@ -49,6 +53,8 @@ new class extends Component
'country' => 'nullable|string|max:255', 'country' => 'nullable|string|max:255',
'phone' => 'nullable|string|max:255', 'phone' => 'nullable|string|max:255',
'is_primary' => 'boolean', 'is_primary' => 'boolean',
'latitude' => 'nullable|numeric|between:-90,90',
'longitude' => 'nullable|numeric|between:-180,180',
]); ]);
if ($validated['is_primary']) { if ($validated['is_primary']) {
@@ -127,6 +133,28 @@ new class extends Component
</div> </div>
</div> </div>
<div x-data="locationMap({{ $latitude ?? 'null' }}, {{ $longitude ?? 'null' }})">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700">Latitude</label>
<input type="number" step="any" min="-90" max="90" wire:model="latitude"
x-ref="latInput" x-on:input="onFieldInput()"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('latitude') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Longitude</label>
<input type="number" step="any" min="-180" max="180" wire:model="longitude"
x-ref="lngInput" x-on:input="onFieldInput()"
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('longitude') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
</div>
<div class="mt-3 h-64 w-full overflow-hidden rounded border border-slate-200" x-ref="mapEl" wire:ignore></div>
<p class="mt-1 text-xs text-slate-500">Renseignez la latitude et la longitude pour positionner le site sur la carte.</p>
</div>
<label class="flex items-center gap-2 text-sm text-slate-700"> <label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" wire:model="is_primary" class="rounded border-slate-300"> <input type="checkbox" wire:model="is_primary" class="rounded border-slate-300">
Site principal Site principal
@@ -141,3 +169,74 @@ new class extends Component
</div> </div>
</form> </form>
</div> </div>
@once
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script>
(function () {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
link.integrity = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
link.crossOrigin = '';
document.head.appendChild(link);
// Leaflet normally auto-detects its marker image path by scanning
// <link> tags already in the document when leaflet.js runs — but
// that link is injected above, after leaflet.js has loaded, so
// detection fails. Point the icons at the CDN explicitly instead.
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
});
})();
function locationMap(initialLat, initialLng) {
return {
map: null,
marker: null,
init() {
const hasCoords = initialLat !== null && initialLng !== null;
this.map = L.map(this.$refs.mapEl).setView(
hasCoords ? [initialLat, initialLng] : [46.6, 2.4], // France, vue d'ensemble par défaut
hasCoords ? 14 : 5
);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19,
}).addTo(this.map);
if (hasCoords) {
this.marker = L.marker([initialLat, initialLng]).addTo(this.map);
}
},
onFieldInput() {
const lat = parseFloat(this.$refs.latInput.value);
const lng = parseFloat(this.$refs.lngInput.value);
if (isNaN(lat) || isNaN(lng)) {
if (this.marker) {
this.map.removeLayer(this.marker);
this.marker = null;
}
return;
}
if (this.marker) {
this.marker.setLatLng([lat, lng]);
} else {
this.marker = L.marker([lat, lng]).addTo(this.map);
}
this.map.setView([lat, lng], Math.max(this.map.getZoom(), 14));
},
};
}
</script>
@endonce
@@ -33,6 +33,18 @@ new class extends Component
->orderByDesc('is_primary') ->orderByDesc('is_primary')
->orderBy('name') ->orderBy('name')
->paginate(15), ->paginate(15),
'mappedLocations' => $this->organization->locations()
->whereNotNull('latitude')
->whereNotNull('longitude')
->orderBy('name')
->get(['id', 'name', 'city', 'latitude', 'longitude'])
->map(fn ($location) => [
'name' => $location->name,
'city' => $location->city,
'latitude' => $location->latitude,
'longitude' => $location->longitude,
'edit_url' => route('organizations.locations.edit', [$this->organization, $location]),
]),
]; ];
} }
}; };
@@ -50,6 +62,11 @@ new class extends Component
</a> </a>
</div> </div>
@if ($mappedLocations->isNotEmpty())
<div class="mb-6 h-80 w-full overflow-hidden rounded border border-slate-200"
x-data="sitesMap(@js($mappedLocations))" wire:ignore></div>
@endif
<input type="text" wire:model.live.debounce.300ms="search" placeholder="Filtrer par nom ou ville..." <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"> class="mb-4 w-full max-w-sm rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@@ -102,3 +119,60 @@ new class extends Component
{{ $locations->links() }} {{ $locations->links() }}
</div> </div>
</div> </div>
@once
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script>
(function () {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
link.integrity = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
link.crossOrigin = '';
document.head.appendChild(link);
// Leaflet normally auto-detects its marker image path by scanning
// <link> tags already in the document when leaflet.js runs — but
// that link is injected above, after leaflet.js has loaded, so
// detection fails. Point the icons at the CDN explicitly instead.
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
});
})();
function sitesMap(locations) {
return {
init() {
const map = L.map(this.$el);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19,
}).addTo(map);
const markers = locations.map(loc => {
const marker = L.marker([loc.latitude, loc.longitude]).addTo(map);
const link = document.createElement('a');
link.href = loc.edit_url;
link.className = 'font-medium text-slate-900 hover:underline';
link.textContent = loc.city ? `${loc.name} — ${loc.city}` : loc.name;
marker.bindPopup(link);
return marker;
});
if (markers.length === 1) {
map.setView(markers[0].getLatLng(), 14);
} else {
map.fitBounds(L.featureGroup(markers).getBounds().pad(0.2));
}
},
};
}
</script>
@endonce
@@ -1,6 +1,9 @@
<?php <?php
use App\Models\Organization; use App\Models\Organization;
use App\Models\Todo;
use App\Models\User;
use App\Services\Glpi\GlpiClient;
use Livewire\Component; use Livewire\Component;
new class extends Component new class extends Component
@@ -10,6 +13,13 @@ new class extends Component
public string $newLogCategory = 'info'; public string $newLogCategory = 'info';
public string $newLogNote = ''; public string $newLogNote = '';
public bool $showTodoForm = false;
public string $newTodoTitle = '';
public string $newTodoCategory = 'infra';
public string $newTodoCriticality = 'medium';
public ?string $newTodoDueDate = null;
public ?int $newTodoAssignedTo = null;
public int $activityPage = 1; public int $activityPage = 1;
const ACTIVITY_PER_PAGE = 4; const ACTIVITY_PER_PAGE = 4;
@@ -44,6 +54,33 @@ new class extends Component
]); ]);
} }
public function toggleTodoForm(): void
{
$this->showTodoForm = ! $this->showTodoForm;
}
public function addTodo(): void
{
$validated = $this->validate([
'newTodoTitle' => 'required|string|max:255',
'newTodoCategory' => 'required|in:'.implode(',', array_keys(Todo::CATEGORIES)),
'newTodoCriticality' => 'required|in:'.implode(',', array_keys(Todo::CRITICALITIES)),
'newTodoDueDate' => 'nullable|date',
'newTodoAssignedTo' => 'nullable|exists:users,id',
]);
$this->organization->todos()->create([
'title' => $validated['newTodoTitle'],
'category' => $validated['newTodoCategory'],
'criticality' => $validated['newTodoCriticality'],
'due_date' => $validated['newTodoDueDate'],
'assigned_to' => $validated['newTodoAssignedTo'],
'created_by' => auth()->id(),
]);
$this->reset(['newTodoTitle', 'newTodoCategory', 'newTodoCriticality', 'newTodoDueDate', 'newTodoAssignedTo', 'showTodoForm']);
}
public function previousActivityPage(): void public function previousActivityPage(): void
{ {
$this->activityPage = max(1, $this->activityPage - 1); $this->activityPage = max(1, $this->activityPage - 1);
@@ -127,7 +164,20 @@ new class extends Component
->limit(8) ->limit(8)
->get(); ->get();
$glpi = ['tickets' => [], 'total' => 0, 'error' => null];
$glpiNewTicketUrl = null;
if ($this->organization->glpi_entity_id !== null) {
$glpiClient = app(GlpiClient::class);
$glpi = $glpiClient->recentTicketsForEntity($this->organization->glpi_entity_id);
$glpiNewTicketUrl = $glpiClient->frontUrl().'/front/ticket.form.php';
}
return [ return [
'glpiTickets' => $glpi['tickets'],
'glpiTicketsTotal' => $glpi['total'],
'glpiError' => $glpi['error'],
'glpiNewTicketUrl' => $glpiNewTicketUrl,
'counts' => [ 'counts' => [
'contacts' => $this->organization->contacts()->count(), 'contacts' => $this->organization->contacts()->count(),
'locations' => $this->organization->locations()->count(), 'locations' => $this->organization->locations()->count(),
@@ -141,6 +191,7 @@ new class extends Component
'recentActivity' => $recentActivity, 'recentActivity' => $recentActivity,
'activityPageCount' => $activityPageCount, 'activityPageCount' => $activityPageCount,
'openTodos' => $openTodos, 'openTodos' => $openTodos,
'assignableUsers' => User::role(['Administrateur', 'Technicien'])->orderBy('name')->get(),
'logEntries' => $this->organization->logEntries()->with('creator')->limit(20)->get(), 'logEntries' => $this->organization->logEntries()->with('creator')->limit(20)->get(),
'categories' => \App\Models\OrganizationLogEntry::CATEGORIES, 'categories' => \App\Models\OrganizationLogEntry::CATEGORIES,
]; ];
@@ -237,12 +288,12 @@ new class extends Component
@if ($activityPageCount > 1) @if ($activityPageCount > 1)
<div class="mt-3 flex items-center justify-between border-t border-slate-100 pt-3 text-xs text-slate-500"> <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) <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"> class="cursor-pointer text-slate-600 hover:text-slate-900 disabled:cursor-not-allowed disabled:text-slate-300 disabled:hover:text-slate-300">
&larr; Précédent &larr; Précédent
</button> </button>
<span>Page {{ $activityPage }} / {{ $activityPageCount }}</span> <span>Page {{ $activityPage }} / {{ $activityPageCount }}</span>
<button type="button" wire:click="nextActivityPage" @disabled($activityPage >= $activityPageCount) <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"> class="cursor-pointer text-slate-600 hover:text-slate-900 disabled:cursor-not-allowed disabled:text-slate-300 disabled:hover:text-slate-300">
Suivant &rarr; Suivant &rarr;
</button> </button>
</div> </div>
@@ -254,11 +305,55 @@ new class extends Component
<div class="rounded border border-slate-200 bg-white p-4"> <div class="rounded border border-slate-200 bg-white p-4">
<div class="mb-3 flex items-center justify-between"> <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> <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"> <div class="flex items-center gap-3">
Voir toutes les tâches &rarr; <button type="button" wire:click="toggleTodoForm"
</a> class="cursor-pointer text-xs text-slate-500 hover:text-slate-900">
{{ $showTodoForm ? 'Annuler' : '+ Nouvelle tâche' }}
</button>
<a href="{{ route('organizations.todos.index', $organization) }}" class="text-xs text-slate-500 hover:text-slate-900">
Voir toutes les tâches &rarr;
</a>
</div>
</div> </div>
@if ($showTodoForm)
<form wire:submit="addTodo" class="mb-4 space-y-2 rounded border border-slate-100 bg-slate-50 p-3">
<div>
<input type="text" wire:model="newTodoTitle" placeholder="Titre de la tâche"
class="w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('newTodoTitle') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
<select wire:model="newTodoCategory"
class="rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@foreach (Todo::CATEGORIES as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
<select wire:model="newTodoCriticality"
class="rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@foreach (Todo::CRITICALITIES as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
<input type="date" wire:model="newTodoDueDate"
class="rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<select wire:model="newTodoAssignedTo"
class="rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<option value="">Assigné à —</option>
@foreach ($assignableUsers as $user)
<option value="{{ $user->id }}">{{ $user->name }}</option>
@endforeach
</select>
</div>
<div class="flex items-center gap-3 pt-1">
<button type="submit" class="cursor-pointer rounded bg-slate-900 px-3 py-1.5 text-sm text-white hover:bg-slate-700">
Créer la tâche
</button>
</div>
</form>
@endif
<ul class="divide-y divide-slate-100"> <ul class="divide-y divide-slate-100">
@forelse ($openTodos as $todo) @forelse ($openTodos as $todo)
<li wire:key="dash-todo-{{ $todo->id }}" class="flex items-center justify-between gap-4 py-2 text-sm"> <li wire:key="dash-todo-{{ $todo->id }}" class="flex items-center justify-between gap-4 py-2 text-sm">
@@ -308,7 +403,7 @@ new class extends Component
</select> </select>
<textarea wire:model="newLogNote" rows="2" placeholder="Projet en cours, point d'attention, intervention prévue..." <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> 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> <button type="submit" class="cursor-pointer rounded bg-slate-900 px-3 py-1.5 text-sm text-white hover:bg-slate-700">Ajouter</button>
</div> </div>
@error('newLogNote') <p class="text-sm text-red-600">{{ $message }}</p> @enderror @error('newLogNote') <p class="text-sm text-red-600">{{ $message }}</p> @enderror
</form> </form>
@@ -331,7 +426,7 @@ new class extends Component
</div> </div>
<button type="button" wire:click="deleteLogEntry({{ $entry->id }})" <button type="button" wire:click="deleteLogEntry({{ $entry->id }})"
wire:confirm="Supprimer cette entrée ?" wire:confirm="Supprimer cette entrée ?"
class="shrink-0 text-xs text-red-500 hover:text-red-700"> class="cursor-pointer shrink-0 text-xs text-red-500 hover:text-red-700">
Supprimer Supprimer
</button> </button>
</div> </div>
@@ -341,4 +436,84 @@ new class extends Component
</div> </div>
</div> </div>
</div> </div>
@if ($organization->glpi_entity_id !== null)
<div class="mt-6 rounded border border-slate-200 bg-white p-4">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Tickets GLPI</h2>
<a href="{{ $glpiNewTicketUrl }}" target="_blank" rel="noopener"
class="text-xs text-slate-500 hover:text-slate-900">
Nouveau ticket &rarr;
</a>
</div>
@if ($glpiError)
<p class="text-sm text-amber-600">{{ $glpiError }}</p>
@elseif (empty($glpiTickets))
<p class="text-sm text-slate-400">Aucun ticket non clos pour le moment.</p>
@else
<ul class="divide-y divide-slate-100">
@foreach ($glpiTickets as $ticket)
<li class="py-2.5 text-sm {{ $ticket['awaiting_technician'] ? '-mx-4 border-l-4 border-amber-400 bg-amber-50 px-4' : '' }}">
<div class="flex items-start justify-between gap-4">
<a href="{{ $ticket['url'] }}" target="_blank" rel="noopener"
class="min-w-0 font-medium text-slate-900 hover:underline">
@if ($ticket['awaiting_technician'])
<span aria-hidden="true" title="En attente d'une réponse du technicien">⚠️</span>
@endif
#{{ $ticket['id'] }} — {{ $ticket['title'] }}
</a>
<div class="flex shrink-0 items-center gap-1.5">
<span class="rounded-full px-2 py-0.5 text-xs font-medium
{{ match(true) {
$ticket['priority'] >= 5 => 'bg-red-100 text-red-700',
$ticket['priority'] === 4 => 'bg-amber-100 text-amber-700',
default => 'bg-slate-100 text-slate-600',
} }}">
{{ $ticket['priority_label'] }}
</span>
<span class="rounded-full px-2 py-0.5 text-xs font-medium
{{ match(true) {
$ticket['status'] === 5 => 'bg-emerald-100 text-emerald-700',
$ticket['status'] === 4 => 'bg-amber-100 text-amber-700',
default => 'bg-blue-100 text-blue-700',
} }}">
{{ $ticket['status_label'] }}
</span>
</div>
</div>
@if ($ticket['excerpt'])
<p class="mt-1 text-xs text-slate-600">{{ $ticket['excerpt'] }}</p>
@endif
<p class="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-slate-400">
<span>{{ \Illuminate\Support\Carbon::parse($ticket['date_mod'])->diffForHumans() }}</span>
@if ($ticket['category'])
<span aria-hidden="true">&middot;</span>
<span>{{ $ticket['category'] }}</span>
@endif
@if ($ticket['technician'])
<span aria-hidden="true">&middot;</span>
<span class="inline-flex items-center gap-1" title="Technicien responsable">
<span aria-hidden="true">👤</span> {{ $ticket['technician'] }}
</span>
@endif
<span aria-hidden="true">&middot;</span>
<span class="inline-flex items-center gap-1" title="Échanges entre le technicien et le demandeur (suivis + tâches)">
<span aria-hidden="true">💬</span> {{ $ticket['interactions_count'] }}
</span>
</p>
</li>
@endforeach
</ul>
@if ($glpiTicketsTotal > count($glpiTickets))
<p class="mt-3 border-t border-slate-100 pt-3 text-xs text-slate-500">
{{ $glpiTicketsTotal }} tickets non clos au total pour cette entité.
</p>
@endif
@endif
</div>
@endif
</div> </div>
@@ -17,6 +17,7 @@ new class extends Component
public string $status = 'active'; public string $status = 'active';
public string $description = ''; public string $description = '';
public string $allowed_ip_ranges = ''; public string $allowed_ip_ranges = '';
public ?int $glpi_entity_id = null;
public $logo = null; public $logo = null;
@@ -29,6 +30,7 @@ new class extends Component
$this->status = $organization->status; $this->status = $organization->status;
$this->description = $organization->description ?? ''; $this->description = $organization->description ?? '';
$this->allowed_ip_ranges = implode("\n", $organization->allowed_ip_ranges ?? []); $this->allowed_ip_ranges = implode("\n", $organization->allowed_ip_ranges ?? []);
$this->glpi_entity_id = $organization->glpi_entity_id;
} }
} }
@@ -40,6 +42,7 @@ new class extends Component
'status' => 'required|in:active,inactive', 'status' => 'required|in:active,inactive',
'description' => 'nullable|string', 'description' => 'nullable|string',
'logo' => 'nullable|image|max:2048', 'logo' => 'nullable|image|max:2048',
'glpi_entity_id' => 'nullable|integer|min:0',
]); ]);
unset($validated['logo']); unset($validated['logo']);
@@ -104,7 +107,8 @@ new class extends Component
@endif @endif
</div> </div>
<form wire:submit="save" class="space-y-4 rounded border border-slate-200 bg-white p-6"> <form wire:submit="save" class="space-y-4 rounded border border-slate-200 bg-white p-6"
wire:loading.class="opacity-60 pointer-events-none" wire:target="save">
<div x-data="logoCropper($wire)"> <div x-data="logoCropper($wire)">
<label class="block text-sm font-medium text-slate-700">Logo</label> <label class="block text-sm font-medium text-slate-700">Logo</label>
<div class="mt-1 flex items-center gap-4"> <div class="mt-1 flex items-center gap-4">
@@ -137,11 +141,20 @@ new class extends Component
<input type="range" :min="minZoom" :max="minZoom * 4" step="0.001" x-model.number="zoom" x-on:input="applyTransform()" class="flex-1"> <input type="range" :min="minZoom" :max="minZoom * 4" step="0.001" x-model.number="zoom" x-on:input="applyTransform()" class="flex-1">
</div> </div>
<p x-show="uploadError" x-cloak class="mt-3 text-sm text-red-600" x-text="uploadError"></p>
<div class="mt-4 flex items-center justify-end gap-3"> <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="cancelCrop()" :disabled="uploading"
<button type="button" x-on:click="confirmCrop()" class="text-sm text-slate-500 hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-60">
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700"> Annuler
Valider le recadrage </button>
<button type="button" x-on:click="confirmCrop()" :disabled="uploading"
class="inline-flex items-center gap-2 rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-60">
<svg x-show="uploading" x-cloak class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span x-text="uploading ? 'Envoi en cours…' : 'Valider le recadrage'"></span>
</button> </button>
</div> </div>
</div> </div>
@@ -189,12 +202,26 @@ new class extends Component
<p class="mt-1 text-xs text-slate-500">Laisser vide pour autoriser l'accès depuis n'importe quelle IP.</p> <p class="mt-1 text-xs text-slate-500">Laisser vide pour autoriser l'accès depuis n'importe quelle IP.</p>
</div> </div>
<div>
<label class="block text-sm font-medium text-slate-700">ID entité GLPI</label>
<input type="number" min="0" wire:model="glpi_entity_id"
class="mt-1 w-full max-w-xs rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<p class="mt-1 text-xs text-slate-500">Lie cette organisation à une entité GLPI pour afficher ses tickets sur le tableau de bord. Laisser vide pour désactiver.</p>
@error('glpi_entity_id') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div class="flex items-center gap-3 pt-2"> <div class="flex items-center gap-3 pt-2">
<button type="submit" <button type="submit" wire:loading.attr="disabled" wire:target="save"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700"> class="inline-flex items-center gap-2 rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-60">
Enregistrer <svg wire:loading wire:target="save" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span wire:loading.remove wire:target="save">Enregistrer</span>
<span wire:loading wire:target="save">Enregistrement…</span>
</button> </button>
<a href="{{ route('organizations.index') }}" class="text-sm text-slate-500 hover:text-slate-900">Annuler</a> <a href="{{ route('organizations.index') }}" wire:loading.class="pointer-events-none opacity-60" wire:target="save"
class="text-sm text-slate-500 hover:text-slate-900">Annuler</a>
</div> </div>
</form> </form>
</div> </div>
@@ -218,6 +245,8 @@ new class extends Component
dragOffsetStartY: 0, dragOffsetStartY: 0,
fileName: 'logo.png', fileName: 'logo.png',
fileType: 'image/png', fileType: 'image/png',
uploading: false,
uploadError: null,
onFileSelected(event) { onFileSelected(event) {
const file = event.target.files[0]; const file = event.target.files[0];
@@ -311,6 +340,8 @@ new class extends Component
canvas.height = viewport; canvas.height = viewport;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
this.uploadError = null;
const img = new Image(); const img = new Image();
img.onload = () => { img.onload = () => {
ctx.drawImage( ctx.drawImage(
@@ -324,10 +355,15 @@ new class extends Component
canvas.toBlob((blob) => { canvas.toBlob((blob) => {
const croppedFile = new File([blob], this.fileName, { type: this.fileType }); const croppedFile = new File([blob], this.fileName, { type: this.fileType });
this.uploading = true;
wire.$upload('logo', croppedFile, () => { wire.$upload('logo', croppedFile, () => {
this.uploading = false;
this.cropping = false; this.cropping = false;
this.imageSrc = null; this.imageSrc = null;
}); }, () => {
this.uploading = false;
this.uploadError = "Échec de l'envoi de l'image. Réessayez.";
}, () => {});
}, this.fileType); }, this.fileType);
}; };
img.src = this.imageSrc; img.src = this.imageSrc;
@@ -0,0 +1,198 @@
<?php
use App\Models\Organization;
use App\Models\Provider;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
new class extends Component
{
public Organization $organization;
public ?int $technicianUserId = null;
public string $ispName = '';
public string $publicIpsText = '';
public ?int $newProviderId = null;
public string $newProviderLabel = '';
public function mount(Organization $organization): void
{
$this->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(),
];
}
};
?>
<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">Fiche d'identité informatique</h1>
@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
<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">Technicien gestionnaire</label>
<select wire:model="technicianUserId"
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 ($technicians as $technician)
<option value="{{ $technician->id }}">{{ $technician->name }}</option>
@endforeach
</select>
@error('technicianUserId') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Fournisseur d'accès internet</label>
<input type="text" wire:model="ispName" placeholder="Orange, Free Pro, Telindus..."
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@error('ispName') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-slate-700">IP(s) publiques</label>
<textarea wire:model="publicIpsText" rows="3" placeholder="Une adresse IP par ligne"
class="mt-1 w-full rounded border-slate-300 font-mono text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500"></textarea>
@error('publicIpsText') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div class="pt-2">
<button type="submit"
class="cursor-pointer rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
Enregistrer
</button>
</div>
</form>
<div class="mt-8 rounded border border-slate-200 bg-white p-6">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">Prestataires</h2>
<ul class="mb-4 divide-y divide-slate-100">
@forelse ($providers as $provider)
<li wire:key="provider-{{ $provider->pivot->id }}" class="flex items-center gap-3 py-2 text-sm">
<img src="{{ $provider->logo_url }}" alt="Logo {{ $provider->name }}"
class="h-6 w-6 shrink-0 rounded border border-slate-200 bg-white object-contain p-0.5">
<div class="flex-1">
<a href="{{ route('providers.edit', $provider) }}" class="font-medium text-slate-900 hover:underline">
{{ $provider->name }}
</a>
@if ($provider->pivot->label)
<span class="text-xs text-slate-500">&middot; {{ $provider->pivot->label }}</span>
@endif
</div>
<button type="button" wire:click="removeProvider({{ $provider->pivot->id }})"
wire:confirm="Retirer ce prestataire ?"
class="cursor-pointer shrink-0 text-xs text-red-500 hover:text-red-700">
Retirer
</button>
</li>
@empty
<li class="py-2 text-sm text-slate-400">Aucun prestataire lié.</li>
@endforelse
</ul>
<form wire:submit="addProvider" class="flex flex-wrap items-start gap-2 rounded border border-slate-100 bg-slate-50 p-3">
<div class="flex-1">
<select wire:model="newProviderId"
class="w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<option value="">Sélectionner un prestataire...</option>
@foreach ($availableProviders as $availableProvider)
<option value="{{ $availableProvider->id }}">{{ $availableProvider->name }}</option>
@endforeach
</select>
@error('newProviderId') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div class="flex-1">
<input type="text" wire:model="newProviderLabel" placeholder="Libellé (ex: Téléphonie, Maintenance copieurs)"
class="w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
</div>
<button type="submit" class="cursor-pointer rounded bg-slate-900 px-3 py-1.5 text-sm text-white hover:bg-slate-700">
Ajouter
</button>
</form>
@if ($availableProviders->isEmpty())
<p class="mt-3 text-xs text-slate-500">
Aucun prestataire n'existe encore.
<a href="{{ route('providers.create') }}" class="underline hover:text-slate-900">En créer un</a>.
</p>
@endif
</div>
</div>
@@ -0,0 +1,217 @@
<?php
use App\Models\Provider;
use App\Models\ProviderLogEntry;
use Illuminate\Support\Facades\Storage;
use Livewire\Component;
use Livewire\WithFileUploads;
new class extends Component
{
use WithFileUploads;
public ?Provider $provider = null;
public string $name = '';
public string $phone = '';
public string $email = '';
public string $website = '';
public string $notes = '';
public $logo = null;
public string $newLogCategory = 'contact';
public string $newLogNote = '';
public function mount(?Provider $provider = null): void
{
if ($provider?->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,
];
}
};
?>
<div class="max-w-2xl">
<h1 class="mb-6 text-2xl font-semibold text-slate-900">
{{ $provider?->exists ? 'Modifier le prestataire' : 'Nouveau prestataire' }}
</h1>
@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
<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">Logo</label>
<div class="mt-1 flex items-center gap-4">
<img src="{{ $logo ? $logo->temporaryUrl() : ($provider?->logo_url ?? asset('images/default-organization-logo.svg')) }}"
class="h-16 w-16 rounded border border-slate-200 object-contain p-1" alt="Logo">
<input type="file" wire:model="logo" accept="image/*"
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">
</div>
@error('logo') <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="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">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">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>
<label class="block text-sm font-medium text-slate-700">Site web</label>
<input type="text" wire:model="website" placeholder="https://..."
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">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="cursor-pointer rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
Enregistrer
</button>
<a href="{{ route('providers.index') }}" class="text-sm text-slate-500 hover:text-slate-900">Annuler</a>
</div>
</form>
@if ($provider?->exists)
<div class="mt-8 rounded border border-slate-200 bg-white p-6">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">Journal de suivi</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="Ex: mail envoyé le 21/08 pour demander des précisions..."
class="flex-1 rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500"></textarea>
<button type="submit" class="cursor-pointer rounded bg-slate-900 px-3 py-1.5 text-sm text-white hover:bg-slate-700">Ajouter</button>
</div>
@error('newLogNote') <p class="text-sm text-red-600">{{ $message }}</p> @enderror
</form>
<div class="space-y-3">
@forelse ($logEntries as $entry)
<div wire:key="log-{{ $entry->id }}" class="flex items-start justify-between gap-4 border-b border-slate-100 pb-3 last:border-0 last:pb-0">
<div>
<span class="mb-1 inline-block rounded-full px-2 py-0.5 text-xs font-medium
{{ match($entry->category) {
'contact' => 'bg-blue-100 text-blue-700',
'observation' => 'bg-slate-100 text-slate-600',
'incident' => 'bg-red-100 text-red-700',
default => 'bg-slate-100 text-slate-600',
} }}">
{{ $entry->categoryLabel() }}
</span>
<p class="whitespace-pre-line text-sm text-slate-900">{{ $entry->note }}</p>
<p class="mt-1 text-xs text-slate-500">{{ $entry->creator?->name ?? 'système' }} &middot; {{ $entry->created_at->diffForHumans() }}</p>
</div>
<button type="button" wire:click="deleteLogEntry({{ $entry->id }})"
wire:confirm="Supprimer cette entrée ?"
class="cursor-pointer shrink-0 text-xs text-red-500 hover:text-red-700">
Supprimer
</button>
</div>
@empty
<p class="text-sm text-slate-400">Aucune entrée dans le journal pour le moment.</p>
@endforelse
</div>
</div>
@endif
</div>
@@ -0,0 +1,100 @@
<?php
use App\Models\Provider;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
new class extends Component
{
use WithPagination;
#[Url]
public string $search = '';
public function delete(int $providerId): void
{
$provider = Provider::findOrFail($providerId);
$provider->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),
];
}
};
?>
<div>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-semibold text-slate-900">Prestataires</h1>
<a href="{{ route('providers.create') }}"
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
+ Nouveau prestataire
</a>
</div>
@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
<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">Téléphone</th>
<th class="px-4 py-3">Email</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@forelse ($providers as $provider)
<tr wire:key="provider-{{ $provider->id }}">
<td class="px-4 py-3">
<img src="{{ $provider->logo_url }}" alt="Logo {{ $provider->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('providers.edit', $provider) }}" class="font-medium text-slate-900 hover:underline">
{{ $provider->name }}
</a>
</td>
<td class="px-4 py-3 text-slate-600">{{ $provider->phone ?? '—' }}</td>
<td class="px-4 py-3 text-slate-600">{{ $provider->email ?? '—' }}</td>
<td class="px-4 py-3 text-right">
<a href="{{ route('providers.edit', $provider) }}" class="text-slate-500 hover:text-slate-900">Modifier</a>
<button type="button"
wire:click="delete({{ $provider->id }})"
wire:confirm="Supprimer ce prestataire ? Cette action est irréversible."
class="ml-3 cursor-pointer 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 prestataire pour le moment.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">
{{ $providers->links() }}
</div>
</div>
@@ -0,0 +1,138 @@
<?php
use App\Models\Setting;
use App\Services\SettingsRegistry;
use Illuminate\Support\Facades\Cache;
use Livewire\Component;
new class extends Component
{
/** @var array<string, string> */
public array $values = [];
public function mount(): void
{
$this->loadValues();
}
public function save(): void
{
foreach ($this->values as $key => $value) {
if (str_contains($value, "\n") || str_contains($value, "\r")) {
$this->addError('values', "La valeur de {$key} ne peut pas contenir de retour à la ligne.");
return;
}
}
foreach (SettingsRegistry::configMap() as $envKey => $path) {
$value = trim($this->values[$envKey] ?? '');
Setting::updateOrCreate(['key' => $envKey], ['value' => $value === '' ? null : $value]);
}
Cache::forget(SettingsRegistry::CACHE_KEY);
session()->flash('status', 'Configuration enregistrée. Les nouvelles valeurs sont actives immédiatement.');
$this->loadValues();
}
public function resetToDefault(string $envKey): void
{
if (! array_key_exists($envKey, SettingsRegistry::configMap())) {
return;
}
Setting::where('key', $envKey)->delete();
Cache::forget(SettingsRegistry::CACHE_KEY);
session()->flash('status', "Valeur par défaut restaurée pour {$envKey}.");
$this->loadValues();
}
protected function loadValues(): void
{
$stored = Setting::query()->pluck('value', 'key');
foreach (SettingsRegistry::configMap() as $envKey => $path) {
$this->values[$envKey] = $stored[$envKey] ?? (string) (config($path) ?? '');
}
}
};
?>
<div class="max-w-3xl">
<h1 class="mb-2 text-2xl font-semibold text-slate-900">Configuration</h1>
<p class="mb-6 text-sm text-slate-500">
Ces réglages sont stockés en base de données et prennent effet immédiatement, sans
redémarrage. Les paramètres d'infrastructure (base de données, clé de chiffrement,
pilote de session…) ne sont pas modifiables ici — ils doivent être changés directement
dans le fichier <code class="rounded bg-slate-100 px-1 py-0.5">.env</code> du serveur.
</p>
@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
@error('values')
<div class="mb-4 rounded border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">
{{ $message }}
</div>
@enderror
<form wire:submit="save" class="space-y-8 rounded border border-slate-200 bg-white p-6">
@foreach (\App\Services\SettingsRegistry::SETTINGS as $section => $keys)
<div>
<p class="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-400">{{ $section }}</p>
<div class="space-y-1.5">
@foreach ($keys as $envKey => $meta)
<div class="flex items-center gap-3 py-1">
<label for="setting-{{ $envKey }}" class="w-48 shrink-0 text-sm text-slate-700">
{{ $meta['label'] }}
</label>
@if (\App\Services\SettingsRegistry::isSensitive($envKey))
<div x-data="{ show: false }" class="flex flex-1 items-center gap-2">
<input :type="show ? 'text' : 'password'" id="setting-{{ $envKey }}"
wire:model="values.{{ $envKey }}" autocomplete="off"
class="w-full rounded border-slate-300 font-mono text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
<button type="button" x-on:click="show = !show"
class="shrink-0 text-xs text-slate-500 hover:text-slate-900">
<span x-text="show ? 'Masquer' : 'Révéler'"></span>
</button>
</div>
@else
<input type="text" id="setting-{{ $envKey }}"
wire:model="values.{{ $envKey }}"
class="flex-1 rounded border-slate-300 font-mono text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
@endif
<button type="button" wire:click="resetToDefault('{{ $envKey }}')"
wire:confirm="Revenir à la valeur par défaut de {{ $envKey }} (celle du .env) ?"
class="shrink-0 text-xs text-slate-400 hover:text-slate-700">
Réinitialiser
</button>
</div>
@endforeach
</div>
</div>
@endforeach
<div class="flex items-center gap-3 border-t border-slate-100 pt-6">
<button type="submit" wire:loading.attr="disabled" wire:target="save"
class="inline-flex items-center gap-2 rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-60">
<svg wire:loading wire:target="save" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span wire:loading.remove wire:target="save">Enregistrer</span>
<span wire:loading wire:target="save">Enregistrement…</span>
</button>
</div>
</form>
</div>
+2
View File
@@ -5,6 +5,8 @@
<livewire:documents.markdown-editor :organization="$organization" :document="$document" /> <livewire:documents.markdown-editor :organization="$organization" :document="$document" />
@elseif ($document->isUpload()) @elseif ($document->isUpload())
<livewire:documents.upload-viewer :organization="$organization" :document="$document" /> <livewire:documents.upload-viewer :organization="$organization" :document="$document" />
@elseif ($document->isDiagram())
<livewire:documents.diagram-editor :organization="$organization" :document="$document" />
@else @else
<livewire:documents.form :organization="$organization" :document="$document" /> <livewire:documents.form :organization="$organization" :document="$document" />
@endif @endif
@@ -0,0 +1,4 @@
<x-layouts.app :title="$document->name">
<livewire:documents.diagram-editor :organization="$organization" :document="$document"
back-route="organizations.infrastructure.index" :folder-id="request()->integer('folderId') ?: null" />
</x-layouts.app>
@@ -0,0 +1,3 @@
<x-layouts.app title="Infrastructure">
<livewire:infrastructure.index :organization="$organization" />
</x-layouts.app>
@@ -0,0 +1,3 @@
<x-layouts.app title="Fiche d'identité informatique">
<livewire:organizations.identity-card :organization="$organization" />
</x-layouts.app>
@@ -0,0 +1,3 @@
<x-layouts.app title="Nouveau prestataire">
<livewire:providers.form />
</x-layouts.app>
+3
View File
@@ -0,0 +1,3 @@
<x-layouts.app :title="$provider->name">
<livewire:providers.form :provider="$provider" />
</x-layouts.app>
@@ -0,0 +1,3 @@
<x-layouts.app title="Prestataires">
<livewire:providers.index />
</x-layouts.app>
+3
View File
@@ -0,0 +1,3 @@
<x-layouts.app title="Configuration">
<livewire:settings.env />
</x-layouts.app>
+20
View File
@@ -8,6 +8,7 @@ use App\Models\FlexibleAssetType;
use App\Models\Location; use App\Models\Location;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Password; use App\Models\Password;
use App\Models\Provider;
use App\Models\Shortcut; use App\Models\Shortcut;
use App\Models\Todo; use App\Models\Todo;
use App\Models\User; 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', fn () => view('organizations.index'))->name('organizations.index');
Route::get('/organizations/create', fn () => view('organizations.create'))->name('organizations.create'); 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::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}/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::prefix('/organizations/{organization}/passwords')->name('organizations.passwords.')->group(function () {
Route::get('/', fn (Organization $organization) => view('passwords.index', ['organization' => $organization]))->name('index'); 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::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::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('/', 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'); Route::get('/create', fn (Organization $organization) => view('virtual-machines.create', ['organization' => $organization]))->name('create');
@@ -116,6 +132,10 @@ Route::middleware(['auth'])->group(function () {
Route::get('/{type}/edit', fn (FlexibleAssetType $type) => view('asset-types.edit', ['type' => $type]))->name('edit'); Route::get('/{type}/edit', fn (FlexibleAssetType $type) => view('asset-types.edit', ['type' => $type]))->name('edit');
}); });
Route::get('/settings/env', fn () => view('settings.env'))
->name('settings.env')
->middleware('role:Administrateur');
Route::get('/organizations/{organization}/assets', function (Organization $organization) { Route::get('/organizations/{organization}/assets', function (Organization $organization) {
return view('assets.picker', [ return view('assets.picker', [
'organization' => $organization, 'organization' => $organization,
+222
View File
@@ -0,0 +1,222 @@
<?php
namespace Tests\Feature;
use App\Models\Location;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class InfrastructureTest extends TestCase
{
use RefreshDatabase;
public function test_infrastructure_index_page_renders_for_an_authenticated_user(): void
{
$user = User::factory()->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' => '<mxfile>old</mxfile>',
'current_version' => 1,
'created_by' => $user->id,
]);
$document->versions()->create(['version' => 1, 'content' => '<mxfile>old</mxfile>', 'created_by' => $user->id]);
$this->actingAs($user);
Livewire::test('documents.diagram-editor', ['organization' => $org, 'document' => $document])
->call('saveDiagram', '<mxfile>new</mxfile>');
$document->refresh();
$this->assertSame('<mxfile>new</mxfile>', $document->content);
$this->assertSame(2, $document->current_version);
$this->assertCount(2, $document->versions);
}
public function test_diagrams_do_not_appear_in_the_documents_browser(): void
{
$user = User::factory()->create();
$org = Organization::create(['name' => 'Test Commune', 'slug' => 'test-commune', 'org_type' => 'client']);
$org->documents()->create([
'name' => 'Schéma réseau - Mairie',
'type' => 'diagram',
'content' => '<mxfile></mxfile>',
'current_version' => 1,
'created_by' => $user->id,
]);
$org->documents()->create([
'name' => 'Compte-rendu.md',
'type' => 'markdown',
'content' => '',
'current_version' => 1,
'created_by' => $user->id,
]);
$this->actingAs($user);
Livewire::test('documents.index', ['organization' => $org])
->assertDontSee('Schéma réseau - Mairie')
->assertSee('Compte-rendu.md');
}
public function test_can_create_a_folder_and_a_diagram_inside_it(): void
{
$user = User::factory()->create();
$org = Organization::create(['name' => 'Test Commune', 'slug' => 'test-commune', 'org_type' => 'client']);
$this->actingAs($user);
$component = Livewire::test('infrastructure.index', ['organization' => $org])
->set('newFolderName', 'Réseau')
->call('createFolder')
->assertHasNoErrors();
$folder = $org->documents()->where('type', 'diagram')->where('is_folder', true)->firstOrFail();
$this->assertSame('Réseau', $folder->name);
$component->set('folderId', $folder->id)
->set('newDiagramName', 'Schéma coeur de réseau')
->call('createDiagram')
->assertHasNoErrors();
$diagram = $org->documents()->where('type', 'diagram')->where('name', 'Schéma coeur de réseau')->firstOrFail();
$this->assertSame($folder->id, $diagram->folder_id);
// The folder itself must not leak into the general Documents browser.
Livewire::test('documents.index', ['organization' => $org])->assertDontSee('Réseau');
}
public function test_a_diagram_can_be_associated_with_a_site(): void
{
$user = User::factory()->create();
$org = Organization::create(['name' => 'Test Commune', 'slug' => 'test-commune', 'org_type' => 'client']);
$location = Location::create(['organization_id' => $org->id, 'name' => 'Mairie']);
$document = $org->documents()->create([
'name' => 'Schéma test',
'type' => 'diagram',
'content' => '<mxfile></mxfile>',
'current_version' => 1,
'created_by' => $user->id,
]);
$this->actingAs($user);
Livewire::test('documents.diagram-editor', ['organization' => $org, 'document' => $document])
->call('updateLocation', $location->id)
->assertHasNoErrors();
$this->assertSame($location->id, $document->refresh()->location_id);
Livewire::test('infrastructure.index', ['organization' => $org, 'locationId' => $location->id])
->assertSee('Schéma test');
$otherLocation = Location::create(['organization_id' => $org->id, 'name' => 'Annexe']);
Livewire::test('infrastructure.index', ['organization' => $org, 'locationId' => $otherLocation->id])
->assertDontSee('Schéma test');
}
public function test_can_move_a_diagram_into_a_folder_and_back_to_the_root(): void
{
$user = User::factory()->create();
$org = Organization::create(['name' => 'Test Commune', 'slug' => 'test-commune', 'org_type' => 'client']);
$folder = $org->documents()->create(['name' => 'Réseau', 'type' => 'diagram', 'is_folder' => true, 'created_by' => $user->id]);
$diagram = $org->documents()->create([
'name' => 'Schéma test', 'type' => 'diagram', 'content' => '<mxfile></mxfile>',
'current_version' => 1, 'created_by' => $user->id,
]);
$this->actingAs($user);
Livewire::test('infrastructure.index', ['organization' => $org])
->call('moveDocument', $diagram->id, $folder->id);
$this->assertSame($folder->id, $diagram->refresh()->folder_id);
Livewire::test('infrastructure.index', ['organization' => $org])
->call('moveDocument', $diagram->id, null);
$this->assertNull($diagram->refresh()->folder_id);
}
public function test_cannot_move_a_folder_into_its_own_descendant(): void
{
$user = User::factory()->create();
$org = Organization::create(['name' => 'Test Commune', 'slug' => 'test-commune', 'org_type' => 'client']);
$parent = $org->documents()->create(['name' => 'Parent', 'type' => 'diagram', 'is_folder' => true, 'created_by' => $user->id]);
$child = $org->documents()->create(['name' => 'Enfant', 'type' => 'diagram', 'is_folder' => true, 'folder_id' => $parent->id, 'created_by' => $user->id]);
$this->actingAs($user);
Livewire::test('infrastructure.index', ['organization' => $org])
->call('moveDocument', $parent->id, $child->id);
$this->assertNull($parent->refresh()->folder_id);
}
public function test_moving_an_item_is_scoped_to_diagram_type_documents(): void
{
$user = User::factory()->create();
$org = Organization::create(['name' => 'Test Commune', 'slug' => 'test-commune', 'org_type' => 'client']);
$regularFolder = $org->documents()->create(['name' => 'Dossier documents', 'is_folder' => true, 'created_by' => $user->id]);
$diagram = $org->documents()->create([
'name' => 'Schéma test', 'type' => 'diagram', 'content' => '<mxfile></mxfile>',
'current_version' => 1, 'created_by' => $user->id,
]);
$this->actingAs($user);
// A plain (non-diagram) folder must not be a valid drop target from Infrastructure.
Livewire::test('infrastructure.index', ['organization' => $org])
->call('moveDocument', $diagram->id, $regularFolder->id);
$this->assertNull($diagram->refresh()->folder_id);
}
}
@@ -0,0 +1,157 @@
<?php
namespace Tests\Feature;
use App\Models\Organization;
use App\Models\Provider;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class OrganizationIdentityCardTest extends TestCase
{
use RefreshDatabase;
public function test_identity_card_page_renders_for_an_authenticated_user(): void
{
$this->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());
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace Tests\Feature;
use App\Models\Provider;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class ProviderTest extends TestCase
{
use RefreshDatabase;
public function test_provider_index_page_renders_for_an_authenticated_user(): void
{
$user = User::factory()->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,
]);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class SettingsEnvPageTest extends TestCase
{
use RefreshDatabase;
public function test_admin_settings_page_renders_with_the_new_drawio_section(): void
{
$this->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');
}
}