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 Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use League\CommonMark\CommonMarkConverter;
|
use League\CommonMark\CommonMarkConverter;
|
||||||
|
|
||||||
#[Fillable(['organization_id', 'folder_id', 'name', 'type', 'is_folder', 'content', 'file_path', 'office_extension', 'office_key', 'current_version', 'created_by'])]
|
#[Fillable(['organization_id', 'folder_id', 'location_id', 'name', 'type', 'is_folder', 'content', 'file_path', 'office_extension', 'office_key', 'current_version', 'created_by'])]
|
||||||
class Document extends Model implements Linkable
|
class Document extends Model implements Linkable
|
||||||
{
|
{
|
||||||
use SoftDeletes;
|
use SoftDeletes;
|
||||||
@@ -35,6 +35,11 @@ class Document extends Model implements Linkable
|
|||||||
return $this->type === 'upload';
|
return $this->type === 'upload';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isDiagram(): bool
|
||||||
|
{
|
||||||
|
return $this->type === 'diagram';
|
||||||
|
}
|
||||||
|
|
||||||
public function renderedMarkdown(): string
|
public function renderedMarkdown(): string
|
||||||
{
|
{
|
||||||
return (new CommonMarkConverter())->convert($this->content ?? '')->getContent();
|
return (new CommonMarkConverter())->convert($this->content ?? '')->getContent();
|
||||||
@@ -55,6 +60,11 @@ class Document extends Model implements Linkable
|
|||||||
return $this->belongsTo(Document::class, 'folder_id');
|
return $this->belongsTo(Document::class, 'folder_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function location()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Location::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function children()
|
public function children()
|
||||||
{
|
{
|
||||||
return $this->hasMany(Document::class, 'folder_id');
|
return $this->hasMany(Document::class, 'folder_id');
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
#[Fillable([
|
#[Fillable([
|
||||||
'organization_id', 'name', 'address_line_1', 'address_line_2',
|
'organization_id', 'name', 'address_line_1', 'address_line_2',
|
||||||
'city', 'region', 'postal_code', 'country', 'phone', 'is_primary',
|
'city', 'region', 'postal_code', 'country', 'phone', 'is_primary',
|
||||||
|
'latitude', 'longitude',
|
||||||
])]
|
])]
|
||||||
class Location extends Model
|
class Location extends Model
|
||||||
{
|
{
|
||||||
@@ -15,6 +16,8 @@ class Location extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'is_primary' => 'boolean',
|
'is_primary' => 'boolean',
|
||||||
|
'latitude' => 'float',
|
||||||
|
'longitude' => 'float',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
#[Fillable(['name', 'slug', 'org_type', 'status', 'description', 'allowed_ip_ranges', 'logo_path'])]
|
#[Fillable(['name', 'slug', 'org_type', 'status', 'description', 'allowed_ip_ranges', 'logo_path', 'glpi_entity_id', 'technician_user_id', 'isp_name', 'public_ips'])]
|
||||||
class Organization extends Model
|
class Organization extends Model
|
||||||
{
|
{
|
||||||
use SoftDeletes;
|
use SoftDeletes;
|
||||||
@@ -17,6 +17,7 @@ class Organization extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'allowed_ip_ranges' => 'array',
|
'allowed_ip_ranges' => 'array',
|
||||||
|
'public_ips' => 'array',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,4 +87,16 @@ class Organization extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(Shortcut::class);
|
return $this->hasMany(Shortcut::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function technician()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'technician_user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function providers()
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Provider::class, 'organization_provider')
|
||||||
|
->withPivot('id', 'label')
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Services\SettingsRegistry;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -11,7 +15,35 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
//
|
$this->applySettingOverrides();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overlay database-stored settings (see SettingsRegistry) onto config,
|
||||||
|
* so the rest of the app keeps reading config('mail.from.address') etc.
|
||||||
|
* without knowing whether the value came from .env or the settings UI.
|
||||||
|
* Silently does nothing if the settings table doesn't exist yet (e.g.
|
||||||
|
* during the very first `migrate` on a fresh install).
|
||||||
|
*/
|
||||||
|
protected function applySettingOverrides(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$overrides = Cache::remember(
|
||||||
|
SettingsRegistry::CACHE_KEY,
|
||||||
|
300,
|
||||||
|
fn () => Setting::query()->whereNotNull('value')->pluck('value', 'key')->all(),
|
||||||
|
);
|
||||||
|
} catch (Throwable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$map = SettingsRegistry::configMap();
|
||||||
|
|
||||||
|
foreach ($overrides as $key => $value) {
|
||||||
|
if (isset($map[$key]) && $value !== '') {
|
||||||
|
config([$map[$key] => $value]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
|
// The app runs behind a reverse proxy that terminates TLS and forwards
|
||||||
|
// plain HTTP — without this, Laravel doesn't know the original request
|
||||||
|
// was HTTPS and generates asset/URL links as http://, which browsers
|
||||||
|
// then block as mixed content on an https:// page.
|
||||||
|
$middleware->trustProxies(at: '*');
|
||||||
|
|
||||||
$middleware->alias([
|
$middleware->alias([
|
||||||
'org.ip_access' => \App\Http\Middleware\CheckOrganizationIpAccess::class,
|
'org.ip_access' => \App\Http\Middleware\CheckOrganizationIpAccess::class,
|
||||||
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
|
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
|
||||||
|
|||||||
+2
-2
@@ -38,8 +38,8 @@ return [
|
|||||||
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||||
'prefix' => '',
|
'prefix' => '',
|
||||||
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||||
'busy_timeout' => null,
|
'busy_timeout' => env('DB_BUSY_TIMEOUT', 5000),
|
||||||
'journal_mode' => null,
|
'journal_mode' => env('DB_JOURNAL_MODE', 'wal'),
|
||||||
'synchronous' => null,
|
'synchronous' => null,
|
||||||
'transaction_mode' => 'DEFERRED',
|
'transaction_mode' => 'DEFERRED',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 App\Models\User;
|
||||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
class DatabaseSeeder extends Seeder
|
class DatabaseSeeder extends Seeder
|
||||||
{
|
{
|
||||||
@@ -19,10 +20,14 @@ class DatabaseSeeder extends Seeder
|
|||||||
|
|
||||||
$this->call(RolesAndPermissionsSeeder::class);
|
$this->call(RolesAndPermissionsSeeder::class);
|
||||||
|
|
||||||
$admin = User::factory()->create([
|
$admin = User::firstOrCreate(
|
||||||
'name' => 'Administrateur',
|
['email' => 'administrateur@cabmit.local'],
|
||||||
'email' => 'administrateur@cabmit.local',
|
[
|
||||||
]);
|
'name' => 'Administrateur',
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
'password' => Hash::make('password'),
|
||||||
|
],
|
||||||
|
);
|
||||||
$admin->assignRole('Administrateur');
|
$admin->assignRole('Administrateur');
|
||||||
|
|
||||||
$this->call(AssetTypeLookupSeeder::class);
|
$this->call(AssetTypeLookupSeeder::class);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Database\Seeders;
|
|||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Spatie\Permission\Models\Permission;
|
use Spatie\Permission\Models\Permission;
|
||||||
use Spatie\Permission\Models\Role;
|
use Spatie\Permission\Models\Role;
|
||||||
|
use Spatie\Permission\PermissionRegistrar;
|
||||||
|
|
||||||
class RolesAndPermissionsSeeder extends Seeder
|
class RolesAndPermissionsSeeder extends Seeder
|
||||||
{
|
{
|
||||||
@@ -22,6 +23,12 @@ class RolesAndPermissionsSeeder extends Seeder
|
|||||||
Permission::findOrCreate($permission);
|
Permission::findOrCreate($permission);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DatabaseSeeder uses WithoutModelEvents, which silences the
|
||||||
|
// saved/deleted events Spatie relies on to invalidate its
|
||||||
|
// permission cache. Reset it manually so newly created
|
||||||
|
// permissions are visible below.
|
||||||
|
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||||
|
|
||||||
$admin = Role::findOrCreate('Administrateur');
|
$admin = Role::findOrCreate('Administrateur');
|
||||||
$admin->syncPermissions($permissions);
|
$admin->syncPermissions($permissions);
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,22 @@ new class extends Component
|
|||||||
session()->flash('status', "\"{$asset->name}\" supprimé.");
|
session()->flash('status', "\"{$asset->name}\" supprimé.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function duplicate(int $assetId): void
|
||||||
|
{
|
||||||
|
$asset = $this->organization->flexibleAssets()->findOrFail($assetId);
|
||||||
|
|
||||||
|
$copy = $this->organization->flexibleAssets()->create([
|
||||||
|
'flexible_asset_type_id' => $asset->flexible_asset_type_id,
|
||||||
|
'name' => "{$asset->name} (copie)",
|
||||||
|
'data' => $asset->data,
|
||||||
|
'created_by' => auth()->id(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->flash('status', "\"{$asset->name}\" dupliqué.");
|
||||||
|
|
||||||
|
$this->redirectRoute('organizations.assets.edit', [$this->organization, $this->type, $copy], navigate: true);
|
||||||
|
}
|
||||||
|
|
||||||
public function with(): array
|
public function with(): array
|
||||||
{
|
{
|
||||||
$listFields = $this->type->fields()->where('show_in_list', true)->get();
|
$listFields = $this->type->fields()->where('show_in_list', true)->get();
|
||||||
@@ -94,6 +110,11 @@ new class extends Component
|
|||||||
@endforeach
|
@endforeach
|
||||||
<td class="px-4 py-3 text-right whitespace-nowrap">
|
<td class="px-4 py-3 text-right whitespace-nowrap">
|
||||||
<a href="{{ route('organizations.assets.edit', [$organization, $type, $asset]) }}" class="text-slate-500 hover:text-slate-900">Modifier</a>
|
<a href="{{ route('organizations.assets.edit', [$organization, $type, $asset]) }}" class="text-slate-500 hover:text-slate-900">Modifier</a>
|
||||||
|
<button type="button"
|
||||||
|
wire:click="duplicate({{ $asset->id }})"
|
||||||
|
class="ml-3 text-slate-500 hover:text-slate-900">
|
||||||
|
Dupliquer
|
||||||
|
</button>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
wire:click="delete({{ $asset->id }})"
|
wire:click="delete({{ $asset->id }})"
|
||||||
wire:confirm="Supprimer cet actif ? Cette action est irréversible."
|
wire:confirm="Supprimer cet actif ? Cette action est irréversible."
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Document;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
new class extends Component
|
||||||
|
{
|
||||||
|
public Organization $organization;
|
||||||
|
public Document $document;
|
||||||
|
public string $backRoute = 'organizations.documents.index';
|
||||||
|
public ?int $folderId = null;
|
||||||
|
|
||||||
|
public bool $renaming = false;
|
||||||
|
public string $name = '';
|
||||||
|
|
||||||
|
public function mount(Organization $organization, Document $document, string $backRoute = 'organizations.documents.index', ?int $folderId = null): void
|
||||||
|
{
|
||||||
|
abort_unless($document->isDiagram(), 404);
|
||||||
|
|
||||||
|
$this->organization = $organization;
|
||||||
|
$this->document = $document;
|
||||||
|
$this->backRoute = $backRoute;
|
||||||
|
$this->folderId = $folderId;
|
||||||
|
$this->name = $document->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rename(): void
|
||||||
|
{
|
||||||
|
$validated = $this->validate(['name' => 'required|string|max:255']);
|
||||||
|
|
||||||
|
$this->document->update($validated);
|
||||||
|
$this->renaming = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateLocation(?int $locationId): void
|
||||||
|
{
|
||||||
|
$this->document->update(['location_id' => $locationId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveDiagram(string $xml): void
|
||||||
|
{
|
||||||
|
$contentChanged = $this->document->content !== $xml;
|
||||||
|
|
||||||
|
$this->document->update(['content' => $xml]);
|
||||||
|
|
||||||
|
if ($contentChanged) {
|
||||||
|
$this->document->increment('current_version');
|
||||||
|
$this->document->versions()->create([
|
||||||
|
'version' => $this->document->current_version,
|
||||||
|
'content' => $xml,
|
||||||
|
'created_by' => auth()->id(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function with(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'locations' => $this->organization->locations()->orderBy('name')->get(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-slate-500">{{ $organization->name }}</p>
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
@if ($renaming)
|
||||||
|
<form wire:submit="rename" class="flex items-center gap-2">
|
||||||
|
<input type="text" wire:model="name" autofocus
|
||||||
|
class="rounded border-slate-300 text-2xl font-semibold shadow-sm focus:border-slate-500 focus:ring-slate-500">
|
||||||
|
<button type="submit" class="cursor-pointer rounded bg-slate-900 px-3 py-1.5 text-sm text-white hover:bg-slate-700">Enregistrer</button>
|
||||||
|
<button type="button" wire:click="$set('renaming', false)" class="cursor-pointer text-sm text-slate-500 hover:text-slate-900">Annuler</button>
|
||||||
|
@error('name') <p class="text-sm text-red-600">{{ $message }}</p> @enderror
|
||||||
|
</form>
|
||||||
|
@else
|
||||||
|
<button type="button" wire:click="$set('renaming', true)" class="group flex cursor-pointer items-center gap-2">
|
||||||
|
<h1 class="text-2xl font-semibold text-slate-900">{{ $document->name }}</h1>
|
||||||
|
<span class="text-sm text-slate-400 opacity-0 group-hover:opacity-100">Renommer</span>
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
<a href="{{ route($backRoute, [$organization, 'folderId' => $folderId]) }}" class="text-sm text-slate-500 hover:text-slate-900">Retour</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4 flex items-center gap-2">
|
||||||
|
<label class="text-sm font-medium text-slate-700">Site associé</label>
|
||||||
|
<select wire:change="updateLocation($event.target.value === '' ? null : $event.target.value)"
|
||||||
|
class="rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
|
||||||
|
<option value="">Aucun</option>
|
||||||
|
@foreach ($locations as $location)
|
||||||
|
<option value="{{ $location->id }}" @selected($document->location_id === $location->id)>{{ $location->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
wire:ignore
|
||||||
|
x-data="drawioEditor(@js($document->content ?? ''), @js(config('drawio.url')))"
|
||||||
|
x-init="init()"
|
||||||
|
class="w-full"
|
||||||
|
style="height: 78vh;"
|
||||||
|
>
|
||||||
|
<iframe x-ref="iframe" src="{{ config('drawio.url') }}?embed=1&proto=json&spin=1&noSaveBtn=0&saveAndExit=0&noExitBtn=1"
|
||||||
|
class="h-full w-full rounded border border-slate-200 bg-white"></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-2 text-xs text-slate-500" x-data="{ status: '' }" x-on:diagram-status.window="status = $event.detail" x-text="status"></p>
|
||||||
|
|
||||||
|
<div class="mt-8 max-w-5xl">
|
||||||
|
<x-documents.version-history :document="$document" />
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<livewire:related-items.panel :organization="$organization" :item="$document" :key="'related-'.$document->id" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@once
|
||||||
|
<script>
|
||||||
|
function drawioEditor(initialXml, baseUrl) {
|
||||||
|
return {
|
||||||
|
origin: null,
|
||||||
|
init() {
|
||||||
|
this.origin = new URL(baseUrl, window.location.href).origin;
|
||||||
|
window.addEventListener('message', (evt) => this.handleMessage(evt));
|
||||||
|
},
|
||||||
|
handleMessage(evt) {
|
||||||
|
if (evt.origin !== this.origin) return;
|
||||||
|
|
||||||
|
let msg;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(evt.data);
|
||||||
|
} catch (e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.event === 'init') {
|
||||||
|
this.post({ action: 'load', xml: initialXml, autosave: 0 });
|
||||||
|
} else if (msg.event === 'save') {
|
||||||
|
this.$wire.saveDiagram(msg.xml).then(() => {
|
||||||
|
window.dispatchEvent(new CustomEvent('diagram-status', { detail: 'Enregistré à ' + new Date().toLocaleTimeString() }));
|
||||||
|
this.post({ action: 'status', message: 'Enregistré', modified: false });
|
||||||
|
});
|
||||||
|
} else if (msg.event === 'exit') {
|
||||||
|
this.post({ action: 'status', message: '', modified: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
post(payload) {
|
||||||
|
this.$refs.iframe.contentWindow.postMessage(JSON.stringify(payload), this.origin);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
@endonce
|
||||||
@@ -206,6 +206,7 @@ new class extends Component
|
|||||||
'breadcrumbs' => $breadcrumbs,
|
'breadcrumbs' => $breadcrumbs,
|
||||||
'items' => $this->organization->documents()
|
'items' => $this->organization->documents()
|
||||||
->where('folder_id', $this->folderId)
|
->where('folder_id', $this->folderId)
|
||||||
|
->where('type', '!=', 'diagram')
|
||||||
->orderByDesc('is_folder')
|
->orderByDesc('is_folder')
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get(),
|
->get(),
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Document;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
new class extends Component
|
||||||
|
{
|
||||||
|
public Organization $organization;
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public ?int $folderId = null;
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public ?int $locationId = null;
|
||||||
|
|
||||||
|
public string $newDiagramName = '';
|
||||||
|
public bool $showNewDiagram = false;
|
||||||
|
|
||||||
|
public string $newFolderName = '';
|
||||||
|
public bool $showNewFolder = false;
|
||||||
|
|
||||||
|
public function createFolder(): void
|
||||||
|
{
|
||||||
|
$this->validate(['newFolderName' => 'required|string|max:255']);
|
||||||
|
|
||||||
|
$this->organization->documents()->create([
|
||||||
|
'folder_id' => $this->folderId,
|
||||||
|
'name' => $this->newFolderName,
|
||||||
|
'type' => 'diagram',
|
||||||
|
'is_folder' => true,
|
||||||
|
'created_by' => auth()->id(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->newFolderName = '';
|
||||||
|
$this->showNewFolder = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createDiagram(): void
|
||||||
|
{
|
||||||
|
$this->validate(['newDiagramName' => 'required|string|max:255']);
|
||||||
|
|
||||||
|
$emptyDiagram = <<<'XML'
|
||||||
|
<mxfile host="app.diagrams.net">
|
||||||
|
<diagram name="Page-1">
|
||||||
|
<mxGraphModel dx="800" dy="600" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
|
||||||
|
<root>
|
||||||
|
<mxCell id="0" />
|
||||||
|
<mxCell id="1" parent="0" />
|
||||||
|
</root>
|
||||||
|
</mxGraphModel>
|
||||||
|
</diagram>
|
||||||
|
</mxfile>
|
||||||
|
XML;
|
||||||
|
|
||||||
|
$document = $this->organization->documents()->create([
|
||||||
|
'folder_id' => $this->folderId,
|
||||||
|
'name' => $this->newDiagramName,
|
||||||
|
'type' => 'diagram',
|
||||||
|
'content' => $emptyDiagram,
|
||||||
|
'current_version' => 1,
|
||||||
|
'created_by' => auth()->id(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$document->versions()->create([
|
||||||
|
'version' => 1,
|
||||||
|
'content' => $emptyDiagram,
|
||||||
|
'created_by' => auth()->id(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->redirectRoute('organizations.infrastructure.edit', [$this->organization, $document, 'folderId' => $this->folderId], navigate: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(int $documentId): void
|
||||||
|
{
|
||||||
|
$document = $this->organization->documents()->where('type', 'diagram')->findOrFail($documentId);
|
||||||
|
$document->delete();
|
||||||
|
|
||||||
|
session()->flash('status', "« {$document->name} » supprimé.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function moveDocument(int $documentId, ?int $targetFolderId): void
|
||||||
|
{
|
||||||
|
$document = $this->organization->documents()->where('type', 'diagram')->findOrFail($documentId);
|
||||||
|
|
||||||
|
if ($document->id === $targetFolderId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($targetFolderId !== null) {
|
||||||
|
$targetFolder = $this->organization->documents()->where('type', 'diagram')->find($targetFolderId);
|
||||||
|
|
||||||
|
if (! $targetFolder || ! $targetFolder->isFolder()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent moving a folder into itself or one of its own descendants.
|
||||||
|
if ($document->isFolder()) {
|
||||||
|
$walk = $targetFolder;
|
||||||
|
while ($walk) {
|
||||||
|
if ($walk->id === $document->id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$walk = $walk->folder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($document->folder_id === $targetFolderId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$document->update(['folder_id' => $targetFolderId]);
|
||||||
|
|
||||||
|
session()->flash('status', "« {$document->name} » déplacé.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function folderTree(\Illuminate\Support\Collection $allFolders): \Illuminate\Support\Collection
|
||||||
|
{
|
||||||
|
$byParent = $allFolders->groupBy('folder_id');
|
||||||
|
|
||||||
|
$walk = function ($parentId, $depth) use (&$walk, $byParent) {
|
||||||
|
return ($byParent->get($parentId) ?? collect())
|
||||||
|
->sortBy('name')
|
||||||
|
->flatMap(fn ($folder) => collect([['folder' => $folder, 'depth' => $depth]])
|
||||||
|
->concat($walk($folder->id, $depth + 1)));
|
||||||
|
};
|
||||||
|
|
||||||
|
return $walk(null, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function with(): array
|
||||||
|
{
|
||||||
|
$currentFolder = $this->folderId
|
||||||
|
? $this->organization->documents()->where('type', 'diagram')->find($this->folderId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$breadcrumbs = collect();
|
||||||
|
$walk = $currentFolder;
|
||||||
|
while ($walk) {
|
||||||
|
$breadcrumbs->prepend($walk);
|
||||||
|
$walk = $walk->folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
$allFolders = $this->organization->documents()->where('type', 'diagram')->where('is_folder', true)->get();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'currentFolder' => $currentFolder,
|
||||||
|
'breadcrumbs' => $breadcrumbs,
|
||||||
|
'folderTree' => $this->folderTree($allFolders),
|
||||||
|
'locations' => $this->organization->locations()->orderBy('name')->get(),
|
||||||
|
'items' => $this->organization->documents()
|
||||||
|
->where('type', 'diagram')
|
||||||
|
->where('folder_id', $this->folderId)
|
||||||
|
->when($this->locationId, fn ($query) => $query->where('location_id', $this->locationId))
|
||||||
|
->with('location')
|
||||||
|
->orderByDesc('is_folder')
|
||||||
|
->orderBy('name')
|
||||||
|
->get(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-semibold text-slate-900">Infrastructure</h1>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="button" wire:click="$set('showNewFolder', true)" title="Nouveau dossier"
|
||||||
|
class="flex h-10 w-10 cursor-pointer items-center justify-center rounded border border-slate-300 text-lg hover:bg-slate-50">
|
||||||
|
📁
|
||||||
|
</button>
|
||||||
|
<button type="button" wire:click="$set('showNewDiagram', true)"
|
||||||
|
class="cursor-pointer rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
|
||||||
|
+ Nouveau schéma
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (session('status'))
|
||||||
|
<div class="mb-4 rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
|
||||||
|
{{ session('status') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="mb-4 flex items-center justify-between gap-4">
|
||||||
|
<div class="flex items-center gap-1 text-sm text-slate-500">
|
||||||
|
<a wire:click="$set('folderId', null)" href="#" class="hover:text-slate-900">Infrastructure</a>
|
||||||
|
@foreach ($breadcrumbs as $crumb)
|
||||||
|
<span>/</span>
|
||||||
|
<a wire:click="$set('folderId', {{ $crumb->id }})" href="#" class="hover:text-slate-900">{{ $crumb->name }}</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($locations->isNotEmpty())
|
||||||
|
<select wire:model.live="locationId" class="rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
|
||||||
|
<option value="">Tous les sites</option>
|
||||||
|
@foreach ($locations as $location)
|
||||||
|
<option value="{{ $location->id }}">{{ $location->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($showNewFolder)
|
||||||
|
<form wire:submit="createFolder" class="mb-4 flex items-center gap-2 rounded border border-slate-200 bg-white p-3">
|
||||||
|
<input type="text" wire:model="newFolderName" placeholder="Nom du dossier" autofocus
|
||||||
|
class="flex-1 rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
|
||||||
|
<button type="submit" class="cursor-pointer rounded bg-slate-900 px-3 py-1.5 text-sm text-white hover:bg-slate-700">Créer</button>
|
||||||
|
<button type="button" wire:click="$set('showNewFolder', false)" class="cursor-pointer text-sm text-slate-500 hover:text-slate-900">Annuler</button>
|
||||||
|
@error('newFolderName') <p class="text-sm text-red-600">{{ $message }}</p> @enderror
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($showNewDiagram)
|
||||||
|
<form wire:submit="createDiagram" class="mb-4 flex items-start gap-2 rounded border border-slate-200 bg-white p-3">
|
||||||
|
<div class="flex-1">
|
||||||
|
<input type="text" wire:model="newDiagramName" placeholder="Ex: Schéma réseau - Mairie, Schéma fonctionnel - SI RH"
|
||||||
|
class="w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
|
||||||
|
@error('newDiagramName') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="cursor-pointer rounded bg-slate-900 px-3 py-1.5 text-sm text-white hover:bg-slate-700">
|
||||||
|
Créer
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($items->isNotEmpty())
|
||||||
|
<p class="mb-2 text-xs text-slate-400">Astuce : glissez-déposez un élément sur un dossier pour le déplacer.</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="overflow-hidden rounded border border-slate-200 bg-white" x-data="{ draggingId: null, overId: null }">
|
||||||
|
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||||
|
<thead class="bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3">Nom</th>
|
||||||
|
<th class="px-4 py-3">Site</th>
|
||||||
|
<th class="px-4 py-3">Mis à jour</th>
|
||||||
|
<th class="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
@forelse ($items as $item)
|
||||||
|
@php $isFolder = $item->isFolder(); @endphp
|
||||||
|
<tr wire:key="diagram-{{ $item->id }}"
|
||||||
|
draggable="true"
|
||||||
|
x-on:dragstart="draggingId = {{ $item->id }}"
|
||||||
|
x-on:dragend="draggingId = null; overId = null"
|
||||||
|
@if ($isFolder)
|
||||||
|
x-on:dragover.prevent="if (draggingId !== {{ $item->id }}) overId = {{ $item->id }}"
|
||||||
|
x-on:dragleave="if (overId === {{ $item->id }}) overId = null"
|
||||||
|
x-on:drop.prevent="if (draggingId && draggingId !== {{ $item->id }}) { $wire.moveDocument(draggingId, {{ $item->id }}); } draggingId = null; overId = null"
|
||||||
|
@endif
|
||||||
|
:class="draggingId === {{ $item->id }} ? 'opacity-40' : (overId === {{ $item->id }} ? 'bg-slate-100 ring-1 ring-inset ring-slate-400' : '')"
|
||||||
|
class="cursor-move">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
@if ($isFolder)
|
||||||
|
<a wire:click="$set('folderId', {{ $item->id }})" href="#" class="font-medium text-slate-900 hover:underline">
|
||||||
|
📁 {{ $item->name }}
|
||||||
|
</a>
|
||||||
|
@else
|
||||||
|
<a href="{{ route('organizations.infrastructure.edit', [$organization, $item, 'folderId' => $this->folderId]) }}" class="font-medium text-slate-900 hover:underline">
|
||||||
|
🗺️ {{ $item->name }}
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-slate-600">{{ $isFolder ? '—' : ($item->location?->name ?? '—') }}</td>
|
||||||
|
<td class="px-4 py-3 text-slate-600">{{ $item->updated_at->diffForHumans() }}</td>
|
||||||
|
<td class="px-4 py-3 text-right whitespace-nowrap">
|
||||||
|
@unless ($isFolder)
|
||||||
|
<a href="{{ route('organizations.infrastructure.edit', [$organization, $item, 'folderId' => $this->folderId]) }}" class="text-slate-500 hover:text-slate-900">Ouvrir</a>
|
||||||
|
@endunless
|
||||||
|
@php
|
||||||
|
// Exclude the item's own subtree (if it's a folder) so it can't be moved into itself or a descendant.
|
||||||
|
$excludedIds = [$item->id, $item->folder_id];
|
||||||
|
if ($isFolder) {
|
||||||
|
$stack = [$item->id];
|
||||||
|
while ($stack) {
|
||||||
|
$id = array_pop($stack);
|
||||||
|
foreach ($folderTree as $node) {
|
||||||
|
if ($node['folder']->folder_id === $id) {
|
||||||
|
$excludedIds[] = $node['folder']->id;
|
||||||
|
$stack[] = $node['folder']->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$moveTargets = $folderTree->reject(fn ($node) => in_array($node['folder']->id, $excludedIds, true));
|
||||||
|
@endphp
|
||||||
|
@if ($moveTargets->isNotEmpty() || $item->folder_id !== null)
|
||||||
|
<select wire:change="moveDocument({{ $item->id }}, $event.target.value === '' ? null : $event.target.value)"
|
||||||
|
class="ml-3 rounded border-slate-300 text-xs shadow-sm focus:border-slate-500 focus:ring-slate-500"
|
||||||
|
onclick="event.stopPropagation()">
|
||||||
|
<option value="" selected disabled>Déplacer vers...</option>
|
||||||
|
@if ($item->folder_id !== null)
|
||||||
|
<option value="">🏠 Racine</option>
|
||||||
|
@endif
|
||||||
|
@foreach ($moveTargets as $node)
|
||||||
|
<option value="{{ $node['folder']->id }}">{{ str_repeat(' ', $node['depth']) }}{{ $node['depth'] > 0 ? '└ ' : '' }}📁 {{ $node['folder']->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@endif
|
||||||
|
<button type="button"
|
||||||
|
wire:click="delete({{ $item->id }})"
|
||||||
|
wire:confirm="Supprimer ce {{ $isFolder ? 'dossier' : 'schéma' }} ? Cette action est irréversible."
|
||||||
|
class="ml-3 cursor-pointer text-red-500 hover:text-red-700">
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="px-4 py-6 text-center text-slate-400">Dossier vide.</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
@if ($currentFolder)
|
||||||
|
<div class="border-t border-slate-200 px-4 py-3 text-sm text-slate-500"
|
||||||
|
x-on:dragover.prevent="overId = 'up'"
|
||||||
|
x-on:dragleave="if (overId === 'up') overId = null"
|
||||||
|
x-on:drop.prevent="if (draggingId) { $wire.moveDocument(draggingId, {{ $currentFolder->folder_id ?? 'null' }}); } draggingId = null; overId = null"
|
||||||
|
:class="overId === 'up' ? 'bg-slate-100 ring-1 ring-inset ring-slate-400' : ''">
|
||||||
|
⬆️ Glisser ici pour remonter au dossier parent
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -19,6 +19,10 @@
|
|||||||
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.*') ? 'bg-slate-800 text-white' : '' }}">
|
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.*') ? 'bg-slate-800 text-white' : '' }}">
|
||||||
Organisations
|
Organisations
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{{ route('providers.index') }}"
|
||||||
|
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('providers.*') ? 'bg-slate-800 text-white' : '' }}">
|
||||||
|
Prestataires
|
||||||
|
</a>
|
||||||
|
|
||||||
@if ($currentOrganization)
|
@if ($currentOrganization)
|
||||||
@php
|
@php
|
||||||
@@ -38,6 +42,10 @@
|
|||||||
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.dashboard') ? 'bg-slate-800 text-white' : '' }}">
|
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.dashboard') ? 'bg-slate-800 text-white' : '' }}">
|
||||||
Tableau de bord
|
Tableau de bord
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{{ route('organizations.identity-card', $currentOrganization) }}"
|
||||||
|
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.identity-card') ? 'bg-slate-800 text-white' : '' }}">
|
||||||
|
Fiche d'identité
|
||||||
|
</a>
|
||||||
<a href="{{ route('organizations.todos.index', $currentOrganization) }}"
|
<a href="{{ route('organizations.todos.index', $currentOrganization) }}"
|
||||||
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.todos.*') ? 'bg-slate-800 text-white' : '' }}">
|
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.todos.*') ? 'bg-slate-800 text-white' : '' }}">
|
||||||
ToDo
|
ToDo
|
||||||
@@ -62,6 +70,10 @@
|
|||||||
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.documents.*') ? 'bg-slate-800 text-white' : '' }}">
|
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.documents.*') ? 'bg-slate-800 text-white' : '' }}">
|
||||||
Documents
|
Documents
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{{ route('organizations.infrastructure.index', $currentOrganization) }}"
|
||||||
|
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.infrastructure.*') ? 'bg-slate-800 text-white' : '' }}">
|
||||||
|
Infrastructure
|
||||||
|
</a>
|
||||||
<a href="{{ route('organizations.virtual-machines.index', $currentOrganization) }}"
|
<a href="{{ route('organizations.virtual-machines.index', $currentOrganization) }}"
|
||||||
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.virtual-machines.*') ? 'bg-slate-800 text-white' : '' }}">
|
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('organizations.virtual-machines.*') ? 'bg-slate-800 text-white' : '' }}">
|
||||||
Machines virtuelles
|
Machines virtuelles
|
||||||
@@ -131,6 +143,10 @@
|
|||||||
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('users.*') ? 'bg-slate-800 text-white' : '' }}">
|
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('users.*') ? 'bg-slate-800 text-white' : '' }}">
|
||||||
Utilisateurs
|
Utilisateurs
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{{ route('settings.env') }}"
|
||||||
|
class="block rounded px-3 py-2 hover:bg-slate-800 {{ request()->routeIs('settings.env') ? 'bg-slate-800 text-white' : '' }}">
|
||||||
|
Configuration
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@endrole
|
@endrole
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ new class extends Component
|
|||||||
public string $country = '';
|
public string $country = '';
|
||||||
public string $phone = '';
|
public string $phone = '';
|
||||||
public bool $is_primary = false;
|
public bool $is_primary = false;
|
||||||
|
public ?float $latitude = null;
|
||||||
|
public ?float $longitude = null;
|
||||||
|
|
||||||
public function mount(Organization $organization, ?Location $location = null): void
|
public function mount(Organization $organization, ?Location $location = null): void
|
||||||
{
|
{
|
||||||
@@ -34,6 +36,8 @@ new class extends Component
|
|||||||
$this->country = $location->country ?? '';
|
$this->country = $location->country ?? '';
|
||||||
$this->phone = $location->phone ?? '';
|
$this->phone = $location->phone ?? '';
|
||||||
$this->is_primary = $location->is_primary;
|
$this->is_primary = $location->is_primary;
|
||||||
|
$this->latitude = $location->latitude;
|
||||||
|
$this->longitude = $location->longitude;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +53,8 @@ new class extends Component
|
|||||||
'country' => 'nullable|string|max:255',
|
'country' => 'nullable|string|max:255',
|
||||||
'phone' => 'nullable|string|max:255',
|
'phone' => 'nullable|string|max:255',
|
||||||
'is_primary' => 'boolean',
|
'is_primary' => 'boolean',
|
||||||
|
'latitude' => 'nullable|numeric|between:-90,90',
|
||||||
|
'longitude' => 'nullable|numeric|between:-180,180',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($validated['is_primary']) {
|
if ($validated['is_primary']) {
|
||||||
@@ -127,6 +133,28 @@ new class extends Component
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div x-data="locationMap({{ $latitude ?? 'null' }}, {{ $longitude ?? 'null' }})">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">Latitude</label>
|
||||||
|
<input type="number" step="any" min="-90" max="90" wire:model="latitude"
|
||||||
|
x-ref="latInput" x-on:input="onFieldInput()"
|
||||||
|
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
|
||||||
|
@error('latitude') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">Longitude</label>
|
||||||
|
<input type="number" step="any" min="-180" max="180" wire:model="longitude"
|
||||||
|
x-ref="lngInput" x-on:input="onFieldInput()"
|
||||||
|
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
|
||||||
|
@error('longitude') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 h-64 w-full overflow-hidden rounded border border-slate-200" x-ref="mapEl" wire:ignore></div>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">Renseignez la latitude et la longitude pour positionner le site sur la carte.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||||
<input type="checkbox" wire:model="is_primary" class="rounded border-slate-300">
|
<input type="checkbox" wire:model="is_primary" class="rounded border-slate-300">
|
||||||
Site principal
|
Site principal
|
||||||
@@ -141,3 +169,74 @@ new class extends Component
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@once
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||||
|
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
||||||
|
link.integrity = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
|
||||||
|
link.crossOrigin = '';
|
||||||
|
document.head.appendChild(link);
|
||||||
|
|
||||||
|
// Leaflet normally auto-detects its marker image path by scanning
|
||||||
|
// <link> tags already in the document when leaflet.js runs — but
|
||||||
|
// that link is injected above, after leaflet.js has loaded, so
|
||||||
|
// detection fails. Point the icons at the CDN explicitly instead.
|
||||||
|
delete L.Icon.Default.prototype._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||||
|
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||||
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
function locationMap(initialLat, initialLng) {
|
||||||
|
return {
|
||||||
|
map: null,
|
||||||
|
marker: null,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
const hasCoords = initialLat !== null && initialLng !== null;
|
||||||
|
|
||||||
|
this.map = L.map(this.$refs.mapEl).setView(
|
||||||
|
hasCoords ? [initialLat, initialLng] : [46.6, 2.4], // France, vue d'ensemble par défaut
|
||||||
|
hasCoords ? 14 : 5
|
||||||
|
);
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||||
|
maxZoom: 19,
|
||||||
|
}).addTo(this.map);
|
||||||
|
|
||||||
|
if (hasCoords) {
|
||||||
|
this.marker = L.marker([initialLat, initialLng]).addTo(this.map);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onFieldInput() {
|
||||||
|
const lat = parseFloat(this.$refs.latInput.value);
|
||||||
|
const lng = parseFloat(this.$refs.lngInput.value);
|
||||||
|
|
||||||
|
if (isNaN(lat) || isNaN(lng)) {
|
||||||
|
if (this.marker) {
|
||||||
|
this.map.removeLayer(this.marker);
|
||||||
|
this.marker = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.marker) {
|
||||||
|
this.marker.setLatLng([lat, lng]);
|
||||||
|
} else {
|
||||||
|
this.marker = L.marker([lat, lng]).addTo(this.map);
|
||||||
|
}
|
||||||
|
this.map.setView([lat, lng], Math.max(this.map.getZoom(), 14));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
@endonce
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ new class extends Component
|
|||||||
->orderByDesc('is_primary')
|
->orderByDesc('is_primary')
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->paginate(15),
|
->paginate(15),
|
||||||
|
'mappedLocations' => $this->organization->locations()
|
||||||
|
->whereNotNull('latitude')
|
||||||
|
->whereNotNull('longitude')
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name', 'city', 'latitude', 'longitude'])
|
||||||
|
->map(fn ($location) => [
|
||||||
|
'name' => $location->name,
|
||||||
|
'city' => $location->city,
|
||||||
|
'latitude' => $location->latitude,
|
||||||
|
'longitude' => $location->longitude,
|
||||||
|
'edit_url' => route('organizations.locations.edit', [$this->organization, $location]),
|
||||||
|
]),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -50,6 +62,11 @@ new class extends Component
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if ($mappedLocations->isNotEmpty())
|
||||||
|
<div class="mb-6 h-80 w-full overflow-hidden rounded border border-slate-200"
|
||||||
|
x-data="sitesMap(@js($mappedLocations))" wire:ignore></div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<input type="text" wire:model.live.debounce.300ms="search" placeholder="Filtrer par nom ou ville..."
|
<input type="text" wire:model.live.debounce.300ms="search" placeholder="Filtrer par nom ou ville..."
|
||||||
class="mb-4 w-full max-w-sm rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
|
class="mb-4 w-full max-w-sm rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
|
||||||
|
|
||||||
@@ -102,3 +119,60 @@ new class extends Component
|
|||||||
{{ $locations->links() }}
|
{{ $locations->links() }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@once
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||||
|
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
||||||
|
link.integrity = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
|
||||||
|
link.crossOrigin = '';
|
||||||
|
document.head.appendChild(link);
|
||||||
|
|
||||||
|
// Leaflet normally auto-detects its marker image path by scanning
|
||||||
|
// <link> tags already in the document when leaflet.js runs — but
|
||||||
|
// that link is injected above, after leaflet.js has loaded, so
|
||||||
|
// detection fails. Point the icons at the CDN explicitly instead.
|
||||||
|
delete L.Icon.Default.prototype._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||||
|
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||||
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
function sitesMap(locations) {
|
||||||
|
return {
|
||||||
|
init() {
|
||||||
|
const map = L.map(this.$el);
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||||
|
maxZoom: 19,
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
const markers = locations.map(loc => {
|
||||||
|
const marker = L.marker([loc.latitude, loc.longitude]).addTo(map);
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = loc.edit_url;
|
||||||
|
link.className = 'font-medium text-slate-900 hover:underline';
|
||||||
|
link.textContent = loc.city ? `${loc.name} — ${loc.city}` : loc.name;
|
||||||
|
marker.bindPopup(link);
|
||||||
|
|
||||||
|
return marker;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (markers.length === 1) {
|
||||||
|
map.setView(markers[0].getLatLng(), 14);
|
||||||
|
} else {
|
||||||
|
map.fitBounds(L.featureGroup(markers).getBounds().pad(0.2));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
@endonce
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
|
use App\Models\Todo;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Glpi\GlpiClient;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
new class extends Component
|
new class extends Component
|
||||||
@@ -10,6 +13,13 @@ new class extends Component
|
|||||||
public string $newLogCategory = 'info';
|
public string $newLogCategory = 'info';
|
||||||
public string $newLogNote = '';
|
public string $newLogNote = '';
|
||||||
|
|
||||||
|
public bool $showTodoForm = false;
|
||||||
|
public string $newTodoTitle = '';
|
||||||
|
public string $newTodoCategory = 'infra';
|
||||||
|
public string $newTodoCriticality = 'medium';
|
||||||
|
public ?string $newTodoDueDate = null;
|
||||||
|
public ?int $newTodoAssignedTo = null;
|
||||||
|
|
||||||
public int $activityPage = 1;
|
public int $activityPage = 1;
|
||||||
|
|
||||||
const ACTIVITY_PER_PAGE = 4;
|
const ACTIVITY_PER_PAGE = 4;
|
||||||
@@ -44,6 +54,33 @@ new class extends Component
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function toggleTodoForm(): void
|
||||||
|
{
|
||||||
|
$this->showTodoForm = ! $this->showTodoForm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addTodo(): void
|
||||||
|
{
|
||||||
|
$validated = $this->validate([
|
||||||
|
'newTodoTitle' => 'required|string|max:255',
|
||||||
|
'newTodoCategory' => 'required|in:'.implode(',', array_keys(Todo::CATEGORIES)),
|
||||||
|
'newTodoCriticality' => 'required|in:'.implode(',', array_keys(Todo::CRITICALITIES)),
|
||||||
|
'newTodoDueDate' => 'nullable|date',
|
||||||
|
'newTodoAssignedTo' => 'nullable|exists:users,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->organization->todos()->create([
|
||||||
|
'title' => $validated['newTodoTitle'],
|
||||||
|
'category' => $validated['newTodoCategory'],
|
||||||
|
'criticality' => $validated['newTodoCriticality'],
|
||||||
|
'due_date' => $validated['newTodoDueDate'],
|
||||||
|
'assigned_to' => $validated['newTodoAssignedTo'],
|
||||||
|
'created_by' => auth()->id(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->reset(['newTodoTitle', 'newTodoCategory', 'newTodoCriticality', 'newTodoDueDate', 'newTodoAssignedTo', 'showTodoForm']);
|
||||||
|
}
|
||||||
|
|
||||||
public function previousActivityPage(): void
|
public function previousActivityPage(): void
|
||||||
{
|
{
|
||||||
$this->activityPage = max(1, $this->activityPage - 1);
|
$this->activityPage = max(1, $this->activityPage - 1);
|
||||||
@@ -127,7 +164,20 @@ new class extends Component
|
|||||||
->limit(8)
|
->limit(8)
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
|
$glpi = ['tickets' => [], 'total' => 0, 'error' => null];
|
||||||
|
$glpiNewTicketUrl = null;
|
||||||
|
|
||||||
|
if ($this->organization->glpi_entity_id !== null) {
|
||||||
|
$glpiClient = app(GlpiClient::class);
|
||||||
|
$glpi = $glpiClient->recentTicketsForEntity($this->organization->glpi_entity_id);
|
||||||
|
$glpiNewTicketUrl = $glpiClient->frontUrl().'/front/ticket.form.php';
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'glpiTickets' => $glpi['tickets'],
|
||||||
|
'glpiTicketsTotal' => $glpi['total'],
|
||||||
|
'glpiError' => $glpi['error'],
|
||||||
|
'glpiNewTicketUrl' => $glpiNewTicketUrl,
|
||||||
'counts' => [
|
'counts' => [
|
||||||
'contacts' => $this->organization->contacts()->count(),
|
'contacts' => $this->organization->contacts()->count(),
|
||||||
'locations' => $this->organization->locations()->count(),
|
'locations' => $this->organization->locations()->count(),
|
||||||
@@ -141,6 +191,7 @@ new class extends Component
|
|||||||
'recentActivity' => $recentActivity,
|
'recentActivity' => $recentActivity,
|
||||||
'activityPageCount' => $activityPageCount,
|
'activityPageCount' => $activityPageCount,
|
||||||
'openTodos' => $openTodos,
|
'openTodos' => $openTodos,
|
||||||
|
'assignableUsers' => User::role(['Administrateur', 'Technicien'])->orderBy('name')->get(),
|
||||||
'logEntries' => $this->organization->logEntries()->with('creator')->limit(20)->get(),
|
'logEntries' => $this->organization->logEntries()->with('creator')->limit(20)->get(),
|
||||||
'categories' => \App\Models\OrganizationLogEntry::CATEGORIES,
|
'categories' => \App\Models\OrganizationLogEntry::CATEGORIES,
|
||||||
];
|
];
|
||||||
@@ -237,12 +288,12 @@ new class extends Component
|
|||||||
@if ($activityPageCount > 1)
|
@if ($activityPageCount > 1)
|
||||||
<div class="mt-3 flex items-center justify-between border-t border-slate-100 pt-3 text-xs text-slate-500">
|
<div class="mt-3 flex items-center justify-between border-t border-slate-100 pt-3 text-xs text-slate-500">
|
||||||
<button type="button" wire:click="previousActivityPage" @disabled($activityPage <= 1)
|
<button type="button" wire:click="previousActivityPage" @disabled($activityPage <= 1)
|
||||||
class="text-slate-600 hover:text-slate-900 disabled:cursor-not-allowed disabled:text-slate-300 disabled:hover:text-slate-300">
|
class="cursor-pointer text-slate-600 hover:text-slate-900 disabled:cursor-not-allowed disabled:text-slate-300 disabled:hover:text-slate-300">
|
||||||
← Précédent
|
← Précédent
|
||||||
</button>
|
</button>
|
||||||
<span>Page {{ $activityPage }} / {{ $activityPageCount }}</span>
|
<span>Page {{ $activityPage }} / {{ $activityPageCount }}</span>
|
||||||
<button type="button" wire:click="nextActivityPage" @disabled($activityPage >= $activityPageCount)
|
<button type="button" wire:click="nextActivityPage" @disabled($activityPage >= $activityPageCount)
|
||||||
class="text-slate-600 hover:text-slate-900 disabled:cursor-not-allowed disabled:text-slate-300 disabled:hover:text-slate-300">
|
class="cursor-pointer text-slate-600 hover:text-slate-900 disabled:cursor-not-allowed disabled:text-slate-300 disabled:hover:text-slate-300">
|
||||||
Suivant →
|
Suivant →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -254,11 +305,55 @@ new class extends Component
|
|||||||
<div class="rounded border border-slate-200 bg-white p-4">
|
<div class="rounded border border-slate-200 bg-white p-4">
|
||||||
<div class="mb-3 flex items-center justify-between">
|
<div class="mb-3 flex items-center justify-between">
|
||||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Tâches ouvertes</h2>
|
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Tâches ouvertes</h2>
|
||||||
<a href="{{ route('organizations.todos.index', $organization) }}" class="text-xs text-slate-500 hover:text-slate-900">
|
<div class="flex items-center gap-3">
|
||||||
Voir toutes les tâches →
|
<button type="button" wire:click="toggleTodoForm"
|
||||||
</a>
|
class="cursor-pointer text-xs text-slate-500 hover:text-slate-900">
|
||||||
|
{{ $showTodoForm ? 'Annuler' : '+ Nouvelle tâche' }}
|
||||||
|
</button>
|
||||||
|
<a href="{{ route('organizations.todos.index', $organization) }}" class="text-xs text-slate-500 hover:text-slate-900">
|
||||||
|
Voir toutes les tâches →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if ($showTodoForm)
|
||||||
|
<form wire:submit="addTodo" class="mb-4 space-y-2 rounded border border-slate-100 bg-slate-50 p-3">
|
||||||
|
<div>
|
||||||
|
<input type="text" wire:model="newTodoTitle" placeholder="Titre de la tâche"
|
||||||
|
class="w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
|
||||||
|
@error('newTodoTitle') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
|
<select wire:model="newTodoCategory"
|
||||||
|
class="rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
|
||||||
|
@foreach (Todo::CATEGORIES as $value => $label)
|
||||||
|
<option value="{{ $value }}">{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<select wire:model="newTodoCriticality"
|
||||||
|
class="rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
|
||||||
|
@foreach (Todo::CRITICALITIES as $value => $label)
|
||||||
|
<option value="{{ $value }}">{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<input type="date" wire:model="newTodoDueDate"
|
||||||
|
class="rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
|
||||||
|
<select wire:model="newTodoAssignedTo"
|
||||||
|
class="rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
|
||||||
|
<option value="">Assigné à —</option>
|
||||||
|
@foreach ($assignableUsers as $user)
|
||||||
|
<option value="{{ $user->id }}">{{ $user->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 pt-1">
|
||||||
|
<button type="submit" class="cursor-pointer rounded bg-slate-900 px-3 py-1.5 text-sm text-white hover:bg-slate-700">
|
||||||
|
Créer la tâche
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
|
||||||
<ul class="divide-y divide-slate-100">
|
<ul class="divide-y divide-slate-100">
|
||||||
@forelse ($openTodos as $todo)
|
@forelse ($openTodos as $todo)
|
||||||
<li wire:key="dash-todo-{{ $todo->id }}" class="flex items-center justify-between gap-4 py-2 text-sm">
|
<li wire:key="dash-todo-{{ $todo->id }}" class="flex items-center justify-between gap-4 py-2 text-sm">
|
||||||
@@ -308,7 +403,7 @@ new class extends Component
|
|||||||
</select>
|
</select>
|
||||||
<textarea wire:model="newLogNote" rows="2" placeholder="Projet en cours, point d'attention, intervention prévue..."
|
<textarea wire:model="newLogNote" rows="2" placeholder="Projet en cours, point d'attention, intervention prévue..."
|
||||||
class="flex-1 rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500"></textarea>
|
class="flex-1 rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500"></textarea>
|
||||||
<button type="submit" class="rounded bg-slate-900 px-3 py-1.5 text-sm text-white hover:bg-slate-700">Ajouter</button>
|
<button type="submit" class="cursor-pointer rounded bg-slate-900 px-3 py-1.5 text-sm text-white hover:bg-slate-700">Ajouter</button>
|
||||||
</div>
|
</div>
|
||||||
@error('newLogNote') <p class="text-sm text-red-600">{{ $message }}</p> @enderror
|
@error('newLogNote') <p class="text-sm text-red-600">{{ $message }}</p> @enderror
|
||||||
</form>
|
</form>
|
||||||
@@ -331,7 +426,7 @@ new class extends Component
|
|||||||
</div>
|
</div>
|
||||||
<button type="button" wire:click="deleteLogEntry({{ $entry->id }})"
|
<button type="button" wire:click="deleteLogEntry({{ $entry->id }})"
|
||||||
wire:confirm="Supprimer cette entrée ?"
|
wire:confirm="Supprimer cette entrée ?"
|
||||||
class="shrink-0 text-xs text-red-500 hover:text-red-700">
|
class="cursor-pointer shrink-0 text-xs text-red-500 hover:text-red-700">
|
||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -341,4 +436,84 @@ new class extends Component
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if ($organization->glpi_entity_id !== null)
|
||||||
|
<div class="mt-6 rounded border border-slate-200 bg-white p-4">
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Tickets GLPI</h2>
|
||||||
|
<a href="{{ $glpiNewTicketUrl }}" target="_blank" rel="noopener"
|
||||||
|
class="text-xs text-slate-500 hover:text-slate-900">
|
||||||
|
Nouveau ticket →
|
||||||
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ new class extends Component
|
|||||||
public string $status = 'active';
|
public string $status = 'active';
|
||||||
public string $description = '';
|
public string $description = '';
|
||||||
public string $allowed_ip_ranges = '';
|
public string $allowed_ip_ranges = '';
|
||||||
|
public ?int $glpi_entity_id = null;
|
||||||
|
|
||||||
public $logo = null;
|
public $logo = null;
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ new class extends Component
|
|||||||
$this->status = $organization->status;
|
$this->status = $organization->status;
|
||||||
$this->description = $organization->description ?? '';
|
$this->description = $organization->description ?? '';
|
||||||
$this->allowed_ip_ranges = implode("\n", $organization->allowed_ip_ranges ?? []);
|
$this->allowed_ip_ranges = implode("\n", $organization->allowed_ip_ranges ?? []);
|
||||||
|
$this->glpi_entity_id = $organization->glpi_entity_id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +42,7 @@ new class extends Component
|
|||||||
'status' => 'required|in:active,inactive',
|
'status' => 'required|in:active,inactive',
|
||||||
'description' => 'nullable|string',
|
'description' => 'nullable|string',
|
||||||
'logo' => 'nullable|image|max:2048',
|
'logo' => 'nullable|image|max:2048',
|
||||||
|
'glpi_entity_id' => 'nullable|integer|min:0',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
unset($validated['logo']);
|
unset($validated['logo']);
|
||||||
@@ -104,7 +107,8 @@ new class extends Component
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form wire:submit="save" class="space-y-4 rounded border border-slate-200 bg-white p-6">
|
<form wire:submit="save" class="space-y-4 rounded border border-slate-200 bg-white p-6"
|
||||||
|
wire:loading.class="opacity-60 pointer-events-none" wire:target="save">
|
||||||
<div x-data="logoCropper($wire)">
|
<div x-data="logoCropper($wire)">
|
||||||
<label class="block text-sm font-medium text-slate-700">Logo</label>
|
<label class="block text-sm font-medium text-slate-700">Logo</label>
|
||||||
<div class="mt-1 flex items-center gap-4">
|
<div class="mt-1 flex items-center gap-4">
|
||||||
@@ -137,11 +141,20 @@ new class extends Component
|
|||||||
<input type="range" :min="minZoom" :max="minZoom * 4" step="0.001" x-model.number="zoom" x-on:input="applyTransform()" class="flex-1">
|
<input type="range" :min="minZoom" :max="minZoom * 4" step="0.001" x-model.number="zoom" x-on:input="applyTransform()" class="flex-1">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p x-show="uploadError" x-cloak class="mt-3 text-sm text-red-600" x-text="uploadError"></p>
|
||||||
|
|
||||||
<div class="mt-4 flex items-center justify-end gap-3">
|
<div class="mt-4 flex items-center justify-end gap-3">
|
||||||
<button type="button" x-on:click="cancelCrop()" class="text-sm text-slate-500 hover:text-slate-900">Annuler</button>
|
<button type="button" x-on:click="cancelCrop()" :disabled="uploading"
|
||||||
<button type="button" x-on:click="confirmCrop()"
|
class="text-sm text-slate-500 hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
|
Annuler
|
||||||
Valider le recadrage
|
</button>
|
||||||
|
<button type="button" x-on:click="confirmCrop()" :disabled="uploading"
|
||||||
|
class="inline-flex items-center gap-2 rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
|
<svg x-show="uploading" x-cloak class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
<span x-text="uploading ? 'Envoi en cours…' : 'Valider le recadrage'"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -189,12 +202,26 @@ new class extends Component
|
|||||||
<p class="mt-1 text-xs text-slate-500">Laisser vide pour autoriser l'accès depuis n'importe quelle IP.</p>
|
<p class="mt-1 text-xs text-slate-500">Laisser vide pour autoriser l'accès depuis n'importe quelle IP.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">ID entité GLPI</label>
|
||||||
|
<input type="number" min="0" wire:model="glpi_entity_id"
|
||||||
|
class="mt-1 w-full max-w-xs rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
|
||||||
|
<p class="mt-1 text-xs text-slate-500">Lie cette organisation à une entité GLPI pour afficher ses tickets sur le tableau de bord. Laisser vide pour désactiver.</p>
|
||||||
|
@error('glpi_entity_id') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3 pt-2">
|
<div class="flex items-center gap-3 pt-2">
|
||||||
<button type="submit"
|
<button type="submit" wire:loading.attr="disabled" wire:target="save"
|
||||||
class="rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
|
class="inline-flex items-center gap-2 rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
Enregistrer
|
<svg wire:loading wire:target="save" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
<span wire:loading.remove wire:target="save">Enregistrer</span>
|
||||||
|
<span wire:loading wire:target="save">Enregistrement…</span>
|
||||||
</button>
|
</button>
|
||||||
<a href="{{ route('organizations.index') }}" class="text-sm text-slate-500 hover:text-slate-900">Annuler</a>
|
<a href="{{ route('organizations.index') }}" wire:loading.class="pointer-events-none opacity-60" wire:target="save"
|
||||||
|
class="text-sm text-slate-500 hover:text-slate-900">Annuler</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -218,6 +245,8 @@ new class extends Component
|
|||||||
dragOffsetStartY: 0,
|
dragOffsetStartY: 0,
|
||||||
fileName: 'logo.png',
|
fileName: 'logo.png',
|
||||||
fileType: 'image/png',
|
fileType: 'image/png',
|
||||||
|
uploading: false,
|
||||||
|
uploadError: null,
|
||||||
|
|
||||||
onFileSelected(event) {
|
onFileSelected(event) {
|
||||||
const file = event.target.files[0];
|
const file = event.target.files[0];
|
||||||
@@ -311,6 +340,8 @@ new class extends Component
|
|||||||
canvas.height = viewport;
|
canvas.height = viewport;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
this.uploadError = null;
|
||||||
|
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
ctx.drawImage(
|
ctx.drawImage(
|
||||||
@@ -324,10 +355,15 @@ new class extends Component
|
|||||||
|
|
||||||
canvas.toBlob((blob) => {
|
canvas.toBlob((blob) => {
|
||||||
const croppedFile = new File([blob], this.fileName, { type: this.fileType });
|
const croppedFile = new File([blob], this.fileName, { type: this.fileType });
|
||||||
|
this.uploading = true;
|
||||||
wire.$upload('logo', croppedFile, () => {
|
wire.$upload('logo', croppedFile, () => {
|
||||||
|
this.uploading = false;
|
||||||
this.cropping = false;
|
this.cropping = false;
|
||||||
this.imageSrc = null;
|
this.imageSrc = null;
|
||||||
});
|
}, () => {
|
||||||
|
this.uploading = false;
|
||||||
|
this.uploadError = "Échec de l'envoi de l'image. Réessayez.";
|
||||||
|
}, () => {});
|
||||||
}, this.fileType);
|
}, this.fileType);
|
||||||
};
|
};
|
||||||
img.src = this.imageSrc;
|
img.src = this.imageSrc;
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\Provider;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
new class extends Component
|
||||||
|
{
|
||||||
|
public Organization $organization;
|
||||||
|
|
||||||
|
public ?int $technicianUserId = null;
|
||||||
|
public string $ispName = '';
|
||||||
|
public string $publicIpsText = '';
|
||||||
|
|
||||||
|
public ?int $newProviderId = null;
|
||||||
|
public string $newProviderLabel = '';
|
||||||
|
|
||||||
|
public function mount(Organization $organization): void
|
||||||
|
{
|
||||||
|
$this->organization = $organization;
|
||||||
|
$this->technicianUserId = $organization->technician_user_id;
|
||||||
|
$this->ispName = $organization->isp_name ?? '';
|
||||||
|
$this->publicIpsText = implode("\n", $organization->public_ips ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$validated = $this->validate([
|
||||||
|
'technicianUserId' => 'nullable|exists:users,id',
|
||||||
|
'ispName' => 'nullable|string|max:255',
|
||||||
|
'publicIpsText' => 'nullable|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ips = collect(preg_split('/[\s,]+/', (string) $validated['publicIpsText'], -1, PREG_SPLIT_NO_EMPTY))
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($ips->contains(fn ($ip) => filter_var($ip, FILTER_VALIDATE_IP) === false)) {
|
||||||
|
$this->addError('publicIpsText', 'Une ou plusieurs adresses IP sont invalides.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->organization->update([
|
||||||
|
'technician_user_id' => $validated['technicianUserId'],
|
||||||
|
'isp_name' => $validated['ispName'] !== '' ? $validated['ispName'] : null,
|
||||||
|
'public_ips' => $ips->all(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->flash('status', "Fiche d'identité mise à jour.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addProvider(): void
|
||||||
|
{
|
||||||
|
$validated = $this->validate([
|
||||||
|
'newProviderId' => 'required|exists:providers,id',
|
||||||
|
'newProviderLabel' => 'nullable|string|max:255',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$label = $validated['newProviderLabel'] ?: null;
|
||||||
|
|
||||||
|
$alreadyLinked = $this->organization->providers()
|
||||||
|
->wherePivot('provider_id', $validated['newProviderId'])
|
||||||
|
->wherePivot('label', '=', $label)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($alreadyLinked) {
|
||||||
|
$this->addError('newProviderId', 'Ce prestataire est déjà lié avec ce libellé.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->organization->providers()->attach($validated['newProviderId'], ['label' => $label]);
|
||||||
|
|
||||||
|
$this->reset(['newProviderId', 'newProviderLabel']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeProvider(int $pivotId): void
|
||||||
|
{
|
||||||
|
DB::table('organization_provider')
|
||||||
|
->where('id', $pivotId)
|
||||||
|
->where('organization_id', $this->organization->id)
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function with(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'technicians' => User::role(['Administrateur', 'Technicien'])->orderBy('name')->get(),
|
||||||
|
'availableProviders' => Provider::orderBy('name')->get(),
|
||||||
|
'providers' => $this->organization->providers()->orderBy('name')->get(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="max-w-3xl">
|
||||||
|
<p class="text-sm text-slate-500">{{ $organization->name }}</p>
|
||||||
|
<h1 class="mb-6 text-2xl font-semibold text-slate-900">Fiche d'identité informatique</h1>
|
||||||
|
|
||||||
|
@if (session('status'))
|
||||||
|
<div class="mb-4 rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
|
||||||
|
{{ session('status') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<form wire:submit="save" class="space-y-4 rounded border border-slate-200 bg-white p-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">Technicien gestionnaire</label>
|
||||||
|
<select wire:model="technicianUserId"
|
||||||
|
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
|
||||||
|
<option value="">—</option>
|
||||||
|
@foreach ($technicians as $technician)
|
||||||
|
<option value="{{ $technician->id }}">{{ $technician->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@error('technicianUserId') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">Fournisseur d'accès internet</label>
|
||||||
|
<input type="text" wire:model="ispName" placeholder="Orange, Free Pro, Telindus..."
|
||||||
|
class="mt-1 w-full rounded border-slate-300 text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500">
|
||||||
|
@error('ispName') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">IP(s) publiques</label>
|
||||||
|
<textarea wire:model="publicIpsText" rows="3" placeholder="Une adresse IP par ligne"
|
||||||
|
class="mt-1 w-full rounded border-slate-300 font-mono text-sm shadow-sm focus:border-slate-500 focus:ring-slate-500"></textarea>
|
||||||
|
@error('publicIpsText') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-2">
|
||||||
|
<button type="submit"
|
||||||
|
class="cursor-pointer rounded bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700">
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-8 rounded border border-slate-200 bg-white p-6">
|
||||||
|
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">Prestataires</h2>
|
||||||
|
|
||||||
|
<ul class="mb-4 divide-y divide-slate-100">
|
||||||
|
@forelse ($providers as $provider)
|
||||||
|
<li wire:key="provider-{{ $provider->pivot->id }}" class="flex items-center gap-3 py-2 text-sm">
|
||||||
|
<img src="{{ $provider->logo_url }}" alt="Logo {{ $provider->name }}"
|
||||||
|
class="h-6 w-6 shrink-0 rounded border border-slate-200 bg-white object-contain p-0.5">
|
||||||
|
<div class="flex-1">
|
||||||
|
<a href="{{ route('providers.edit', $provider) }}" class="font-medium text-slate-900 hover:underline">
|
||||||
|
{{ $provider->name }}
|
||||||
|
</a>
|
||||||
|
@if ($provider->pivot->label)
|
||||||
|
<span class="text-xs text-slate-500">· {{ $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" />
|
<livewire:documents.markdown-editor :organization="$organization" :document="$document" />
|
||||||
@elseif ($document->isUpload())
|
@elseif ($document->isUpload())
|
||||||
<livewire:documents.upload-viewer :organization="$organization" :document="$document" />
|
<livewire:documents.upload-viewer :organization="$organization" :document="$document" />
|
||||||
|
@elseif ($document->isDiagram())
|
||||||
|
<livewire:documents.diagram-editor :organization="$organization" :document="$document" />
|
||||||
@else
|
@else
|
||||||
<livewire:documents.form :organization="$organization" :document="$document" />
|
<livewire:documents.form :organization="$organization" :document="$document" />
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<x-layouts.app :title="$document->name">
|
||||||
|
<livewire:documents.diagram-editor :organization="$organization" :document="$document"
|
||||||
|
back-route="organizations.infrastructure.index" :folder-id="request()->integer('folderId') ?: null" />
|
||||||
|
</x-layouts.app>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<x-layouts.app title="Infrastructure">
|
||||||
|
<livewire:infrastructure.index :organization="$organization" />
|
||||||
|
</x-layouts.app>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<x-layouts.app title="Fiche d'identité informatique">
|
||||||
|
<livewire:organizations.identity-card :organization="$organization" />
|
||||||
|
</x-layouts.app>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<x-layouts.app title="Nouveau prestataire">
|
||||||
|
<livewire:providers.form />
|
||||||
|
</x-layouts.app>
|
||||||
@@ -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\Location;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\Password;
|
use App\Models\Password;
|
||||||
|
use App\Models\Provider;
|
||||||
use App\Models\Shortcut;
|
use App\Models\Shortcut;
|
||||||
use App\Models\Todo;
|
use App\Models\Todo;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@@ -23,7 +24,14 @@ Route::middleware(['auth'])->group(function () {
|
|||||||
Route::get('/organizations', fn () => view('organizations.index'))->name('organizations.index');
|
Route::get('/organizations', fn () => view('organizations.index'))->name('organizations.index');
|
||||||
Route::get('/organizations/create', fn () => view('organizations.create'))->name('organizations.create');
|
Route::get('/organizations/create', fn () => view('organizations.create'))->name('organizations.create');
|
||||||
Route::get('/organizations/{organization}/edit', fn (Organization $organization) => view('organizations.edit', ['organization' => $organization]))->name('organizations.edit');
|
Route::get('/organizations/{organization}/edit', fn (Organization $organization) => view('organizations.edit', ['organization' => $organization]))->name('organizations.edit');
|
||||||
|
|
||||||
|
Route::prefix('/providers')->name('providers.')->group(function () {
|
||||||
|
Route::get('/', fn () => view('providers.index'))->name('index');
|
||||||
|
Route::get('/create', fn () => view('providers.create'))->name('create');
|
||||||
|
Route::get('/{provider}/edit', fn (Provider $provider) => view('providers.edit', ['provider' => $provider]))->name('edit');
|
||||||
|
});
|
||||||
Route::get('/organizations/{organization}/dashboard', fn (Organization $organization) => view('organizations.dashboard', ['organization' => $organization]))->name('organizations.dashboard');
|
Route::get('/organizations/{organization}/dashboard', fn (Organization $organization) => view('organizations.dashboard', ['organization' => $organization]))->name('organizations.dashboard');
|
||||||
|
Route::get('/organizations/{organization}/identity-card', fn (Organization $organization) => view('organizations.identity-card', ['organization' => $organization]))->name('organizations.identity-card');
|
||||||
|
|
||||||
Route::prefix('/organizations/{organization}/passwords')->name('organizations.passwords.')->group(function () {
|
Route::prefix('/organizations/{organization}/passwords')->name('organizations.passwords.')->group(function () {
|
||||||
Route::get('/', fn (Organization $organization) => view('passwords.index', ['organization' => $organization]))->name('index');
|
Route::get('/', fn (Organization $organization) => view('passwords.index', ['organization' => $organization]))->name('index');
|
||||||
@@ -74,6 +82,14 @@ Route::middleware(['auth'])->group(function () {
|
|||||||
Route::get('/{document}/download', DocumentDownloadController::class)->name('download');
|
Route::get('/{document}/download', DocumentDownloadController::class)->name('download');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Route::prefix('/organizations/{organization}/infrastructure')->name('organizations.infrastructure.')->group(function () {
|
||||||
|
Route::get('/', fn (Organization $organization) => view('infrastructure.index', ['organization' => $organization]))->name('index');
|
||||||
|
Route::get('/{document}/edit', fn (Organization $organization, Document $document) => view('infrastructure.edit', [
|
||||||
|
'organization' => $organization,
|
||||||
|
'document' => $document,
|
||||||
|
]))->name('edit');
|
||||||
|
});
|
||||||
|
|
||||||
Route::prefix('/organizations/{organization}/virtual-machines')->name('organizations.virtual-machines.')->group(function () {
|
Route::prefix('/organizations/{organization}/virtual-machines')->name('organizations.virtual-machines.')->group(function () {
|
||||||
Route::get('/', fn (Organization $organization) => view('virtual-machines.index', ['organization' => $organization]))->name('index');
|
Route::get('/', fn (Organization $organization) => view('virtual-machines.index', ['organization' => $organization]))->name('index');
|
||||||
Route::get('/create', fn (Organization $organization) => view('virtual-machines.create', ['organization' => $organization]))->name('create');
|
Route::get('/create', fn (Organization $organization) => view('virtual-machines.create', ['organization' => $organization]))->name('create');
|
||||||
@@ -116,6 +132,10 @@ Route::middleware(['auth'])->group(function () {
|
|||||||
Route::get('/{type}/edit', fn (FlexibleAssetType $type) => view('asset-types.edit', ['type' => $type]))->name('edit');
|
Route::get('/{type}/edit', fn (FlexibleAssetType $type) => view('asset-types.edit', ['type' => $type]))->name('edit');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Route::get('/settings/env', fn () => view('settings.env'))
|
||||||
|
->name('settings.env')
|
||||||
|
->middleware('role:Administrateur');
|
||||||
|
|
||||||
Route::get('/organizations/{organization}/assets', function (Organization $organization) {
|
Route::get('/organizations/{organization}/assets', function (Organization $organization) {
|
||||||
return view('assets.picker', [
|
return view('assets.picker', [
|
||||||
'organization' => $organization,
|
'organization' => $organization,
|
||||||
|
|||||||
@@ -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