feat: module budgets complet avec sécurité, performance et métier

## Fonctionnalités
- Module Budgets : enveloppes, lignes budgétaires, arbitrage DSI/Direction
- Suivi de l'exécution budgétaire avec alertes visuelles (dépassement, seuil 80%)
- Blocage des commandes si budget insuffisant (store + update)
- Audit trail complet des arbitrages via HistoriqueBudget
- Page d'index budgets refaite en tableau avec filtres et tri côté client
- Page Services avec sélecteur d'icônes FontAwesome (solid + regular + brands)

## Sécurité
- BudgetPolicy centralisée (viewAny, view, create, update, addLigne, updateLigne, deleteLigne, arbitrerLigne)
- Autorisation sur tous les endpoints LigneBudget et Budget
- Protection XSS : remplacement v-html par classes dynamiques
- Validation des paramètres d'export (type, envelope)
- Validation montant_arbitre ≤ montant_propose côté serveur

## Performance
- Eager loading lignes.commandes.commune dans execution() et exportPdf()
- Calculs montant_consomme/engage en mémoire sur collections déjà chargées
- Null-safety sur montant_arbitre dans getMontantDisponibleAttribute

## Technique
- Migration historique_budgets, budgets, ligne_budgets, rôle raf
- SearchableSelect avec affichage du disponible budgétaire
- FontAwesome enregistré globalement (fas, far, fab)
- 33 tests Feature (sécurité, performance, métier)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jeremy bayse
2026-04-11 20:20:05 +02:00
parent b28c56c94c
commit 0ad77de412
31 changed files with 3574 additions and 37 deletions

View File

@@ -0,0 +1,290 @@
<?php
namespace App\Http\Controllers;
use App\Models\Budget;
use App\Models\Service;
use App\Models\LigneBudget;
use App\Exports\BudgetExecutionExport;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Maatwebsite\Excel\Facades\Excel;
use Barryvdh\DomPDF\Facade\Pdf;
class BudgetController extends Controller
{
public function index(Request $request)
{
$user = $request->user();
$query = Budget::with(['service', 'lignes', 'commune']);
// Le responsable ne voit que les budgets de son service
if (!$user->hasRole(['admin', 'directeur', 'raf'])) {
$query->where('service_id', $user->service_id);
}
$annee = $request->get('annee', now()->year);
$query->where('annee', $annee);
$budgets = $query->orderBy('annee', 'desc')->get()->map(function ($budget) {
return [
'id' => $budget->id,
'service' => $budget->service->nom,
'commune' => $budget->commune?->nom,
'annee' => $budget->annee,
'type_budget' => $budget->type_budget,
'statut' => $budget->statut,
'total_invest_propose' => $budget->totalPropose('investissement'),
'total_fonct_propose' => $budget->totalPropose('fonctionnement'),
'total_invest_arbitre' => $budget->totalArbitre('investissement'),
'total_fonct_arbitre' => $budget->totalArbitre('fonctionnement'),
];
});
return Inertia::render('Budgets/Index', [
'budgets' => $budgets,
'services' => Service::all(),
'communes' => \App\Models\Commune::orderBy('nom')->get(),
'filters' => ['annee' => $annee]
]);
}
public function store(Request $request)
{
$request->validate([
'service_id' => 'required|exists:services,id',
'annee' => 'required|integer',
'type_budget' => 'required|in:agglo,mutualise',
]);
Budget::create($request->all());
return redirect()->back()->with('success', 'Enveloppe budgétaire créée avec succès.');
}
public function show(Budget $budget, Request $request)
{
$user = $request->user();
if (!$user->hasRole(['admin', 'directeur', 'raf']) && $budget->service_id !== $user->service_id) {
abort(403);
}
$budget->load(['service', 'lignes.commandes', 'lignes.historique.user']);
$totals = [
'invest_propose' => $budget->totalPropose('investissement'),
'invest_arbitre' => $budget->totalArbitre('investissement'),
'fonct_propose' => $budget->totalPropose('fonctionnement'),
'fonct_arbitre' => $budget->totalArbitre('fonctionnement'),
];
$lignes = $budget->lignes->map(function ($ligne) {
return array_merge($ligne->toArray(), [
'historique' => $ligne->historique->map(fn($h) => [
'id' => $h->id,
'ancien_statut' => $h->ancien_statut,
'nouveau_statut' => $h->nouveau_statut,
'ancien_montant_arbitre' => $h->ancien_montant_arbitre,
'nouveau_montant_arbitre'=> $h->nouveau_montant_arbitre,
'commentaire' => $h->commentaire,
'user' => $h->user->name,
'created_at' => $h->created_at->format('d/m/Y H:i'),
]),
]);
});
return Inertia::render('Budgets/Show', [
'budget' => $budget,
'lignes' => $lignes,
'totals' => $totals,
'communes'=> \App\Models\Commune::orderBy('nom')->get(),
]);
}
public function update(Request $request, Budget $budget)
{
$this->authorize('update', $budget);
$request->validate([
'statut' => 'required|in:preparation,arbitrage_dsi,arbitrage_direction,valide,cloture',
]);
$budget->update(['statut' => $request->statut]);
return redirect()->back()->with('success', 'Statut du budget mis à jour.');
}
public function execution(Request $request)
{
$user = $request->user();
$annee = $request->get('annee', now()->year);
$query = \App\Models\LigneBudget::with(['budget.service', 'commandes.commune'])
->whereHas('budget', function($q) use ($annee) {
$q->where('annee', $annee);
});
// Filtrage par service si pas admin/directeur/raf
if (!$user->hasRole(['admin', 'directeur', 'raf'])) {
$query->whereHas('budget', function($q) use ($user) {
$q->where('service_id', $user->service_id);
});
}
$statutsConsommes = ['validee', 'commandee', 'partiellement_recue', 'recue_complete', 'cloturee'];
$statutsEngages = ['brouillon', 'en_attente_validation'];
$lignes = $query->get()->map(function($lb) use ($statutsConsommes, $statutsEngages) {
// Toutes les commandes déjà chargées en mémoire — 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');
$totalEngage = $consomme + $engage;
// Ventilation par commune — calculée en mémoire
$ventilation = $commandes
->whereNotIn('statut', ['annulee'])
->groupBy('commune_id')
->map(fn($group, $communeId) => [
'commune' => $group->first()->commune?->nom ?? 'Non défini',
'total' => (float) $group->sum('montant_ttc'),
])
->values();
// Liste détaillée des commandes pour affichage
$commandesList = $commandes
->whereNotIn('statut', ['annulee'])
->map(fn($c) => [
'id' => $c->id,
'reference' => $c->numero_commande ?? 'CMD-'.$c->id,
'nom' => $c->objet,
'commune' => $c->commune?->nom,
'montant_ttc'=> (float) $c->montant_ttc,
'statut' => $c->statut,
])
->values();
return [
'id' => $lb->id,
'nom' => $lb->nom,
'type_depense' => $lb->type_depense,
'type_budget' => $lb->budget->type_budget,
'service' => $lb->budget->service->nom,
'annee' => $lb->budget->annee,
'montant_arbitre' => (float) $lb->montant_arbitre,
'consomme' => $consomme,
'engage' => $engage,
'total_cumule' => $totalEngage,
'reste' => (float) $lb->montant_arbitre - $totalEngage,
'ventilation' => $ventilation,
'commandes' => $commandesList
];
});
return Inertia::render('Budgets/Execution', [
'lignes' => $lignes,
'services' => \App\Models\Service::orderBy('nom')->get(),
'filters' => [
'annee' => $annee
]
]);
}
private function getExportFilename(string $base, int $annee, array $filters, string $ext): string
{
$name = $base . '-' . $annee;
if ($filters['type']) $name .= '-' . $filters['type'];
if ($filters['envelope']) $name .= '-' . $filters['envelope'];
if ($filters['service']) $name .= '-' . \Str::slug($filters['service']);
return $name . '.' . $ext;
}
/**
* Valide et extrait les filtres d'export depuis la requête.
* Les valeurs non reconnues sont ignorées pour éviter toute injection de paramètres.
*/
private function resolveExportFilters(Request $request): array
{
$type = $request->get('type');
$envelope = $request->get('envelope');
return [
'type' => in_array($type, ['investissement', 'fonctionnement']) ? $type : null,
'envelope' => in_array($envelope, ['agglo', 'mutualise']) ? $envelope : null,
'service' => $request->get('service') !== 'all' ? $request->get('service') : null,
];
}
public function exportExcel(Request $request)
{
$annee = (int) $request->get('annee', now()->year);
$filters = $this->resolveExportFilters($request);
$filename = $this->getExportFilename('budget-execution', $annee, $filters, 'xlsx');
return Excel::download(new BudgetExecutionExport($request->user(), $annee, $filters), $filename);
}
public function exportOds(Request $request)
{
$annee = (int) $request->get('annee', now()->year);
$filters = $this->resolveExportFilters($request);
$filename = $this->getExportFilename('budget-execution', $annee, $filters, 'ods');
return Excel::download(new BudgetExecutionExport($request->user(), $annee, $filters), $filename, \Maatwebsite\Excel\Excel::ODS);
}
public function exportPdf(Request $request)
{
$user = $request->user();
$annee = (int) $request->get('annee', now()->year);
$filters = $this->resolveExportFilters($request);
$query = LigneBudget::with(['budget.service', 'commandes'])
->whereHas('budget', function($q) use ($annee) {
$q->where('annee', $annee);
});
if (!$user->hasRole(['admin', 'directeur', 'raf'])) {
$query->whereHas('budget', function($q) use ($user) {
$q->where('service_id', $user->service_id);
});
}
if ($filters['type']) {
$query->where('type_depense', $filters['type']);
}
if ($filters['envelope']) {
$query->whereHas('budget', fn($q) => $q->where('type_budget', $filters['envelope']));
}
if ($filters['service']) {
$query->whereHas('budget.service', fn($q) => $q->where('nom', $filters['service']));
}
$statutsConsommes = ['validee', 'commandee', 'partiellement_recue', 'recue_complete', 'cloturee'];
$statutsEngages = ['brouillon', 'en_attente_validation'];
$lignes = $query->get()->map(function($lb) use ($statutsConsommes, $statutsEngages) {
$commandes = $lb->commandes;
$lb->consomme = (float) $commandes->whereIn('statut', $statutsConsommes)->sum('montant_ttc');
$lb->engage = (float) $commandes->whereIn('statut', $statutsEngages)->sum('montant_ttc');
$lb->total_cumule = $lb->consomme + $lb->engage;
$lb->reste = (float) ($lb->montant_arbitre ?? 0) - $lb->total_cumule;
return $lb;
});
$pdf = Pdf::loadView('exports.budgets_pdf', [
'lignes' => $lignes,
'annee' => $annee,
'user' => $user,
'type' => $filters['type'] ? ucfirst($filters['type']) : 'Tous types',
'envelope' => $filters['envelope'] ? ucfirst($filters['envelope']) : 'Toutes enveloppes',
'service' => !$user->hasRole(['admin', 'directeur', 'raf'])
? ($user->service->nom ?? 'Inconnu')
: ($filters['service'] ?? 'Tous les services'),
])->setPaper('a4', 'landscape');
$filename = $this->getExportFilename('rapport-budgetaire', $annee, $filters, 'pdf');
return $pdf->download($filename);
}
}

View File

@@ -7,6 +7,7 @@ use App\Models\Categorie;
use App\Models\Commande; use App\Models\Commande;
use App\Models\Fournisseur; use App\Models\Fournisseur;
use App\Models\Service; use App\Models\Service;
use App\Models\LigneBudget;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -54,6 +55,7 @@ class CommandeController extends Controller
'communes' => \App\Models\Commune::orderBy('nom')->get(), 'communes' => \App\Models\Commune::orderBy('nom')->get(),
'categories' => Categorie::active()->orderBy('ordre')->get(), 'categories' => Categorie::active()->orderBy('ordre')->get(),
'articles' => Article::active()->with('categorie')->orderBy('designation')->get(), 'articles' => Article::active()->with('categorie')->orderBy('designation')->get(),
'lignesBudget' => LigneBudget::with('budget.service')->orderByDesc('created_at')->get(),
]); ]);
} }
@@ -71,6 +73,7 @@ class CommandeController extends Controller
'priorite' => 'required|in:normale,haute,urgente', 'priorite' => 'required|in:normale,haute,urgente',
'reference_fournisseur' => 'nullable|string|max:100', 'reference_fournisseur' => 'nullable|string|max:100',
'imputation_budgetaire' => 'nullable|string|max:100', 'imputation_budgetaire' => 'nullable|string|max:100',
'ligne_budget_id' => 'nullable|exists:ligne_budgets,id',
'date_demande' => 'required|date', 'date_demande' => 'required|date',
'date_souhaitee' => 'nullable|date|after_or_equal:date_demande', 'date_souhaitee' => 'nullable|date|after_or_equal:date_demande',
'date_livraison_prevue' => 'nullable|date', 'date_livraison_prevue' => 'nullable|date',
@@ -88,6 +91,18 @@ class CommandeController extends Controller
'lignes.*.notes' => 'nullable|string', 'lignes.*.notes' => 'nullable|string',
]); ]);
// Vérification du budget disponible avant création
if (!empty($validated['ligne_budget_id'])) {
$montantEstime = $this->estimerMontantTtc($validated);
$budgetError = $this->checkBudgetDisponible(
$validated['ligne_budget_id'],
$montantEstime
);
if ($budgetError) {
return back()->withErrors(['ligne_budget_id' => $budgetError])->withInput();
}
}
DB::transaction(function () use ($validated, $request) { DB::transaction(function () use ($validated, $request) {
$commande = Commande::create([ $commande = Commande::create([
...$validated, ...$validated,
@@ -115,6 +130,7 @@ class CommandeController extends Controller
'historique.user', 'historique.user',
'piecesJointes.user', 'piecesJointes.user',
'assets', 'assets',
'ligneBudget.budget.service'
]); ]);
$transitionsDisponibles = collect(Commande::STATUT_TRANSITIONS[$commande->statut] ?? []) $transitionsDisponibles = collect(Commande::STATUT_TRANSITIONS[$commande->statut] ?? [])
@@ -140,6 +156,7 @@ class CommandeController extends Controller
'communes' => \App\Models\Commune::orderBy('nom')->get(), 'communes' => \App\Models\Commune::orderBy('nom')->get(),
'categories' => Categorie::active()->orderBy('ordre')->get(), 'categories' => Categorie::active()->orderBy('ordre')->get(),
'articles' => Article::active()->with('categorie')->orderBy('designation')->get(), 'articles' => Article::active()->with('categorie')->orderBy('designation')->get(),
'lignesBudget' => LigneBudget::with('budget.service')->orderByDesc('created_at')->get(),
]); ]);
} }
@@ -157,6 +174,7 @@ class CommandeController extends Controller
'priorite' => 'required|in:normale,haute,urgente', 'priorite' => 'required|in:normale,haute,urgente',
'reference_fournisseur' => 'nullable|string|max:100', 'reference_fournisseur' => 'nullable|string|max:100',
'imputation_budgetaire' => 'nullable|string|max:100', 'imputation_budgetaire' => 'nullable|string|max:100',
'ligne_budget_id' => 'nullable|exists:ligne_budgets,id',
'date_demande' => 'required|date', 'date_demande' => 'required|date',
'date_souhaitee' => 'nullable|date', 'date_souhaitee' => 'nullable|date',
'date_livraison_prevue' => 'nullable|date', 'date_livraison_prevue' => 'nullable|date',
@@ -176,6 +194,19 @@ class CommandeController extends Controller
'lignes.*.notes' => 'nullable|string', 'lignes.*.notes' => 'nullable|string',
]); ]);
// Vérification du budget disponible si la ligne budget change ou si le montant change
if (!empty($validated['ligne_budget_id'])) {
$montantEstime = $this->estimerMontantTtc($validated);
$budgetError = $this->checkBudgetDisponible(
$validated['ligne_budget_id'],
$montantEstime,
$commande->id
);
if ($budgetError) {
return back()->withErrors(['ligne_budget_id' => $budgetError])->withInput();
}
}
DB::transaction(function () use ($validated, $commande) { DB::transaction(function () use ($validated, $commande) {
$commande->update($validated); $commande->update($validated);
@@ -236,6 +267,61 @@ class CommandeController extends Controller
} }
} }
/**
* Estime le montant TTC total depuis les lignes soumises dans la requête.
* Utilisé avant la création/mise à jour pour vérifier le budget disponible.
*/
private function estimerMontantTtc(array $validated): float
{
$total = 0.0;
foreach ($validated['lignes'] ?? [] as $ligne) {
$ht = (float) ($ligne['prix_unitaire_ht'] ?? 0);
$qty = (float) ($ligne['quantite'] ?? 1);
$tva = (float) ($ligne['taux_tva'] ?? 20);
$total += $ht * $qty * (1 + $tva / 100);
}
return $total;
}
/**
* Vérifie que la ligne budgétaire a suffisamment de disponible pour accueillir le montant.
* Retourne un message d'erreur ou null si tout va bien.
* Le paramètre $excludeCommandeId permet d'exclure la commande en cours d'édition du calcul.
*/
private function checkBudgetDisponible(int $ligneBudgetId, float $montantTtc, ?int $excludeCommandeId = null): ?string
{
$ligne = LigneBudget::find($ligneBudgetId);
if (!$ligne || $ligne->montant_arbitre === null) {
return null; // Pas encore arbitrée, pas de blocage
}
// Recalcule le disponible en excluant éventuellement la commande en cours d'édition,
// en utilisant les mêmes statuts que getMontantDisponibleAttribute pour la cohérence.
$statutsConsommes = ['validee', 'commandee', 'partiellement_recue', 'recue_complete', 'cloturee'];
$statutsEngages = ['brouillon', 'en_attente_validation'];
$queryConsomme = $ligne->commandes()->whereIn('statut', $statutsConsommes);
$queryEngage = $ligne->commandes()->whereIn('statut', $statutsEngages);
if ($excludeCommandeId) {
$queryConsomme->where('id', '!=', $excludeCommandeId);
$queryEngage->where('id', '!=', $excludeCommandeId);
}
$disponible = (float) $ligne->montant_arbitre
- (float) $queryConsomme->sum('montant_ttc')
- (float) $queryEngage->sum('montant_ttc');
if ($montantTtc > $disponible) {
return sprintf(
'Budget insuffisant sur "%s". Disponible : %s €, demandé : %s €.',
$ligne->nom,
number_format($disponible, 2, ',', ' '),
number_format($montantTtc, 2, ',', ' ')
);
}
return null;
}
public function exportPdf(Commande $commande) public function exportPdf(Commande $commande)
{ {
$this->authorize('view', $commande); $this->authorize('view', $commande);

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Http\Controllers;
use App\Models\Budget;
use App\Models\HistoriqueBudget;
use App\Models\LigneBudget;
use Illuminate\Http\Request;
class LigneBudgetController extends Controller
{
public function store(Request $request)
{
$request->validate([
'budget_id' => 'required|exists:budgets,id',
'nom' => 'required|string|max:255',
'description' => 'nullable|string',
'type_depense' => 'required|in:investissement,fonctionnement',
'montant_propose' => 'required|numeric|min:0',
'commune_id' => 'nullable|exists:communes,id',
]);
$budget = Budget::findOrFail($request->budget_id);
$this->authorize('addLigne', $budget);
LigneBudget::create($request->only([
'budget_id', 'nom', 'description', 'type_depense', 'montant_propose', 'commune_id',
]));
return redirect()->back()->with('success', 'Ligne budgétaire ajoutée.');
}
public function update(Request $request, LigneBudget $lignes_budget)
{
$this->authorize('updateLigne', $lignes_budget);
$request->validate([
'nom' => 'required|string|max:255',
'description' => 'nullable|string',
'type_depense' => 'required|in:investissement,fonctionnement',
'montant_propose' => 'required|numeric|min:0',
'commune_id' => 'nullable|exists:communes,id',
]);
$lignes_budget->update($request->only([
'nom', 'description', 'type_depense', 'montant_propose', 'commune_id',
]));
return redirect()->back()->with('success', 'Ligne budgétaire mise à jour.');
}
public function arbitrer(Request $request, LigneBudget $ligneBudget)
{
$this->authorize('arbitrerLigne', $ligneBudget);
$request->validate([
'statut_arbitrage' => 'required|in:propose,accepte_dsi,accepte_direction,valide_definitif,refuse,reporte',
'commentaire' => 'nullable|string|max:1000',
'montant_arbitre' => [
'nullable',
'numeric',
'min:0',
function ($attribute, $value, $fail) use ($ligneBudget) {
if ($value !== null && $value > $ligneBudget->montant_propose) {
$fail("Le montant arbitré ({$value} €) ne peut pas dépasser le montant proposé ({$ligneBudget->montant_propose} €).");
}
},
],
]);
HistoriqueBudget::create([
'ligne_budget_id' => $ligneBudget->id,
'user_id' => $request->user()->id,
'ancien_statut' => $ligneBudget->statut_arbitrage,
'nouveau_statut' => $request->statut_arbitrage,
'ancien_montant_arbitre' => $ligneBudget->montant_arbitre,
'nouveau_montant_arbitre'=> $request->montant_arbitre,
'commentaire' => $request->commentaire,
]);
$ligneBudget->update([
'statut_arbitrage' => $request->statut_arbitrage,
'montant_arbitre' => $request->montant_arbitre,
]);
return redirect()->back()->with('success', 'Arbitrage enregistré.');
}
public function destroy(LigneBudget $lignes_budget)
{
$this->authorize('deleteLigne', $lignes_budget);
$lignes_budget->delete();
return redirect()->back()->with('success', 'Ligne budgétaire supprimée.');
}
}

60
app/Models/Budget.php Normal file
View File

@@ -0,0 +1,60 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Budget extends Model
{
use HasFactory;
protected $fillable = [
'service_id',
'commune_id',
'annee',
'type_budget',
'statut',
];
public function service(): BelongsTo
{
return $this->belongsTo(Service::class);
}
public function commune(): BelongsTo
{
return $this->belongsTo(Commune::class);
}
public function lignes(): HasMany
{
return $this->hasMany(LigneBudget::class);
}
/**
* Calcul du total proposé selon le type de dépense (optionnel: 'investissement' ou 'fonctionnement').
*/
public function totalPropose(?string $typeDepense = null): float
{
$query = $this->lignes();
if ($typeDepense) {
$query->where('type_depense', $typeDepense);
}
return $query->sum('montant_propose') ?? 0;
}
/**
* Calcul du total arbitré (validé) selon le type de dépense.
*/
public function totalArbitre(?string $typeDepense = null): float
{
$query = $this->lignes();
if ($typeDepense) {
$query->where('type_depense', $typeDepense);
}
return $query->sum('montant_arbitre') ?? 0;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class HistoriqueBudget extends Model
{
protected $fillable = [
'ligne_budget_id',
'user_id',
'ancien_statut',
'nouveau_statut',
'ancien_montant_arbitre',
'nouveau_montant_arbitre',
'commentaire',
];
protected $casts = [
'ancien_montant_arbitre' => 'decimal:2',
'nouveau_montant_arbitre' => 'decimal:2',
];
public function ligneBudget(): BelongsTo
{
return $this->belongsTo(LigneBudget::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class LigneBudget extends Model
{
use HasFactory;
protected $fillable = [
'budget_id',
'commune_id',
'nom',
'description',
'type_depense',
'montant_propose',
'montant_arbitre',
'statut_arbitrage',
];
protected $appends = [
'montant_consomme',
'montant_engage',
'montant_disponible',
];
protected $casts = [
'montant_propose' => 'decimal:2',
'montant_arbitre' => 'decimal:2',
];
public function budget(): BelongsTo
{
return $this->belongsTo(Budget::class);
}
public function commune(): BelongsTo
{
return $this->belongsTo(Commune::class);
}
public function commandes(): HasMany
{
return $this->hasMany(Commande::class, 'ligne_budget_id');
}
public function historique(): HasMany
{
return $this->hasMany(HistoriqueBudget::class)->latest();
}
/**
* Calcule le total des commandes réelles liées à cette ligne
*/
public function getMontantConsommeAttribute(): float
{
// On somme le montant total des commandes validées ou plus loin
return $this->commandes()
->whereIn('statut', ['validee', 'commandee', 'partiellement_recue', 'recue_complete', 'cloturee'])
->sum('montant_ttc') ?? 0;
}
/**
* Calcule le total des commandes en brouillon ou attente
*/
public function getMontantEngageAttribute(): float
{
return $this->commandes()
->whereIn('statut', ['brouillon', 'en_attente_validation'])
->sum('montant_ttc') ?? 0;
}
/**
* Calcule le montant restant (Arbitré - Consommé - Engagé).
* Retourne 0.0 si aucun montant n'a encore été arbitré.
*/
public function getMontantDisponibleAttribute(): float
{
return (float) ($this->montant_arbitre ?? 0) - ($this->montant_consomme + $this->montant_engage);
}
}

View File

@@ -2,11 +2,14 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
class Service extends Model class Service extends Model
{ {
use HasFactory;
protected $fillable = ['nom', 'description', 'couleur', 'icone']; protected $fillable = ['nom', 'description', 'couleur', 'icone'];
public function users(): HasMany public function users(): HasMany

View File

@@ -0,0 +1,110 @@
<?php
namespace App\Policies;
use App\Models\Budget;
use App\Models\LigneBudget;
use App\Models\User;
class BudgetPolicy
{
/**
* Tout utilisateur authentifié peut voir la liste des budgets.
* Le filtrage par service est géré dans le contrôleur.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Un utilisateur peut voir un budget s'il appartient à son service,
* ou s'il est admin/directeur/raf.
*/
public function view(User $user, Budget $budget): bool
{
return $user->hasRole(['admin', 'directeur', 'raf'])
|| $budget->service_id === $user->service_id;
}
/**
* Seuls les admin et directeur peuvent créer des budgets.
*/
public function create(User $user): bool
{
return $user->hasRole(['admin', 'directeur', 'raf']);
}
/**
* Seuls les admin et directeur peuvent changer le statut d'un budget.
*/
public function update(User $user, Budget $budget): bool
{
return $user->hasRole(['admin', 'directeur']);
}
/**
* Seuls les admin peuvent supprimer un budget.
*/
public function delete(User $user, Budget $budget): bool
{
return $user->hasRole('admin');
}
/**
* Un utilisateur peut ajouter une ligne budgétaire sur un budget
* si le budget lui appartient (son service) ou s'il est admin/directeur/raf,
* et si le budget n'est pas verrouillé.
*/
public function addLigne(User $user, Budget $budget): bool
{
if (in_array($budget->statut, ['arbitrage_direction', 'valide', 'cloture'])) {
return false;
}
return $user->hasRole(['admin', 'directeur', 'raf'])
|| $budget->service_id === $user->service_id;
}
/**
* Un utilisateur peut modifier une ligne budgétaire si :
* - le budget parent lui appartient (son service) ou il est admin/directeur/raf
* - le budget n'est pas verrouillé (arbitrage_direction, valide, cloture)
*/
public function updateLigne(User $user, LigneBudget $ligneBudget): bool
{
$budget = $ligneBudget->budget;
if (!$budget || in_array($budget->statut, ['arbitrage_direction', 'valide', 'cloture'])) {
return false;
}
return $user->hasRole(['admin', 'directeur', 'raf'])
|| $budget->service_id === $user->service_id;
}
/**
* Un utilisateur peut supprimer une ligne budgétaire si :
* - le budget parent lui appartient (son service) ou il est admin/directeur/raf
* - le budget n'est pas verrouillé
*/
public function deleteLigne(User $user, LigneBudget $ligneBudget): bool
{
$budget = $ligneBudget->budget;
if (!$budget || in_array($budget->statut, ['arbitrage_direction', 'valide', 'cloture'])) {
return false;
}
return $user->hasRole(['admin', 'directeur', 'raf'])
|| $budget->service_id === $user->service_id;
}
/**
* Seuls les admin, directeur et raf peuvent arbitrer les lignes budgétaires.
*/
public function arbitrerLigne(User $user, LigneBudget $ligneBudget): bool
{
return $user->hasRole(['admin', 'directeur', 'raf']);
}
}

View File

@@ -2,11 +2,14 @@
namespace App\Providers; namespace App\Providers;
use App\Models\Budget;
use App\Models\Categorie; use App\Models\Categorie;
use App\Models\Commande; use App\Models\Commande;
use App\Models\Fournisseur; use App\Models\Fournisseur;
use App\Models\LigneBudget;
use App\Models\PieceJointe; use App\Models\PieceJointe;
use App\Models\User; use App\Models\User;
use App\Policies\BudgetPolicy;
use App\Policies\CommandePolicy; use App\Policies\CommandePolicy;
use App\Policies\FournisseurPolicy; use App\Policies\FournisseurPolicy;
use App\Policies\PieceJointePolicy; use App\Policies\PieceJointePolicy;
@@ -18,6 +21,8 @@ use Illuminate\Support\Facades\Vite;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
protected $policies = [ protected $policies = [
Budget::class => BudgetPolicy::class,
LigneBudget::class => BudgetPolicy::class,
Commande::class => CommandePolicy::class, Commande::class => CommandePolicy::class,
Fournisseur::class => FournisseurPolicy::class, Fournisseur::class => FournisseurPolicy::class,
PieceJointe::class => PieceJointePolicy::class, PieceJointe::class => PieceJointePolicy::class,

View File

@@ -0,0 +1,24 @@
<?php
namespace Database\Factories;
use App\Models\Service;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Service>
*/
class ServiceFactory extends Factory
{
protected $model = Service::class;
public function definition(): array
{
return [
'nom' => fake()->unique()->words(2, true),
'description' => fake()->sentence(),
'couleur' => fake()->hexColor(),
'icone' => 'folder',
];
}
}

View File

@@ -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('budgets', function (Blueprint $table) {
$table->id();
$table->foreignId('service_id')->constrained()->cascadeOnDelete();
$table->integer('annee');
$table->enum('type_budget', ['agglo', 'mutualise'])->default('agglo');
$table->enum('statut', ['preparation', 'arbitrage_dsi', 'arbitrage_direction', 'valide', 'cloture'])->default('preparation');
$table->timestamps();
// Un service ne peut avoir qu'un seul budget d'un type donné par an
$table->unique(['service_id', 'annee', 'type_budget']);
});
}
public function down(): void
{
Schema::dropIfExists('budgets');
}
};

View File

@@ -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('ligne_budgets', function (Blueprint $table) {
$table->id();
$table->foreignId('budget_id')->constrained()->cascadeOnDelete();
$table->string('nom');
$table->text('description')->nullable();
$table->enum('type_depense', ['investissement', 'fonctionnement'])->default('fonctionnement');
$table->decimal('montant_propose', 10, 2)->default(0);
$table->decimal('montant_arbitre', 10, 2)->nullable();
$table->enum('statut_arbitrage', ['brouillon', 'propose', 'accepte_dsi', 'accepte_direction', 'valide_definitif', 'refuse', 'reporte'])->default('brouillon');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('ligne_budgets');
}
};

View File

@@ -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('commandes', function (Blueprint $table) {
$table->foreignId('ligne_budget_id')->nullable()->constrained('ligne_budgets')->nullOnDelete();
});
}
public function down(): void
{
Schema::table('commandes', function (Blueprint $table) {
$table->dropForeign(['ligne_budget_id']);
$table->dropColumn('ligne_budget_id');
});
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('budgets', function (Blueprint $table) {
$table->foreignId('commune_id')->nullable()->after('service_id')->constrained('communes')->nullOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('budgets', function (Blueprint $table) {
$table->dropForeign(['commune_id']);
$table->dropColumn('commune_id');
});
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('ligne_budgets', function (Blueprint $table) {
$table->foreignId('commune_id')->nullable()->after('budget_id')->constrained('communes')->nullOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('ligne_budgets', function (Blueprint $table) {
$table->dropForeign(['commune_id']);
$table->dropColumn('commune_id');
});
}
};

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Spatie\Permission\Models\Role;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Role::firstOrCreate(['name' => 'raf', 'guard_name' => 'web']);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Role::where('name', 'raf')->delete();
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('historique_budgets', function (Blueprint $table) {
$table->id();
$table->foreignId('ligne_budget_id')->constrained('ligne_budgets')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->restrictOnDelete();
$table->string('ancien_statut')->nullable();
$table->string('nouveau_statut');
$table->decimal('ancien_montant_arbitre', 10, 2)->nullable();
$table->decimal('nouveau_montant_arbitre', 10, 2)->nullable();
$table->text('commentaire')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('historique_budgets');
}
};

72
package-lock.json generated
View File

@@ -5,6 +5,11 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-brands-svg-icons": "^7.2.0",
"@fortawesome/free-regular-svg-icons": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/vue-fontawesome": "^3.1.3",
"@fullcalendar/core": "^6.1.20", "@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20", "@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/interaction": "^6.1.20", "@fullcalendar/interaction": "^6.1.20",
@@ -118,6 +123,73 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.2.0.tgz",
"integrity": "sha512-IpR0bER9FY25p+e7BmFH25MZKEwFHTfRAfhOyJubgiDnoJNsSvJ7nigLraHtp4VOG/cy8D7uiV0dLkHOne5Fhw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.2.0.tgz",
"integrity": "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q==",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-7.2.0.tgz",
"integrity": "sha512-VNG8xqOip1JuJcC3zsVsKRQ60oXG9+oYNDCosjoU/H9pgYmLTEwWw8pE0jhPz/JWdHeUuK6+NQ3qsM4gIbdbYQ==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-regular-svg-icons": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-7.2.0.tgz",
"integrity": "sha512-iycmlN51EULlQ4D/UU9WZnHiN0CvjJ2TuuCrAh+1MVdzD+4ViKYH2deNAll4XAAYlZa8WAefHR5taSK8hYmSMw==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.2.0.tgz",
"integrity": "sha512-YTVITFGN0/24PxzXrwqCgnyd7njDuzp5ZvaCx5nq/jg55kUYd94Nj8UTchBdBofi/L0nwRfjGOg0E41d2u9T1w==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/vue-fontawesome": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.1.3.tgz",
"integrity": "sha512-OHHUTLPEzdwP8kcYIzhioUdUOjZ4zzmi+midwa4bqscza4OJCOvTKJEHkXNz8PgZ23kWci1HkKVX0bm8f9t9gQ==",
"license": "MIT",
"peerDependencies": {
"@fortawesome/fontawesome-svg-core": "~1 || ~6 || ~7",
"vue": ">= 3.0.0 < 4"
}
},
"node_modules/@fullcalendar/core": { "node_modules/@fullcalendar/core": {
"version": "6.1.20", "version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz", "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz",

View File

@@ -21,6 +21,11 @@
"vue": "^3.4.0" "vue": "^3.4.0"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-brands-svg-icons": "^7.2.0",
"@fortawesome/free-regular-svg-icons": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/vue-fontawesome": "^3.1.3",
"@fullcalendar/core": "^6.1.20", "@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20", "@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/interaction": "^6.1.20", "@fullcalendar/interaction": "^6.1.20",

View File

@@ -0,0 +1,134 @@
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
const props = defineProps({
modelValue: {
type: [String, Number, null],
default: ''
},
options: {
type: Array,
default: () => []
},
placeholder: {
type: String,
default: 'Rechercher...'
}
});
const emit = defineEmits(['update:modelValue']);
const isOpen = ref(false);
const searchQuery = ref('');
const selectContainer = ref(null);
// Initialize searchQuery based on modelValue
watch(() => props.modelValue, (newVal) => {
const opt = props.options.find(o => o.value === newVal);
if (opt && !isOpen.value) {
searchQuery.value = opt.label;
} else if (!newVal) {
searchQuery.value = '';
}
}, { immediate: true });
const filteredOptions = computed(() => {
if (!searchQuery.value) return props.options;
const lowerQuery = searchQuery.value.toLowerCase();
return props.options.filter(opt =>
opt.label.toLowerCase().includes(lowerQuery)
);
});
function selectOption(option) {
if (!option) {
emit('update:modelValue', '');
searchQuery.value = '';
} else {
emit('update:modelValue', option.value);
searchQuery.value = option.label;
}
isOpen.value = false;
}
function onClickOutside(event) {
if (selectContainer.value && !selectContainer.value.contains(event.target)) {
isOpen.value = false;
// Revert searchQuery to selected item if clicked outside without selecting
if (props.modelValue) {
const opt = props.options.find(o => o.value === props.modelValue);
searchQuery.value = opt ? opt.label : '';
} else {
searchQuery.value = '';
}
}
}
// Ensure the dropdown handles tab and escape properly
function onKeyDown(event) {
if (event.key === 'Escape') {
isOpen.value = false;
}
}
onMounted(() => {
document.addEventListener('click', onClickOutside);
});
onBeforeUnmount(() => {
document.removeEventListener('click', onClickOutside);
});
</script>
<template>
<div class="relative" ref="selectContainer" @keydown="onKeyDown">
<!-- Input field -->
<input
type="text"
v-model="searchQuery"
@focus="isOpen = true; searchQuery = ''"
:placeholder="placeholder"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm pr-10"
/>
<!-- Clear button / Toggle indicator -->
<div class="absolute inset-y-0 right-0 flex items-center pr-2">
<button v-if="modelValue" type="button" @click.stop="selectOption(null)" class="text-gray-400 hover:text-gray-600 focus:outline-none p-1 z-10" title="Effacer la sélection">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
<svg v-else class="h-5 w-5 text-gray-400 pointer-events-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z" clip-rule="evenodd" />
</svg>
</div>
<!-- Dropdown -->
<transition leave-active-class="transition ease-in duration-100" leave-from-class="opacity-100" leave-to-class="opacity-0">
<div v-show="isOpen" class="absolute z-20 mt-1 w-full rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none max-h-60 overflow-y-auto">
<ul tabindex="-1" role="listbox" aria-labelledby="listbox-label">
<!-- If no results -->
<li v-if="filteredOptions.length === 0" class="text-gray-500 cursor-default select-none relative py-2 pl-3 pr-9 text-sm">
Aucun résultat trouvé.
</li>
<!-- Options -->
<li
v-for="option in filteredOptions"
:key="option.value"
@click.stop="selectOption(option)"
class="text-gray-900 hover:bg-indigo-600 hover:text-white cursor-pointer select-none relative py-2 pl-3 pr-9 text-sm transition-colors"
>
<span class="block truncate" :class="{ 'font-semibold': option.value === modelValue, 'font-normal': option.value !== modelValue }">
{{ option.label }}
</span>
<span v-if="option.value === modelValue" class="absolute inset-y-0 right-0 flex items-center pr-4 text-indigo-600">
<!-- Adjusted hover states internally since parent hover handles it -->
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
</svg>
</span>
</li>
</ul>
</div>
</transition>
</div>
</template>

View File

@@ -0,0 +1,298 @@
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import { Head, Link, router } from '@inertiajs/vue3'
import { ref, watch, computed } from 'vue'
const props = defineProps({
lignes: Array,
services: Array,
filters: Object
})
const annee = ref(props.filters.annee)
const formatCurrency = (v) => {
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(v)
}
const calculatePercent = (consumed, total) => {
if (!total || total <= 0) return 0
return Math.min(Math.round((consumed / total) * 100), 100)
}
const getProgressBarColor = (percent) => {
if (percent < 50) return 'bg-blue-500'
if (percent < 80) return 'bg-yellow-500'
if (percent < 100) return 'bg-orange-500'
return 'bg-red-500'
}
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
}
watch(annee, (val) => {
router.get(route('budgets.execution'), { annee: val }, { preserveState: true })
})
const filterType = ref('all')
const filterEnvelope = ref('all')
const filterService = ref('all')
const expandedLines = ref([])
const toggleLine = (id) => {
if (expandedLines.value.includes(id)) {
expandedLines.value = expandedLines.value.filter(i => i !== id)
} else {
expandedLines.value.push(id)
}
}
const lignesEnDepassement = computed(() => props.lignes.filter(l => l.reste < 0))
const filteredLignes = computed(() => {
return props.lignes.filter(l => {
const matchType = filterType.value === 'all' || l.type_depense === filterType.value
const matchEnvelope = filterEnvelope.value === 'all' || l.type_budget === filterEnvelope.value
const matchService = filterService.value === 'all' || l.service === filterService.value
return matchType && matchEnvelope && matchService
})
})</script>
<template>
<Head title="Exécution Budgétaire" />
<AuthenticatedLayout>
<template #header>
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 tracking-tight">Exécution Budgétaire</h1>
<p class="text-sm text-gray-500 mt-1">Suivi du consommé (TTC) par rapport aux enveloppes arbitrées.</p>
</div>
<div class="flex items-center gap-3">
<div class="relative group">
<button class="flex items-center gap-2 px-4 py-2 bg-white border border-gray-200 rounded-lg text-sm font-bold text-gray-700 hover:bg-gray-50 transition-colors shadow-sm">
<svg class="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
Exporter
<svg class="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
</button>
<div class="absolute right-0 mt-2 w-48 bg-white border border-gray-100 rounded-xl shadow-xl opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-50 py-2">
<a :href="route('budgets.export.excel', { annee: annee, type: filterType, envelope: filterEnvelope, service: filterService })" class="flex items-center gap-3 px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-700 transition-colors">
<span class="p-1.5 bg-green-100 text-green-700 rounded-lg">
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM13 3.5L18.5 9H13V3.5zM6 20V4h6V10h6v10H6z"/></svg>
</span>
Excel (.xlsx)
</a>
<a :href="route('budgets.export.pdf', { annee: annee, type: filterType, envelope: filterEnvelope, service: filterService })" class="flex items-center gap-3 px-4 py-2 text-sm text-gray-700 hover:bg-red-50 hover:text-red-700 transition-colors">
<span class="p-1.5 bg-red-100 text-red-700 rounded-lg">
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14h-2v-2h2v2zm0-4h-2V7h2v5z"/></svg>
</span>
PDF (.pdf)
</a>
<a :href="route('budgets.export.ods', { annee: annee, type: filterType, envelope: filterEnvelope, service: filterService })" class="flex items-center gap-3 px-4 py-2 text-sm text-gray-700 hover:bg-indigo-50 hover:text-indigo-700 transition-colors">
<span class="p-1.5 bg-indigo-100 text-indigo-700 rounded-lg">
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 7V3.5L18.5 9H13z"/></svg>
</span>
OpenDoc (.ods)
</a>
</div>
</div>
<select v-model="annee" class="rounded-lg border-gray-300 text-sm font-bold text-gray-700 focus:ring-blue-500 focus:border-blue-500 shadow-sm">
<option :value="new Date().getFullYear() + 1">{{ new Date().getFullYear() + 1 }}</option>
<option :value="new Date().getFullYear()">{{ new Date().getFullYear() }}</option>
<option :value="new Date().getFullYear() - 1">{{ new Date().getFullYear() - 1 }}</option>
</select>
</div>
</div>
</template>
<div class="space-y-6">
<!-- Filters -->
<div class="flex flex-wrap items-center gap-4 bg-white p-4 rounded-xl shadow-sm border border-gray-100">
<div class="flex items-center gap-2">
<label class="text-xs font-bold text-gray-500 uppercase">Type :</label>
<select v-model="filterType" class="rounded-lg border-gray-200 text-xs focus:ring-blue-500 focus:border-blue-500">
<option value="all">Tous les types</option>
<option value="investissement">Investissement</option>
<option value="fonctionnement">Fonctionnement</option>
</select>
</div>
<div class="flex items-center gap-2">
<label class="text-xs font-bold text-gray-500 uppercase">Enveloppe :</label>
<select v-model="filterEnvelope" class="rounded-lg border-gray-200 text-xs focus:ring-blue-500 focus:border-blue-500">
<option value="all">Toutes les enveloppes</option>
<option value="agglo">Agglo</option>
<option value="mutualise">Mutualisé</option>
</select>
</div>
<div class="flex items-center gap-2">
<label class="text-xs font-bold text-gray-500 uppercase">Service :</label>
<select v-model="filterService" class="rounded-lg border-gray-200 text-xs focus:ring-blue-500 focus:border-blue-500 max-w-[150px]">
<option value="all">Tous les services</option>
<option v-for="s in services" :key="s.id" :value="s.nom">{{ s.nom }}</option>
</select>
</div>
<div class="ml-auto flex items-center gap-3">
<span v-if="lignesEnDepassement.length" class="flex items-center gap-1.5 px-3 py-1.5 bg-red-50 border border-red-200 rounded-lg text-xs font-semibold text-red-700">
<svg class="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
{{ lignesEnDepassement.length }} dépassement(s)
</span>
<span class="text-xs text-gray-400">{{ filteredLignes.length }} ligne(s) affichée(s)</span>
</div>
</div>
<!-- Table of execution -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-100">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Service / Ligne</th>
<th class="px-6 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Type</th>
<th class="px-6 py-4 text-right text-xs font-bold text-gray-500 uppercase tracking-wider">Enveloppe</th>
<th class="px-6 py-4 text-right text-xs font-bold text-gray-500 uppercase tracking-wider">Engagé (Brouillon)</th>
<th class="px-6 py-4 text-right text-xs font-bold text-gray-500 uppercase tracking-wider">Consommé (Validé)</th>
<th class="px-6 py-4 text-right text-xs font-bold text-gray-500 uppercase tracking-wider">Reste à dépenser</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider w-40">Progression</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
<template v-for="lb in filteredLignes" :key="lb.id">
<tr :class="['transition-colors cursor-pointer', lb.reste < 0 ? 'bg-red-50 hover:bg-red-100' : 'hover:bg-gray-50']" @click="toggleLine(lb.id)">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<svg :class="['h-4 w-4 text-gray-400 transition-transform', expandedLines.includes(lb.id) ? 'rotate-180' : '']" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7 7" />
</svg>
<div>
<div class="flex items-center gap-2">
<span class="text-sm font-bold text-gray-900">{{ lb.nom }}</span>
<span v-if="lb.reste < 0" class="inline-flex items-center gap-1 px-1.5 py-0.5 bg-red-100 text-red-700 text-[10px] font-bold rounded uppercase">
Dépassement {{ formatCurrency(Math.abs(lb.reste)) }}
</span>
<span v-else-if="lb.montant_arbitre > 0 && calculatePercent(lb.total_cumule, lb.montant_arbitre) >= 80" class="inline-flex items-center gap-1 px-1.5 py-0.5 bg-orange-100 text-orange-700 text-[10px] font-bold rounded uppercase">
{{ calculatePercent(lb.total_cumule, lb.montant_arbitre) }}%
</span>
</div>
<div class="text-xs text-gray-500">{{ lb.service }} {{ lb.type_budget === 'agglo' ? 'Agglo' : 'Mutualisé' }}</div>
</div>
</div>
</td>
<td class="px-6 py-4 text-center">
<span :class="['inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider',
lb.type_depense === 'investissement' ? 'bg-purple-100 text-purple-700' : 'bg-teal-100 text-teal-700']">
{{ lb.type_depense }}
</span>
</td>
<td class="px-6 py-4 text-right text-sm font-medium text-gray-900">
{{ formatCurrency(lb.montant_arbitre) }}
</td>
<td class="px-6 py-4 text-right text-sm font-medium text-orange-600">
{{ formatCurrency(lb.engage) }}
</td>
<td class="px-6 py-4 text-right text-sm font-medium text-blue-600">
{{ formatCurrency(lb.consomme) }}
</td>
<td class="px-6 py-4 text-right text-sm font-bold" :class="lb.reste < 0 ? 'text-red-600' : 'text-green-600'">
{{ formatCurrency(lb.reste) }}
</td>
<td class="px-6 py-4">
<div class="flex items-center justify-end gap-3">
<span class="text-sm font-black text-gray-900 leading-none">{{ calculatePercent(lb.total_cumule, lb.montant_arbitre) }}%</span>
<div class="relative w-14 h-8 flex items-center justify-center">
<svg class="w-full h-full" viewBox="0 0 100 60">
<!-- Cadre -->
<path d="M 12 55 A 38 38 0 0 1 88 55" fill="#edf2f7" stroke="#2d3748" stroke-width="2" />
<!-- Secteurs -->
<path d="M 12 55 A 38 38 0 0 1 50 17 L 50 55 Z" fill="#48bb78" opacity="0.6" />
<path d="M 50 17 A 38 38 0 0 1 88 55 L 50 55 Z" fill="#f6ad55" opacity="0.6" />
<!-- Aiguille -->
<g :style="{ transform: `rotate(${(calculatePercent(lb.total_cumule, lb.montant_arbitre) * 1.8) - 90}deg)`, transformOrigin: '50px 55px' }" class="transition-transform duration-1000">
<line x1="50" y1="55" x2="50" y2="22" stroke="#e53e3e" stroke-width="4" stroke-linecap="round" />
<circle cx="50" cy="55" r="4" fill="#2d3748" />
</g>
</svg>
</div>
</div>
</td>
</tr>
<!-- Détail ventilation par commune et liste commandes -->
<tr v-if="expandedLines.includes(lb.id)" class="bg-gray-50/50">
<td colspan="7" class="px-12 py-6">
<div class="space-y-6">
<!-- Ventilation -->
<div class="bg-white rounded-lg border border-gray-100 shadow-sm p-4 max-w-4xl">
<h4 class="text-xs font-bold text-gray-500 uppercase mb-3 px-1">Répartition par commune</h4>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div v-for="v in lb.ventilation" :key="v.commune" class="flex flex-col border-l-2 border-indigo-200 pl-3">
<span class="text-[10px] text-gray-500 truncate" :title="v.commune">{{ v.commune }}</span>
<span class="text-sm font-bold text-gray-900">{{ formatCurrency(v.total) }}</span>
</div>
</div>
</div>
<!-- Liste des commandes -->
<div class="bg-white rounded-lg border border-gray-100 shadow-sm overflow-hidden">
<div class="px-4 py-3 bg-gray-50 border-b border-gray-100 flex justify-between items-center">
<h4 class="text-xs font-bold text-gray-500 uppercase">Détail des commandes liées</h4>
<span class="text-[10px] font-bold text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">{{ lb.commandes.length }} commande(s)</span>
</div>
<table class="min-w-full divide-y divide-gray-100">
<thead class="bg-gray-50/50">
<tr>
<th class="px-4 py-2 text-left text-[10px] font-bold text-gray-400 uppercase">Réf / Objet</th>
<th class="px-4 py-2 text-left text-[10px] font-bold text-gray-400 uppercase">Commune</th>
<th class="px-4 py-2 text-center text-[10px] font-bold text-gray-400 uppercase">Statut</th>
<th class="px-4 py-2 text-right text-[10px] font-bold text-gray-400 uppercase">Montant TTC</th>
<th class="px-4 py-2"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
<tr v-for="cmd in lb.commandes" :key="cmd.id" class="hover:bg-gray-50 transition-colors">
<td class="px-4 py-3">
<div class="text-xs font-bold text-indigo-600">{{ cmd.reference }}</div>
<div class="text-[10px] text-gray-500 truncate max-w-[200px]" :title="cmd.nom">{{ cmd.nom }}</div>
</td>
<td class="px-4 py-3 text-xs text-gray-600 italic">
{{ cmd.commune || '—' }}
</td>
<td class="px-4 py-3 text-center">
<span class="text-[9px] font-bold uppercase tracking-tight px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">
{{ cmd.statut.replace('_', ' ') }}
</span>
</td>
<td class="px-4 py-3 text-right text-xs font-bold text-gray-900">
{{ formatCurrency(cmd.montant_ttc) }}
</td>
<td class="px-4 py-3 text-right">
<Link :href="route('commandes.show', cmd.id)" class="text-indigo-500 hover:text-indigo-700">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
</Link>
</td>
</tr>
<tr v-if="!lb.commandes.length">
<td colspan="5" class="px-4 py-8 text-center text-xs text-gray-400 italic">Aucune commande liée.</td>
</tr>
</tbody>
</table>
</div>
</div>
</td>
</tr>
</template>
<tr v-if="!filteredLignes.length">
<td colspan="7" class="px-6 py-12 text-center text-gray-500 italic">
Aucune ligne budgétaire trouvée pour cette année.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>

View File

@@ -0,0 +1,350 @@
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import { Head, Link, useForm, router } from '@inertiajs/vue3'
import Modal from '@/Components/Modal.vue'
import InputLabel from '@/Components/InputLabel.vue'
import PrimaryButton from '@/Components/PrimaryButton.vue'
import SecondaryButton from '@/Components/SecondaryButton.vue'
import { ref, computed } from 'vue'
const props = defineProps({
budgets: Array,
services: Array,
communes: Array,
filters: Object
})
const yearFilter = ref(props.filters.annee || new Date().getFullYear())
function filterByYear() {
router.get(route('budgets.index'), { annee: yearFilter.value }, { preserveState: true })
}
const showCreateModal = ref(false)
const form = useForm({
service_id: '',
annee: new Date().getFullYear(),
type_budget: 'agglo',
})
function submitBudget() {
form.post(route('budgets.store'), {
onSuccess: () => {
showCreateModal.value = false
form.reset()
}
})
}
function formatCurrency(value) {
if (!value && value !== 0) return '—'
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }).format(value)
}
const STATUS_LABELS = {
preparation: 'En préparation',
arbitrage_dsi: 'Arbitrage DSI',
arbitrage_direction: 'Arbitrage Direction',
valide: 'Validé',
cloture: 'Clôturé',
}
const STATUS_CLASSES = {
preparation: 'bg-gray-100 text-gray-700',
arbitrage_dsi: 'bg-yellow-100 text-yellow-800',
arbitrage_direction: 'bg-orange-100 text-orange-800',
valide: 'bg-green-100 text-green-800',
cloture: 'bg-blue-100 text-blue-800',
}
// Filtres locaux
const filterService = ref('')
const filterType = ref('')
const filterStatut = ref('')
const sortKey = ref('service')
const sortDir = ref('asc')
function toggleSort(key) {
if (sortKey.value === key) {
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
} else {
sortKey.value = key
sortDir.value = 'asc'
}
}
const filtered = computed(() => {
let list = props.budgets ?? []
if (filterService.value)
list = list.filter(b => b.service === filterService.value)
if (filterType.value)
list = list.filter(b => b.type_budget === filterType.value)
if (filterStatut.value)
list = list.filter(b => b.statut === filterStatut.value)
const dir = sortDir.value === 'asc' ? 1 : -1
list = [...list].sort((a, b) => {
let va, vb
if (sortKey.value === 'service') { va = a.service; vb = b.service }
else if (sortKey.value === 'type') { va = a.type_budget; vb = b.type_budget }
else if (sortKey.value === 'statut') { va = a.statut; vb = b.statut }
else if (sortKey.value === 'invest') { va = a.total_invest_arbitre ?? 0; vb = b.total_invest_arbitre ?? 0 }
else if (sortKey.value === 'fonct') { va = a.total_fonct_arbitre ?? 0; vb = b.total_fonct_arbitre ?? 0 }
else if (sortKey.value === 'total') {
va = (a.total_invest_arbitre ?? 0) + (a.total_fonct_arbitre ?? 0)
vb = (b.total_invest_arbitre ?? 0) + (b.total_fonct_arbitre ?? 0)
}
if (typeof va === 'string') return va.localeCompare(vb) * dir
return (va - vb) * dir
})
return list
})
const uniqueServices = computed(() => [...new Set((props.budgets ?? []).map(b => b.service))].sort())
const totalInvest = computed(() => filtered.value.reduce((s, b) => s + (b.total_invest_arbitre ?? 0), 0))
const totalFonct = computed(() => filtered.value.reduce((s, b) => s + (b.total_fonct_arbitre ?? 0), 0))
const totalGlobal = computed(() => totalInvest.value + totalFonct.value)
function sortIcon(key) {
if (sortKey.value !== key) return '↕'
return sortDir.value === 'asc' ? '↑' : '↓'
}
</script>
<template>
<Head title="Suivi Budgétaire" />
<AuthenticatedLayout>
<template #header>
<div class="flex justify-between items-center w-full">
<h1 class="text-xl font-bold text-gray-900 flex items-center gap-2">
<svg class="h-5 w-5 text-indigo-500" 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>
Suivi Budgétaire & Arbitrages
</h1>
<div class="flex gap-3 items-center">
<div class="flex items-center gap-2">
<label class="text-sm font-medium text-gray-600">Année</label>
<select v-model="yearFilter" @change="filterByYear"
class="rounded-md border-gray-300 shadow-sm text-sm py-1.5 focus:border-indigo-500 focus:ring-indigo-500">
<option v-for="y in [2024, 2025, 2026, 2027]" :key="y" :value="y">{{ y }}</option>
</select>
</div>
<button
v-if="$page.props.auth.user.roles.some(r => ['admin', 'directeur'].includes(r.name))"
@click="showCreateModal = true"
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 transition-colors">
+ Ouvrir une enveloppe
</button>
</div>
</div>
</template>
<div class="space-y-4">
<!-- Barre de filtres -->
<div class="flex flex-wrap gap-3 items-center bg-white rounded-xl border border-gray-100 shadow-sm px-4 py-3">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-400 mr-1">Filtrer</span>
<select v-model="filterService"
class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none">
<option value="">Tous les services</option>
<option v-for="s in uniqueServices" :key="s" :value="s">{{ s }}</option>
</select>
<select v-model="filterType"
class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none">
<option value="">Toutes les enveloppes</option>
<option value="agglo">Agglo</option>
<option value="mutualise">Mutualisé</option>
</select>
<select v-model="filterStatut"
class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none">
<option value="">Tous les statuts</option>
<option v-for="(label, key) in STATUS_LABELS" :key="key" :value="key">{{ label }}</option>
</select>
<button v-if="filterService || filterType || filterStatut"
@click="filterService = ''; filterType = ''; filterStatut = ''"
class="text-xs text-gray-400 hover:text-gray-700 underline">
Réinitialiser
</button>
<span class="ml-auto text-xs text-gray-400">{{ filtered.length }} enveloppe{{ filtered.length > 1 ? 's' : '' }}</span>
</div>
<!-- Tableau -->
<div class="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
<table class="min-w-full divide-y divide-gray-100 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left">
<button @click="toggleSort('service')" class="flex items-center gap-1 font-semibold text-xs uppercase tracking-wide text-gray-500 hover:text-gray-900">
Service <span class="text-gray-300">{{ sortIcon('service') }}</span>
</button>
</th>
<th class="px-4 py-3 text-left">
<button @click="toggleSort('type')" class="flex items-center gap-1 font-semibold text-xs uppercase tracking-wide text-gray-500 hover:text-gray-900">
Enveloppe <span class="text-gray-300">{{ sortIcon('type') }}</span>
</button>
</th>
<th class="px-4 py-3 text-left">
<button @click="toggleSort('statut')" class="flex items-center gap-1 font-semibold text-xs uppercase tracking-wide text-gray-500 hover:text-gray-900">
Statut <span class="text-gray-300">{{ sortIcon('statut') }}</span>
</button>
</th>
<th class="px-4 py-3 text-right">
<button @click="toggleSort('invest')" class="flex items-center gap-1 font-semibold text-xs uppercase tracking-wide text-gray-500 hover:text-gray-900 ml-auto">
Investissement (arbitré) <span class="text-gray-300">{{ sortIcon('invest') }}</span>
</button>
</th>
<th class="px-4 py-3 text-right">
<button @click="toggleSort('fonct')" class="flex items-center gap-1 font-semibold text-xs uppercase tracking-wide text-gray-500 hover:text-gray-900 ml-auto">
Fonctionnement (arbitré) <span class="text-gray-300">{{ sortIcon('fonct') }}</span>
</button>
</th>
<th class="px-4 py-3 text-right">
<button @click="toggleSort('total')" class="flex items-center gap-1 font-semibold text-xs uppercase tracking-wide text-gray-500 hover:text-gray-900 ml-auto">
Total <span class="text-gray-300">{{ sortIcon('total') }}</span>
</button>
</th>
<th class="px-4 py-3 w-10"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
<tr v-for="budget in filtered" :key="budget.id"
class="hover:bg-indigo-50/30 transition-colors group">
<td class="px-4 py-3 font-medium text-gray-900">
{{ budget.service }}
</td>
<td class="px-4 py-3">
<span :class="[
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium',
budget.type_budget === 'agglo'
? 'bg-blue-50 text-blue-700 border border-blue-100'
: 'bg-purple-50 text-purple-700 border border-purple-100'
]">
{{ budget.type_budget === 'agglo' ? 'Agglo' : 'Mutualisé' }}
<span v-if="budget.commune" class="ml-1 text-gray-400">· {{ budget.commune }}</span>
</span>
</td>
<td class="px-4 py-3">
<span :class="['inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium', STATUS_CLASSES[budget.statut]]">
{{ STATUS_LABELS[budget.statut] ?? budget.statut }}
</span>
</td>
<td class="px-4 py-3 text-right tabular-nums">
<span v-if="budget.total_invest_propose > 0" class="text-gray-900 font-medium">
{{ formatCurrency(budget.total_invest_arbitre) }}
</span>
<span v-else class="text-gray-300"></span>
<div v-if="budget.total_invest_propose > 0 && budget.total_invest_propose !== budget.total_invest_arbitre"
class="text-xs text-gray-400">
proposé : {{ formatCurrency(budget.total_invest_propose) }}
</div>
</td>
<td class="px-4 py-3 text-right tabular-nums">
<span v-if="budget.total_fonct_propose > 0" class="text-gray-900 font-medium">
{{ formatCurrency(budget.total_fonct_arbitre) }}
</span>
<span v-else class="text-gray-300"></span>
<div v-if="budget.total_fonct_propose > 0 && budget.total_fonct_propose !== budget.total_fonct_arbitre"
class="text-xs text-gray-400">
proposé : {{ formatCurrency(budget.total_fonct_propose) }}
</div>
</td>
<td class="px-4 py-3 text-right tabular-nums font-semibold text-indigo-700">
{{ formatCurrency((budget.total_invest_arbitre ?? 0) + (budget.total_fonct_arbitre ?? 0)) }}
</td>
<td class="px-4 py-3 text-right">
<Link :href="route('budgets.show', budget.id)"
class="inline-flex items-center gap-1 text-xs font-medium text-indigo-600 hover:text-indigo-800 opacity-0 group-hover:opacity-100 transition-opacity bg-indigo-50 hover:bg-indigo-100 px-2.5 py-1 rounded-md">
Détails
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</Link>
</td>
</tr>
<tr v-if="filtered.length === 0">
<td colspan="7" class="px-4 py-12 text-center text-sm text-gray-400">
Aucun budget ne correspond aux filtres sélectionnés.
</td>
</tr>
</tbody>
<!-- Ligne totaux -->
<tfoot v-if="filtered.length > 1" class="border-t-2 border-gray-200 bg-gray-50">
<tr>
<td colspan="3" class="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
Total ({{ filtered.length }} enveloppes)
</td>
<td class="px-4 py-3 text-right tabular-nums font-semibold text-gray-800">
{{ formatCurrency(totalInvest) }}
</td>
<td class="px-4 py-3 text-right tabular-nums font-semibold text-gray-800">
{{ formatCurrency(totalFonct) }}
</td>
<td class="px-4 py-3 text-right tabular-nums font-bold text-indigo-700">
{{ formatCurrency(totalGlobal) }}
</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</div>
<!-- Modal Création d'enveloppe -->
<Modal :show="showCreateModal" @close="showCreateModal = false">
<div class="p-6">
<h2 class="text-lg font-bold text-gray-900 mb-6">Ouvrir une enveloppe budgétaire</h2>
<form @submit.prevent="submitBudget" class="space-y-4">
<div>
<InputLabel value="Service" />
<select v-model="form.service_id"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
required>
<option value="">Sélectionner un service</option>
<option v-for="s in services" :key="s.id" :value="s.id">{{ s.nom }}</option>
</select>
<p v-if="form.errors.service_id" class="mt-1 text-xs text-red-600">{{ form.errors.service_id }}</p>
</div>
<div>
<InputLabel value="Année visée" />
<input v-model.number="form.annee" type="number" min="2020" max="2035"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
required />
</div>
<div>
<InputLabel value="Type de budget" />
<select v-model="form.type_budget"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
required>
<option value="agglo">Budget Agglomération (Interne)</option>
<option value="mutualise">Budget Mutualisation (Communes)</option>
</select>
<p v-if="form.errors.type_budget" class="mt-1 text-xs text-red-600">{{ form.errors.type_budget }}</p>
</div>
<div class="mt-6 flex justify-end gap-3">
<SecondaryButton @click="showCreateModal = false">Annuler</SecondaryButton>
<PrimaryButton :class="{ 'opacity-25': form.processing }" :disabled="form.processing">Créer</PrimaryButton>
</div>
</form>
</div>
</Modal>
</AuthenticatedLayout>
</template>

View File

@@ -0,0 +1,416 @@
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import { Head, Link, useForm, router } from '@inertiajs/vue3'
import Modal from '@/Components/Modal.vue'
import TextInput from '@/Components/TextInput.vue'
import InputLabel from '@/Components/InputLabel.vue'
import PrimaryButton from '@/Components/PrimaryButton.vue'
import SecondaryButton from '@/Components/SecondaryButton.vue'
import { ref, computed } from 'vue'
const props = defineProps({
budget: Object,
lignes: Array,
totals: Object,
communes: Array
})
function formatCurrency(value) {
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value)
}
const investissements = computed(() => props.lignes.filter(l => l.type_depense === 'investissement'))
const fonctionnements = computed(() => props.lignes.filter(l => l.type_depense === 'fonctionnement'))
const showAddLigneModal = ref(false)
const ligneForm = useForm({
id: null,
budget_id: props.budget.id,
commune_id: '',
type_depense: 'investissement',
nom: '',
description: '',
montant_propose: 0
})
const isLocked = computed(() => ['arbitrage_direction', 'valide', 'cloture'].includes(props.budget.statut))
function submitLigne() {
if (ligneForm.id) {
ligneForm.put(route('lignes-budget.update', ligneForm.id), {
onSuccess: () => {
showAddLigneModal.value = false
ligneForm.reset()
}
})
} else {
ligneForm.post(route('lignes-budget.store'), {
onSuccess: () => {
showAddLigneModal.value = false
ligneForm.reset('nom', 'description', 'montant_propose')
}
})
}
}
function openEditLigne(ligne) {
ligneForm.id = ligne.id
ligneForm.budget_id = props.budget.id
ligneForm.commune_id = ligne.commune_id || ''
ligneForm.type_depense = ligne.type_depense
ligneForm.nom = ligne.nom
ligneForm.description = ligne.description
ligneForm.montant_propose = ligne.montant_propose
showAddLigneModal.value = true
}
function openAddLigne() {
ligneForm.reset()
ligneForm.id = null
ligneForm.budget_id = props.budget.id
showAddLigneModal.value = true
}
// Historique
const showHistoriqueModal = ref(false)
const selectedLigneHistorique = ref(null)
function openHistorique(ligne) {
selectedLigneHistorique.value = ligne
showHistoriqueModal.value = true
}
const statusLabelMap = {
'brouillon': 'Brouillon', 'propose': 'Proposé', 'accepte_dsi': 'Accepté DSI',
'accepte_direction': 'Accepté Direction', 'valide_definitif': 'Validé Définitif',
'refuse': 'Refusé', 'reporte': 'Reporté',
}
function labelStatut(s) { return statusLabelMap[s] ?? s }
// Arbitrage
const showArbitrageModal = ref(false)
const selectedLigne = ref(null)
const arbitrageForm = useForm({
statut_arbitrage: '',
montant_arbitre: '',
commentaire: '',
})
function openArbitrage(ligne) {
selectedLigne.value = ligne
arbitrageForm.statut_arbitrage = ligne.statut_arbitrage
arbitrageForm.montant_arbitre = ligne.montant_arbitre || ligne.montant_propose
showArbitrageModal.value = true
}
function submitArbitrage() {
arbitrageForm.patch(route('lignes-budget.arbitrer', selectedLigne.value.id), {
onSuccess: () => showArbitrageModal.value = false
})
}
const statusOptions = [
{ value: 'propose', label: 'Proposé' },
{ value: 'accepte_dsi', label: 'Accepté (Pré-arbitrage DSI)' },
{ value: 'accepte_direction', label: 'Accepté (Pré-arbitrage Direction)' },
{ value: 'valide_definitif', label: 'Validé Définitif' },
{ value: 'refuse', label: 'Refusé' },
{ value: 'reporte', label: 'Reporté à A+1' }
]
const statusBadgeConfig = {
'brouillon': { class: 'bg-gray-100 text-gray-800', label: 'Brouillon' },
'propose': { class: 'bg-blue-100 text-blue-800', label: 'Proposé' },
'accepte_dsi': { class: 'bg-indigo-100 text-indigo-800 border border-indigo-200', label: 'Accepté (Pré-arbitrage DSI)' },
'accepte_direction': { class: 'bg-purple-100 text-purple-800 border border-purple-200', label: 'Accepté (Pré-arbitrage Direction)' },
'valide_definitif': { class: 'bg-green-100 text-green-800', label: 'Validé Définitif' },
'refuse': { class: 'bg-red-100 text-red-800', label: 'Refusé' },
'reporte': { class: 'bg-orange-100 text-orange-800', label: 'Reporté à A+1' },
}
function getStatusClass(status) {
return statusBadgeConfig[status]?.class ?? statusBadgeConfig['brouillon'].class
}
function getStatusLabel(status) {
return statusBadgeConfig[status]?.label ?? status
}
function updateBudgetStatus(newStatus) {
router.put(route('budgets.update', props.budget.id), { statut: newStatus })
}
</script>
<template>
<Head :title="`Budget ${budget.annee} - ${budget.service.nom}`" />
<AuthenticatedLayout>
<template #header>
<div class="flex items-center gap-3">
<Link :href="route('budgets.index')" class="text-gray-400 hover:text-gray-600">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>
</Link>
<div>
<h1 class="text-xl font-bold text-gray-900">
{{ budget.service.nom }} Budget {{ budget.annee }}
</h1>
<p class="text-sm text-gray-500 flex items-center gap-2 mt-1">
<span class="uppercase tracking-wider font-semibold text-xs text-indigo-600">{{ budget.type_budget === 'agglo' ? 'Agglo' : 'Mutualisé' }}</span>
Statut :
<select v-if="$page.props.auth.user.roles.some(r => ['admin', 'directeur'].includes(r.name))"
:value="budget.statut" @change="updateBudgetStatus($event.target.value)"
class="text-xs rounded border-gray-300 py-0 pl-2 pr-6 h-6">
<option value="preparation">En préparation</option>
<option value="arbitrage_dsi">Arbitrage DSI</option>
<option value="arbitrage_direction">Arbitrage Direction</option>
<option value="valide">Validé</option>
<option value="cloture">Clôturé</option>
</select>
<span v-else class="font-medium text-gray-700 capitalize">{{ budget.statut.replace('_', ' ') }}</span>
</p>
</div>
</div>
<div class="ml-auto flex gap-3">
<button v-if="!isLocked" @click="openAddLigne" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500">
+ Ajouter une demande
</button>
</div>
</template>
<div class="space-y-8 pb-12">
<!-- Tableaux -->
<div class="grid xl:grid-cols-2 gap-8">
<!-- Investissement -->
<div>
<div class="flex justify-between items-end mb-4">
<h2 class="text-lg font-bold text-gray-900 border-b-2 border-indigo-500 pb-1 inline-block">Investissement</h2>
<div class="text-right">
<p class="text-sm text-gray-500 font-medium">Proposé : {{ formatCurrency(totals.invest_propose) }}</p>
<p class="text-base text-indigo-700 font-bold">Arbitré : {{ formatCurrency(totals.invest_arbitre) }}</p>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Projet / Achat</th>
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-500 uppercase">Proposé</th>
<th class="px-4 py-3 text-right text-xs font-semibold text-indigo-600 uppercase">Arbitré</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Statut</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-for="ligne in investissements" :key="ligne.id" class="hover:bg-gray-50 group">
<td class="px-4 py-4 text-sm text-gray-900">
<p class="font-medium">{{ ligne.nom }}</p>
<div class="flex items-center gap-2 mt-0.5">
<span v-if="ligne.commune_id" class="text-[10px] bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded border border-blue-100 font-bold uppercase">
{{ communes.find(c => c.id === ligne.commune_id)?.nom }}
</span>
<p class="text-xs text-gray-500 truncate max-w-[150px]" :title="ligne.description">{{ ligne.description }}</p>
</div>
</td>
<td class="px-4 py-4 text-sm text-right text-gray-500 whitespace-nowrap">{{ formatCurrency(ligne.montant_propose) }}</td>
<td class="px-4 py-4 text-sm text-right font-bold text-gray-900 whitespace-nowrap">
{{ ligne.montant_arbitre ? formatCurrency(ligne.montant_arbitre) : '-' }}
</td>
<td class="px-4 py-4 text-sm">
<span class="px-2 py-1 text-xs rounded-md font-medium" :class="getStatusClass(ligne.statut_arbitrage)">
{{ getStatusLabel(ligne.statut_arbitrage) }}
</span>
</td>
<td class="px-4 py-4 text-right whitespace-nowrap">
<div class="flex items-center justify-end gap-2">
<button v-if="ligne.historique?.length" @click="openHistorique(ligne)" class="text-gray-400 hover:text-gray-700 text-xs font-medium" :title="`${ligne.historique.length} arbitrage(s)`">
📋 {{ ligne.historique.length }}
</button>
<button v-if="!isLocked" @click="openEditLigne(ligne)" class="text-gray-600 hover:text-gray-900 text-xs font-medium">Éditer</button>
<button v-if="$page.props.auth.user.roles.some(r => ['admin', 'directeur', 'raf'].includes(r.name))" @click="openArbitrage(ligne)" class="text-indigo-600 hover:text-indigo-900 text-xs font-medium">Arbitrer</button>
</div>
</td>
</tr>
<tr v-if="investissements.length === 0">
<td colspan="5" class="px-4 py-8 text-center text-sm text-gray-500">Aucun projet d'investissement.</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Fonctionnement -->
<div>
<div class="flex justify-between items-end mb-4">
<h2 class="text-lg font-bold text-gray-900 border-b-2 border-emerald-500 pb-1 inline-block">Fonctionnement</h2>
<div class="text-right">
<p class="text-sm text-gray-500 font-medium">Proposé : {{ formatCurrency(totals.fonct_propose) }}</p>
<p class="text-base text-emerald-700 font-bold">Arbitré : {{ formatCurrency(totals.fonct_arbitre) }}</p>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Achat / Suivi</th>
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-500 uppercase">Proposé</th>
<th class="px-4 py-3 text-right text-xs font-semibold text-emerald-600 uppercase">Arbitré</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Statut</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-for="ligne in fonctionnements" :key="ligne.id" class="hover:bg-gray-50 group">
<td class="px-4 py-4 text-sm text-gray-900">
<p class="font-medium">{{ ligne.nom }}</p>
<div class="flex items-center gap-3 mt-0.5">
<span v-if="ligne.commune_id" class="text-[10px] bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded border border-blue-100 font-bold uppercase whitespace-nowrap">
{{ communes.find(c => c.id === ligne.commune_id)?.nom }}
</span>
<p class="text-xs text-gray-500 truncate max-w-[150px]" :title="ligne.description">{{ ligne.description }}</p>
</div>
</td>
<td class="px-4 py-4 text-sm text-right text-gray-500 whitespace-nowrap">{{ formatCurrency(ligne.montant_propose) }}</td>
<td class="px-4 py-4 text-sm text-right font-bold text-gray-900 whitespace-nowrap">
{{ ligne.montant_arbitre ? formatCurrency(ligne.montant_arbitre) : '-' }}
</td>
<td class="px-4 py-4 text-sm">
<span class="px-2 py-1 text-xs rounded-md font-medium" :class="getStatusClass(ligne.statut_arbitrage)">
{{ getStatusLabel(ligne.statut_arbitrage) }}
</span>
</td>
<td class="px-4 py-4 text-right whitespace-nowrap">
<div class="flex items-center justify-end gap-2">
<button v-if="ligne.historique?.length" @click="openHistorique(ligne)" class="text-gray-400 hover:text-gray-700 text-xs font-medium" :title="`${ligne.historique.length} arbitrage(s)`">
📋 {{ ligne.historique.length }}
</button>
<button v-if="!isLocked" @click="openEditLigne(ligne)" class="text-gray-600 hover:text-gray-900 text-xs font-medium">Éditer</button>
<button v-if="$page.props.auth.user.roles.some(r => ['admin', 'directeur', 'raf'].includes(r.name))" @click="openArbitrage(ligne)" class="text-indigo-600 hover:text-indigo-900 text-xs font-medium">Arbitrer</button>
</div>
</td>
</tr>
<tr v-if="fonctionnements.length === 0">
<td colspan="5" class="px-4 py-8 text-center text-sm text-gray-500">Aucun achat de fonctionnement.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Modal Ajout Ligne -->
<Modal :show="showAddLigneModal" @close="showAddLigneModal = false">
<div class="p-6">
<h2 class="text-lg font-bold text-gray-900 mb-6">{{ ligneForm.id ? 'Modifier la demande' : 'Ajouter une demande budgétaire' }}</h2>
<form @submit.prevent="submitLigne" class="space-y-4">
<div>
<InputLabel value="Type de dépense" />
<select v-model="ligneForm.type_depense" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" required>
<option value="investissement">Investissement (Projets, Nouveaux matériels...)</option>
<option value="fonctionnement">Fonctionnement (Licences, Maintenance...)</option>
</select>
</div>
<div v-if="budget.type_budget === 'mutualise'">
<InputLabel value="Commune bénéficiaire" />
<select v-model="ligneForm.commune_id" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" required>
<option value="">Sélectionner une ville</option>
<option v-for="c in communes" :key="c.id" :value="c.id">{{ c.nom }}</option>
</select>
</div>
<div>
<InputLabel value="Nom de l'achat ou du projet" />
<TextInput v-model="ligneForm.nom" class="mt-1 block w-full" required />
</div>
<div>
<InputLabel value="Description (optionnel)" />
<textarea v-model="ligneForm.description" rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"></textarea>
</div>
<div>
<InputLabel value="Montant estimé / proposé ()" />
<input v-model.number="ligneForm.montant_propose" type="number" step="0.01" min="0"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
required />
</div>
<div class="mt-6 flex justify-end gap-3">
<SecondaryButton @click="showAddLigneModal = false">Annuler</SecondaryButton>
<PrimaryButton :class="{ 'opacity-25': ligneForm.processing }" :disabled="ligneForm.processing">
{{ ligneForm.id ? 'Mettre à jour' : 'Ajouter' }}
</PrimaryButton>
</div>
</form>
</div>
</Modal>
<!-- Modal Arbitrage -->
<Modal :show="showArbitrageModal" @close="showArbitrageModal = false" max-width="md">
<div class="p-6" v-if="selectedLigne">
<h2 class="text-lg font-bold text-gray-900 mb-2">Arbitrer la ligne</h2>
<p class="text-sm font-medium mb-1">{{ selectedLigne.nom }}</p>
<div class="bg-gray-50 p-3 rounded-lg mb-6 border border-gray-200">
<p class="text-xs text-gray-500">Montant initialement proposé :</p>
<p class="text-lg font-bold text-gray-900">{{ formatCurrency(selectedLigne.montant_propose) }}</p>
</div>
<form @submit.prevent="submitArbitrage" class="space-y-4">
<div>
<InputLabel value="Décision / Statut" />
<select v-model="arbitrageForm.statut_arbitrage" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" required>
<option v-for="opt in statusOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<div>
<InputLabel value="Montant accordé (arbitré) en " />
<input v-model.number="arbitrageForm.montant_arbitre" type="number" step="0.01" min="0"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-lg font-bold text-indigo-700" />
<p class="text-xs text-gray-500 mt-1">Le montant ne peut pas dépasser le montant proposé.</p>
</div>
<div>
<InputLabel value="Commentaire (optionnel)" />
<textarea v-model="arbitrageForm.commentaire" rows="2" placeholder="Motif de la décision..." class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"></textarea>
</div>
<div class="mt-6 flex justify-end gap-3">
<SecondaryButton @click="showArbitrageModal = false">Annuler</SecondaryButton>
<PrimaryButton :class="{ 'opacity-25': arbitrageForm.processing }" :disabled="arbitrageForm.processing">Enregistrer l'arbitrage</PrimaryButton>
</div>
</form>
</div>
</Modal>
<!-- Modal Historique Arbitrages -->
<Modal :show="showHistoriqueModal" @close="showHistoriqueModal = false" max-width="lg">
<div class="p-6" v-if="selectedLigneHistorique">
<h2 class="text-lg font-bold text-gray-900 mb-1">Historique des arbitrages</h2>
<p class="text-sm text-gray-500 mb-5">{{ selectedLigneHistorique.nom }}</p>
<div v-if="selectedLigneHistorique.historique?.length" class="space-y-3">
<div v-for="h in selectedLigneHistorique.historique" :key="h.id"
class="bg-gray-50 border border-gray-200 rounded-lg p-4 text-sm">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900">{{ h.user }}</span>
<span class="text-gray-400">·</span>
<span class="text-gray-500 text-xs">{{ h.created_at }}</span>
</div>
<div class="flex items-center gap-1 text-xs">
<span class="text-gray-500">{{ labelStatut(h.ancien_statut) }}</span>
<span class="text-gray-400">→</span>
<span class="font-semibold text-indigo-700">{{ labelStatut(h.nouveau_statut) }}</span>
</div>
</div>
<div v-if="h.ancien_montant_arbitre !== h.nouveau_montant_arbitre" class="text-xs text-gray-600 mb-1">
Montant : {{ formatCurrency(h.ancien_montant_arbitre ?? 0) }} → <span class="font-semibold">{{ formatCurrency(h.nouveau_montant_arbitre ?? 0) }}</span>
</div>
<p v-if="h.commentaire" class="text-xs text-gray-700 italic mt-1 border-l-2 border-indigo-300 pl-2">{{ h.commentaire }}</p>
</div>
</div>
<p v-else class="text-sm text-gray-500 text-center py-4">Aucun arbitrage enregistré.</p>
<div class="mt-5 flex justify-end">
<SecondaryButton @click="showHistoriqueModal = false">Fermer</SecondaryButton>
</div>
</div>
</Modal>
</AuthenticatedLayout>
</template>

View File

@@ -1,6 +1,8 @@
<script setup> <script setup>
import { computed, watch } from 'vue'
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue' import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import LignesCommandeForm from '@/Components/Commandes/LignesCommandeForm.vue' import LignesCommandeForm from '@/Components/Commandes/LignesCommandeForm.vue'
import SearchableSelect from '@/Components/SearchableSelect.vue'
import { Head, Link, useForm } from '@inertiajs/vue3' import { Head, Link, useForm } from '@inertiajs/vue3'
const props = defineProps({ const props = defineProps({
@@ -9,6 +11,7 @@ const props = defineProps({
communes: Array, communes: Array,
categories: Array, categories: Array,
articles: Array, articles: Array,
lignesBudget: Array,
}) })
const form = useForm({ const form = useForm({
@@ -21,6 +24,7 @@ const form = useForm({
priorite: 'normale', priorite: 'normale',
reference_fournisseur: '', reference_fournisseur: '',
imputation_budgetaire: '', imputation_budgetaire: '',
ligne_budget_id: '',
date_demande: new Date().toISOString().slice(0, 10), date_demande: new Date().toISOString().slice(0, 10),
date_souhaitee: '', date_souhaitee: '',
date_livraison_prevue: '', date_livraison_prevue: '',
@@ -29,6 +33,49 @@ const form = useForm({
lignes: [], lignes: [],
}) })
const ligneBudgetOptions = computed(() => {
const targetAnnee = form.date_demande
? new Date(form.date_demande).getFullYear()
: new Date().getFullYear()
const targetTypeBudget = (!form.commune_id || String(form.commune_id) === '1')
? 'agglo'
: 'mutualise'
return props.lignesBudget?.filter(lb => {
// Toujours conserver la ligne actuellement sélectionnée pour l'affichage
if (form.ligne_budget_id && lb.id === form.ligne_budget_id) return true
if (form.service_id && lb.budget?.service_id !== form.service_id) return false
if (lb.budget?.annee !== targetAnnee) return false
if (lb.budget?.type_budget !== targetTypeBudget) return false
return true
}).map(lb => ({
value: lb.id,
label: `[${lb.budget?.annee} - ${lb.budget?.service?.nom}] ${lb.nom} (Dispo: ${new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(lb.montant_disponible)})`
})) || []
})
watch([() => form.service_id, () => form.commune_id, () => form.date_demande], ([newService, newCommune, newDate]) => {
if (form.ligne_budget_id) {
const selectedLb = props.lignesBudget?.find(lb => lb.id === form.ligne_budget_id)
if (selectedLb) {
const targetAnnee = newDate ? new Date(newDate).getFullYear() : new Date().getFullYear()
const targetTypeBudget = (!newCommune || String(newCommune) === '1') ? 'agglo' : 'mutualise'
let isValid = true
if (newService && selectedLb.budget?.service_id !== newService) isValid = false
if (selectedLb.budget?.annee !== targetAnnee) isValid = false
if (selectedLb.budget?.type_budget !== targetTypeBudget) isValid = false
if (!isValid) {
form.ligne_budget_id = ''
}
}
}
})
function submit() { function submit() {
form.post(route('commandes.store')) form.post(route('commandes.store'))
} }
@@ -117,11 +164,22 @@ function submit() {
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700">Imputation budgétaire</label> <label class="block text-sm font-medium text-gray-700">Imputation budgétaire (texte)</label>
<input v-model="form.imputation_budgetaire" type="text" <input v-model="form.imputation_budgetaire" type="text"
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none" /> class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none" />
</div> </div>
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700">Ligne Budgétaire (Module Budget)</label>
<SearchableSelect
v-model="form.ligne_budget_id"
:options="ligneBudgetOptions"
placeholder="Rechercher un projet / enveloppe..."
:class="form.errors.ligne_budget_id ? 'ring-1 ring-red-500 rounded-lg' : ''"
/>
<p v-if="form.errors.ligne_budget_id" class="mt-1 text-xs text-red-600">{{ form.errors.ligne_budget_id }}</p>
</div>
<div class="sm:col-span-2"> <div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700">Description</label> <label class="block text-sm font-medium text-gray-700">Description</label>
<textarea v-model="form.description" rows="2" <textarea v-model="form.description" rows="2"

View File

@@ -1,6 +1,8 @@
<script setup> <script setup>
import { computed, watch } from 'vue'
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue' import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import LignesCommandeForm from '@/Components/Commandes/LignesCommandeForm.vue' import LignesCommandeForm from '@/Components/Commandes/LignesCommandeForm.vue'
import SearchableSelect from '@/Components/SearchableSelect.vue'
import StatutBadge from '@/Components/Commandes/StatutBadge.vue' import StatutBadge from '@/Components/Commandes/StatutBadge.vue'
import { Head, Link, useForm } from '@inertiajs/vue3' import { Head, Link, useForm } from '@inertiajs/vue3'
@@ -11,6 +13,7 @@ const props = defineProps({
communes: Array, communes: Array,
categories: Array, categories: Array,
articles: Array, articles: Array,
lignesBudget: Array,
}) })
const form = useForm({ const form = useForm({
@@ -20,10 +23,11 @@ const form = useForm({
objet: props.commande.objet, objet: props.commande.objet,
description: props.commande.description ?? '', description: props.commande.description ?? '',
justification: props.commande.justification ?? '', justification: props.commande.justification ?? '',
priorite: props.commande.priorite, priorite: props.commande.priorite || 'normale',
reference_fournisseur: props.commande.reference_fournisseur ?? '', reference_fournisseur: props.commande.reference_fournisseur || '',
imputation_budgetaire: props.commande.imputation_budgetaire ?? '', imputation_budgetaire: props.commande.imputation_budgetaire || '',
date_demande: props.commande.date_demande, ligne_budget_id: props.commande.ligne_budget_id || '',
date_demande: props.commande.date_demande ? props.commande.date_demande.substring(0, 10) : '',
date_souhaitee: props.commande.date_souhaitee ?? '', date_souhaitee: props.commande.date_souhaitee ?? '',
date_livraison_prevue: props.commande.date_livraison_prevue ?? '', date_livraison_prevue: props.commande.date_livraison_prevue ?? '',
notes: props.commande.notes ?? '', notes: props.commande.notes ?? '',
@@ -31,6 +35,49 @@ const form = useForm({
lignes: props.commande.lignes ?? [], lignes: props.commande.lignes ?? [],
}) })
const ligneBudgetOptions = computed(() => {
const targetAnnee = form.date_demande
? new Date(form.date_demande).getFullYear()
: new Date().getFullYear()
const targetTypeBudget = (!form.commune_id || String(form.commune_id) === '1')
? 'agglo'
: 'mutualise'
return props.lignesBudget?.filter(lb => {
// Toujours conserver la ligne actuellement sélectionnée pour l'affichage
if (form.ligne_budget_id && lb.id === form.ligne_budget_id) return true
if (form.service_id && lb.budget?.service_id !== form.service_id) return false
if (lb.budget?.annee !== targetAnnee) return false
if (lb.budget?.type_budget !== targetTypeBudget) return false
return true
}).map(lb => ({
value: lb.id,
label: `[${lb.budget?.annee} - ${lb.budget?.service?.nom}] ${lb.nom} (Dispo: ${new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(lb.montant_disponible)})`
})) || []
})
watch([() => form.service_id, () => form.commune_id, () => form.date_demande], ([newService, newCommune, newDate]) => {
if (form.ligne_budget_id) {
const selectedLb = props.lignesBudget?.find(lb => lb.id === form.ligne_budget_id)
if (selectedLb) {
const targetAnnee = newDate ? new Date(newDate).getFullYear() : new Date().getFullYear()
const targetTypeBudget = (!newCommune || String(newCommune) === '1') ? 'agglo' : 'mutualise'
let isValid = true
if (newService && selectedLb.budget?.service_id !== newService) isValid = false
if (selectedLb.budget?.annee !== targetAnnee) isValid = false
if (selectedLb.budget?.type_budget !== targetTypeBudget) isValid = false
if (!isValid) {
form.ligne_budget_id = ''
}
}
}
})
function submit() { function submit() {
form.put(route('commandes.update', props.commande.id)) form.put(route('commandes.update', props.commande.id))
} }
@@ -111,10 +158,21 @@ const showReceived = ['commandee','partiellement_recue','recue_complete'].includ
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none" /> class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none" />
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700">Imputation budgétaire</label> <label class="block text-sm font-medium text-gray-700">Imputation budgétaire (texte)</label>
<input v-model="form.imputation_budgetaire" type="text" <input v-model="form.imputation_budgetaire" type="text"
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none" /> class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none" />
</div> </div>
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700">Ligne Budgétaire (Module Budget)</label>
<SearchableSelect
v-model="form.ligne_budget_id"
:options="ligneBudgetOptions"
placeholder="Rechercher un projet / enveloppe..."
:class="form.errors.ligne_budget_id ? 'ring-1 ring-red-500 rounded-lg' : ''"
/>
<p v-if="form.errors.ligne_budget_id" class="mt-1 text-xs text-red-600">{{ form.errors.ligne_budget_id }}</p>
</div>
<div class="sm:col-span-2"> <div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700">Description</label> <label class="block text-sm font-medium text-gray-700">Description</label>
<textarea v-model="form.description" rows="2" <textarea v-model="form.description" rows="2"

View File

@@ -135,7 +135,13 @@ const transitionColors = {
<p class="text-xs text-gray-500">Référence fournisseur</p> <p class="text-xs text-gray-500">Référence fournisseur</p>
<p class="mt-0.5 font-medium text-gray-900">{{ commande.reference_fournisseur }}</p> <p class="mt-0.5 font-medium text-gray-900">{{ commande.reference_fournisseur }}</p>
</div> </div>
<div v-if="commande.imputation_budgetaire"> <div v-if="commande.ligne_budget_id && commande.ligne_budget">
<p class="text-xs text-indigo-600 font-semibold">Ligne Budgétaire</p>
<Link :href="route('budgets.show', commande.ligne_budget.budget_id)" class="mt-0.5 font-medium text-indigo-700 hover:underline">
{{ commande.ligne_budget.nom }} <span class="text-xs text-gray-500">({{ commande.ligne_budget.budget.annee }})</span>
</Link>
</div>
<div v-else-if="commande.imputation_budgetaire">
<p class="text-xs text-gray-500">Imputation budgétaire</p> <p class="text-xs text-gray-500">Imputation budgétaire</p>
<p class="mt-0.5 font-medium text-gray-900">{{ commande.imputation_budgetaire }}</p> <p class="mt-0.5 font-medium text-gray-900">{{ commande.imputation_budgetaire }}</p>
</div> </div>

View File

@@ -1,26 +1,106 @@
<script setup> <script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue' import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import { Head, useForm, router } from '@inertiajs/vue3' import { Head, useForm, router } from '@inertiajs/vue3'
import { ref } from 'vue' import { ref, computed } from 'vue'
defineProps({ services: Array }) defineProps({ services: Array })
const showForm = ref(false) // ── Icônes suggérées (solid + brands) ───────────────────────────────────────
const editTarget = ref(null) const ICON_SUGGESTIONS = [
const form = useForm({ nom: '', description: '', couleur: '#3B82F6', icone: '' }) // Infrastructure / IT
{ icon: ['fas', 'server'], label: 'Serveur' },
{ icon: ['fas', 'network-wired'], label: 'Réseau' },
{ icon: ['fas', 'database'], label: 'Base de données' },
{ icon: ['fas', 'shield-halved'], label: 'Sécurité' },
{ icon: ['fas', 'cloud'], label: 'Cloud' },
{ icon: ['fas', 'laptop'], label: 'Poste de travail' },
{ icon: ['fas', 'print'], label: 'Impression' },
{ icon: ['fas', 'wifi'], label: 'Wi-Fi' },
{ icon: ['fas', 'hard-drive'], label: 'Stockage' },
{ icon: ['fas', 'microchip'], label: 'Microchip' },
// Métier
{ icon: ['fas', 'users'], label: 'Utilisateurs' },
{ icon: ['fas', 'building'], label: 'Bâtiment' },
{ icon: ['fas', 'folder-open'], label: 'Dossiers' },
{ icon: ['fas', 'chart-line'], label: 'Statistiques' },
{ icon: ['fas', 'headset'], label: 'Support' },
{ icon: ['fas', 'code'], label: 'Développement' },
{ icon: ['fas', 'globe'], label: 'Web' },
{ icon: ['fas', 'envelope'], label: 'Messagerie' },
{ icon: ['fas', 'calendar-days'], label: 'Planning' },
{ icon: ['fas', 'file-contract'], label: 'Contrats' },
// Logiciels / Marques
{ icon: ['fab', 'laravel'], label: 'Laravel' },
{ icon: ['fab', 'linux'], label: 'Linux' },
{ icon: ['fab', 'windows'], label: 'Windows' },
{ icon: ['fab', 'microsoft'], label: 'Microsoft' },
{ icon: ['fab', 'docker'], label: 'Docker' },
{ icon: ['fab', 'github'], label: 'GitHub' },
{ icon: ['fab', 'wordpress'], label: 'WordPress' },
{ icon: ['fab', 'python'], label: 'Python' },
{ icon: ['fab', 'vuejs'], label: 'Vue.js' },
{ icon: ['fab', 'js'], label: 'JavaScript' },
]
// ── Formulaires ──────────────────────────────────────────────────────────────
const showForm = ref(false)
const editTarget = ref(null)
const showPicker = ref(null) // 'create' | 'edit'
const iconSearch = ref('')
const form = useForm({ nom: '', description: '', couleur: '#3B82F6', icone: '' })
const editForm = useForm({ nom: '', description: '', couleur: '', icone: '' }) const editForm = useForm({ nom: '', description: '', couleur: '', icone: '' })
function openEdit(s) { function openEdit(s) {
editTarget.value = s editTarget.value = s
editForm.nom = s.nom; editForm.description = s.description ?? ''; editForm.couleur = s.couleur ?? '#3B82F6'; editForm.icone = s.icone ?? '' editForm.nom = s.nom
editForm.description = s.description ?? ''
editForm.couleur = s.couleur ?? '#3B82F6'
editForm.icone = s.icone ?? ''
showPicker.value = null
iconSearch.value = ''
} }
function submitCreate() { form.post(route('services.store'), { onSuccess: () => { showForm.value = false; form.reset() } }) } function submitCreate() {
function submitEdit() { editForm.put(route('services.update', editTarget.value.id), { onSuccess: () => editTarget.value = null }) } form.post(route('services.store'), {
onSuccess: () => { showForm.value = false; form.reset(); showPicker.value = null }
})
}
function submitEdit() {
editForm.put(route('services.update', editTarget.value.id), {
onSuccess: () => { editTarget.value = null; showPicker.value = null }
})
}
function deleteService(s) { function deleteService(s) {
if (confirm(`Supprimer "${s.nom}" ?`)) router.delete(route('services.destroy', s.id)) if (confirm(`Supprimer "${s.nom}" ?`)) router.delete(route('services.destroy', s.id))
} }
// ── Picker ───────────────────────────────────────────────────────────────────
const filteredIcons = computed(() => {
const q = iconSearch.value.toLowerCase()
if (!q) return ICON_SUGGESTIONS
return ICON_SUGGESTIONS.filter(i => i.label.toLowerCase().includes(q) || i.icon[1].includes(q))
})
function pickIcon(icon, target) {
const val = `${icon[0]}:${icon[1]}`
if (target === 'create') form.icone = val
else editForm.icone = val
showPicker.value = null
iconSearch.value = ''
}
// Convertit "fas:server" → ['fas','server'] pour font-awesome-icon
function parseIcon(str) {
if (!str) return ['fas', 'circle-question']
const parts = str.split(':')
return parts.length === 2 ? parts : ['fas', str]
}
function clearIcon(target) {
if (target === 'create') form.icone = ''
else editForm.icone = ''
}
</script> </script>
<template> <template>
@@ -29,51 +109,206 @@ function deleteService(s) {
<template #header> <template #header>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h1 class="text-xl font-semibold text-gray-900">Services</h1> <h1 class="text-xl font-semibold text-gray-900">Services</h1>
<button @click="showForm = !showForm" class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors">+ Ajouter</button> <button @click="showForm = !showForm; editTarget = null"
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors">
+ Ajouter un service
</button>
</div> </div>
</template> </template>
<!-- Formulaire création -->
<div v-if="showForm" class="mb-6 rounded-xl bg-white p-5 shadow-sm border border-blue-200"> <div v-if="showForm" class="mb-6 rounded-xl bg-white p-5 shadow-sm border border-blue-200">
<form @submit.prevent="submitCreate" class="grid gap-3 sm:grid-cols-3"> <h2 class="text-sm font-semibold text-gray-700 mb-4">Nouveau service</h2>
<div><label class="block text-xs font-medium text-gray-600 mb-1">Nom *</label><input v-model="form.nom" type="text" required class="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" /></div> <form @submit.prevent="submitCreate" class="grid gap-4 sm:grid-cols-2">
<div><label class="block text-xs font-medium text-gray-600 mb-1">Couleur</label><input v-model="form.couleur" type="color" class="block h-10 w-full rounded-lg border border-gray-300" /></div> <div>
<div><label class="block text-xs font-medium text-gray-600 mb-1">Icône</label><input v-model="form.icone" type="text" placeholder="ex: server" class="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" /></div> <label class="block text-xs font-medium text-gray-600 mb-1">Nom *</label>
<div class="sm:col-span-3"><label class="block text-xs font-medium text-gray-600 mb-1">Description</label><input v-model="form.description" type="text" class="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" /></div> <input v-model="form.nom" type="text" required
<div class="sm:col-span-3 flex gap-2"> class="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" />
<button type="submit" :disabled="form.processing" class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50 transition-colors">Créer</button> <p v-if="form.errors.nom" class="mt-1 text-xs text-red-600">{{ form.errors.nom }}</p>
<button type="button" @click="showForm = false" class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">Annuler</button> </div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Couleur</label>
<div class="flex items-center gap-2">
<input v-model="form.couleur" type="color"
class="h-9 w-14 rounded-lg border border-gray-300 cursor-pointer" />
<span class="text-sm text-gray-500">{{ form.couleur }}</span>
</div>
</div>
<!-- Icône picker -->
<div class="sm:col-span-2">
<label class="block text-xs font-medium text-gray-600 mb-1">Icône</label>
<div class="flex items-center gap-3">
<!-- Aperçu -->
<div class="flex h-10 w-10 items-center justify-center rounded-lg border border-gray-200 bg-gray-50">
<font-awesome-icon v-if="form.icone" :icon="parseIcon(form.icone)" class="text-xl" :style="{ color: form.couleur }" />
<span v-else class="text-xs text-gray-300">?</span>
</div>
<button type="button" @click="showPicker = showPicker === 'create' ? null : 'create'; iconSearch = ''"
class="rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
{{ form.icone ? 'Changer' : 'Choisir une icône' }}
</button>
<button v-if="form.icone" type="button" @click="clearIcon('create')"
class="text-xs text-gray-400 hover:text-red-500"> Retirer</button>
<span v-if="form.icone" class="text-xs font-mono text-gray-400">{{ form.icone }}</span>
</div>
<!-- Grille de sélection -->
<div v-if="showPicker === 'create'" class="mt-2 rounded-xl border border-gray-200 bg-white shadow-lg p-3 z-10">
<input v-model="iconSearch" type="text" placeholder="Rechercher une icône..."
class="mb-3 block w-full rounded-lg border border-gray-200 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none" />
<div class="grid grid-cols-6 sm:grid-cols-10 gap-1 max-h-48 overflow-y-auto">
<button v-for="item in filteredIcons" :key="item.icon.join('-')"
type="button"
@click="pickIcon(item.icon, 'create')"
:title="item.label"
class="flex flex-col items-center gap-1 rounded-lg p-2 hover:bg-blue-50 transition-colors group">
<font-awesome-icon :icon="item.icon" class="text-lg text-gray-600 group-hover:text-blue-600" />
<span class="text-xs text-gray-400 truncate w-full text-center">{{ item.label }}</span>
</button>
</div>
</div>
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-medium text-gray-600 mb-1">Description</label>
<input v-model="form.description" type="text"
class="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" />
</div>
<div class="sm:col-span-2 flex gap-2">
<button type="submit" :disabled="form.processing"
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50 transition-colors">
Créer
</button>
<button type="button" @click="showForm = false; showPicker = null"
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">
Annuler
</button>
</div> </div>
</form> </form>
</div> </div>
<!-- Grille des services -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div v-for="s in services" :key="s.id" class="rounded-xl bg-white p-5 shadow-sm border border-gray-100"> <div v-for="s in services" :key="s.id"
class="rounded-xl bg-white p-5 shadow-sm border border-gray-100 hover:shadow-md transition-shadow">
<!-- Mode lecture -->
<template v-if="editTarget?.id !== s.id"> <template v-if="editTarget?.id !== s.id">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="h-4 w-4 rounded-full flex-shrink-0" :style="{ backgroundColor: s.couleur || '#6B7280' }" /> <!-- Icône du service -->
<h3 class="font-semibold text-gray-900">{{ s.nom }}</h3> <div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl"
:style="{ backgroundColor: (s.couleur || '#6B7280') + '20' }">
<font-awesome-icon v-if="s.icone"
:icon="parseIcon(s.icone)"
class="text-lg"
:style="{ color: s.couleur || '#6B7280' }" />
<span v-else class="h-3 w-3 rounded-full" :style="{ backgroundColor: s.couleur || '#6B7280' }" />
</div>
<div>
<h3 class="font-semibold text-gray-900">{{ s.nom }}</h3>
<p v-if="s.description" class="text-xs text-gray-500 mt-0.5">{{ s.description }}</p>
</div>
</div> </div>
<div class="flex gap-1"> <div class="flex gap-1 flex-shrink-0">
<button @click="openEdit(s)" class="text-gray-400 hover:text-indigo-600 transition-colors p-1"><svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg></button> <button @click="openEdit(s)"
<button @click="deleteService(s)" class="text-gray-400 hover:text-red-600 transition-colors p-1"><svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg></button> class="text-gray-400 hover:text-indigo-600 transition-colors p-1 rounded">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button @click="deleteService(s)"
class="text-gray-400 hover:text-red-600 transition-colors p-1 rounded">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div> </div>
</div> </div>
<p v-if="s.description" class="mt-2 text-sm text-gray-500">{{ s.description }}</p> <div class="mt-3 flex gap-4 text-xs text-gray-400 border-t border-gray-50 pt-3">
<div class="mt-3 flex gap-4 text-xs text-gray-500">
<span>{{ s.users_count }} utilisateur(s)</span> <span>{{ s.users_count }} utilisateur(s)</span>
<span>{{ s.commandes_count }} commande(s)</span> <span>{{ s.commandes_count }} commande(s)</span>
</div> </div>
</template> </template>
<!-- Mode édition inline -->
<form v-else @submit.prevent="submitEdit" class="space-y-3"> <form v-else @submit.prevent="submitEdit" class="space-y-3">
<div><label class="block text-xs font-medium text-gray-600 mb-1">Nom *</label><input v-model="editForm.nom" type="text" required class="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" /></div> <div class="flex items-center justify-between mb-1">
<div><label class="block text-xs font-medium text-gray-600 mb-1">Description</label><input v-model="editForm.description" type="text" class="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" /></div> <span class="text-sm font-semibold text-gray-700">Modifier le service</span>
<div class="flex gap-2"> <button type="button" @click="editTarget = null; showPicker = null"
<button type="submit" :disabled="editForm.processing" class="flex-1 rounded-lg bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50 transition-colors">Enregistrer</button> class="text-gray-400 hover:text-gray-600 text-lg leading-none"></button>
<button type="button" @click="editTarget = null" class="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">Annuler</button> </div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Nom *</label>
<input v-model="editForm.nom" type="text" required
class="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Description</label>
<input v-model="editForm.description" type="text"
class="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" />
</div>
<div class="flex gap-3 items-end">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Couleur</label>
<input v-model="editForm.couleur" type="color"
class="h-9 w-14 rounded-lg border border-gray-300 cursor-pointer" />
</div>
<div class="flex-1">
<label class="block text-xs font-medium text-gray-600 mb-1">Icône</label>
<div class="flex items-center gap-2">
<div class="flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 bg-gray-50">
<font-awesome-icon v-if="editForm.icone" :icon="parseIcon(editForm.icone)" class="text-base" :style="{ color: editForm.couleur }" />
<span v-else class="text-xs text-gray-300">?</span>
</div>
<button type="button" @click="showPicker = showPicker === 'edit' ? null : 'edit'; iconSearch = ''"
class="rounded-lg border border-gray-300 px-2 py-1.5 text-xs text-gray-700 hover:bg-gray-50 transition-colors">
{{ editForm.icone ? 'Changer' : 'Choisir' }}
</button>
<button v-if="editForm.icone" type="button" @click="clearIcon('edit')"
class="text-xs text-gray-400 hover:text-red-500"></button>
</div>
</div>
</div>
<!-- Picker édition -->
<div v-if="showPicker === 'edit'" class="rounded-xl border border-gray-200 bg-white shadow-lg p-3">
<input v-model="iconSearch" type="text" placeholder="Rechercher..."
class="mb-2 block w-full rounded-lg border border-gray-200 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none" />
<div class="grid grid-cols-5 gap-1 max-h-40 overflow-y-auto">
<button v-for="item in filteredIcons" :key="item.icon.join('-')"
type="button"
@click="pickIcon(item.icon, 'edit')"
:title="item.label"
class="flex flex-col items-center gap-1 rounded-lg p-2 hover:bg-blue-50 transition-colors group">
<font-awesome-icon :icon="item.icon" class="text-base text-gray-600 group-hover:text-blue-600" />
<span class="text-xs text-gray-400 truncate w-full text-center leading-tight">{{ item.label }}</span>
</button>
</div>
</div>
<div class="flex gap-2 pt-1">
<button type="submit" :disabled="editForm.processing"
class="flex-1 rounded-lg bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50 transition-colors">
Enregistrer
</button>
<button type="button" @click="editTarget = null; showPicker = null"
class="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">
Annuler
</button>
</div> </div>
</form> </form>
</div> </div>
<div v-if="!services?.length" class="col-span-full py-12 text-center text-sm text-gray-400">
Aucun service configuré.
</div>
</div> </div>
</AuthenticatedLayout> </AuthenticatedLayout>
</template> </template>

View File

@@ -6,6 +6,14 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { createApp, h } from 'vue'; import { createApp, h } from 'vue';
import { ZiggyVue } from '../../vendor/tightenco/ziggy'; import { ZiggyVue } from '../../vendor/tightenco/ziggy';
// FontAwesome
import { library } from '@fortawesome/fontawesome-svg-core';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { fas } from '@fortawesome/free-solid-svg-icons';
import { far } from '@fortawesome/free-regular-svg-icons';
import { fab } from '@fortawesome/free-brands-svg-icons';
library.add(fas, far, fab);
const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
createInertiaApp({ createInertiaApp({
@@ -19,6 +27,7 @@ createInertiaApp({
return createApp({ render: () => h(App, props) }) return createApp({ render: () => h(App, props) })
.use(plugin) .use(plugin)
.use(ZiggyVue) .use(ZiggyVue)
.component('font-awesome-icon', FontAwesomeIcon)
.mount(el); .mount(el);
}, },
progress: { progress: {

View File

@@ -0,0 +1,255 @@
<?php
namespace Tests\Feature;
use App\Models\Budget;
use App\Models\HistoriqueBudget;
use App\Models\LigneBudget;
use App\Models\Service;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class BudgetMetierTest extends TestCase
{
use RefreshDatabase;
private function createRole(string $name): Role
{
return Role::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
}
private function userWithRole(string $role, ?Service $service = null): User
{
$service ??= Service::factory()->create();
$user = User::factory()->create(['service_id' => $service->id, 'email_verified_at' => now()]);
$user->assignRole($this->createRole($role));
return $user;
}
private function makeBudget(Service $service, string $statut = 'preparation'): Budget
{
return Budget::create([
'service_id' => $service->id,
'annee' => now()->year,
'type_budget' => 'agglo',
'statut' => $statut,
]);
}
private function makeLigne(Budget $budget, float $propose = 5000, ?float $arbitre = null): LigneBudget
{
return LigneBudget::create([
'budget_id' => $budget->id,
'nom' => 'Ligne test',
'type_depense' => 'investissement',
'montant_propose' => $propose,
'montant_arbitre' => $arbitre,
]);
}
private function makeCommande(array $overrides = []): \App\Models\Commande
{
static $counter = 1;
$service = $overrides['service_id'] ?? Service::factory()->create()->id;
$user = $overrides['user_id'] ?? User::factory()->create(['service_id' => $service, 'email_verified_at' => now()])->id;
return \App\Models\Commande::create(array_merge([
'service_id' => $service,
'user_id' => $user,
'numero_commande' => 'CMD-METIER-' . $counter++,
'objet' => 'Commande test',
'statut' => 'brouillon',
'montant_ht' => 1000,
'montant_ttc' => 1200,
'date_demande' => now()->toDateString(),
], $overrides));
}
// -------------------------------------------------------------------------
// Historique des arbitrages
// -------------------------------------------------------------------------
public function test_arbitrage_creates_historique_entry(): void
{
$service = Service::factory()->create();
$raf = $this->userWithRole('raf', $service);
$budget = $this->makeBudget($service);
$ligne = $this->makeLigne($budget, 1000);
$this->actingAs($raf)->patch(route('lignes-budget.arbitrer', $ligne), [
'statut_arbitrage' => 'accepte_dsi',
'montant_arbitre' => 800,
'commentaire' => 'Réduit suite révision priorités',
]);
$this->assertDatabaseHas('historique_budgets', [
'ligne_budget_id' => $ligne->id,
'user_id' => $raf->id,
'ancien_statut' => 'brouillon',
'nouveau_statut' => 'accepte_dsi',
'ancien_montant_arbitre' => null,
'nouveau_montant_arbitre' => 800,
'commentaire' => 'Réduit suite révision priorités',
]);
}
public function test_multiple_arbitrages_build_full_history(): void
{
$service = Service::factory()->create();
$raf = $this->userWithRole('raf', $service);
$budget = $this->makeBudget($service);
$ligne = $this->makeLigne($budget, 1000);
$this->actingAs($raf)->patch(route('lignes-budget.arbitrer', $ligne), [
'statut_arbitrage' => 'accepte_dsi',
'montant_arbitre' => 800,
]);
$this->actingAs($raf)->patch(route('lignes-budget.arbitrer', $ligne), [
'statut_arbitrage' => 'valide_definitif',
'montant_arbitre' => 750,
]);
$this->assertSame(2, HistoriqueBudget::where('ligne_budget_id', $ligne->id)->count());
// Le deuxième arbitrage (le plus récent) doit tracer le passage de accepte_dsi → valide_definitif
$last = HistoriqueBudget::where('ligne_budget_id', $ligne->id)->oldest()->skip(1)->first();
$this->assertEquals('accepte_dsi', $last->ancien_statut);
$this->assertEquals('valide_definitif', $last->nouveau_statut);
$this->assertEquals(800, (float) $last->ancien_montant_arbitre);
$this->assertEquals(750, (float) $last->nouveau_montant_arbitre);
}
public function test_historique_is_included_in_budget_show(): void
{
$service = Service::factory()->create();
$raf = $this->userWithRole('raf', $service);
$budget = $this->makeBudget($service);
$ligne = $this->makeLigne($budget, 1000);
$this->actingAs($raf)->patch(route('lignes-budget.arbitrer', $ligne), [
'statut_arbitrage' => 'accepte_dsi',
'montant_arbitre' => 800,
'commentaire' => 'Premier arbitrage',
]);
$response = $this->actingAs($raf)->get(route('budgets.show', $budget));
$response->assertOk();
$response->assertInertia(fn ($page) =>
$page->has('lignes.0.historique', 1)
->where('lignes.0.historique.0.commentaire', 'Premier arbitrage')
->where('lignes.0.historique.0.user', $raf->name)
);
}
public function test_historique_deleted_when_ligne_deleted(): void
{
$service = Service::factory()->create();
$raf = $this->userWithRole('raf', $service);
$budget = $this->makeBudget($service);
$ligne = $this->makeLigne($budget, 1000);
$this->actingAs($raf)->patch(route('lignes-budget.arbitrer', $ligne), [
'statut_arbitrage' => 'accepte_dsi',
'montant_arbitre' => 800,
]);
$ligneId = $ligne->id;
$this->actingAs($this->userWithRole('admin'))->delete(route('lignes-budget.destroy', $ligne));
$this->assertDatabaseMissing('historique_budgets', ['ligne_budget_id' => $ligneId]);
}
// -------------------------------------------------------------------------
// Blocage des commandes si budget insuffisant
// -------------------------------------------------------------------------
public function test_commande_creation_blocked_when_budget_exceeded(): void
{
$service = Service::factory()->create();
$acheteur = $this->userWithRole('acheteur', $service);
$budget = $this->makeBudget($service);
$ligne = $this->makeLigne($budget, propose: 5000, arbitre: 1000);
// Commande existante qui consomme 800 € TTC
$this->makeCommande([
'ligne_budget_id' => $ligne->id,
'service_id' => $service->id,
'user_id' => $acheteur->id,
'montant_ttc' => 800,
'statut' => 'validee',
]);
// Tentative de créer une commande avec une ligne à 500 € HT + 20% TVA = 600 € TTC
// Reste dispo : 1000 - 800 = 200 € → 600 > 200 → bloqué
$response = $this->actingAs($acheteur)->post(route('commandes.store'), [
'service_id' => $service->id,
'objet' => 'Dépassement test',
'priorite' => 'normale',
'date_demande' => now()->toDateString(),
'ligne_budget_id' => $ligne->id,
'lignes' => [
['designation' => 'Article test', 'quantite' => 1, 'prix_unitaire_ht' => 500, 'taux_tva' => 20],
],
]);
$response->assertSessionHasErrors('ligne_budget_id');
}
public function test_commande_creation_allowed_when_budget_sufficient(): void
{
$service = Service::factory()->create();
$acheteur = $this->userWithRole('acheteur', $service);
$budget = $this->makeBudget($service);
$ligne = $this->makeLigne($budget, propose: 5000, arbitre: 2000);
// Commande existante de 800 € TTC, reste 1200 €
$this->makeCommande([
'ligne_budget_id' => $ligne->id,
'service_id' => $service->id,
'user_id' => $acheteur->id,
'montant_ttc' => 800,
'statut' => 'validee',
]);
// Nouvelle ligne à 100 € HT + 20% = 120 € TTC → OK (800+120=920 < 2000)
$response = $this->actingAs($acheteur)->post(route('commandes.store'), [
'service_id' => $service->id,
'objet' => 'Commande dans le budget',
'priorite' => 'normale',
'date_demande' => now()->toDateString(),
'ligne_budget_id' => $ligne->id,
'lignes' => [
['designation' => 'Article test', 'quantite' => 1, 'prix_unitaire_ht' => 100, 'taux_tva' => 20],
],
]);
$response->assertSessionDoesntHaveErrors('ligne_budget_id');
}
public function test_commande_creation_not_blocked_when_ligne_not_yet_arbitree(): void
{
$service = Service::factory()->create();
$acheteur = $this->userWithRole('acheteur', $service);
$budget = $this->makeBudget($service);
// Ligne sans montant_arbitre
$ligne = $this->makeLigne($budget, propose: 1000, arbitre: null);
// Ligne à 9000 € HT → normalement dépasserait, mais pas arbitrée = pas de blocage
$response = $this->actingAs($acheteur)->post(route('commandes.store'), [
'service_id' => $service->id,
'objet' => 'Commande avant arbitrage',
'priorite' => 'normale',
'date_demande' => now()->toDateString(),
'ligne_budget_id' => $ligne->id,
'lignes' => [
['designation' => 'Article test', 'quantite' => 1, 'prix_unitaire_ht' => 9000, 'taux_tva' => 20],
],
]);
$response->assertSessionDoesntHaveErrors('ligne_budget_id');
}
}

View File

@@ -0,0 +1,294 @@
<?php
namespace Tests\Feature;
use App\Models\Budget;
use App\Models\Commande;
use App\Models\LigneBudget;
use App\Models\Service;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class BudgetPerformanceTest extends TestCase
{
use RefreshDatabase;
private function adminUser(): User
{
Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']);
$user = User::factory()->create([
'service_id' => Service::factory()->create()->id,
'email_verified_at' => now(),
]);
$user->assignRole('admin');
return $user;
}
private function rafUser(Service $service): User
{
Role::firstOrCreate(['name' => 'raf', 'guard_name' => 'web']);
$user = User::factory()->create([
'service_id' => $service->id,
'email_verified_at' => now(),
]);
$user->assignRole('raf');
return $user;
}
private function makeCommande(array $overrides = []): Commande
{
static $counter = 1;
$service = $overrides['service_id'] ?? Service::factory()->create()->id;
$user = $overrides['user_id'] ?? User::factory()->create(['service_id' => $service, 'email_verified_at' => now()])->id;
return Commande::create(array_merge([
'service_id' => $service,
'user_id' => $user,
'numero_commande' => 'CMD-PERF-' . $counter++,
'objet' => 'Commande de test',
'statut' => 'brouillon',
'montant_ht' => 1000,
'montant_ttc' => 1200,
'date_demande' => now()->toDateString(),
], $overrides));
}
private function makeBudgetWithLignes(int $nbLignes = 3, int $commandesPerLigne = 2): array
{
$service = Service::factory()->create();
$budget = Budget::create([
'service_id' => $service->id,
'annee' => now()->year,
'type_budget' => 'agglo',
'statut' => 'preparation',
]);
$lignes = collect();
for ($i = 0; $i < $nbLignes; $i++) {
$ligne = LigneBudget::create([
'budget_id' => $budget->id,
'nom' => "Ligne $i",
'type_depense' => $i % 2 === 0 ? 'investissement' : 'fonctionnement',
'montant_propose' => 10000,
'montant_arbitre' => 8000,
]);
for ($j = 0; $j < $commandesPerLigne; $j++) {
$this->makeCommande([
'ligne_budget_id' => $ligne->id,
'service_id' => $service->id,
'statut' => $j === 0 ? 'validee' : 'brouillon',
]);
}
$lignes->push($ligne);
}
return [$budget, $lignes, $service];
}
// -------------------------------------------------------------------------
// N+1 — execution()
// -------------------------------------------------------------------------
public function test_execution_does_not_trigger_n_plus_1_queries(): void
{
[$budget, $lignes, $service] = $this->makeBudgetWithLignes(nbLignes: 5, commandesPerLigne: 3);
$admin = $this->adminUser();
// Warmup — on ignore la première requête de setup
DB::flushQueryLog();
DB::enableQueryLog();
$this->actingAs($admin)->get(route('budgets.execution', ['annee' => now()->year]));
$queries = DB::getQueryLog();
DB::disableQueryLog();
// Sans N+1 : ~5 requêtes max (session, user, lignes, eager loads)
// Avec N+1 : 3 requêtes × nb_lignes supplémentaires
// On vérifie qu'on reste sous un seuil raisonnable (15 = large marge)
$this->assertLessThan(
15,
count($queries),
sprintf(
'Trop de requêtes SQL dans execution() : %d requêtes pour 5 lignes. Probable N+1.',
count($queries)
)
);
}
// -------------------------------------------------------------------------
// N+1 — exportPdf()
// -------------------------------------------------------------------------
public function test_export_pdf_does_not_trigger_n_plus_1_queries(): void
{
[$budget, $lignes, $service] = $this->makeBudgetWithLignes(nbLignes: 5, commandesPerLigne: 3);
$admin = $this->adminUser();
DB::flushQueryLog();
DB::enableQueryLog();
$this->actingAs($admin)->get(route('budgets.export.pdf', ['annee' => now()->year]));
$queries = DB::getQueryLog();
DB::disableQueryLog();
$this->assertLessThan(
15,
count($queries),
sprintf(
'Trop de requêtes SQL dans exportPdf() : %d requêtes pour 5 lignes. Probable N+1.',
count($queries)
)
);
}
// -------------------------------------------------------------------------
// null-safety — montant_disponible quand montant_arbitre est null
// -------------------------------------------------------------------------
public function test_montant_disponible_is_zero_when_montant_arbitre_is_null(): void
{
$service = Service::factory()->create();
$budget = Budget::create([
'service_id' => $service->id,
'annee' => now()->year,
'type_budget' => 'agglo',
'statut' => 'preparation',
]);
$ligne = LigneBudget::create([
'budget_id' => $budget->id,
'nom' => 'Ligne sans arbitrage',
'type_depense' => 'investissement',
'montant_propose' => 5000,
'montant_arbitre' => null,
]);
// Avant la correction, ceci retournait une valeur imprévisible ou une erreur
$this->assertSame(0.0, $ligne->montant_disponible);
$this->assertIsFloat($ligne->montant_disponible);
}
public function test_montant_disponible_is_correct_with_arbitre_set(): void
{
$service = Service::factory()->create();
$budget = Budget::create([
'service_id' => $service->id,
'annee' => now()->year,
'type_budget' => 'agglo',
'statut' => 'preparation',
]);
$ligne = LigneBudget::create([
'budget_id' => $budget->id,
'nom' => 'Ligne arbitrée',
'type_depense' => 'investissement',
'montant_propose' => 5000,
'montant_arbitre' => 4000,
]);
// Pas de commandes : disponible = arbitré = 4000
$this->assertSame(4000.0, $ligne->montant_disponible);
}
// -------------------------------------------------------------------------
// Validation montant_arbitre <= montant_propose
// -------------------------------------------------------------------------
public function test_arbitrage_rejects_montant_arbitre_exceeding_montant_propose(): void
{
Role::firstOrCreate(['name' => 'raf', 'guard_name' => 'web']);
$service = Service::factory()->create();
$raf = $this->rafUser($service);
$budget = Budget::create([
'service_id' => $service->id,
'annee' => now()->year,
'type_budget' => 'agglo',
'statut' => 'arbitrage_dsi',
]);
$ligne = LigneBudget::create([
'budget_id' => $budget->id,
'nom' => 'Ligne à arbitrer',
'type_depense' => 'investissement',
'montant_propose' => 1000,
]);
$response = $this->actingAs($raf)->patch(route('lignes-budget.arbitrer', $ligne), [
'statut_arbitrage' => 'accepte_dsi',
'montant_arbitre' => 1500, // > montant_propose
]);
$response->assertSessionHasErrors('montant_arbitre');
$this->assertDatabaseHas('ligne_budgets', [
'id' => $ligne->id,
'montant_arbitre' => null, // inchangé
]);
}
public function test_arbitrage_accepts_montant_arbitre_equal_to_montant_propose(): void
{
Role::firstOrCreate(['name' => 'raf', 'guard_name' => 'web']);
$service = Service::factory()->create();
$raf = $this->rafUser($service);
$budget = Budget::create([
'service_id' => $service->id,
'annee' => now()->year,
'type_budget' => 'agglo',
'statut' => 'arbitrage_dsi',
]);
$ligne = LigneBudget::create([
'budget_id' => $budget->id,
'nom' => 'Ligne à arbitrer',
'type_depense' => 'investissement',
'montant_propose' => 1000,
]);
$response = $this->actingAs($raf)->patch(route('lignes-budget.arbitrer', $ligne), [
'statut_arbitrage' => 'accepte_dsi',
'montant_arbitre' => 1000, // = montant_propose, OK
]);
$response->assertRedirect();
$this->assertDatabaseHas('ligne_budgets', [
'id' => $ligne->id,
'montant_arbitre' => 1000,
'statut_arbitrage' => 'accepte_dsi',
]);
}
public function test_arbitrage_accepts_montant_arbitre_below_montant_propose(): void
{
Role::firstOrCreate(['name' => 'raf', 'guard_name' => 'web']);
$service = Service::factory()->create();
$raf = $this->rafUser($service);
$budget = Budget::create([
'service_id' => $service->id,
'annee' => now()->year,
'type_budget' => 'agglo',
'statut' => 'arbitrage_dsi',
]);
$ligne = LigneBudget::create([
'budget_id' => $budget->id,
'nom' => 'Ligne à arbitrer',
'type_depense' => 'investissement',
'montant_propose' => 1000,
]);
$response = $this->actingAs($raf)->patch(route('lignes-budget.arbitrer', $ligne), [
'statut_arbitrage' => 'accepte_dsi',
'montant_arbitre' => 750,
]);
$response->assertRedirect();
$this->assertDatabaseHas('ligne_budgets', [
'id' => $ligne->id,
'montant_arbitre' => 750,
]);
}
}

View File

@@ -0,0 +1,354 @@
<?php
namespace Tests\Feature;
use App\Models\Budget;
use App\Models\LigneBudget;
use App\Models\Service;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class BudgetSecurityTest extends TestCase
{
use RefreshDatabase;
private function createRole(string $name): Role
{
return Role::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
}
private function userWithRole(string $role, ?Service $service = null): User
{
$service ??= Service::factory()->create();
$user = User::factory()->create(['service_id' => $service->id, 'email_verified_at' => now()]);
$user->assignRole($this->createRole($role));
return $user;
}
private function makeBudget(Service $service, string $statut = 'preparation'): Budget
{
return Budget::create([
'service_id' => $service->id,
'annee' => 2026,
'type_budget' => 'agglo',
'statut' => $statut,
]);
}
private function makeLigne(Budget $budget): LigneBudget
{
return LigneBudget::create([
'budget_id' => $budget->id,
'nom' => 'Ligne test',
'type_depense' => 'investissement',
'montant_propose' => 1000,
]);
}
// -------------------------------------------------------------------------
// LigneBudgetController::destroy — faille critique
// -------------------------------------------------------------------------
public function test_responsable_can_delete_ligne_of_own_service(): void
{
$service = Service::factory()->create();
$user = $this->userWithRole('responsable', $service);
$budget = $this->makeBudget($service);
$ligne = $this->makeLigne($budget);
$response = $this->actingAs($user)->delete(route('lignes-budget.destroy', $ligne));
$response->assertRedirect();
$this->assertDatabaseMissing('ligne_budgets', ['id' => $ligne->id]);
}
public function test_responsable_cannot_delete_ligne_of_another_service(): void
{
$otherService = Service::factory()->create();
$myService = Service::factory()->create();
$user = $this->userWithRole('responsable', $myService);
$budget = $this->makeBudget($otherService);
$ligne = $this->makeLigne($budget);
$response = $this->actingAs($user)->delete(route('lignes-budget.destroy', $ligne));
$response->assertForbidden();
$this->assertDatabaseHas('ligne_budgets', ['id' => $ligne->id]);
}
public function test_unauthenticated_user_cannot_delete_ligne(): void
{
$service = Service::factory()->create();
$budget = $this->makeBudget($service);
$ligne = $this->makeLigne($budget);
$response = $this->delete(route('lignes-budget.destroy', $ligne));
$response->assertRedirect(route('login'));
$this->assertDatabaseHas('ligne_budgets', ['id' => $ligne->id]);
}
public function test_cannot_delete_ligne_when_budget_is_locked(): void
{
$service = Service::factory()->create();
$user = $this->userWithRole('responsable', $service);
$budget = $this->makeBudget($service, 'valide');
$ligne = $this->makeLigne($budget);
$response = $this->actingAs($user)->delete(route('lignes-budget.destroy', $ligne));
$response->assertForbidden();
$this->assertDatabaseHas('ligne_budgets', ['id' => $ligne->id]);
}
public function test_admin_can_delete_ligne_of_any_service(): void
{
$otherService = Service::factory()->create();
$admin = $this->userWithRole('admin');
$budget = $this->makeBudget($otherService);
$ligne = $this->makeLigne($budget);
$response = $this->actingAs($admin)->delete(route('lignes-budget.destroy', $ligne));
$response->assertRedirect();
$this->assertDatabaseMissing('ligne_budgets', ['id' => $ligne->id]);
}
// -------------------------------------------------------------------------
// LigneBudgetController::update — vérification service_id
// -------------------------------------------------------------------------
public function test_responsable_can_update_ligne_of_own_service(): void
{
$service = Service::factory()->create();
$user = $this->userWithRole('responsable', $service);
$budget = $this->makeBudget($service);
$ligne = $this->makeLigne($budget);
$response = $this->actingAs($user)->put(route('lignes-budget.update', $ligne), [
'nom' => 'Ligne modifiée',
'type_depense' => 'fonctionnement',
'montant_propose' => 2000,
]);
$response->assertRedirect();
$this->assertDatabaseHas('ligne_budgets', ['id' => $ligne->id, 'nom' => 'Ligne modifiée']);
}
public function test_responsable_cannot_update_ligne_of_another_service(): void
{
$otherService = Service::factory()->create();
$myService = Service::factory()->create();
$user = $this->userWithRole('responsable', $myService);
$budget = $this->makeBudget($otherService);
$ligne = $this->makeLigne($budget);
$response = $this->actingAs($user)->put(route('lignes-budget.update', $ligne), [
'nom' => 'Tentative de modification',
'type_depense' => 'fonctionnement',
'montant_propose' => 9999,
]);
$response->assertForbidden();
$this->assertDatabaseHas('ligne_budgets', ['id' => $ligne->id, 'nom' => 'Ligne test']);
}
public function test_cannot_update_ligne_when_budget_is_locked(): void
{
$service = Service::factory()->create();
$user = $this->userWithRole('responsable', $service);
$budget = $this->makeBudget($service, 'arbitrage_direction');
$ligne = $this->makeLigne($budget);
$response = $this->actingAs($user)->put(route('lignes-budget.update', $ligne), [
'nom' => 'Tentative modif budget verrouillé',
'type_depense' => 'investissement',
'montant_propose' => 5000,
]);
$response->assertForbidden();
}
// -------------------------------------------------------------------------
// LigneBudgetController::store — vérification service_id
// -------------------------------------------------------------------------
public function test_responsable_can_add_ligne_to_own_budget(): void
{
$service = Service::factory()->create();
$user = $this->userWithRole('responsable', $service);
$budget = $this->makeBudget($service);
$response = $this->actingAs($user)->post(route('lignes-budget.store'), [
'budget_id' => $budget->id,
'nom' => 'Nouvelle ligne',
'type_depense' => 'investissement',
'montant_propose' => 500,
]);
$response->assertRedirect();
$this->assertDatabaseHas('ligne_budgets', ['budget_id' => $budget->id, 'nom' => 'Nouvelle ligne']);
}
public function test_responsable_cannot_add_ligne_to_another_service_budget(): void
{
$otherService = Service::factory()->create();
$myService = Service::factory()->create();
$user = $this->userWithRole('responsable', $myService);
$budget = $this->makeBudget($otherService);
$response = $this->actingAs($user)->post(route('lignes-budget.store'), [
'budget_id' => $budget->id,
'nom' => 'Ligne injectée',
'type_depense' => 'investissement',
'montant_propose' => 500,
]);
$response->assertForbidden();
$this->assertDatabaseMissing('ligne_budgets', ['budget_id' => $budget->id]);
}
// -------------------------------------------------------------------------
// BudgetController::update — statut réservé aux admin/directeur
// -------------------------------------------------------------------------
public function test_directeur_can_change_budget_status(): void
{
$service = Service::factory()->create();
$user = $this->userWithRole('directeur', $service);
$budget = $this->makeBudget($service, 'preparation');
$response = $this->actingAs($user)->put(route('budgets.update', $budget), [
'statut' => 'arbitrage_dsi',
]);
$response->assertRedirect();
$this->assertDatabaseHas('budgets', ['id' => $budget->id, 'statut' => 'arbitrage_dsi']);
}
public function test_responsable_cannot_change_budget_status(): void
{
$service = Service::factory()->create();
$user = $this->userWithRole('responsable', $service);
$budget = $this->makeBudget($service, 'preparation');
$response = $this->actingAs($user)->put(route('budgets.update', $budget), [
'statut' => 'valide',
]);
$response->assertForbidden();
$this->assertDatabaseHas('budgets', ['id' => $budget->id, 'statut' => 'preparation']);
}
public function test_acheteur_cannot_change_budget_status(): void
{
$service = Service::factory()->create();
$user = $this->userWithRole('acheteur', $service);
$budget = $this->makeBudget($service, 'preparation');
$response = $this->actingAs($user)->put(route('budgets.update', $budget), [
'statut' => 'valide',
]);
$response->assertForbidden();
}
// -------------------------------------------------------------------------
// BudgetController exports — validation des paramètres enum
// -------------------------------------------------------------------------
public function test_export_excel_ignores_invalid_type_parameter(): void
{
$admin = $this->userWithRole('admin');
// Paramètre type invalide — ne doit pas causer d'erreur ni être utilisé
$response = $this->actingAs($admin)->get(route('budgets.export.excel', [
'annee' => 2026,
'type' => 'INJECTED_VALUE; DROP TABLE budgets;',
]));
// Doit retourner le fichier (200) sans planter
$response->assertOk();
}
public function test_export_pdf_ignores_invalid_envelope_parameter(): void
{
$admin = $this->userWithRole('admin');
$response = $this->actingAs($admin)->get(route('budgets.export.pdf', [
'annee' => 2026,
'envelope' => 'invalid_value',
]));
$response->assertOk();
}
public function test_export_accepts_valid_type_parameter(): void
{
$admin = $this->userWithRole('admin');
$response = $this->actingAs($admin)->get(route('budgets.export.excel', [
'annee' => 2026,
'type' => 'investissement',
]));
$response->assertOk();
}
public function test_export_accepts_valid_envelope_parameter(): void
{
$admin = $this->userWithRole('admin');
$response = $this->actingAs($admin)->get(route('budgets.export.pdf', [
'annee' => 2026,
'envelope' => 'agglo',
]));
$response->assertOk();
}
// -------------------------------------------------------------------------
// Arbitrage — réservé aux admin/directeur/raf
// -------------------------------------------------------------------------
public function test_raf_can_arbitrer_ligne(): void
{
$service = Service::factory()->create();
$user = $this->userWithRole('raf', $service);
$budget = $this->makeBudget($service);
$ligne = $this->makeLigne($budget);
$response = $this->actingAs($user)->patch(route('lignes-budget.arbitrer', $ligne), [
'statut_arbitrage' => 'accepte_dsi',
'montant_arbitre' => 800,
]);
$response->assertRedirect();
$this->assertDatabaseHas('ligne_budgets', [
'id' => $ligne->id,
'statut_arbitrage' => 'accepte_dsi',
'montant_arbitre' => 800,
]);
}
public function test_responsable_cannot_arbitrer_ligne(): void
{
$service = Service::factory()->create();
$user = $this->userWithRole('responsable', $service);
$budget = $this->makeBudget($service);
$ligne = $this->makeLigne($budget);
$response = $this->actingAs($user)->patch(route('lignes-budget.arbitrer', $ligne), [
'statut_arbitrage' => 'valide_definitif',
'montant_arbitre' => 1000,
]);
$response->assertForbidden();
$this->assertDatabaseHas('ligne_budgets', [
'id' => $ligne->id,
'statut_arbitrage' => 'brouillon',
]);
}
}