Compare commits
10
Commits
2d12d3c2b2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e614f2929 | ||
|
|
c1c6d56d1e | ||
|
|
3e5878192e | ||
|
|
3c87ced1e4 | ||
|
|
74c0b01a2e | ||
|
|
c35833ed0b | ||
|
|
da589d0627 | ||
|
|
2f576ca286 | ||
|
|
2bf169ed88 | ||
|
|
01233928cc |
+11
-1
@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
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
|
||||
{
|
||||
use SoftDeletes;
|
||||
@@ -35,6 +35,11 @@ class Document extends Model implements Linkable
|
||||
return $this->type === 'upload';
|
||||
}
|
||||
|
||||
public function isDiagram(): bool
|
||||
{
|
||||
return $this->type === 'diagram';
|
||||
}
|
||||
|
||||
public function renderedMarkdown(): string
|
||||
{
|
||||
return (new CommonMarkConverter())->convert($this->content ?? '')->getContent();
|
||||
@@ -55,6 +60,11 @@ class Document extends Model implements Linkable
|
||||
return $this->belongsTo(Document::class, 'folder_id');
|
||||
}
|
||||
|
||||
public function location()
|
||||
{
|
||||
return $this->belongsTo(Location::class);
|
||||
}
|
||||
|
||||
public function children()
|
||||
{
|
||||
return $this->hasMany(Document::class, 'folder_id');
|
||||
|
||||
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
#[Fillable([
|
||||
'organization_id', 'name', 'address_line_1', 'address_line_2',
|
||||
'city', 'region', 'postal_code', 'country', 'phone', 'is_primary',
|
||||
'latitude', 'longitude',
|
||||
])]
|
||||
class Location extends Model
|
||||
{
|
||||
@@ -15,6 +16,8 @@ class Location extends Model
|
||||
{
|
||||
return [
|
||||
'is_primary' => 'boolean',
|
||||
'latitude' => 'float',
|
||||
'longitude' => 'float',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
#[Fillable(['name', 'slug', 'org_type', 'status', 'description', 'allowed_ip_ranges', 'logo_path'])]
|
||||
#[Fillable(['name', 'slug', 'org_type', 'status', 'description', 'allowed_ip_ranges', 'logo_path', 'glpi_entity_id', 'technician_user_id', 'isp_name', 'public_ips'])]
|
||||
class Organization extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
@@ -17,6 +17,7 @@ class Organization extends Model
|
||||
{
|
||||
return [
|
||||
'allowed_ip_ranges' => 'array',
|
||||
'public_ips' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -86,4 +87,16 @@ class Organization extends Model
|
||||
{
|
||||
return $this->hasMany(Shortcut::class);
|
||||
}
|
||||
|
||||
public function technician()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'technician_user_id');
|
||||
}
|
||||
|
||||
public function providers()
|
||||
{
|
||||
return $this->belongsToMany(Provider::class, 'organization_provider')
|
||||
->withPivot('id', 'label')
|
||||
->withTimestamps();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Services\SettingsRegistry;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Throwable;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -11,7 +15,35 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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. "<p>" 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 "&"), and again because the
|
||||
* search API encodes the whole field for its JSON response. A single
|
||||
* decode leaves artifacts like "&" 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Glpi;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class GlpiException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,12 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->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([
|
||||
'org.ip_access' => \App\Http\Middleware\CheckOrganizationIpAccess::class,
|
||||
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
|
||||
|
||||
+2
-2
@@ -38,8 +38,8 @@ return [
|
||||
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||
'busy_timeout' => null,
|
||||
'journal_mode' => null,
|
||||
'busy_timeout' => env('DB_BUSY_TIMEOUT', 5000),
|
||||
'journal_mode' => env('DB_JOURNAL_MODE', 'wal'),
|
||||
'synchronous' => null,
|
||||
'transaction_mode' => 'DEFERRED',
|
||||
],
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
+25
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -5,6 +5,7 @@ namespace Database\Seeders;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
@@ -19,10 +20,14 @@ class DatabaseSeeder extends Seeder
|
||||
|
||||
$this->call(RolesAndPermissionsSeeder::class);
|
||||
|
||||
$admin = User::factory()->create([
|
||||
'name' => 'Administrateur',
|
||||
'email' => 'administrateur@cabmit.local',
|
||||
]);
|
||||
$admin = User::firstOrCreate(
|
||||
['email' => 'administrateur@cabmit.local'],
|
||||
[
|
||||
'name' => 'Administrateur',
|
||||
'email_verified_at' => now(),
|
||||
'password' => Hash::make('password'),
|
||||
],
|
||||
);
|
||||
$admin->assignRole('Administrateur');
|
||||
|
||||
$this->call(AssetTypeLookupSeeder::class);
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Database\Seeders;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
|
||||
class RolesAndPermissionsSeeder extends Seeder
|
||||
{
|
||||
@@ -22,6 +23,12 @@ class RolesAndPermissionsSeeder extends Seeder
|
||||
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->syncPermissions($permissions);
|
||||
|
||||
|
||||
@@ -25,6 +25,22 @@ new class extends Component
|
||||
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
|
||||
{
|
||||
$listFields = $this->type->fields()->where('show_in_list', true)->get();
|
||||
@@ -94,6 +110,11 @@ new class extends Component
|
||||
@endforeach
|
||||
<td class="px-4 py-3 text-right whitespace-nowrap">
|
||||
<a href="{{ route('organizations.assets.edit', [$organization, $type, $asset]) }}" class="text-slate-500 hover:text-slate-900">Modifier</a>
|
||||
<button type="button"
|
||||
wire:click="duplicate({{ $asset->id }})"
|
||||
class="ml-3 text-slate-500 hover:text-slate-900">
|
||||
Dupliquer
|
||||
</button>
|
||||
<button type="button"
|
||||
wire:click="delete({{ $asset->id }})"
|
||||
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,
|
||||
'items' => $this->organization->documents()
|
||||
->where('folder_id', $this->folderId)
|
||||
->where('type', '!=', 'diagram')
|
||||
->orderByDesc('is_folder')
|
||||
->orderBy('name')
|
||||
->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' : '' }}">
|
||||
Organisations
|
||||
</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)
|
||||
@php
|
||||
@@ -38,6 +42,10 @@
|
||||
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.dashboard') ? 'bg-slate-800 text-white' : '' }}">
|
||||
Tableau de bord
|
||||
</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) }}"
|
||||
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.todos.*') ? 'bg-slate-800 text-white' : '' }}">
|
||||
ToDo
|
||||
@@ -62,6 +70,10 @@
|
||||
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.documents.*') ? 'bg-slate-800 text-white' : '' }}">
|
||||
Documents
|
||||
</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) }}"
|
||||
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.virtual-machines.*') ? 'bg-slate-800 text-white' : '' }}">
|
||||
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' : '' }}">
|
||||
Utilisateurs
|
||||
</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>
|
||||
@endrole
|
||||
</nav>
|
||||
|
||||
@@ -18,6 +18,8 @@ new class extends Component
|
||||
public string $country = '';
|
||||
public string $phone = '';
|
||||
public bool $is_primary = false;
|
||||
public ?float $latitude = null;
|
||||
public ?float $longitude = null;
|
||||
|
||||
public function mount(Organization $organization, ?Location $location = null): void
|
||||
{
|
||||
@@ -34,6 +36,8 @@ new class extends Component
|
||||
$this->country = $location->country ?? '';
|
||||
$this->phone = $location->phone ?? '';
|
||||
$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',
|
||||
'phone' => 'nullable|string|max:255',
|
||||
'is_primary' => 'boolean',
|
||||
'latitude' => 'nullable|numeric|between:-90,90',
|
||||
'longitude' => 'nullable|numeric|between:-180,180',
|
||||
]);
|
||||
|
||||
if ($validated['is_primary']) {
|
||||
@@ -127,6 +133,28 @@ new class extends Component
|
||||
</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">
|
||||
<input type="checkbox" wire:model="is_primary" class="rounded border-slate-300">
|
||||
Site principal
|
||||
@@ -141,3 +169,74 @@ new class extends Component
|
||||
</div>
|
||||
</form>
|
||||
</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: '© <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')
|
||||
->orderBy('name')
|
||||
->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>
|
||||
</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..."
|
||||
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() }}
|
||||
</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: '© <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
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\Todo;
|
||||
use App\Models\User;
|
||||
use App\Services\Glpi\GlpiClient;
|
||||
use Livewire\Component;
|
||||
|
||||
new class extends Component
|
||||
@@ -10,6 +13,13 @@ new class extends Component
|
||||
public string $newLogCategory = 'info';
|
||||
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;
|
||||
|
||||
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
|
||||
{
|
||||
$this->activityPage = max(1, $this->activityPage - 1);
|
||||
@@ -127,7 +164,20 @@ new class extends Component
|
||||
->limit(8)
|
||||
->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 [
|
||||
'glpiTickets' => $glpi['tickets'],
|
||||
'glpiTicketsTotal' => $glpi['total'],
|
||||
'glpiError' => $glpi['error'],
|
||||
'glpiNewTicketUrl' => $glpiNewTicketUrl,
|
||||
'counts' => [
|
||||
'contacts' => $this->organization->contacts()->count(),
|
||||
'locations' => $this->organization->locations()->count(),
|
||||
@@ -141,6 +191,7 @@ new class extends Component
|
||||
'recentActivity' => $recentActivity,
|
||||
'activityPageCount' => $activityPageCount,
|
||||
'openTodos' => $openTodos,
|
||||
'assignableUsers' => User::role(['Administrateur', 'Technicien'])->orderBy('name')->get(),
|
||||
'logEntries' => $this->organization->logEntries()->with('creator')->limit(20)->get(),
|
||||
'categories' => \App\Models\OrganizationLogEntry::CATEGORIES,
|
||||
];
|
||||
@@ -237,12 +288,12 @@ new class extends Component
|
||||
@if ($activityPageCount > 1)
|
||||
<div class="mt-3 flex items-center justify-between border-t border-slate-100 pt-3 text-xs text-slate-500">
|
||||
<button type="button" wire:click="previousActivityPage" @disabled($activityPage <= 1)
|
||||
class="text-slate-600 hover:text-slate-900 disabled:cursor-not-allowed disabled:text-slate-300 disabled:hover:text-slate-300">
|
||||
class="cursor-pointer text-slate-600 hover:text-slate-900 disabled:cursor-not-allowed disabled:text-slate-300 disabled:hover:text-slate-300">
|
||||
← Précédent
|
||||
</button>
|
||||
<span>Page {{ $activityPage }} / {{ $activityPageCount }}</span>
|
||||
<button type="button" wire:click="nextActivityPage" @disabled($activityPage >= $activityPageCount)
|
||||
class="text-slate-600 hover:text-slate-900 disabled:cursor-not-allowed disabled:text-slate-300 disabled:hover:text-slate-300">
|
||||
class="cursor-pointer text-slate-600 hover:text-slate-900 disabled:cursor-not-allowed disabled:text-slate-300 disabled:hover:text-slate-300">
|
||||
Suivant →
|
||||
</button>
|
||||
</div>
|
||||
@@ -254,11 +305,55 @@ new class extends Component
|
||||
<div class="rounded border border-slate-200 bg-white p-4">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Tâches ouvertes</h2>
|
||||
<a href="{{ route('organizations.todos.index', $organization) }}" class="text-xs text-slate-500 hover:text-slate-900">
|
||||
Voir toutes les tâches →
|
||||
</a>
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="button" wire:click="toggleTodoForm"
|
||||
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 →
|
||||
</a>
|
||||
</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">
|
||||
@forelse ($openTodos as $todo)
|
||||
<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>
|
||||
<textarea wire:model="newLogNote" rows="2" placeholder="Projet en cours, point d'attention, intervention prévue..."
|
||||
class="flex-1 rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500"></textarea>
|
||||
<button type="submit" class="rounded bg-slate-900 px-3 py-1.5 text-sm text-white hover:bg-slate-700">Ajouter</button>
|
||||
<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>
|
||||
@@ -331,7 +426,7 @@ new class extends Component
|
||||
</div>
|
||||
<button type="button" wire:click="deleteLogEntry({{ $entry->id }})"
|
||||
wire:confirm="Supprimer cette entrée ?"
|
||||
class="shrink-0 text-xs text-red-500 hover:text-red-700">
|
||||
class="cursor-pointer shrink-0 text-xs text-red-500 hover:text-red-700">
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
@@ -341,4 +436,84 @@ new class extends Component
|
||||
</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 →
|
||||
</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">·</span>
|
||||
<span>{{ $ticket['category'] }}</span>
|
||||
@endif
|
||||
@if ($ticket['technician'])
|
||||
<span aria-hidden="true">·</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">·</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>
|
||||
|
||||
@@ -17,6 +17,7 @@ new class extends Component
|
||||
public string $status = 'active';
|
||||
public string $description = '';
|
||||
public string $allowed_ip_ranges = '';
|
||||
public ?int $glpi_entity_id = null;
|
||||
|
||||
public $logo = null;
|
||||
|
||||
@@ -29,6 +30,7 @@ new class extends Component
|
||||
$this->status = $organization->status;
|
||||
$this->description = $organization->description ?? '';
|
||||
$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',
|
||||
'description' => 'nullable|string',
|
||||
'logo' => 'nullable|image|max:2048',
|
||||
'glpi_entity_id' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
unset($validated['logo']);
|
||||
@@ -104,7 +107,8 @@ new class extends Component
|
||||
@endif
|
||||
</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)">
|
||||
<label class="block text-sm font-medium text-slate-700">Logo</label>
|
||||
<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">
|
||||
</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">
|
||||
<button type="button" x-on:click="cancelCrop()" class="text-sm text-slate-500 hover:text-slate-900">Annuler</button>
|
||||
<button type="button" x-on:click="confirmCrop()"
|
||||
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
|
||||
Valider le recadrage
|
||||
<button type="button" x-on:click="cancelCrop()" :disabled="uploading"
|
||||
class="text-sm text-slate-500 hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-60">
|
||||
Annuler
|
||||
</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>
|
||||
</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>
|
||||
</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">
|
||||
<button type="submit"
|
||||
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
|
||||
Enregistrer
|
||||
<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>
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
@@ -218,6 +245,8 @@ new class extends Component
|
||||
dragOffsetStartY: 0,
|
||||
fileName: 'logo.png',
|
||||
fileType: 'image/png',
|
||||
uploading: false,
|
||||
uploadError: null,
|
||||
|
||||
onFileSelected(event) {
|
||||
const file = event.target.files[0];
|
||||
@@ -311,6 +340,8 @@ new class extends Component
|
||||
canvas.height = viewport;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
this.uploadError = null;
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
ctx.drawImage(
|
||||
@@ -324,10 +355,15 @@ new class extends Component
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
const croppedFile = new File([blob], this.fileName, { type: this.fileType });
|
||||
this.uploading = true;
|
||||
wire.$upload('logo', croppedFile, () => {
|
||||
this.uploading = false;
|
||||
this.cropping = false;
|
||||
this.imageSrc = null;
|
||||
});
|
||||
}, () => {
|
||||
this.uploading = false;
|
||||
this.uploadError = "Échec de l'envoi de l'image. Réessayez.";
|
||||
}, () => {});
|
||||
}, this.fileType);
|
||||
};
|
||||
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">· {{ $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' }} · {{ $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>
|
||||
@@ -5,6 +5,8 @@
|
||||
<livewire:documents.markdown-editor :organization="$organization" :document="$document" />
|
||||
@elseif ($document->isUpload())
|
||||
<livewire:documents.upload-viewer :organization="$organization" :document="$document" />
|
||||
@elseif ($document->isDiagram())
|
||||
<livewire:documents.diagram-editor :organization="$organization" :document="$document" />
|
||||
@else
|
||||
<livewire:documents.form :organization="$organization" :document="$document" />
|
||||
@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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
<x-layouts.app title="Configuration">
|
||||
<livewire:settings.env />
|
||||
</x-layouts.app>
|
||||
@@ -8,6 +8,7 @@ use App\Models\FlexibleAssetType;
|
||||
use App\Models\Location;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Password;
|
||||
use App\Models\Provider;
|
||||
use App\Models\Shortcut;
|
||||
use App\Models\Todo;
|
||||
use App\Models\User;
|
||||
@@ -23,7 +24,14 @@ Route::middleware(['auth'])->group(function () {
|
||||
Route::get('/organizations', fn () => view('organizations.index'))->name('organizations.index');
|
||||
Route::get('/organizations/create', fn () => view('organizations.create'))->name('organizations.create');
|
||||
Route::get('/organizations/{organization}/edit', fn (Organization $organization) => view('organizations.edit', ['organization' => $organization]))->name('organizations.edit');
|
||||
|
||||
Route::prefix('/providers')->name('providers.')->group(function () {
|
||||
Route::get('/', fn () => view('providers.index'))->name('index');
|
||||
Route::get('/create', fn () => view('providers.create'))->name('create');
|
||||
Route::get('/{provider}/edit', fn (Provider $provider) => view('providers.edit', ['provider' => $provider]))->name('edit');
|
||||
});
|
||||
Route::get('/organizations/{organization}/dashboard', fn (Organization $organization) => view('organizations.dashboard', ['organization' => $organization]))->name('organizations.dashboard');
|
||||
Route::get('/organizations/{organization}/identity-card', fn (Organization $organization) => view('organizations.identity-card', ['organization' => $organization]))->name('organizations.identity-card');
|
||||
|
||||
Route::prefix('/organizations/{organization}/passwords')->name('organizations.passwords.')->group(function () {
|
||||
Route::get('/', fn (Organization $organization) => view('passwords.index', ['organization' => $organization]))->name('index');
|
||||
@@ -74,6 +82,14 @@ Route::middleware(['auth'])->group(function () {
|
||||
Route::get('/{document}/download', DocumentDownloadController::class)->name('download');
|
||||
});
|
||||
|
||||
Route::prefix('/organizations/{organization}/infrastructure')->name('organizations.infrastructure.')->group(function () {
|
||||
Route::get('/', fn (Organization $organization) => view('infrastructure.index', ['organization' => $organization]))->name('index');
|
||||
Route::get('/{document}/edit', fn (Organization $organization, Document $document) => view('infrastructure.edit', [
|
||||
'organization' => $organization,
|
||||
'document' => $document,
|
||||
]))->name('edit');
|
||||
});
|
||||
|
||||
Route::prefix('/organizations/{organization}/virtual-machines')->name('organizations.virtual-machines.')->group(function () {
|
||||
Route::get('/', fn (Organization $organization) => view('virtual-machines.index', ['organization' => $organization]))->name('index');
|
||||
Route::get('/create', fn (Organization $organization) => view('virtual-machines.create', ['organization' => $organization]))->name('create');
|
||||
@@ -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('/settings/env', fn () => view('settings.env'))
|
||||
->name('settings.env')
|
||||
->middleware('role:Administrateur');
|
||||
|
||||
Route::get('/organizations/{organization}/assets', function (Organization $organization) {
|
||||
return view('assets.picker', [
|
||||
'organization' => $organization,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user