feat: dashboard amélioré, exports budgets, alertes expiration et correctifs

## Dashboard
- Refonte complète du tableau de bord avec widgets budgets, commandes, contrats
- Intégration des données d'exécution budgétaire en temps réel

## Exports & Rapports
- BudgetExecutionExport : export Excel de l'exécution budgétaire
- Template PDF budgets (budgets_pdf.blade.php)
- Routes d'export PDF et Excel

## Alertes & Notifications
- Commande CheckExpirations : détection des contrats/assets arrivant à échéance
- Mail ExpiringElementsMail avec template Blade
- Planification via routes/console.php

## Correctifs
- CommandePolicy et ContratPolicy : ajustements des règles d'autorisation
- ContratController : corrections mineures
- Commande model : ajustements relations/casts
- AuthenticatedLayout : refonte navigation avec icônes budgets
- Assets/Form.vue : corrections formulaire
- Seeder rôles/permissions mis à jour
- Dépendances composer mises à jour (barryvdh/laravel-dompdf, maatwebsite/excel)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jeremy bayse
2026-04-11 20:20:49 +02:00
parent 0ad77de412
commit 04fc56cd70
18 changed files with 1403 additions and 229 deletions

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Console\Commands;
use App\Models\Asset;
use App\Models\Contrat;
use App\Models\Licence;
use App\Models\Domaine;
use App\Models\User;
use App\Mail\ExpiringElementsMail;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
class CheckExpirations extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:check-expirations';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Vérifie les éléments arrivant à échéance (contrats, licences, garanties) et envoie une notification par email.';
/**
* Execute the console command.
*/
public function handle()
{
$threshold = 30; // jours
$today = Carbon::today();
$limitDate = Carbon::today()->addDays($threshold);
// 1. Contrats arrivant à échéance
$contrats = Contrat::whereBetween('date_echeance', [$today, $limitDate])
->whereNotIn('statut', ['resilie', 'expire'])
->get();
// 2. Licences expirant bientôt
$licences = Licence::whereBetween('date_expiration', [$today, $limitDate])
->get();
// 3. Garanties d'assets expirant bientôt
$assets = Asset::whereBetween('date_fin_garantie', [$today, $limitDate])
->get();
// 4. Domaines arrivant à échéance
$domaines = Domaine::whereBetween('date_echeance', [$today, $limitDate])
->get();
if ($contrats->isEmpty() && $licences->isEmpty() && $assets->isEmpty() && $domaines->isEmpty()) {
$this->info('Aucun élément n\'arrive à échéance prochainement.');
return 0;
}
$this->info(sprintf(
'Trouvé : %d contrats, %d licences, %d assets et %d domaines à notifier.',
$contrats->count(),
$licences->count(),
$assets->count(),
$domaines->count()
));
// Récupérer les administrateurs et responsables
$recipients = User::role(['admin', 'responsable'])->get();
if ($recipients->isEmpty()) {
$this->warn('Aucun destinataire (admin ou responsable) trouvé.');
return 0;
}
foreach ($recipients as $recipient) {
Mail::to($recipient->email)->send(new ExpiringElementsMail($contrats, $licences, $assets, $domaines));
}
$this->info('Emails de notification envoyés.');
return 0;
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace App\Exports;
use App\Models\LigneBudget;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
use Maatwebsite\Excel\Concerns\WithTitle;
class BudgetExecutionExport implements FromCollection, WithHeadings, WithMapping, ShouldAutoSize, WithTitle
{
protected $user;
protected $annee;
protected $filters;
public function __construct($user, $annee, $filters = [])
{
$this->user = $user;
$this->annee = $annee;
$this->filters = $filters;
}
public function title(): string
{
$parts = ['Exécution Budgétaire', $this->annee];
if (!empty($this->filters['type'])) {
$parts[] = ucfirst($this->filters['type']);
}
if (!empty($this->filters['envelope'])) {
$parts[] = ucfirst($this->filters['envelope']);
}
if (!empty($this->filters['service'])) {
$parts[] = $this->filters['service'];
}
return substr(implode(' - ', $parts), 0, 31); // Max 31 chars for Excel sheet title
}
public function collection()
{
$query = LigneBudget::with(['budget.service', 'commandes'])
->whereHas('budget', function($q) {
$q->where('annee', $this->annee);
});
// Role based access
if (!$this->user->hasRole(['admin', 'directeur'])) {
$query->whereHas('budget', function($q) {
$q->where('service_id', $this->user->service_id);
});
}
// Dynamic filters
if (!empty($this->filters['type'])) {
$query->where('type_depense', $this->filters['type']);
}
if (!empty($this->filters['envelope'])) {
$query->whereHas('budget', function($q) {
$q->where('type_budget', $this->filters['envelope']);
});
}
if (!empty($this->filters['service'])) {
$query->whereHas('budget.service', function($q) {
$q->where('nom', $this->filters['service']);
});
}
$statutsConsommes = ['validee', 'commandee', 'partiellement_recue', 'recue_complete', 'cloturee'];
$statutsEngages = ['brouillon', 'en_attente_validation'];
return $query->get()->map(function($lb) use ($statutsConsommes, $statutsEngages) {
// Travail en mémoire sur la relation déjà eager-loadée — zéro requête supplémentaire
$commandes = $lb->commandes;
$consomme = (float) $commandes->whereIn('statut', $statutsConsommes)->sum('montant_ttc');
$engage = (float) $commandes->whereIn('statut', $statutsEngages)->sum('montant_ttc');
$lb->consomme = $consomme;
$lb->engage = $engage;
$lb->total_cumule = $consomme + $engage;
$lb->reste = (float) ($lb->montant_arbitre ?? 0) - $lb->total_cumule;
$lb->percent = ($lb->montant_arbitre ?? 0) > 0
? round(($lb->total_cumule / $lb->montant_arbitre) * 100, 2)
: 0;
return $lb;
});
}
public function headings(): array
{
return [
'ID',
'Service',
'Type Budget',
'Ligne Budgétaire',
'Type Dépense',
'Enveloppe Arbitrée (€)',
'Consommé (€)',
'Engagé (€)',
'Total Engagé (€)',
'Reste (€)',
'Taux d\'exécution (%)'
];
}
public function map($lb): array
{
return [
$lb->id,
$lb->budget->service->nom ?? 'Agglo',
$lb->budget->type_budget === 'agglo' ? 'Agglomération' : 'Mutualisé',
$lb->nom,
$lb->type_depense === 'fonctionnement' ? 'Fonctionnement' : 'Investissement',
$lb->montant_arbitre,
$lb->consomme,
$lb->engage,
$lb->total_cumule,
$lb->reste,
$lb->percent . '%'
];
}
}

View File

@@ -19,7 +19,7 @@ class ContratController extends Controller
$query = Contrat::with(['fournisseur', 'service']);
if (!$request->user()->hasRole('admin')) {
if (!$request->user()->hasAnyRole(['admin', 'raf'])) {
$query->where('service_id', $request->user()->service_id);
}
@@ -29,7 +29,7 @@ class ContratController extends Controller
->orWhereHas('fournisseur', fn($f) => $f->where('nom', 'like', "%{$search}%"));
});
})->when($request->service_id, function ($q, $serviceId) use ($request) {
if ($request->user()->hasRole('admin')) {
if ($request->user()->hasAnyRole(['admin', 'raf'])) {
$q->where('service_id', $serviceId);
}
})->when($request->fournisseur_id, function ($q, $fournisseurId) {
@@ -50,7 +50,7 @@ class ContratController extends Controller
return Inertia::render('Contrats/Index', [
'contrats' => $contrats,
'services' => $request->user()->hasRole('admin') ? Service::all() : Service::where('id', $request->user()->service_id)->get(),
'services' => $request->user()->hasAnyRole(['admin', 'raf']) ? Service::all() : Service::where('id', $request->user()->service_id)->get(),
'fournisseurs' => Fournisseur::active()->orderBy('nom')->get(),
'communes' => \App\Models\Commune::orderBy('nom')->get(),
'filters' => $request->only(['search', 'service_id', 'fournisseur_id', 'statut']),
@@ -63,7 +63,7 @@ class ContratController extends Controller
$this->authorize('create', Contrat::class);
return Inertia::render('Contrats/Create', [
'services' => $request->user()->hasRole('admin') ? Service::all() : Service::where('id', $request->user()->service_id)->get(),
'services' => $request->user()->hasAnyRole(['admin', 'raf']) ? Service::all() : Service::where('id', $request->user()->service_id)->get(),
'fournisseurs' => Fournisseur::active()->orderBy('nom')->get(),
'communes' => \App\Models\Commune::orderBy('nom')->get(),
'statuts' => Contrat::STATUTS_LABELS,
@@ -86,13 +86,13 @@ class ContratController extends Controller
];
// Only admins can select other services, otherwise we force the user's service
if ($request->user()->hasRole('admin')) {
if ($request->user()->hasAnyRole(['admin', 'raf'])) {
$rules['service_id'] = 'required|exists:services,id';
}
$validated = $request->validate($rules);
if (!$request->user()->hasRole('admin')) {
if (!$request->user()->hasAnyRole(['admin', 'raf'])) {
$validated['service_id'] = $request->user()->service_id;
}
@@ -120,7 +120,7 @@ class ContratController extends Controller
return Inertia::render('Contrats/Edit', [
'contrat' => $contrat,
'services' => $request->user()->hasRole('admin') ? Service::all() : Service::where('id', $request->user()->service_id)->get(),
'services' => $request->user()->hasAnyRole(['admin', 'raf']) ? Service::all() : Service::where('id', $request->user()->service_id)->get(),
'fournisseurs' => Fournisseur::active()->orderBy('nom')->get(),
'communes' => \App\Models\Commune::orderBy('nom')->get(),
'statuts' => Contrat::STATUTS_LABELS,
@@ -142,7 +142,7 @@ class ContratController extends Controller
'preavis_jours' => 'nullable|integer|min:0',
];
if ($request->user()->hasRole('admin')) {
if ($request->user()->hasAnyRole(['admin', 'raf'])) {
$rules['service_id'] = 'required|exists:services,id';
}

View File

@@ -6,6 +6,8 @@ use App\Models\Commande;
use App\Models\Service;
use App\Models\Contrat;
use App\Models\Domaine;
use App\Models\Budget;
use App\Models\LigneBudget;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
@@ -16,47 +18,61 @@ class DashboardController extends Controller
public function index(Request $request): Response
{
$user = $request->user();
$isRestricted = !$user->hasRole(['admin', 'directeur', 'raf']);
$serviceId = $user->service_id;
$baseCommande = Commande::query();
if ($isRestricted) {
$baseCommande->where('service_id', $serviceId);
}
$stats = [
'total' => Commande::count(),
'en_cours' => Commande::enCours()->count(),
'en_retard' => Commande::enRetard()->count(),
'brouillons' => Commande::parStatut('brouillon')->count(),
'en_attente_validation' => Commande::parStatut('en_attente_validation')->count(),
'validees' => Commande::parStatut('validee')->count(),
'commandees' => Commande::parStatut('commandee')->count(),
'partiellement_recues' => Commande::parStatut('partiellement_recue')->count(),
'recues_complete' => Commande::parStatut('recue_complete')->count(),
'total' => (clone $baseCommande)->count(),
'en_cours' => (clone $baseCommande)->enCours()->count(),
'en_retard' => (clone $baseCommande)->enRetard()->count(),
'brouillons' => (clone $baseCommande)->parStatut('brouillon')->count(),
'en_attente_validation' => (clone $baseCommande)->parStatut('en_attente_validation')->count(),
'validees' => (clone $baseCommande)->parStatut('validee')->count(),
'commandees' => (clone $baseCommande)->parStatut('commandee')->count(),
'partiellement_recues' => (clone $baseCommande)->parStatut('partiellement_recue')->count(),
'recues_complete' => (clone $baseCommande)->parStatut('recue_complete')->count(),
];
$commandesRecentes = Commande::with(['service', 'fournisseur', 'demandeur'])
$commandesRecentes = (clone $baseCommande)->with(['service', 'fournisseur', 'demandeur'])
->latest()
->limit(8)
->get();
$commandesEnRetard = Commande::enRetard()
$commandesEnRetard = (clone $baseCommande)->enRetard()
->with(['service', 'fournisseur', 'demandeur'])
->orderBy('date_souhaitee')
->limit(10)
->get();
$commandesUrgentes = Commande::urgentes()
$commandesUrgentes = (clone $baseCommande)->urgentes()
->enCours()
->with(['service', 'fournisseur', 'demandeur'])
->latest()
->limit(5)
->get();
$statsParStatut = Commande::select('statut', DB::raw('count(*) as total'))
$statsParStatut = (clone $baseCommande)->select('statut', DB::raw('count(*) as total'))
->groupBy('statut')
->get()
->keyBy('statut');
$statsParService = Service::withCount([
$statsParServiceQuery = Service::withCount([
'commandes',
'commandes as commandes_en_cours_count' => fn ($q) => $q->enCours(),
])->get();
]);
$montantParMois = Commande::select(
if ($isRestricted) {
$statsParServiceQuery->where('id', $serviceId);
}
$statsParService = $statsParServiceQuery->get();
$montantParMois = (clone $baseCommande)->select(
DB::raw('YEAR(date_demande) as annee'),
DB::raw('MONTH(date_demande) as mois'),
DB::raw('SUM(montant_ttc) as total_ttc'),
@@ -70,7 +86,7 @@ class DashboardController extends Controller
// Stats Contrats
$contratsQuery = Contrat::query();
if (!$user->hasRole('admin')) {
if (!$user->hasRole(['admin', 'raf'])) {
$contratsQuery->where('service_id', $user->service_id);
}
$tousContrats = $contratsQuery->get();
@@ -88,6 +104,40 @@ class DashboardController extends Controller
'en_retard' => $tousDomaines->filter(fn($d) => $d->est_en_retard)->count(),
];
// Stats Budget
$anneeCourante = now()->year;
$budgetQuery = LigneBudget::whereHas('budget', function($q) use ($anneeCourante) {
$q->where('annee', $anneeCourante);
});
if ($isRestricted) {
$budgetQuery->whereHas('budget', function($q) use ($serviceId) {
$q->where('service_id', $serviceId);
});
}
$budgetsStats = [];
foreach (['agglo', 'mutualise'] as $typeBudget) {
$budgetQueryType = (clone $budgetQuery)->whereHas('budget', fn($q) => $q->where('type_budget', $typeBudget));
$baseCommandeType = (clone $baseCommande)->whereHas('ligneBudget.budget', function($q) use ($anneeCourante, $typeBudget) {
$q->where('annee', $anneeCourante)->where('type_budget', $typeBudget);
})->whereNotIn('statut', ['annulee']);
$budgetsStats[$typeBudget] = [
'total_arbitre' => $budgetQueryType->sum('montant_arbitre'),
'consomme' => $baseCommandeType->sum('montant_ttc'),
'fonctionnement' => [
'total_arbitre' => (clone $budgetQueryType)->where('type_depense', 'fonctionnement')->sum('montant_arbitre'),
'consomme' => (clone $baseCommandeType)->whereHas('ligneBudget', fn($q) => $q->where('type_depense', 'fonctionnement'))->sum('montant_ttc'),
],
'investissement' => [
'total_arbitre' => (clone $budgetQueryType)->where('type_depense', 'investissement')->sum('montant_arbitre'),
'consomme' => (clone $baseCommandeType)->whereHas('ligneBudget', fn($q) => $q->where('type_depense', 'investissement'))->sum('montant_ttc'),
],
];
}
return Inertia::render('Dashboard/Index', compact(
'stats',
'commandesRecentes',
@@ -98,6 +148,7 @@ class DashboardController extends Controller
'montantParMois',
'statsContrats',
'statsDomaines',
'budgetsStats',
));
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
class ExpiringElementsMail extends Mailable
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct(
public Collection $contrats,
public Collection $licences,
public Collection $assets,
public Collection $domaines
) {}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: '[DSICommander] Alerte : Éléments arrivant à échéance prochainement',
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
markdown: 'emails.expiring_elements',
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

View File

@@ -16,7 +16,7 @@ class Commande extends Model
protected $fillable = [
'numero_commande', 'service_id', 'fournisseur_id', 'user_id', 'commune_id',
'validateur_id', 'acheteur_id', 'objet', 'description', 'justification',
'statut', 'priorite', 'reference_fournisseur', 'imputation_budgetaire',
'statut', 'priorite', 'reference_fournisseur', 'imputation_budgetaire', 'ligne_budget_id',
'montant_ht', 'montant_ttc',
'date_demande', 'date_souhaitee', 'date_validation', 'date_commande',
'date_livraison_prevue', 'date_reception', 'date_reception_complete', 'date_cloture',
@@ -133,6 +133,11 @@ class Commande extends Model
return $this->hasMany(PieceJointe::class)->orderBy('created_at', 'desc');
}
public function ligneBudget(): BelongsTo
{
return $this->belongsTo(LigneBudget::class, 'ligne_budget_id');
}
// -----------------------------------------------------------------------
// Scopes
// -----------------------------------------------------------------------

View File

@@ -19,12 +19,12 @@ class CommandePolicy
public function create(User $user): bool
{
return $user->hasAnyRole(['admin', 'responsable', 'acheteur']);
return $user->hasAnyRole(['admin', 'responsable', 'acheteur', 'raf']);
}
public function update(User $user, Commande $commande): bool
{
if ($user->hasRole('admin')) {
if ($user->hasAnyRole(['admin', 'raf'])) {
return true;
}
@@ -39,7 +39,7 @@ class CommandePolicy
public function delete(User $user, Commande $commande): bool
{
return $user->hasRole('admin');
return $user->hasAnyRole(['admin', 'raf']);
}
public function transition(User $user, Commande $commande, string $targetStatut): bool
@@ -48,7 +48,7 @@ class CommandePolicy
return false;
}
if ($user->hasRole('admin')) {
if ($user->hasAnyRole(['admin', 'raf'])) {
return true;
}

View File

@@ -14,7 +14,7 @@ class ContratPolicy
public function view(User $user, Contrat $contrat): bool
{
if ($user->hasRole('admin')) {
if ($user->hasAnyRole(['admin', 'raf'])) {
return true;
}
@@ -28,7 +28,7 @@ class ContratPolicy
public function update(User $user, Contrat $contrat): bool
{
if ($user->hasRole('admin')) {
if ($user->hasAnyRole(['admin', 'raf'])) {
return true;
}
@@ -37,7 +37,7 @@ class ContratPolicy
public function delete(User $user, Contrat $contrat): bool
{
if ($user->hasRole('admin')) {
if ($user->hasAnyRole(['admin', 'raf'])) {
return true;
}

View File

@@ -13,6 +13,7 @@
"laravel/framework": "^13.0",
"laravel/sanctum": "^4.0",
"laravel/tinker": "^3.0",
"maatwebsite/excel": "^3.1",
"spatie/laravel-activitylog": "^4.11",
"spatie/laravel-permission": "^7.1",
"tightenco/ziggy": "^2.0"

593
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "f9fee4ecbd004da6b6646fdb7509c872",
"content-hash": "aa28b946cc1fa745ad8a1dc2fd38cae6",
"packages": [
{
"name": "barryvdh/laravel-dompdf",
@@ -212,6 +212,162 @@
],
"time": "2024-02-09T16:56:22+00:00"
},
{
"name": "composer/pcre",
"version": "3.3.2",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<1.11.10"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-strict-rules": "^1 || ^2",
"phpunit/phpunit": "^8 || ^9"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
"PCRE",
"preg",
"regex",
"regular expression"
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.3.2"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
"type": "tidelift"
}
],
"time": "2024-11-12T16:29:46+00:00"
},
{
"name": "composer/semver",
"version": "3.4.4",
"source": {
"type": "git",
"url": "https://github.com/composer/semver.git",
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95",
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95",
"shasum": ""
},
"require": {
"php": "^5.3.2 || ^7.0 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.11",
"symfony/phpunit-bridge": "^3 || ^7"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Semver\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nils Adermann",
"email": "naderman@naderman.de",
"homepage": "http://www.naderman.de"
},
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
},
{
"name": "Rob Bast",
"email": "rob.bast@gmail.com",
"homepage": "http://robbast.nl"
}
],
"description": "Semver library that offers utilities, version constraint parsing and validation.",
"keywords": [
"semantic",
"semver",
"validation",
"versioning"
],
"support": {
"irc": "ircs://irc.libera.chat:6697/composer",
"issues": "https://github.com/composer/semver/issues",
"source": "https://github.com/composer/semver/tree/3.4.4"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
}
],
"time": "2025-08-20T19:15:30+00:00"
},
{
"name": "dflydev/dot-access-data",
"version": "v3.0.3",
@@ -740,6 +896,67 @@
],
"time": "2025-03-06T22:45:56+00:00"
},
{
"name": "ezyang/htmlpurifier",
"version": "v4.19.0",
"source": {
"type": "git",
"url": "https://github.com/ezyang/htmlpurifier.git",
"reference": "b287d2a16aceffbf6e0295559b39662612b77fcf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf",
"reference": "b287d2a16aceffbf6e0295559b39662612b77fcf",
"shasum": ""
},
"require": {
"php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
},
"require-dev": {
"cerdic/css-tidy": "^1.7 || ^2.0",
"simpletest/simpletest": "dev-master"
},
"suggest": {
"cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.",
"ext-bcmath": "Used for unit conversion and imagecrash protection",
"ext-iconv": "Converts text to and from non-UTF-8 encodings",
"ext-tidy": "Used for pretty-printing HTML"
},
"type": "library",
"autoload": {
"files": [
"library/HTMLPurifier.composer.php"
],
"psr-0": {
"HTMLPurifier": "library/"
},
"exclude-from-classmap": [
"/library/HTMLPurifier/Language/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "Edward Z. Yang",
"email": "admin@htmlpurifier.org",
"homepage": "http://ezyang.com"
}
],
"description": "Standards compliant HTML filter written in PHP",
"homepage": "http://htmlpurifier.org/",
"keywords": [
"html"
],
"support": {
"issues": "https://github.com/ezyang/htmlpurifier/issues",
"source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0"
},
"time": "2025-10-17T16:34:55+00:00"
},
{
"name": "fruitcake/php-cors",
"version": "v1.4.0",
@@ -2453,6 +2670,272 @@
],
"time": "2026-03-08T20:05:35+00:00"
},
{
"name": "maatwebsite/excel",
"version": "3.1.68",
"source": {
"type": "git",
"url": "https://github.com/SpartnerNL/Laravel-Excel.git",
"reference": "1854739267d81d38eae7d8c623caf523f30f256b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/1854739267d81d38eae7d8c623caf523f30f256b",
"reference": "1854739267d81d38eae7d8c623caf523f30f256b",
"shasum": ""
},
"require": {
"composer/semver": "^3.3",
"ext-json": "*",
"illuminate/support": "5.8.*||^6.0||^7.0||^8.0||^9.0||^10.0||^11.0||^12.0||^13.0",
"php": "^7.0||^8.0",
"phpoffice/phpspreadsheet": "^1.30.0",
"psr/simple-cache": "^1.0||^2.0||^3.0"
},
"require-dev": {
"laravel/scout": "^7.0||^8.0||^9.0||^10.0||^11.0",
"orchestra/testbench": "^6.0||^7.0||^8.0||^9.0||^10.0||^11.0",
"predis/predis": "^1.1"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Excel": "Maatwebsite\\Excel\\Facades\\Excel"
},
"providers": [
"Maatwebsite\\Excel\\ExcelServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Maatwebsite\\Excel\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Patrick Brouwers",
"email": "patrick@spartner.nl"
}
],
"description": "Supercharged Excel exports and imports in Laravel",
"keywords": [
"PHPExcel",
"batch",
"csv",
"excel",
"export",
"import",
"laravel",
"php",
"phpspreadsheet"
],
"support": {
"issues": "https://github.com/SpartnerNL/Laravel-Excel/issues",
"source": "https://github.com/SpartnerNL/Laravel-Excel/tree/3.1.68"
},
"funding": [
{
"url": "https://laravel-excel.com/commercial-support",
"type": "custom"
},
{
"url": "https://github.com/patrickbrouwers",
"type": "github"
}
],
"time": "2026-03-17T20:51:10+00:00"
},
{
"name": "maennchen/zipstream-php",
"version": "3.2.1",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5",
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-zlib": "*",
"php-64bit": "^8.3"
},
"require-dev": {
"brianium/paratest": "^7.7",
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.86",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
"phpunit/phpunit": "^12.0",
"vimeo/psalm": "^6.0"
},
"suggest": {
"guzzlehttp/psr7": "^2.4",
"psr/http-message": "^2.0"
},
"type": "library",
"autoload": {
"psr-4": {
"ZipStream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paul Duncan",
"email": "pabs@pablotron.org"
},
{
"name": "Jonatan Männchen",
"email": "jonatan@maennchen.ch"
},
{
"name": "Jesse Donat",
"email": "donatj@gmail.com"
},
{
"name": "András Kolesár",
"email": "kolesar@kolesar.hu"
}
],
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
"keywords": [
"stream",
"zip"
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1"
},
"funding": [
{
"url": "https://github.com/maennchen",
"type": "github"
}
],
"time": "2025-12-10T09:58:31+00:00"
},
{
"name": "markbaker/complex",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPComplex.git",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Complex\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@lange.demon.co.uk"
}
],
"description": "PHP Class for working with complex numbers",
"homepage": "https://github.com/MarkBaker/PHPComplex",
"keywords": [
"complex",
"mathematics"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
},
"time": "2022-12-06T16:21:08+00:00"
},
{
"name": "markbaker/matrix",
"version": "3.0.1",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPMatrix.git",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpdocumentor/phpdocumentor": "2.*",
"phploc/phploc": "^4.0",
"phpmd/phpmd": "2.*",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"sebastian/phpcpd": "^4.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Matrix\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@demon-angel.eu"
}
],
"description": "PHP Class for working with matrices",
"homepage": "https://github.com/MarkBaker/PHPMatrix",
"keywords": [
"mathematics",
"matrix",
"vector"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
},
"time": "2022-12-02T22:17:43+00:00"
},
{
"name": "masterminds/html5",
"version": "2.10.0",
@@ -3031,6 +3514,114 @@
],
"time": "2026-02-16T23:10:27+00:00"
},
{
"name": "phpoffice/phpspreadsheet",
"version": "1.30.3",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "d28d4827f934469e7ca4de940ab0abd0788d1e65"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/d28d4827f934469e7ca4de940ab0abd0788d1e65",
"reference": "d28d4827f934469e7ca4de940ab0abd0788d1e65",
"shasum": ""
},
"require": {
"composer/pcre": "^1||^2||^3",
"ext-ctype": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-gd": "*",
"ext-iconv": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-simplexml": "*",
"ext-xml": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"ext-zip": "*",
"ext-zlib": "*",
"ezyang/htmlpurifier": "^4.15",
"maennchen/zipstream-php": "^2.1 || ^3.0",
"markbaker/complex": "^3.0",
"markbaker/matrix": "^3.0",
"php": ">=7.4.0 <8.5.0",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
"doctrine/instantiator": "^1.5",
"dompdf/dompdf": "^1.0 || ^2.0 || ^3.0",
"friendsofphp/php-cs-fixer": "^3.2",
"mitoteam/jpgraph": "^10.3",
"mpdf/mpdf": "^8.1.1",
"phpcompatibility/php-compatibility": "^9.3",
"phpstan/phpstan": "^1.1",
"phpstan/phpstan-phpunit": "^1.0",
"phpunit/phpunit": "^8.5 || ^9.0",
"squizlabs/php_codesniffer": "^3.7",
"tecnickcom/tcpdf": "^6.5"
},
"suggest": {
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
"ext-intl": "PHP Internationalization Functions",
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Maarten Balliauw",
"homepage": "https://blog.maartenballiauw.be"
},
{
"name": "Mark Baker",
"homepage": "https://markbakeruk.net"
},
{
"name": "Franck Lefevre",
"homepage": "https://rootslabs.net"
},
{
"name": "Erik Tilt"
},
{
"name": "Adrien Crivelli"
},
{
"name": "Owen Leibman"
}
],
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
"keywords": [
"OpenXML",
"excel",
"gnumeric",
"ods",
"php",
"spreadsheet",
"xls",
"xlsx"
],
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.3"
},
"time": "2026-04-10T03:47:16+00:00"
},
{
"name": "phpoption/phpoption",
"version": "1.9.5",

View File

@@ -11,7 +11,7 @@ class RolesPermissionsSeeder extends Seeder
{
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
$roles = ['admin', 'responsable', 'acheteur', 'lecteur'];
$roles = ['admin', 'responsable', 'acheteur', 'lecteur', 'directeur', 'raf'];
foreach ($roles as $role) {
Role::firstOrCreate(['name' => $role, 'guard_name' => 'web']);

View File

@@ -11,13 +11,14 @@ const userInitials = computed(() => {
?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) ?? '??'
})
const roleLabels = { admin: 'Administrateur', responsable: 'Responsable', acheteur: 'Acheteur', lecteur: 'Lecteur' }
const roleLabels = { admin: 'Administrateur', responsable: 'Responsable', acheteur: 'Acheteur', lecteur: 'Lecteur', raf: 'Responsable Administratif et Financier' }
const userRole = computed(() => {
const role = user.value?.roles?.[0]?.name
return roleLabels[role] ?? role ?? ''
})
const isAdmin = computed(() => user.value?.roles?.some(r => r.name === 'admin'))
const isRAF = computed(() => user.value?.roles?.some(r => r.name === 'raf'))
const canCreate = computed(() => user.value?.roles?.some(r => ['admin', 'responsable', 'acheteur'].includes(r.name)))
function isActive(...names) {
@@ -42,6 +43,8 @@ function isActive(...names) {
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto px-3 py-4 space-y-6">
<!-- Dashboard -->
<div>
<Link :href="route('dashboard')"
@@ -55,6 +58,42 @@ function isActive(...names) {
</Link>
</div>
<!-- Calendrier -->
<div>
<Link :href="route('calendar.index')"
:class="['flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive('calendar') ? 'bg-blue-600 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white']">
<svg class="h-5 w-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Calendrier
</Link>
</div>
<!-- Budgets -->
<div>
<p class="mb-1 px-3 text-xs font-semibold uppercase tracking-wider text-gray-500">Budgets</p>
<div class="space-y-1">
<Link :href="route('budgets.index')"
:class="['flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
route().current('budgets.index') || route().current('budgets.show') ? 'bg-indigo-600 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white']">
<svg class="h-5 w-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Planification
</Link>
<Link :href="route('budgets.execution')"
:class="['flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
route().current('budgets.execution') ? 'bg-indigo-600 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white']">
<svg class="h-5 w-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Exécution réelle
</Link>
</div>
</div>
<!-- Commandes -->
<div>
<p class="mb-1 px-3 text-xs font-semibold uppercase tracking-wider text-gray-500">Commandes</p>
@@ -66,15 +105,9 @@ function isActive(...names) {
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
Toutes les commandes
</Link>
<Link v-if="canCreate" :href="route('commandes.create')"
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-gray-300 hover:bg-gray-800 hover:text-white transition-colors">
<svg class="h-5 w-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Nouvelle commande
Commandes
</Link>
</div>
</div>
@@ -88,13 +121,22 @@ function isActive(...names) {
<svg class="h-5 w-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Tous les contrats
Contrats
</Link>
<Link :href="route('licences.index')"
:class="['flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive('licences') ? 'bg-blue-600 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white']">
<svg class="h-5 w-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
Licences
</Link>
</div>
</div>
<!-- Domaines -->
<div>
<div v-if="!isRAF">
<p class="mb-1 px-3 text-xs font-semibold uppercase tracking-wider text-gray-500">Infrastructures</p>
<div class="space-y-1">
<Link :href="route('domaines.index')"
@@ -111,13 +153,13 @@ function isActive(...names) {
<svg class="h-5 w-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
Assets (Matériels)
Matériels
</Link>
</div>
</div>
<!-- Catalogue -->
<div>
<div v-if="!isRAF">
<p class="mb-1 px-3 text-xs font-semibold uppercase tracking-wider text-gray-500">Catalogue</p>
<div class="space-y-1">
<Link :href="route('fournisseurs.index')"
@@ -172,24 +214,7 @@ function isActive(...names) {
</svg>
Services
</Link>
<Link :href="route('licences.index')"
:class="['flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive('licences') ? 'bg-blue-600 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white']">
<svg class="h-5 w-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
Licences
</Link>
<Link :href="route('calendar.index')"
:class="['flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive('calendar') ? 'bg-blue-600 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white']">
<svg class="h-5 w-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Calendrier
</Link>
<Link :href="route('communes.index')"
:class="['flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive('communes') ? 'bg-blue-600 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white']">

View File

@@ -1,7 +1,8 @@
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import { Head, Link, useForm } from '@inertiajs/vue3'
import { ref } from 'vue'
import { ref, computed } from 'vue'
import SearchableSelect from '@/Components/SearchableSelect.vue'
const props = defineProps({
asset: Object,
@@ -27,6 +28,13 @@ const form = useForm({
notes: props.asset?.notes || '',
})
const commandesOptions = computed(() => {
return props.commandes.map(cmd => ({
value: cmd.id,
label: `${cmd.numero_commande ?? 'Sans-N'} - ${cmd.objet}`
}))
})
const isSearchingEan = ref(false)
const eanError = ref('')
const eanSuccess = ref('')
@@ -220,12 +228,11 @@ function submit() {
<h2 class="mb-5 text-sm font-semibold uppercase tracking-wide text-gray-500 font-bold">Achat & Commande</h2>
<div>
<label class="block text-sm font-medium text-gray-700">Commande associée (optionnel)</label>
<select v-model="form.commande_id" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
<option value="">Aucune commande liée</option>
<option v-for="cmd in commandes" :key="cmd.id" :value="cmd.id">
N° {{ cmd.numero_commande ?? 'Sans-N' }} - {{ cmd.objet }}
</option>
</select>
<SearchableSelect
v-model="form.commande_id"
:options="commandesOptions"
placeholder="Rechercher par numéro ou objet..."
/>
<p class="mt-1 text-xs text-gray-500">Permet de retrouver l'origine de l'achat et les justificatifs.</p>
</div>
</div>

View File

@@ -1,31 +1,39 @@
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import StatCard from '@/Components/StatCard.vue'
import StatutBadge from '@/Components/Commandes/StatutBadge.vue'
import PrioriteBadge from '@/Components/Commandes/PrioriteBadge.vue'
import { Link } from '@inertiajs/vue3'
import { Head } from '@inertiajs/vue3'
import { Link, Head } from '@inertiajs/vue3'
const props = defineProps({
stats: Object,
commandesRecentes: Array,
commandesEnRetard: Array,
commandesUrgentes: Array,
statsParStatut: Object,
statsParService: Array,
montantParMois: Array,
statsContrats: Object,
statsDomaines: Object,
budgetsStats: Object,
})
function formatDate(d) {
const formatDate = (d) => {
if (!d) return '—'
return new Intl.DateTimeFormat('fr-FR').format(new Date(d))
}
function formatCurrency(v) {
if (v == null) return ''
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(v)
const formatCurrency = (v) => {
if (v == null) return '0 €'
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }).format(v)
}
const calculatePercent = (consumed, total) => {
if (!total || total <= 0) return 0
return Math.min(Math.round((consumed / total) * 100), 100)
}
const getProgressBarHexColor = (percent) => {
if (percent < 50) return '#3b82f6' // Blue 500
if (percent < 80) return '#eab308' // Yellow 500
if (percent < 100) return '#f97316' // Orange 500
return '#ef4444' // Red 500
}
</script>
@@ -34,179 +42,240 @@ function formatCurrency(v) {
<AuthenticatedLayout>
<template #header>
<h1 class="text-xl font-semibold text-gray-900">Tableau de bord</h1>
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900 tracking-tight">Tableau de bord <span class="text-gray-400 font-normal">/ {{ new Date().getFullYear() }}</span></h1>
<div class="flex items-center gap-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Système Opérationnel
</span>
</div>
</div>
</template>
<div class="space-y-6">
<!-- Stat cards -->
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
<StatCard label="Total commandes" :value="stats.total" color="blue" />
<StatCard label="En cours" :value="stats.en_cours" color="indigo" />
<StatCard label="En retard" :value="stats.en_retard" color="red" />
<StatCard label="Urgentes actives" :value="stats.commandees + stats.validees" color="amber" />
<div class="space-y-8 pb-10">
<!-- Top Stats Row -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
<p class="text-sm font-medium text-gray-500">Commandes en cours</p>
<div class="mt-2 flex items-baseline gap-2">
<span class="text-3xl font-bold text-gray-900">{{ stats.en_cours }}</span>
<span class="text-sm text-red-600 font-medium" v-if="stats.en_retard > 0">{{ stats.en_retard }} en retard</span>
</div>
</div>
<div class="bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
<p class="text-sm font-medium text-gray-500">Budget Consommé (Agglo)</p>
<div class="mt-2 flex items-baseline gap-2">
<span class="text-3xl font-bold text-blue-600">{{ formatCurrency(budgetsStats.agglo.consomme) }}</span>
<span class="text-xs text-gray-400">/ {{ formatCurrency(budgetsStats.agglo.total_arbitre) }}</span>
</div>
</div>
<div class="bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
<p class="text-sm font-medium text-gray-500">Budget Consommé (Mutualisé)</p>
<div class="mt-2 flex items-baseline gap-2">
<span class="text-3xl font-bold text-indigo-600">{{ formatCurrency(budgetsStats.mutualise.consomme) }}</span>
<span class="text-xs text-gray-400">/ {{ formatCurrency(budgetsStats.mutualise.total_arbitre) }}</span>
</div>
</div>
<div class="bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
<p class="text-sm font-medium text-gray-500">Contrats à renouveler</p>
<div class="mt-2 flex items-baseline gap-2">
<span class="text-3xl font-bold text-orange-600">{{ statsContrats.proches + statsContrats.en_retard }}</span>
<span class="text-sm text-gray-400">échéances</span>
</div>
</div>
</div>
<!-- Contrats & Domaines Stats -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<!-- Contrats -->
<div class="rounded-xl bg-white p-5 shadow-sm border border-gray-100 flex items-center justify-between">
<div>
<h2 class="text-sm font-semibold uppercase tracking-wide text-gray-500 mb-2">Contrats en cours</h2>
<div class="flex gap-6">
<Link :href="route('contrats.index')" class="flex flex-col">
<span class="text-xs text-gray-400">Total</span>
<span class="font-bold text-gray-900">{{ statsContrats.total }}</span>
</Link>
<Link :href="route('contrats.index')" class="flex flex-col">
<span class="text-xs text-gray-400">À renouveler ()</span>
<span class="font-bold text-orange-600">{{ statsContrats.proches }}</span>
</Link>
<Link :href="route('contrats.index')" class="flex flex-col">
<span class="text-xs text-gray-400">Expirés ()</span>
<span class="font-bold text-red-600">{{ statsContrats.en_retard }}</span>
</Link>
<!-- Budget Gauges -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Agglo Card -->
<div class="bg-white p-6 rounded-3xl shadow-sm border border-gray-100">
<h3 class="font-bold text-gray-900 mb-6 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-blue-500"></span>
Exécution Agglomération
</h3>
<div class="grid grid-cols-2 gap-8">
<!-- Fonctionnement -->
<div class="flex flex-col items-center">
<div class="relative w-full h-24 flex items-center justify-center">
<svg class="w-full h-full" viewBox="0 0 100 60">
<path d="M 12 55 A 38 38 0 0 1 88 55" fill="#edf2f7" stroke="#2d3748" stroke-width="1.5" />
<path d="M 12 55 A 38 38 0 0 1 31 22 L 50 55 Z" fill="#48bb78" />
<path d="M 31 22 A 38 38 0 0 1 69 22 L 50 55 Z" fill="#ecc94b" />
<path d="M 69 22 A 38 38 0 0 1 88 55 L 50 55 Z" fill="#f6ad55" />
<g :style="{ transform: `rotate(${(calculatePercent(budgetsStats.agglo.fonctionnement.consomme, budgetsStats.agglo.fonctionnement.total_arbitre) * 1.8) - 90}deg)`, transformOrigin: '50px 55px' }" class="transition-transform duration-1000 ease-out">
<path d="M 48.5 55 L 50 20 L 51.5 55 Z" fill="#e53e3e" stroke="#2d3748" stroke-width="0.3" />
<circle cx="50" cy="55" r="2" fill="#2d3748" />
</g>
</svg>
<div class="absolute -bottom-1 left-0 flex items-baseline gap-1">
<span class="text-xl font-black text-gray-900">{{ calculatePercent(budgetsStats.agglo.fonctionnement.consomme, budgetsStats.agglo.fonctionnement.total_arbitre) }}%</span>
</div>
</div>
<div class="mt-4 w-full">
<p class="text-[10px] uppercase font-black text-gray-400 tracking-widest leading-none mb-1">Fonctionnement</p>
<p class="text-sm font-bold text-gray-900">{{ formatCurrency(budgetsStats.agglo.fonctionnement.consomme) }}</p>
</div>
</div>
<!-- Investissement -->
<div class="flex flex-col items-center">
<div class="relative w-full h-24 flex items-center justify-center">
<svg class="w-full h-full" viewBox="0 0 100 60">
<path d="M 12 55 A 38 38 0 0 1 88 55" fill="#edf2f7" stroke="#2d3748" stroke-width="1.5" />
<path d="M 12 55 A 38 38 0 0 1 31 22 L 50 55 Z" fill="#48bb78" />
<path d="M 31 22 A 38 38 0 0 1 69 22 L 50 55 Z" fill="#ecc94b" />
<path d="M 69 22 A 38 38 0 0 1 88 55 L 50 55 Z" fill="#f6ad55" />
<g :style="{ transform: `rotate(${(calculatePercent(budgetsStats.agglo.investissement.consomme, budgetsStats.agglo.investissement.total_arbitre) * 1.8) - 90}deg)`, transformOrigin: '50px 55px' }" class="transition-transform duration-1000 ease-out">
<path d="M 48.5 55 L 50 20 L 51.5 55 Z" fill="#e53e3e" stroke="#2d3748" stroke-width="0.3" />
<circle cx="50" cy="55" r="2" fill="#2d3748" />
</g>
</svg>
<div class="absolute -bottom-1 left-0 flex items-baseline gap-1">
<span class="text-xl font-black text-gray-900">{{ calculatePercent(budgetsStats.agglo.investissement.consomme, budgetsStats.agglo.investissement.total_arbitre) }}%</span>
</div>
</div>
<div class="mt-4 w-full">
<p class="text-[10px] uppercase font-black text-gray-400 tracking-widest leading-none mb-1">Investissement</p>
<p class="text-sm font-bold text-gray-900">{{ formatCurrency(budgetsStats.agglo.investissement.consomme) }}</p>
</div>
</div>
</div>
<Link :href="route('contrats.index')" class="text-gray-400 hover:text-indigo-600 transition-colors">
<svg class="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
</Link>
</div>
<!-- Domaines -->
<div class="rounded-xl bg-white p-5 shadow-sm border border-gray-100 flex items-center justify-between">
<div>
<h2 class="text-sm font-semibold uppercase tracking-wide text-gray-500 mb-2">Noms de domaine</h2>
<div class="flex gap-6">
<Link :href="route('domaines.index')" class="flex flex-col">
<span class="text-xs text-gray-400">Total</span>
<span class="font-bold text-gray-900">{{ statsDomaines.total }}</span>
</Link>
<Link :href="route('domaines.index')" class="flex flex-col">
<span class="text-xs text-gray-400">À renouveler ()</span>
<span class="font-bold text-orange-600">{{ statsDomaines.proches }}</span>
</Link>
<Link :href="route('domaines.index')" class="flex flex-col">
<span class="text-xs text-gray-400">Expirés ()</span>
<span class="font-bold text-red-600">{{ statsDomaines.en_retard }}</span>
</Link>
<!-- Mutualise Card -->
<div class="bg-white p-6 rounded-3xl shadow-sm border border-gray-100">
<h3 class="font-bold text-gray-900 mb-6 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-indigo-500"></span>
Exécution Mutualisation
</h3>
<div class="grid grid-cols-2 gap-8">
<!-- Fonctionnement -->
<div class="flex flex-col items-center">
<div class="relative w-full h-24 flex items-center justify-center">
<svg class="w-full h-full" viewBox="0 0 100 60">
<path d="M 12 55 A 38 38 0 0 1 88 55" fill="#edf2f7" stroke="#2d3748" stroke-width="1.5" />
<path d="M 12 55 A 38 38 0 0 1 31 22 L 50 55 Z" fill="#48bb78" />
<path d="M 31 22 A 38 38 0 0 1 69 22 L 50 55 Z" fill="#ecc94b" />
<path d="M 69 22 A 38 38 0 0 1 88 55 L 50 55 Z" fill="#f6ad55" />
<g :style="{ transform: `rotate(${(calculatePercent(budgetsStats.mutualise.fonctionnement.consomme, budgetsStats.mutualise.fonctionnement.total_arbitre) * 1.8) - 90}deg)`, transformOrigin: '50px 55px' }" class="transition-transform duration-1000 ease-out">
<path d="M 48.5 55 L 50 20 L 51.5 55 Z" fill="#e53e3e" stroke="#2d3748" stroke-width="0.3" />
<circle cx="50" cy="55" r="2" fill="#2d3748" />
</g>
</svg>
<div class="absolute -bottom-1 left-0 flex items-baseline gap-1">
<span class="text-xl font-black text-gray-900">{{ calculatePercent(budgetsStats.mutualise.fonctionnement.consomme, budgetsStats.mutualise.fonctionnement.total_arbitre) }}%</span>
</div>
</div>
<div class="mt-4 w-full">
<p class="text-[10px] uppercase font-black text-gray-400 tracking-widest leading-none mb-1">Fonctionnement</p>
<p class="text-sm font-bold text-gray-900">{{ formatCurrency(budgetsStats.mutualise.fonctionnement.consomme) }}</p>
</div>
</div>
<!-- Investissement -->
<div class="flex flex-col items-center">
<div class="relative w-full h-24 flex items-center justify-center">
<svg class="w-full h-full" viewBox="0 0 100 60">
<path d="M 12 55 A 38 38 0 0 1 88 55" fill="#edf2f7" stroke="#2d3748" stroke-width="1.5" />
<path d="M 12 55 A 38 38 0 0 1 31 22 L 50 55 Z" fill="#48bb78" />
<path d="M 31 22 A 38 38 0 0 1 69 22 L 50 55 Z" fill="#ecc94b" />
<path d="M 69 22 A 38 38 0 0 1 88 55 L 50 55 Z" fill="#f6ad55" />
<g :style="{ transform: `rotate(${(calculatePercent(budgetsStats.mutualise.investissement.consomme, budgetsStats.mutualise.investissement.total_arbitre) * 1.8) - 90}deg)`, transformOrigin: '50px 55px' }" class="transition-transform duration-1000 ease-out">
<path d="M 48.5 55 L 50 20 L 51.5 55 Z" fill="#e53e3e" stroke="#2d3748" stroke-width="0.3" />
<circle cx="50" cy="55" r="2" fill="#2d3748" />
</g>
</svg>
<div class="absolute -bottom-1 left-0 flex items-baseline gap-1">
<span class="text-xl font-black text-gray-900">{{ calculatePercent(budgetsStats.mutualise.investissement.consomme, budgetsStats.mutualise.investissement.total_arbitre) }}%</span>
</div>
</div>
<div class="mt-4 w-full">
<p class="text-[10px] uppercase font-black text-gray-400 tracking-widest leading-none mb-1">Investissement</p>
<p class="text-sm font-bold text-gray-900">{{ formatCurrency(budgetsStats.mutualise.investissement.consomme) }}</p>
</div>
</div>
</div>
<Link :href="route('domaines.index')" class="text-gray-400 hover:text-indigo-600 transition-colors">
<svg class="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>
</Link>
</div>
</div>
<!-- Statut breakdown -->
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div class="rounded-xl bg-white p-4 shadow-sm border border-gray-100">
<p class="text-xs font-medium text-gray-500 uppercase">Brouillons</p>
<p class="mt-1 text-2xl font-bold text-gray-700">{{ stats.brouillons }}</p>
</div>
<div class="rounded-xl bg-white p-4 shadow-sm border border-gray-100">
<p class="text-xs font-medium text-yellow-600 uppercase">En validation</p>
<p class="mt-1 text-2xl font-bold text-yellow-700">{{ stats.en_attente_validation }}</p>
</div>
<div class="rounded-xl bg-white p-4 shadow-sm border border-gray-100">
<p class="text-xs font-medium text-blue-600 uppercase">Validées</p>
<p class="mt-1 text-2xl font-bold text-blue-700">{{ stats.validees }}</p>
</div>
<div class="rounded-xl bg-white p-4 shadow-sm border border-gray-100">
<p class="text-xs font-medium text-indigo-600 uppercase">Commandées</p>
<p class="mt-1 text-2xl font-bold text-indigo-700">{{ stats.commandees }}</p>
</div>
</div>
<!-- Alertes retard + Services -->
<div class="grid gap-6 lg:grid-cols-2">
<!-- En retard -->
<div class="rounded-xl bg-white shadow-sm border border-gray-100">
<div class="flex items-center justify-between border-b border-gray-100 px-5 py-4">
<h2 class="font-semibold text-gray-900">Commandes en retard</h2>
<span class="rounded-full bg-red-100 px-2.5 py-1 text-xs font-medium text-red-700">{{ stats.en_retard }}</span>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Main Content: Recent Orders -->
<div class="lg:col-span-2 space-y-6">
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-50 flex items-center justify-between">
<h3 class="font-bold text-gray-900">Dernières commandes</h3>
<Link :href="route('commandes.index')" class="text-sm text-blue-600 font-medium hover:underline">Voir tout</Link>
</div>
<div class="divide-y divide-gray-50">
<div v-for="cmd in commandesRecentes" :key="cmd.id" class="px-6 py-4 hover:bg-gray-50 transition-colors flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="p-2 rounded-lg bg-gray-50">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" /></svg>
</div>
<div>
<Link :href="route('commandes.show', cmd.id)" class="text-sm font-bold text-gray-900 hover:text-blue-600">{{ cmd.numero_commande }}</Link>
<p class="text-xs text-gray-500 truncate max-w-[200px]">{{ cmd.objet }}</p>
</div>
</div>
<div class="flex items-center gap-4">
<StatutBadge :statut="cmd.statut" size="sm" />
<span class="text-sm font-bold text-gray-900 min-w-[80px] text-right">{{ formatCurrency(cmd.montant_ttc) }}</span>
</div>
</div>
</div>
</div>
<div class="divide-y divide-gray-50">
<div v-for="cmd in commandesEnRetard" :key="cmd.id"
class="flex items-center justify-between px-5 py-3 hover:bg-gray-50 transition-colors">
<div class="min-w-0">
<Link :href="route('commandes.show', cmd.id)"
class="truncate text-sm font-medium text-blue-600 hover:underline">
{{ cmd.numero_commande }}
</div>
<!-- Sidebar: Action alerts -->
<div class="space-y-6">
<!-- Urgent/Late Orders -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 border-l-4 border-l-red-500 overflow-hidden">
<div class="px-5 py-4 border-b border-gray-50">
<h3 class="font-bold text-gray-900 text-sm">Alertes prioritaires</h3>
</div>
<div class="p-2">
<div v-for="cmd in commandesEnRetard" :key="cmd.id" class="p-3 mb-1 rounded-xl bg-red-50 hover:bg-red-100 transition-colors">
<Link :href="route('commandes.show', cmd.id)" class="block">
<div class="flex justify-between items-start mb-1">
<span class="text-xs font-bold text-red-700">{{ cmd.numero_commande }}</span>
<span class="text-[10px] uppercase font-bold text-red-600">Retard</span>
</div>
<p class="text-xs text-red-800 line-clamp-1">{{ cmd.objet }}</p>
</Link>
<p class="truncate text-xs text-gray-500">{{ cmd.objet }}</p>
</div>
<div class="ml-4 flex-shrink-0 text-right">
<p class="text-xs text-red-600 font-medium">{{ formatDate(cmd.date_souhaitee) }}</p>
<p class="text-xs text-gray-400">{{ cmd.service?.nom }}</p>
<div v-if="!commandesEnRetard.length" class="p-10 text-center">
<div class="inline-flex p-3 rounded-full bg-green-50 text-green-600 mb-2">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
</div>
<p class="text-sm text-gray-500 font-medium">Tout est à jour !</p>
</div>
</div>
<p v-if="!commandesEnRetard.length" class="px-5 py-6 text-center text-sm text-gray-400">
Aucune commande en retard.
</p>
</div>
</div>
<!-- Par service -->
<div class="rounded-xl bg-white shadow-sm border border-gray-100">
<div class="border-b border-gray-100 px-5 py-4">
<h2 class="font-semibold text-gray-900">Par service</h2>
</div>
<div class="divide-y divide-gray-50">
<div v-for="service in statsParService" :key="service.id" class="px-5 py-4">
<!-- Contract Expiry -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<div class="px-5 py-4 border-b border-gray-50">
<h3 class="font-bold text-gray-900 text-sm">Échéancier Contrats</h3>
</div>
<div class="p-5 space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="h-3 w-3 rounded-full" :style="{ backgroundColor: service.couleur || '#6B7280' }" />
<span class="text-sm font-medium text-gray-900">{{ service.nom }}</span>
</div>
<div class="text-right">
<p class="text-sm font-bold text-gray-900">{{ service.commandes_count }}</p>
<p class="text-xs text-gray-500">{{ service.commandes_en_cours_count }} en cours</p>
</div>
<span class="text-xs text-gray-500">Expirés</span>
<span class="px-2 py-0.5 rounded-full bg-red-100 text-red-700 text-xs font-bold">{{ statsContrats.en_retard }}</span>
</div>
<div class="mt-2 h-1.5 rounded-full bg-gray-100 overflow-hidden">
<div class="h-full rounded-full transition-all"
:style="{ width: stats.total ? (service.commandes_count / stats.total * 100) + '%' : '0%', backgroundColor: service.couleur || '#6B7280' }" />
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500">À renouveler (90j)</span>
<span class="px-2 py-0.5 rounded-full bg-orange-100 text-orange-700 text-xs font-bold">{{ statsContrats.proches }}</span>
</div>
<Link :href="route('contrats.index')" class="block text-center py-2 text-xs font-bold text-indigo-600 bg-indigo-50 rounded-lg hover:bg-indigo-100">Gérer les contrats</Link>
</div>
</div>
</div>
</div>
<!-- Commandes récentes -->
<div class="rounded-xl bg-white shadow-sm border border-gray-100">
<div class="flex items-center justify-between border-b border-gray-100 px-5 py-4">
<h2 class="font-semibold text-gray-900">Commandes récentes</h2>
<Link :href="route('commandes.index')" class="text-sm text-blue-600 hover:underline">Voir tout</Link>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-100 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-5 py-3 text-left text-xs font-medium text-gray-500 uppercase">N°</th>
<th class="px-5 py-3 text-left text-xs font-medium text-gray-500 uppercase">Objet</th>
<th class="px-5 py-3 text-left text-xs font-medium text-gray-500 uppercase">Service</th>
<th class="px-5 py-3 text-left text-xs font-medium text-gray-500 uppercase">Statut</th>
<th class="px-5 py-3 text-left text-xs font-medium text-gray-500 uppercase">Priorité</th>
<th class="px-5 py-3 text-right text-xs font-medium text-gray-500 uppercase">Montant TTC</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
<tr v-for="cmd in commandesRecentes" :key="cmd.id" class="hover:bg-gray-50 transition-colors">
<td class="px-5 py-3 font-medium">
<Link :href="route('commandes.show', cmd.id)" class="text-blue-600 hover:underline">
{{ cmd.numero_commande }}
</Link>
</td>
<td class="px-5 py-3 max-w-xs truncate text-gray-700">{{ cmd.objet }}</td>
<td class="px-5 py-3 text-gray-500">{{ cmd.service?.nom }}</td>
<td class="px-5 py-3"><StatutBadge :statut="cmd.statut" /></td>
<td class="px-5 py-3"><PrioriteBadge :priorite="cmd.priorite" /></td>
<td class="px-5 py-3 text-right font-medium text-gray-900">{{ formatCurrency(cmd.montant_ttc) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>

View File

@@ -0,0 +1,51 @@
<x-mail::message>
# Alerte d'échéance
Bonjour,
Ceci est un message automatique pour vous informer que les éléments suivants arrivent à échéance dans les **30 prochains jours**.
@if($contrats->isNotEmpty())
## 📄 Contrats
| Titre | Fournisseur | Échéance |
| :--- | :--- | :--- |
@foreach($contrats as $contrat)
| {{ $contrat->titre }} | {{ $contrat->fournisseur?->nom ?? 'N/A' }} | {{ $contrat->date_echeance->format('d/m/Y') }} |
@endforeach
@endif
@if($licences->isNotEmpty())
## 🔑 Licences
| Nom | Fournisseur | Expiration |
| :--- | :--- | :--- |
@foreach($licences as $licence)
| {{ $licence->nom }} | {{ $licence->fournisseur?->nom ?? 'N/A' }} | {{ $licence->date_expiration->format('d/m/Y') }} |
@endforeach
@endif
@if($assets->isNotEmpty())
## 💻 Matériels (Garantie)
| Nom | Modèle | Fin Garantie |
| :--- | :--- | :--- |
@foreach($assets as $asset)
| {{ $asset->nom }} | {{ $asset->modele }} | {{ $asset->date_fin_garantie->format('d/m/Y') }} |
@endforeach
@endif
@if($domaines->isNotEmpty())
## 🌐 Domaines
| Nom | Prestataire | Échéance |
| :--- | :--- | :--- |
@foreach($domaines as $domaine)
| {{ $domaine->nom }} | {{ $domaine->prestataire ?? 'N/A' }} | {{ $domaine->date_echeance->format('d/m/Y') }} |
@endforeach
@endif
<x-mail::button :url="config('app.url')">
Accéder au tableau de bord
</x-mail::button>
<br>
{{ config('app.name') }}
</x-mail::message>

View File

@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Rapport Budgétaire {{ $annee }}</title>
<style>
body { font-family: 'Helvetica', sans-serif; font-size: 10px; color: #333; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { bg-color: #f8fafc; font-weight: bold; }
.header { text-align: center; margin-bottom: 20px; }
.footer { position: fixed; bottom: 0; width: 100%; text-align: right; font-size: 8px; }
.text-right { text-align: right; }
.font-bold { font-weight: bold; }
.bg-gray { background-color: #f1f5f9; }
.title { font-size: 18px; font-bold; color: #1e293b; margin-bottom: 5px; }
.subtitle { font-size: 12px; color: #64748b; }
</style>
</head>
<body>
<div class="header">
<div class="title">Rapport d'Exécution Budgétaire {{ $annee }}</div>
<div class="subtitle">
{{ $service }} | {{ $type }} | {{ $envelope }}
<br>
Généré le {{ date('d/m/Y H:i') }} par {{ $user->name }}
</div>
</div>
<table>
<thead>
<tr class="bg-gray">
<th>Budget / Ligne</th>
<th>Service</th>
<th>Type</th>
<th class="text-right">Arbitré ()</th>
<th class="text-right">Consommé ()</th>
<th class="text-right">Engagé ()</th>
<th class="text-right">Total ()</th>
<th class="text-right">Reste ()</th>
<th class="text-right border-l">%</th>
</tr>
</thead>
<tbody>
@foreach($lignes as $lb)
<tr>
<td>
<div class="font-bold">{{ $lb->nom }}</div>
<div style="font-size: 8px; color: #64748b;">{{ ucfirst($lb->type_depense) }}</div>
</td>
<td>{{ $lb->budget->service->nom ?? 'Agglo' }}</td>
<td>{{ $lb->budget->type_budget === 'agglo' ? 'Agglomération' : 'Mutualisé' }}</td>
<td class="text-right">{{ number_format($lb->montant_arbitre, 2, ',', ' ') }}</td>
<td class="text-right">{{ number_format($lb->consomme, 2, ',', ' ') }}</td>
<td class="text-right">{{ number_format($lb->engage, 2, ',', ' ') }}</td>
<td class="text-right font-bold">{{ number_format($lb->total_cumule, 2, ',', ' ') }}</td>
<td class="text-right" style="color: {{ $lb->reste < 0 ? '#e11d48' : '#334155' }};">
{{ number_format($lb->reste, 2, ',', ' ') }}
</td>
<td class="text-right font-bold">
{{ $lb->montant_arbitre > 0 ? round(($lb->total_cumule / $lb->montant_arbitre) * 100, 1) : 0 }}%
</td>
</tr>
@endforeach
</tbody>
<tfoot>
<tr class="bg-gray">
<td colspan="3" class="font-bold">TOTAL GÉNÉRAL</td>
<td class="text-right font-bold">{{ number_format($lignes->sum('montant_arbitre'), 2, ',', ' ') }} </td>
<td class="text-right font-bold">{{ number_format($lignes->sum('consomme'), 2, ',', ' ') }} </td>
<td class="text-right font-bold">{{ number_format($lignes->sum('engage'), 2, ',', ' ') }} </td>
<td class="text-right font-bold">{{ number_format($lignes->sum('total_cumule'), 2, ',', ' ') }} </td>
<td class="text-right font-bold">{{ number_format($lignes->sum('montant_arbitre') - $lignes->sum('total_cumule'), 2, ',', ' ') }} </td>
<td class="text-right font-bold">
{{ $lignes->sum('montant_arbitre') > 0 ? round(($lignes->sum('total_cumule') / $lignes->sum('montant_arbitre')) * 100, 1) : 0 }}%
</td>
</tr>
</tfoot>
</table>
<div class="footer">
DSI-Commander - Système de gestion budgétaire - Page 1/1
</div>
</body>
</html>

View File

@@ -6,3 +6,8 @@ use Illuminate\Support\Facades\Artisan;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
use Illuminate\Support\Facades\Schedule;
Schedule::command('app:check-expirations')->dailyAt('08:00');

View File

@@ -90,6 +90,15 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/calendar', [CalendarController::class, 'index'])->name('calendar.index');
Route::get('/calendar/events', [CalendarController::class, 'events'])->name('calendar.events');
// Budgets
Route::get('/budgets/execution', [\App\Http\Controllers\BudgetController::class, 'execution'])->name('budgets.execution');
Route::get('/budgets/export/excel', [\App\Http\Controllers\BudgetController::class, 'exportExcel'])->name('budgets.export.excel');
Route::get('/budgets/export/ods', [\App\Http\Controllers\BudgetController::class, 'exportOds'])->name('budgets.export.ods');
Route::get('/budgets/export/pdf', [\App\Http\Controllers\BudgetController::class, 'exportPdf'])->name('budgets.export.pdf');
Route::resource('budgets', \App\Http\Controllers\BudgetController::class);
Route::resource('lignes-budget', \App\Http\Controllers\LigneBudgetController::class)->except(['index', 'show', 'create', 'edit']);
Route::patch('/lignes-budget/{ligneBudget}/arbitrer', [\App\Http\Controllers\LigneBudgetController::class, 'arbitrer'])->name('lignes-budget.arbitrer');
// Assets
Route::resource('assets', AssetController::class);
Route::get('/api/ean-lookup/{ean}', [AssetController::class, 'lookupEan'])->name('assets.ean-lookup');