Initial commit with contrats and domaines modules

This commit is contained in:
mrKamoo
2026-04-08 18:07:08 +02:00
commit 092a6a0484
191 changed files with 24639 additions and 0 deletions

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers;
use App\Models\Article;
use App\Models\Categorie;
use App\Models\Fournisseur;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ArticleController extends Controller
{
public function index(Request $request): Response
{
$articles = Article::with(['categorie', 'fournisseur'])
->when($request->search, fn ($q) => $q->where(function ($sub) use ($request) {
$sub->where('designation', 'like', "%{$request->search}%")
->orWhere('reference', 'like', "%{$request->search}%");
}))
->when($request->categorie_id, fn ($q) => $q->where('categorie_id', $request->categorie_id))
->orderBy('designation')
->paginate(20)
->withQueryString();
return Inertia::render('Articles/Index', [
'articles' => $articles,
'categories' => Categorie::active()->orderBy('ordre')->get(),
'filters' => $request->only(['search', 'categorie_id']),
]);
}
public function create(): Response
{
return Inertia::render('Articles/Create', [
'categories' => Categorie::active()->orderBy('ordre')->get(),
'fournisseurs' => Fournisseur::active()->orderBy('nom')->get(),
]);
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'reference' => 'nullable|string|max:100',
'designation' => 'required|string|max:255',
'description' => 'nullable|string',
'categorie_id' => 'nullable|exists:categories,id',
'fournisseur_id' => 'nullable|exists:fournisseurs,id',
'prix_unitaire_ht' => 'nullable|numeric|min:0',
'unite' => 'nullable|string|max:30',
]);
Article::create($validated);
return redirect()->route('articles.index')
->with('success', 'Article créé avec succès.');
}
public function edit(Article $article): Response
{
return Inertia::render('Articles/Edit', [
'article' => $article,
'categories' => Categorie::active()->orderBy('ordre')->get(),
'fournisseurs' => Fournisseur::active()->orderBy('nom')->get(),
]);
}
public function update(Request $request, Article $article): RedirectResponse
{
$validated = $request->validate([
'reference' => 'nullable|string|max:100',
'designation' => 'required|string|max:255',
'description' => 'nullable|string',
'categorie_id' => 'nullable|exists:categories,id',
'fournisseur_id' => 'nullable|exists:fournisseurs,id',
'prix_unitaire_ht' => 'nullable|numeric|min:0',
'unite' => 'nullable|string|max:30',
'active' => 'boolean',
]);
$article->update($validated);
return redirect()->route('articles.index')
->with('success', 'Article mis à jour.');
}
public function destroy(Article $article): RedirectResponse
{
$article->update(['active' => false]);
return back()->with('success', 'Article désactivé.');
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use Inertia\Response;
class AuthenticatedSessionController extends Controller
{
/**
* Display the login view.
*/
public function create(): Response
{
return Inertia::render('Auth/Login', [
'canResetPassword' => Route::has('password.request'),
'status' => session('status'),
]);
}
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
return redirect()->intended(route('dashboard', absolute: false));
}
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class ConfirmablePasswordController extends Controller
{
/**
* Show the confirm password view.
*/
public function show(): Response
{
return Inertia::render('Auth/ConfirmPassword');
}
/**
* Confirm the user's password.
*/
public function store(Request $request): RedirectResponse
{
if (! Auth::guard('web')->validate([
'email' => $request->user()->email,
'password' => $request->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
$request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(route('dashboard', absolute: false));
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EmailVerificationNotificationController extends Controller
{
/**
* Send a new email verification notification.
*/
public function store(Request $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false));
}
$request->user()->sendEmailVerificationNotification();
return back()->with('status', 'verification-link-sent');
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class EmailVerificationPromptController extends Controller
{
/**
* Display the email verification prompt.
*/
public function __invoke(Request $request): RedirectResponse|Response
{
return $request->user()->hasVerifiedEmail()
? redirect()->intended(route('dashboard', absolute: false))
: Inertia::render('Auth/VerifyEmail', ['status' => session('status')]);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class NewPasswordController extends Controller
{
/**
* Display the password reset view.
*/
public function create(Request $request): Response
{
return Inertia::render('Auth/ResetPassword', [
'email' => $request->email,
'token' => $request->route('token'),
]);
}
/**
* Handle an incoming new password request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'token' => 'required',
'email' => 'required|email',
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function ($user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
if ($status == Password::PASSWORD_RESET) {
return redirect()->route('login')->with('status', __($status));
}
throw ValidationException::withMessages([
'email' => [trans($status)],
]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class PasswordController extends Controller
{
/**
* Update the user's password.
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back();
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class PasswordResetLinkController extends Controller
{
/**
* Display the password reset link request view.
*/
public function create(): Response
{
return Inertia::render('Auth/ForgotPassword', [
'status' => session('status'),
]);
}
/**
* Handle an incoming password reset link request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'email' => 'required|email',
]);
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
$status = Password::sendResetLink(
$request->only('email')
);
if ($status == Password::RESET_LINK_SENT) {
return back()->with('status', __($status));
}
throw ValidationException::withMessages([
'email' => [trans($status)],
]);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class RegisteredUserController extends Controller
{
/**
* Display the registration view.
*/
public function create(): Response
{
return Inertia::render('Auth/Register');
}
/**
* Handle an incoming registration request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|lowercase|email|max:255|unique:'.User::class,
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
event(new Registered($user));
Auth::login($user);
return redirect(route('dashboard', absolute: false));
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user()));
}
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers;
use App\Models\Categorie;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class CategorieController extends Controller
{
public function index(): Response
{
return Inertia::render('Categories/Index', [
'categories' => Categorie::withCount('articles')->orderBy('ordre')->get(),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorize('create', Categorie::class);
$validated = $request->validate([
'nom' => 'required|string|max:100|unique:categories,nom',
'description' => 'nullable|string',
'couleur' => 'nullable|string|max:7',
'icone' => 'nullable|string|max:50',
'ordre' => 'nullable|integer|min:0',
]);
Categorie::create($validated);
return back()->with('success', 'Catégorie créée.');
}
public function update(Request $request, Categorie $categorie): RedirectResponse
{
$this->authorize('update', Categorie::class);
$validated = $request->validate([
'nom' => 'required|string|max:100|unique:categories,nom,' . $categorie->id,
'description' => 'nullable|string',
'couleur' => 'nullable|string|max:7',
'icone' => 'nullable|string|max:50',
'ordre' => 'nullable|integer|min:0',
'active' => 'boolean',
]);
$categorie->update($validated);
return back()->with('success', 'Catégorie mise à jour.');
}
public function destroy(Categorie $categorie): RedirectResponse
{
$this->authorize('delete', Categorie::class);
$categorie->update(['active' => false]);
return back()->with('success', 'Catégorie désactivée.');
}
}

View File

@@ -0,0 +1,247 @@
<?php
namespace App\Http\Controllers;
use App\Models\Article;
use App\Models\Categorie;
use App\Models\Commande;
use App\Models\Fournisseur;
use App\Models\Service;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
class CommandeController extends Controller
{
public function index(Request $request): Response
{
$query = Commande::with(['service', 'fournisseur', 'demandeur'])
->when($request->service_id, fn ($q) => $q->parService($request->service_id))
->when($request->fournisseur_id, fn ($q) => $q->parFournisseur($request->fournisseur_id))
->when($request->statut, fn ($q) => $q->parStatut($request->statut))
->when($request->priorite, fn ($q) => $q->where('priorite', $request->priorite))
->when($request->date_from, fn ($q) => $q->whereDate('date_demande', '>=', $request->date_from))
->when($request->date_to, fn ($q) => $q->whereDate('date_demande', '<=', $request->date_to))
->when($request->search, function ($q) use ($request) {
$q->where(function ($sub) use ($request) {
$sub->where('numero_commande', 'like', "%{$request->search}%")
->orWhere('objet', 'like', "%{$request->search}%")
->orWhereHas('fournisseur', fn ($f) => $f->where('nom', 'like', "%{$request->search}%"));
});
});
$commandes = $query->latest()->paginate(20)->withQueryString();
return Inertia::render('Commandes/Index', [
'commandes' => $commandes,
'services' => Service::all(),
'fournisseurs' => Fournisseur::active()->orderBy('nom')->get(),
'filters' => $request->only(['search', 'service_id', 'fournisseur_id', 'statut', 'priorite', 'date_from', 'date_to']),
]);
}
public function create(): Response
{
$this->authorize('create', Commande::class);
return Inertia::render('Commandes/Create', [
'services' => Service::all(),
'fournisseurs' => Fournisseur::active()->orderBy('nom')->get(),
'categories' => Categorie::active()->orderBy('ordre')->get(),
'articles' => Article::active()->with('categorie')->orderBy('designation')->get(),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorize('create', Commande::class);
$validated = $request->validate([
'service_id' => 'required|exists:services,id',
'fournisseur_id' => 'nullable|exists:fournisseurs,id',
'objet' => 'required|string|max:255',
'description' => 'nullable|string',
'justification' => 'nullable|string',
'priorite' => 'required|in:normale,haute,urgente',
'reference_fournisseur' => 'nullable|string|max:100',
'imputation_budgetaire' => 'nullable|string|max:100',
'date_demande' => 'required|date',
'date_souhaitee' => 'nullable|date|after_or_equal:date_demande',
'date_livraison_prevue' => 'nullable|date',
'notes' => 'nullable|string',
'notes_fournisseur' => 'nullable|string',
'lignes' => 'array',
'lignes.*.designation' => 'required|string|max:255',
'lignes.*.reference' => 'nullable|string|max:100',
'lignes.*.quantite' => 'required|numeric|min:0.001',
'lignes.*.unite' => 'nullable|string|max:30',
'lignes.*.prix_unitaire_ht' => 'nullable|numeric|min:0',
'lignes.*.taux_tva' => 'nullable|numeric|min:0|max:100',
'lignes.*.categorie_id' => 'nullable|exists:categories,id',
'lignes.*.article_id' => 'nullable|exists:articles,id',
'lignes.*.notes' => 'nullable|string',
]);
DB::transaction(function () use ($validated, $request) {
$commande = Commande::create([
...$validated,
'numero_commande' => Commande::genererNumero(),
'user_id' => $request->user()->id,
'statut' => 'brouillon',
]);
foreach ($validated['lignes'] ?? [] as $index => $ligne) {
$commande->lignes()->create(array_merge($ligne, ['ordre' => $index]));
}
});
return redirect()->route('commandes.index')
->with('success', 'Commande créée avec succès.');
}
public function show(Commande $commande): Response
{
$this->authorize('view', $commande);
$commande->load([
'service', 'fournisseur', 'demandeur', 'validateur', 'acheteur',
'lignes.categorie',
'historique.user',
'piecesJointes.user',
]);
$transitionsDisponibles = collect(Commande::STATUT_TRANSITIONS[$commande->statut] ?? [])
->filter(fn ($statut) => request()->user()->hasRole('admin') || $this->userCanTransition(request()->user(), $commande, $statut))
->values();
return Inertia::render('Commandes/Show', [
'commande' => $commande,
'transitionsDisponibles' => $transitionsDisponibles,
]);
}
public function edit(Commande $commande): Response
{
$this->authorize('update', $commande);
$commande->load('lignes.categorie');
return Inertia::render('Commandes/Edit', [
'commande' => $commande,
'services' => Service::all(),
'fournisseurs' => Fournisseur::active()->orderBy('nom')->get(),
'categories' => Categorie::active()->orderBy('ordre')->get(),
'articles' => Article::active()->with('categorie')->orderBy('designation')->get(),
]);
}
public function update(Request $request, Commande $commande): RedirectResponse
{
$this->authorize('update', $commande);
$validated = $request->validate([
'service_id' => 'required|exists:services,id',
'fournisseur_id' => 'nullable|exists:fournisseurs,id',
'objet' => 'required|string|max:255',
'description' => 'nullable|string',
'justification' => 'nullable|string',
'priorite' => 'required|in:normale,haute,urgente',
'reference_fournisseur' => 'nullable|string|max:100',
'imputation_budgetaire' => 'nullable|string|max:100',
'date_demande' => 'required|date',
'date_souhaitee' => 'nullable|date',
'date_livraison_prevue' => 'nullable|date',
'notes' => 'nullable|string',
'notes_fournisseur' => 'nullable|string',
'lignes' => 'array',
'lignes.*.id' => 'nullable|integer',
'lignes.*.designation' => 'required|string|max:255',
'lignes.*.reference' => 'nullable|string|max:100',
'lignes.*.quantite' => 'required|numeric|min:0.001',
'lignes.*.quantite_recue' => 'nullable|numeric|min:0',
'lignes.*.unite' => 'nullable|string|max:30',
'lignes.*.prix_unitaire_ht' => 'nullable|numeric|min:0',
'lignes.*.taux_tva' => 'nullable|numeric|min:0|max:100',
'lignes.*.categorie_id' => 'nullable|exists:categories,id',
'lignes.*.article_id' => 'nullable|exists:articles,id',
'lignes.*.notes' => 'nullable|string',
]);
DB::transaction(function () use ($validated, $commande) {
$commande->update($validated);
$lignesData = $validated['lignes'] ?? [];
$lignesIds = collect($lignesData)->pluck('id')->filter()->all();
// Supprimer les lignes retirées
$commande->lignes()->whereNotIn('id', $lignesIds)->delete();
foreach ($lignesData as $index => $ligneData) {
$commande->lignes()->updateOrCreate(
['id' => $ligneData['id'] ?? null],
array_merge($ligneData, ['ordre' => $index, 'commande_id' => $commande->id])
);
}
});
return redirect()->route('commandes.show', $commande)
->with('success', 'Commande mise à jour.');
}
public function destroy(Commande $commande): RedirectResponse
{
$this->authorize('delete', $commande);
$commande->delete();
return redirect()->route('commandes.index')
->with('success', 'Commande supprimée.');
}
public function transition(Request $request, Commande $commande): RedirectResponse
{
$validated = $request->validate([
'statut' => 'required|string',
'commentaire' => 'nullable|string|max:1000',
]);
$this->authorize('transition', [$commande, $validated['statut']]);
$ok = $commande->transitionnerVers($validated['statut'], $request->user(), $validated['commentaire'] ?? null);
if (!$ok) {
return back()->with('error', 'Transition non autorisée.');
}
return redirect()->route('commandes.show', $commande)
->with('success', 'Statut mis à jour : ' . Commande::STATUTS_LABELS[$validated['statut']]);
}
private function userCanTransition($user, Commande $commande, string $statut): bool
{
try {
$this->authorize('transition', [$commande, $statut]);
return true;
} catch (\Exception) {
return false;
}
}
public function exportPdf(Commande $commande)
{
$this->authorize('view', $commande);
$commande->load([
'service', 'fournisseur', 'demandeur', 'validateur', 'acheteur',
'lignes.categorie',
]);
$pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('pdf.commande', [
'commande' => $commande,
]);
return $pdf->stream('commande-' . $commande->numero_commande . '.pdf');
}
}

View File

@@ -0,0 +1,161 @@
<?php
namespace App\Http\Controllers;
use App\Models\Contrat;
use App\Models\Fournisseur;
use App\Models\Service;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Illuminate\Support\Facades\DB;
class ContratController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('viewAny', Contrat::class);
$query = Contrat::with(['fournisseur', 'service']);
if (!$request->user()->hasRole('admin')) {
$query->where('service_id', $request->user()->service_id);
}
$query->when($request->search, function ($q, $search) {
$q->where(function ($sub) use ($search) {
$sub->where('titre', 'like', "%{$search}%")
->orWhereHas('fournisseur', fn($f) => $f->where('nom', 'like', "%{$search}%"));
});
})->when($request->service_id, function ($q, $serviceId) use ($request) {
if ($request->user()->hasRole('admin')) {
$q->where('service_id', $serviceId);
}
})->when($request->fournisseur_id, function ($q, $fournisseurId) {
$q->where('fournisseur_id', $fournisseurId);
})->when($request->statut, function ($q, $statut) {
$q->where('statut', $statut);
});
$contrats = $query->orderBy('date_echeance', 'asc')->paginate(20)->withQueryString();
// Calculate estProcheEcheance for the frontend
$contrats->getCollection()->transform(function ($contrat) {
$contrat->append(['est_proche_echeance', 'est_en_retard']);
return $contrat;
});
return Inertia::render('Contrats/Index', [
'contrats' => $contrats,
'services' => $request->user()->hasRole('admin') ? Service::all() : Service::where('id', $request->user()->service_id)->get(),
'fournisseurs' => Fournisseur::active()->orderBy('nom')->get(),
'filters' => $request->only(['search', 'service_id', 'fournisseur_id', 'statut']),
'statuts' => Contrat::STATUTS_LABELS,
]);
}
public function create(Request $request): Response
{
$this->authorize('create', Contrat::class);
return Inertia::render('Contrats/Create', [
'services' => $request->user()->hasRole('admin') ? Service::all() : Service::where('id', $request->user()->service_id)->get(),
'fournisseurs' => Fournisseur::active()->orderBy('nom')->get(),
'statuts' => Contrat::STATUTS_LABELS,
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorize('create', Contrat::class);
$rules = [
'titre' => 'required|string|max:255',
'description' => 'nullable|string',
'fournisseur_id' => 'required|exists:fournisseurs,id',
'date_debut' => 'nullable|date',
'date_echeance' => 'required|date',
'statut' => 'required|in:actif,a_renouveler,expire,resilie',
'montant' => 'nullable|numeric|min:0',
'preavis_jours' => 'nullable|integer|min:0',
];
// Only admins can select other services, otherwise we force the user's service
if ($request->user()->hasRole('admin')) {
$rules['service_id'] = 'required|exists:services,id';
}
$validated = $request->validate($rules);
if (!$request->user()->hasRole('admin')) {
$validated['service_id'] = $request->user()->service_id;
}
$contrat = Contrat::create($validated);
return redirect()->route('contrats.index')
->with('success', 'Contrat créé avec succès.');
}
public function show(Contrat $contrat): Response
{
$this->authorize('view', $contrat);
$contrat->load(['fournisseur', 'service', 'piecesJointes.user']);
$contrat->append(['est_proche_echeance', 'est_en_retard']);
return Inertia::render('Contrats/Show', [
'contrat' => $contrat,
]);
}
public function edit(Contrat $contrat, Request $request): Response
{
$this->authorize('update', $contrat);
return Inertia::render('Contrats/Edit', [
'contrat' => $contrat,
'services' => $request->user()->hasRole('admin') ? Service::all() : Service::where('id', $request->user()->service_id)->get(),
'fournisseurs' => Fournisseur::active()->orderBy('nom')->get(),
'statuts' => Contrat::STATUTS_LABELS,
]);
}
public function update(Request $request, Contrat $contrat): RedirectResponse
{
$this->authorize('update', $contrat);
$rules = [
'titre' => 'required|string|max:255',
'description' => 'nullable|string',
'fournisseur_id' => 'required|exists:fournisseurs,id',
'date_debut' => 'nullable|date',
'date_echeance' => 'required|date',
'statut' => 'required|in:actif,a_renouveler,expire,resilie',
'montant' => 'nullable|numeric|min:0',
'preavis_jours' => 'nullable|integer|min:0',
];
if ($request->user()->hasRole('admin')) {
$rules['service_id'] = 'required|exists:services,id';
}
$validated = $request->validate($rules);
$contrat->update($validated);
return redirect()->route('contrats.show', $contrat)
->with('success', 'Contrat mis à jour.');
}
public function destroy(Contrat $contrat): RedirectResponse
{
$this->authorize('delete', $contrat);
$contrat->delete();
return redirect()->route('contrats.index')
->with('success', 'Contrat supprimé.');
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
use \Illuminate\Foundation\Auth\Access\AuthorizesRequests;
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers;
use App\Models\Commande;
use App\Models\Service;
use App\Models\Contrat;
use App\Models\Domaine;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
public function index(Request $request): Response
{
$user = $request->user();
$stats = [
'total' => Commande::count(),
'en_cours' => Commande::enCours()->count(),
'en_retard' => Commande::enRetard()->count(),
'brouillons' => Commande::parStatut('brouillon')->count(),
'en_attente_validation' => Commande::parStatut('en_attente_validation')->count(),
'validees' => Commande::parStatut('validee')->count(),
'commandees' => Commande::parStatut('commandee')->count(),
'partiellement_recues' => Commande::parStatut('partiellement_recue')->count(),
'recues_complete' => Commande::parStatut('recue_complete')->count(),
];
$commandesRecentes = Commande::with(['service', 'fournisseur', 'demandeur'])
->latest()
->limit(8)
->get();
$commandesEnRetard = Commande::enRetard()
->with(['service', 'fournisseur', 'demandeur'])
->orderBy('date_souhaitee')
->limit(10)
->get();
$commandesUrgentes = Commande::urgentes()
->enCours()
->with(['service', 'fournisseur', 'demandeur'])
->latest()
->limit(5)
->get();
$statsParStatut = Commande::select('statut', DB::raw('count(*) as total'))
->groupBy('statut')
->get()
->keyBy('statut');
$statsParService = Service::withCount([
'commandes',
'commandes as commandes_en_cours_count' => fn ($q) => $q->enCours(),
])->get();
$montantParMois = Commande::select(
DB::raw('YEAR(date_demande) as annee'),
DB::raw('MONTH(date_demande) as mois'),
DB::raw('SUM(montant_ttc) as total_ttc'),
DB::raw('COUNT(*) as nb_commandes')
)
->whereYear('date_demande', now()->year)
->whereNotIn('statut', ['annulee'])
->groupBy('annee', 'mois')
->orderBy('mois')
->get();
// Stats Contrats
$contratsQuery = Contrat::query();
if (!$user->hasRole('admin')) {
$contratsQuery->where('service_id', $user->service_id);
}
$tousContrats = $contratsQuery->get();
$statsContrats = [
'total' => $tousContrats->count(),
'proches' => $tousContrats->filter(fn($c) => $c->est_proche_echeance && !$c->est_en_retard)->count(),
'en_retard' => $tousContrats->filter(fn($c) => $c->est_en_retard)->count(),
];
// Stats Domaines (Visibles par tous)
$tousDomaines = Domaine::all();
$statsDomaines = [
'total' => $tousDomaines->count(),
'proches' => $tousDomaines->filter(fn($d) => $d->est_proche_echeance && !$d->est_en_retard)->count(),
'en_retard' => $tousDomaines->filter(fn($d) => $d->est_en_retard)->count(),
];
return Inertia::render('Dashboard/Index', compact(
'stats',
'commandesRecentes',
'commandesEnRetard',
'commandesUrgentes',
'statsParStatut',
'statsParService',
'montantParMois',
'statsContrats',
'statsDomaines',
));
}
}

View File

@@ -0,0 +1,148 @@
<?php
namespace App\Http\Controllers;
use App\Models\Domaine;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Iodev\Whois\Factory as WhoisFactory;
use Iodev\Whois\Exceptions\ConnectionException;
use Iodev\Whois\Exceptions\ServerMismatchException;
use Iodev\Whois\Exceptions\WhoisException;
use Carbon\Carbon;
class DomaineController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('viewAny', Domaine::class);
$query = Domaine::query();
$query->when($request->search, function ($q, $search) {
$q->where('nom', 'like', "%{$search}%")
->orWhere('prestataire', 'like', "%{$search}%")
->orWhere('hebergeur', 'like', "%{$search}%");
});
$domaines = $query->orderBy('date_echeance', 'asc')->paginate(20)->withQueryString();
$domaines->getCollection()->transform(function ($domaine) {
$domaine->append(['est_proche_echeance', 'est_en_retard']);
return $domaine;
});
return Inertia::render('Domaines/Index', [
'domaines' => $domaines,
'filters' => $request->only(['search']),
]);
}
public function create(): Response
{
$this->authorize('create', Domaine::class);
return Inertia::render('Domaines/Create');
}
public function store(Request $request): RedirectResponse
{
$this->authorize('create', Domaine::class);
$validated = $request->validate([
'nom' => 'required|string|max:255|unique:domaines,nom',
'date_echeance' => 'nullable|date',
'prestataire' => 'nullable|string',
'hebergeur' => 'nullable|string|max:255',
]);
$domaine = Domaine::create($validated);
// Si la date d'échéance n'est pas fournie, on tente de la récupérer via WHOIS
if (empty($validated['date_echeance'])) {
$this->syncWhoisDate($domaine);
}
return redirect()->route('domaines.index')
->with('success', 'Domaine créé avec succès.');
}
public function edit(Domaine $domaine): Response
{
$this->authorize('update', $domaine);
return Inertia::render('Domaines/Edit', [
'domaine' => $domaine,
]);
}
public function update(Request $request, Domaine $domaine): RedirectResponse
{
$this->authorize('update', $domaine);
$validated = $request->validate([
'nom' => 'required|string|max:255|unique:domaines,nom,' . $domaine->id,
'date_echeance' => 'nullable|date',
'prestataire' => 'nullable|string',
'hebergeur' => 'nullable|string|max:255',
]);
$domaine->update($validated);
return redirect()->route('domaines.index')
->with('success', 'Domaine mis à jour.');
}
public function destroy(Domaine $domaine): RedirectResponse
{
$this->authorize('delete', $domaine);
$domaine->delete();
return redirect()->route('domaines.index')
->with('success', 'Domaine supprimé.');
}
public function syncWhois(Domaine $domaine): RedirectResponse
{
$this->authorize('update', $domaine);
if ($this->syncWhoisDate($domaine)) {
return back()->with('success', 'Date d\'échéance synchronisée avec le Whois depuis le port 43.');
}
return back()->with('error', 'Impossible de récupérer la date d\'échéance pour ce domaine (Whois indisponible ou format inconnu).');
}
private function syncWhoisDate(Domaine $domaine): bool
{
try {
$whois = WhoisFactory::get()->createWhois();
$info = $whois->loadDomainInfo($domaine->nom);
if ($info && $info->expirationDate) {
// expirationDate is a unix timestamp integer in the package
$domaine->update([
'date_echeance' => Carbon::createFromTimestamp($info->expirationDate)->format('Y-m-d')
]);
return true;
}
} catch (ConnectionException $e) {
// Port 43 bloqué ou timeout
return false;
} catch (ServerMismatchException $e) {
// Serveur Whois inconnu
return false;
} catch (WhoisException $e) {
// Autre erreur Whois
return false;
} catch (\Exception $e) {
// Erreur inattendue
return false;
}
return false;
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace App\Http\Controllers;
use App\Models\Fournisseur;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class FournisseurController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('viewAny', Fournisseur::class);
$query = Fournisseur::withCount('commandes')
->when($request->search, fn ($q) => $q->where(function ($sub) use ($request) {
$sub->where('nom', 'like', "%{$request->search}%")
->orWhere('ville', 'like', "%{$request->search}%")
->orWhere('email', 'like', "%{$request->search}%");
}))
->when($request->has('active') && $request->active !== '', fn ($q) => $q->where('active', $request->active));
$fournisseurs = $query->orderBy('nom')->paginate(20)->withQueryString();
return Inertia::render('Fournisseurs/Index', [
'fournisseurs' => $fournisseurs,
'filters' => $request->only(['search', 'active']),
]);
}
public function create(): Response
{
$this->authorize('create', Fournisseur::class);
return Inertia::render('Fournisseurs/Create');
}
public function store(Request $request): RedirectResponse
{
$this->authorize('create', Fournisseur::class);
$validated = $this->validateFournisseur($request);
Fournisseur::create($validated);
return redirect()->route('fournisseurs.index')
->with('success', 'Fournisseur créé avec succès.');
}
public function show(Fournisseur $fournisseur): Response
{
$this->authorize('view', $fournisseur);
$fournisseur->load(['commandes' => fn ($q) => $q->with('service', 'demandeur')->latest()->limit(10)]);
return Inertia::render('Fournisseurs/Show', compact('fournisseur'));
}
public function edit(Fournisseur $fournisseur): Response
{
$this->authorize('update', $fournisseur);
return Inertia::render('Fournisseurs/Edit', compact('fournisseur'));
}
public function update(Request $request, Fournisseur $fournisseur): RedirectResponse
{
$this->authorize('update', $fournisseur);
$validated = $this->validateFournisseur($request);
$fournisseur->update($validated);
return redirect()->route('fournisseurs.show', $fournisseur)
->with('success', 'Fournisseur mis à jour.');
}
public function destroy(Fournisseur $fournisseur): RedirectResponse
{
$this->authorize('delete', $fournisseur);
$fournisseur->delete();
return redirect()->route('fournisseurs.index')
->with('success', 'Fournisseur supprimé.');
}
public function toggleActive(Fournisseur $fournisseur): RedirectResponse
{
$this->authorize('update', $fournisseur);
$fournisseur->update(['active' => !$fournisseur->active]);
return back()->with('success', 'Statut du fournisseur mis à jour.');
}
private function validateFournisseur(Request $request): array
{
return $request->validate([
'nom' => 'required|string|max:255',
'raison_sociale' => 'nullable|string|max:255',
'siret' => 'nullable|string|max:14',
'adresse' => 'nullable|string',
'code_postal' => 'nullable|string|max:10',
'ville' => 'nullable|string|max:100',
'telephone' => 'nullable|string|max:20',
'email' => 'nullable|email|max:255',
'contact_commercial' => 'nullable|string|max:255',
'email_commercial' => 'nullable|email|max:255',
'telephone_commercial' => 'nullable|string|max:20',
'site_web' => 'nullable|url|max:255',
'notes' => 'nullable|string',
'active' => 'boolean',
]);
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace App\Http\Controllers;
use App\Models\Commande;
use App\Models\PieceJointe;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class PieceJointeController extends Controller
{
/**
* Upload une pièce jointe sur une commande.
*/
public function store(Request $request, Commande $commande): RedirectResponse
{
$request->validate([
'type' => 'required|in:devis,bon_commande,bon_livraison,facture,autre',
'fichier' => [
'required',
'file',
'max:20480', // 20 Mo max
'mimes:pdf,jpg,jpeg,png,gif,webp,doc,docx,xls,xlsx,odt,ods,csv,zip',
],
'description' => 'nullable|string|max:500',
], [
'fichier.mimes' => 'Types autorisés : PDF, images, Word, Excel, OpenDocument, CSV, ZIP.',
'fichier.max' => 'La pièce jointe ne peut pas dépasser 20 Mo.',
]);
$fichier = $request->file('fichier');
$chemin = $fichier->store(
'commandes/' . $commande->id,
'private'
);
$commande->piecesJointes()->create([
'user_id' => $request->user()->id,
'type' => $request->type,
'nom_original' => $fichier->getClientOriginalName(),
'chemin' => $chemin,
'mime_type' => $fichier->getMimeType(),
'taille' => $fichier->getSize(),
'description' => $request->description,
]);
return back()->with('success', 'Pièce jointe ajoutée avec succès.');
}
/**
* Upload une pièce jointe sur un contrat.
*/
public function storeContrat(Request $request, \App\Models\Contrat $contrat): RedirectResponse
{
$request->validate([
'type' => 'required|in:contrat,avenant,autre',
'fichier' => [
'required',
'file',
'max:20480', // 20 Mo max
'mimes:pdf,jpg,jpeg,png,gif,webp,doc,docx,xls,xlsx,odt,ods,csv,zip',
],
'description' => 'nullable|string|max:500',
], [
'fichier.mimes' => 'Types autorisés : PDF, images, Word, Excel, OpenDocument, CSV, ZIP.',
'fichier.max' => 'La pièce jointe ne peut pas dépasser 20 Mo.',
]);
$fichier = $request->file('fichier');
$chemin = $fichier->store(
'contrats/' . $contrat->id,
'private'
);
$contrat->piecesJointes()->create([
'user_id' => $request->user()->id,
'type' => $request->type,
'nom_original' => $fichier->getClientOriginalName(),
'chemin' => $chemin,
'mime_type' => $fichier->getMimeType(),
'taille' => $fichier->getSize(),
'description' => $request->description,
]);
return back()->with('success', 'Pièce jointe ajoutée avec succès au contrat.');
}
/**
* Télécharger une pièce jointe (accès sécurisé, sans URL publique).
*/
public function download(PieceJointe $pieceJointe): StreamedResponse
{
// Vérifier que l'utilisateur est authentifié (middleware auth déjà sur le groupe)
abort_unless(Storage::disk('private')->exists($pieceJointe->chemin), 404);
return Storage::disk('private')->download(
$pieceJointe->chemin,
$pieceJointe->nom_original
);
}
/**
* Supprimer une pièce jointe.
*/
public function destroy(PieceJointe $pieceJointe): RedirectResponse
{
$this->authorize('delete', $pieceJointe);
$pieceJointe->supprimer();
return back()->with('success', 'Pièce jointe supprimée.');
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ProfileUpdateRequest;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Inertia\Inertia;
use Inertia\Response;
class ProfileController extends Controller
{
/**
* Display the user's profile form.
*/
public function edit(Request $request): Response
{
return Inertia::render('Profile/Edit', [
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
'status' => session('status'),
]);
}
/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->user()->fill($request->validated());
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
$request->user()->save();
return Redirect::route('profile.edit');
}
/**
* Delete the user's account.
*/
public function destroy(Request $request): RedirectResponse
{
$request->validate([
'password' => ['required', 'current_password'],
]);
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return Redirect::to('/');
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers;
use App\Models\Service;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ServiceController extends Controller
{
public function index(): Response
{
return Inertia::render('Services/Index', [
'services' => Service::withCount('users', 'commandes')->get(),
]);
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'nom' => 'required|string|max:100|unique:services,nom',
'description' => 'nullable|string',
'couleur' => 'nullable|string|max:7',
'icone' => 'nullable|string|max:50',
]);
Service::create($validated);
return back()->with('success', 'Service créé.');
}
public function update(Request $request, Service $service): RedirectResponse
{
$validated = $request->validate([
'nom' => 'required|string|max:100|unique:services,nom,' . $service->id,
'description' => 'nullable|string',
'couleur' => 'nullable|string|max:7',
'icone' => 'nullable|string|max:50',
]);
$service->update($validated);
return back()->with('success', 'Service mis à jour.');
}
public function destroy(Service $service): RedirectResponse
{
if ($service->users()->exists() || $service->commandes()->exists()) {
return back()->with('error', 'Impossible de supprimer un service ayant des utilisateurs ou des commandes.');
}
$service->delete();
return back()->with('success', 'Service supprimé.');
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers;
use App\Models\Service;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Inertia\Inertia;
use Inertia\Response;
use Spatie\Permission\Models\Role;
class UserController extends Controller
{
public function index(): Response
{
$this->authorize('viewAny', User::class);
return Inertia::render('Users/Index', [
'users' => User::with('service', 'roles')->orderBy('name')->paginate(20),
'services' => Service::all(),
'roles' => Role::all(),
]);
}
public function edit(User $user): Response
{
$this->authorize('update', $user);
return Inertia::render('Users/Edit', [
'user' => $user->load('service', 'roles'),
'services' => Service::all(),
'roles' => Role::all(),
]);
}
public function update(Request $request, User $user): RedirectResponse
{
$this->authorize('update', $user);
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email,' . $user->id,
'service_id' => 'nullable|exists:services,id',
'telephone' => 'nullable|string|max:20',
'role' => 'required|string|exists:roles,name',
'password' => 'nullable|string|min:8|confirmed',
]);
$user->update([
'name' => $validated['name'],
'email' => $validated['email'],
'service_id' => $validated['service_id'],
'telephone' => $validated['telephone'],
...(isset($validated['password']) ? ['password' => Hash::make($validated['password'])] : []),
]);
$user->syncRoles([$validated['role']]);
return redirect()->route('users.index')
->with('success', 'Utilisateur mis à jour.');
}
public function toggleActive(User $user): RedirectResponse
{
$this->authorize('update', $user);
$user->update(['active' => !$user->active]);
return back()->with('success', 'Statut de l\'utilisateur mis à jour.');
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Middleware;
use App\Models\Commande;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
protected $rootView = 'app';
public function version(Request $request): ?string
{
return parent::version($request);
}
public function share(Request $request): array
{
return [
...parent::share($request),
'auth' => [
'user' => $request->user()?->load('service', 'roles'),
],
'flash' => [
'success' => fn () => $request->session()->get('success'),
'error' => fn () => $request->session()->get('error'),
],
'config' => [
'statuts' => Commande::STATUTS_LABELS,
'priorites' => Commande::PRIORITES_LABELS,
'statuts_couleurs' => Commande::STATUTS_COULEURS,
],
];
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
];
}
/**
* Attempt to authenticate the request's credentials.
*
* @throws ValidationException
*/
public function authenticate(): void
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
}
/**
* Ensure the login request is not rate limited.
*
* @throws ValidationException
*/
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout($this));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the rate limiting throttle key for the request.
*/
public function throttleKey(): string
{
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProfileUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($this->user()->id),
],
];
}
}

35
app/Models/Article.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Builder;
class Article extends Model
{
protected $fillable = [
'reference', 'designation', 'description',
'categorie_id', 'fournisseur_id', 'prix_unitaire_ht', 'unite', 'active',
];
protected $casts = [
'prix_unitaire_ht' => 'decimal:2',
'active' => 'boolean',
];
public function categorie(): BelongsTo
{
return $this->belongsTo(Categorie::class);
}
public function fournisseur(): BelongsTo
{
return $this->belongsTo(Fournisseur::class);
}
public function scopeActive(Builder $query): Builder
{
return $query->where('active', true);
}
}

32
app/Models/Categorie.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Builder;
class Categorie extends Model
{
protected $fillable = ['nom', 'description', 'couleur', 'icone', 'ordre', 'active'];
protected $casts = [
'active' => 'boolean',
'ordre' => 'integer',
];
public function articles(): HasMany
{
return $this->hasMany(Article::class);
}
public function lignesCommande(): HasMany
{
return $this->hasMany(LigneCommande::class);
}
public function scopeActive(Builder $query): Builder
{
return $query->where('active', true);
}
}

267
app/Models/Commande.php Normal file
View File

@@ -0,0 +1,267 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Builder;
use Carbon\Carbon;
class Commande extends Model
{
use SoftDeletes;
protected $fillable = [
'numero_commande', 'service_id', 'fournisseur_id', 'user_id',
'validateur_id', 'acheteur_id', 'objet', 'description', 'justification',
'statut', 'priorite', 'reference_fournisseur', 'imputation_budgetaire',
'montant_ht', 'montant_ttc',
'date_demande', 'date_souhaitee', 'date_validation', 'date_commande',
'date_livraison_prevue', 'date_reception', 'date_reception_complete', 'date_cloture',
'notes', 'notes_fournisseur',
];
protected $casts = [
'montant_ht' => 'decimal:2',
'montant_ttc' => 'decimal:2',
'date_demande' => 'date',
'date_souhaitee' => 'date',
'date_validation' => 'datetime',
'date_commande' => 'datetime',
'date_livraison_prevue' => 'date',
'date_reception' => 'datetime',
'date_reception_complete' => 'datetime',
'date_cloture' => 'datetime',
];
// -----------------------------------------------------------------------
// Constantes
// -----------------------------------------------------------------------
const STATUTS_LABELS = [
'brouillon' => 'Brouillon',
'en_attente_validation' => 'En attente de validation',
'validee' => 'Validée',
'commandee' => 'Commandée',
'partiellement_recue' => 'Partiellement reçue',
'recue_complete' => 'Reçue complète',
'cloturee' => 'Clôturée',
'annulee' => 'Annulée',
];
const STATUTS_COULEURS = [
'brouillon' => 'gray',
'en_attente_validation' => 'yellow',
'validee' => 'blue',
'commandee' => 'indigo',
'partiellement_recue' => 'orange',
'recue_complete' => 'green',
'cloturee' => 'slate',
'annulee' => 'red',
];
const PRIORITES_LABELS = [
'normale' => 'Normale',
'haute' => 'Haute',
'urgente' => 'Urgente',
];
const STATUT_TRANSITIONS = [
'brouillon' => ['en_attente_validation', 'annulee'],
'en_attente_validation' => ['validee', 'brouillon', 'annulee'],
'validee' => ['commandee', 'annulee'],
'commandee' => ['partiellement_recue', 'recue_complete', 'annulee'],
'partiellement_recue' => ['recue_complete', 'annulee'],
'recue_complete' => ['cloturee'],
'cloturee' => [],
'annulee' => [],
];
// -----------------------------------------------------------------------
// Relations
// -----------------------------------------------------------------------
public function service(): BelongsTo
{
return $this->belongsTo(Service::class);
}
public function fournisseur(): BelongsTo
{
return $this->belongsTo(Fournisseur::class);
}
public function demandeur(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function validateur(): BelongsTo
{
return $this->belongsTo(User::class, 'validateur_id');
}
public function acheteur(): BelongsTo
{
return $this->belongsTo(User::class, 'acheteur_id');
}
public function lignes(): HasMany
{
return $this->hasMany(LigneCommande::class)->orderBy('ordre');
}
public function historique(): HasMany
{
return $this->hasMany(HistoriqueCommande::class)->orderBy('created_at', 'desc');
}
public function piecesJointes(): HasMany
{
return $this->hasMany(PieceJointe::class)->orderBy('created_at', 'desc');
}
// -----------------------------------------------------------------------
// Scopes
// -----------------------------------------------------------------------
public function scopeEnCours(Builder $query): Builder
{
return $query->whereNotIn('statut', ['cloturee', 'annulee']);
}
public function scopeEnRetard(Builder $query): Builder
{
return $query->enCours()->whereNotNull('date_souhaitee')->where('date_souhaitee', '<', now()->toDateString());
}
public function scopeUrgentes(Builder $query): Builder
{
return $query->where('priorite', 'urgente');
}
public function scopeParService(Builder $query, int $serviceId): Builder
{
return $query->where('service_id', $serviceId);
}
public function scopeParStatut(Builder $query, string $statut): Builder
{
return $query->where('statut', $statut);
}
public function scopeParFournisseur(Builder $query, int $fournisseurId): Builder
{
return $query->where('fournisseur_id', $fournisseurId);
}
// -----------------------------------------------------------------------
// Accesseurs
// -----------------------------------------------------------------------
public function getStatutLabelAttribute(): string
{
return self::STATUTS_LABELS[$this->statut] ?? $this->statut;
}
public function getStatutCouleurAttribute(): string
{
return self::STATUTS_COULEURS[$this->statut] ?? 'gray';
}
public function getEstEnRetardAttribute(): bool
{
return !in_array($this->statut, ['cloturee', 'annulee'])
&& $this->date_souhaitee !== null
&& $this->date_souhaitee->isPast();
}
public function getTauxReceptionAttribute(): float
{
$total = $this->lignes->sum(fn ($l) => (float) $l->quantite);
if ($total <= 0) {
return 0.0;
}
$recu = $this->lignes->sum(fn ($l) => (float) $l->quantite_recue);
return round(min($recu / $total * 100, 100), 1);
}
// -----------------------------------------------------------------------
// Numérotation automatique
// -----------------------------------------------------------------------
public static function genererNumero(): string
{
$annee = now()->year;
$derniere = static::withTrashed()
->where('numero_commande', 'like', "CMD-IT-{$annee}-%")
->orderByDesc('id')
->value('numero_commande');
$sequence = 1;
if ($derniere) {
$sequence = (int) substr($derniere, strrpos($derniere, '-') + 1) + 1;
}
return sprintf('CMD-IT-%d-%04d', $annee, $sequence);
}
// -----------------------------------------------------------------------
// Moteur de transitions
// -----------------------------------------------------------------------
public function peutTransitionnerVers(string $statut): bool
{
return in_array($statut, self::STATUT_TRANSITIONS[$this->statut] ?? []);
}
public function transitionnerVers(string $statut, User $user, ?string $commentaire = null): bool
{
if (!$this->peutTransitionnerVers($statut)) {
return false;
}
$ancienStatut = $this->statut;
$this->statut = $statut;
// Mettre à jour les dates et les acteurs selon le nouveau statut
match ($statut) {
'validee' => $this->fill(['date_validation' => now(), 'validateur_id' => $user->id]),
'commandee' => $this->fill(['date_commande' => now(), 'acheteur_id' => $user->id]),
'partiellement_recue' => $this->fill(['date_reception' => $this->date_reception ?? now()]),
'recue_complete' => $this->fill(['date_reception_complete' => now(), 'date_reception' => $this->date_reception ?? now()]),
'cloturee' => $this->fill(['date_cloture' => now()]),
default => null,
};
$this->save();
HistoriqueCommande::create([
'commande_id' => $this->id,
'user_id' => $user->id,
'ancien_statut' => $ancienStatut,
'nouveau_statut' => $statut,
'commentaire' => $commentaire,
]);
return true;
}
// -----------------------------------------------------------------------
// Recalcul des montants (appelé depuis LigneCommande)
// -----------------------------------------------------------------------
public function recalculerMontants(): void
{
$this->lignes()->get()->tap(function ($lignes) {
$ht = $lignes->sum(fn ($l) => (float) $l->montant_ht);
$ttc = $lignes->sum(fn ($l) => (float) $l->montant_ttc);
$this->withoutEvents(function () use ($ht, $ttc) {
$this->update(['montant_ht' => $ht, 'montant_ttc' => $ttc]);
});
});
}
}

75
app/Models/Contrat.php Normal file
View File

@@ -0,0 +1,75 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Contrat extends Model
{
use HasFactory;
protected $fillable = [
'titre',
'description',
'fournisseur_id',
'service_id',
'date_debut',
'date_echeance',
'statut',
'montant',
'preavis_jours',
];
protected $casts = [
'date_debut' => 'date',
'date_echeance' => 'date',
'montant' => 'decimal:2',
];
const STATUTS_LABELS = [
'actif' => 'Actif',
'a_renouveler' => 'À renouveler',
'expire' => 'Expiré',
'resilie' => 'Résilié',
];
public function fournisseur(): BelongsTo
{
return $this->belongsTo(Fournisseur::class);
}
public function service(): BelongsTo
{
return $this->belongsTo(Service::class);
}
public function piecesJointes(): HasMany
{
return $this->hasMany(PieceJointe::class)->orderByDesc('created_at');
}
// Un contrat est considéré "proche d'expiration" si l'échéance est dans moins de 30 jours (ou selon son préavis)
public function getEstProcheEcheanceAttribute(): bool
{
if (!$this->date_echeance || in_array($this->statut, ['resilie', 'expire'])) {
return false;
}
$joursAvantAlerte = $this->preavis_jours > 0 ? $this->preavis_jours + 15 : 30;
return Carbon::now()->diffInDays($this->date_echeance, false) <= $joursAvantAlerte;
}
public function getEstEnRetardAttribute(): bool
{
if (!$this->date_echeance || $this->statut === 'resilie') {
return false;
}
return Carbon::now()->isAfter($this->date_echeance);
}
}

42
app/Models/Domaine.php Normal file
View File

@@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Domaine extends Model
{
use HasFactory;
protected $fillable = [
'nom',
'date_echeance',
'prestataire',
'hebergeur',
];
protected $casts = [
'date_echeance' => 'date',
];
// Un domaine est considéré "proche d'expiration" si l'échéance est dans moins de 30 jours
public function getEstProcheEcheanceAttribute(): bool
{
if (!$this->date_echeance) {
return false;
}
return Carbon::now()->diffInDays($this->date_echeance, false) <= 30;
}
public function getEstEnRetardAttribute(): bool
{
if (!$this->date_echeance) {
return false;
}
return Carbon::now()->isAfter($this->date_echeance);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Builder;
class Fournisseur extends Model
{
protected $fillable = [
'nom', 'raison_sociale', 'siret', 'adresse', 'code_postal', 'ville',
'telephone', 'email', 'contact_commercial', 'email_commercial',
'telephone_commercial', 'site_web', 'notes', 'active',
];
protected $casts = [
'active' => 'boolean',
];
public function commandes(): HasMany
{
return $this->hasMany(Commande::class);
}
public function articles(): HasMany
{
return $this->hasMany(Article::class);
}
public function scopeActive(Builder $query): Builder
{
return $query->where('active', true);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class HistoriqueCommande extends Model
{
public $timestamps = false;
protected $fillable = ['commande_id', 'user_id', 'ancien_statut', 'nouveau_statut', 'commentaire'];
protected $casts = [
'created_at' => 'datetime',
];
public function commande(): BelongsTo
{
return $this->belongsTo(Commande::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class LigneCommande extends Model
{
protected $table = 'lignes_commande';
protected $fillable = [
'commande_id', 'article_id', 'categorie_id',
'designation', 'reference', 'quantite', 'quantite_recue',
'unite', 'prix_unitaire_ht', 'taux_tva',
'montant_ht', 'montant_ttc', 'notes', 'ordre',
];
protected $casts = [
'quantite' => 'decimal:3',
'quantite_recue' => 'decimal:3',
'prix_unitaire_ht' => 'decimal:2',
'taux_tva' => 'decimal:2',
'montant_ht' => 'decimal:2',
'montant_ttc' => 'decimal:2',
];
protected static function booted(): void
{
static::saving(function (LigneCommande $ligne) {
if ($ligne->prix_unitaire_ht !== null && $ligne->quantite !== null) {
$ligne->montant_ht = round((float) $ligne->quantite * (float) $ligne->prix_unitaire_ht, 2);
$ligne->montant_ttc = round((float) $ligne->montant_ht * (1 + (float) $ligne->taux_tva / 100), 2);
}
});
static::saved(function (LigneCommande $ligne) {
$ligne->commande?->recalculerMontants();
});
static::deleted(function (LigneCommande $ligne) {
$ligne->commande?->recalculerMontants();
});
}
public function commande(): BelongsTo
{
return $this->belongsTo(Commande::class);
}
public function article(): BelongsTo
{
return $this->belongsTo(Article::class);
}
public function categorie(): BelongsTo
{
return $this->belongsTo(Categorie::class);
}
}

113
app/Models/PieceJointe.php Normal file
View File

@@ -0,0 +1,113 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
class PieceJointe extends Model
{
public $timestamps = false;
protected $table = 'pieces_jointes';
protected $fillable = [
'commande_id', 'contrat_id', 'user_id', 'type',
'nom_original', 'chemin', 'mime_type', 'taille', 'description',
];
protected $casts = [
'taille' => 'integer',
'created_at' => 'datetime',
];
const TYPES_LABELS = [
'devis' => 'Devis',
'bon_commande' => 'Bon de commande',
'bon_livraison' => 'Bon de livraison',
'facture' => 'Facture',
'contrat' => 'Contrat',
'avenant' => 'Avenant',
'autre' => 'Autre',
];
const TYPES_ICONES = [
'devis' => '📋',
'bon_commande' => '🛒',
'bon_livraison' => '📦',
'facture' => '🧾',
'contrat' => '📄',
'avenant' => '📑',
'autre' => '📎',
];
const TYPES_COULEURS = [
'devis' => 'purple',
'bon_commande' => 'blue',
'bon_livraison' => 'orange',
'facture' => 'green',
'contrat' => 'indigo',
'avenant' => 'teal',
'autre' => 'gray',
];
// -----------------------------------------------------------------------
// Relations
// -----------------------------------------------------------------------
public function commande(): BelongsTo
{
return $this->belongsTo(Commande::class);
}
public function contrat(): BelongsTo
{
return $this->belongsTo(Contrat::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
// -----------------------------------------------------------------------
// Accesseurs
// -----------------------------------------------------------------------
public function getTypeLabelAttribute(): string
{
return self::TYPES_LABELS[$this->type] ?? $this->type;
}
public function getTailleFormatteeAttribute(): string
{
$taille = $this->taille;
if ($taille < 1024) {
return $taille . ' o';
} elseif ($taille < 1024 * 1024) {
return round($taille / 1024, 1) . ' Ko';
}
return round($taille / (1024 * 1024), 1) . ' Mo';
}
public function getEstImageAttribute(): bool
{
return str_starts_with($this->mime_type, 'image/');
}
public function getEstPdfAttribute(): bool
{
return $this->mime_type === 'application/pdf';
}
// -----------------------------------------------------------------------
// Méthodes
// -----------------------------------------------------------------------
public function supprimer(): void
{
Storage::disk('private')->delete($this->chemin);
$this->delete();
}
}

21
app/Models/Service.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Service extends Model
{
protected $fillable = ['nom', 'description', 'couleur', 'icone'];
public function users(): HasMany
{
return $this->hasMany(User::class);
}
public function commandes(): HasMany
{
return $this->hasMany(Commande::class);
}
}

48
app/Models/User.php Normal file
View File

@@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
use HasFactory, Notifiable, HasRoles;
protected $fillable = [
'name', 'email', 'password', 'service_id', 'telephone', 'active',
];
protected $hidden = [
'password', 'remember_token',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'active' => 'boolean',
];
}
public function service(): BelongsTo
{
return $this->belongsTo(Service::class);
}
public function commandesCreees(): HasMany
{
return $this->hasMany(Commande::class, 'user_id');
}
public function commandesValidees(): HasMany
{
return $this->hasMany(Commande::class, 'validateur_id');
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Policies;
use App\Models\Commande;
use App\Models\User;
class CommandePolicy
{
public function viewAny(User $user): bool
{
return true;
}
public function view(User $user, Commande $commande): bool
{
return true;
}
public function create(User $user): bool
{
return $user->hasAnyRole(['admin', 'responsable', 'acheteur']);
}
public function update(User $user, Commande $commande): bool
{
if ($user->hasRole('admin')) {
return true;
}
$statutsEditables = ['brouillon', 'en_attente_validation'];
if (!in_array($commande->statut, $statutsEditables)) {
return false;
}
return $user->hasAnyRole(['responsable', 'acheteur'])
&& ($commande->user_id === $user->id || $commande->service_id === $user->service_id);
}
public function delete(User $user, Commande $commande): bool
{
return $user->hasRole('admin');
}
public function transition(User $user, Commande $commande, string $targetStatut): bool
{
if (!$commande->peutTransitionnerVers($targetStatut)) {
return false;
}
if ($user->hasRole('admin')) {
return true;
}
return match ($targetStatut) {
'en_attente_validation' => $user->hasAnyRole(['responsable', 'acheteur']) && $commande->user_id === $user->id,
'brouillon' => $user->hasAnyRole(['responsable', 'acheteur']) && $commande->user_id === $user->id,
'validee' => $user->hasRole('responsable') && $commande->service_id === $user->service_id,
'commandee' => $user->hasAnyRole(['responsable', 'acheteur']),
'partiellement_recue',
'recue_complete' => $user->hasAnyRole(['responsable', 'acheteur']),
'cloturee' => $user->hasAnyRole(['responsable', 'acheteur']),
'annulee' => $user->hasAnyRole(['responsable', 'acheteur'])
&& !in_array($commande->statut, ['commandee', 'partiellement_recue', 'recue_complete', 'cloturee']),
default => false,
};
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Policies;
use App\Models\Contrat;
use App\Models\User;
class ContratPolicy
{
public function viewAny(User $user): bool
{
return true;
}
public function view(User $user, Contrat $contrat): bool
{
if ($user->hasRole('admin')) {
return true;
}
return $user->service_id === $contrat->service_id;
}
public function create(User $user): bool
{
return true; // Anyone can create, but in the controller we'll restrict to their own service
}
public function update(User $user, Contrat $contrat): bool
{
if ($user->hasRole('admin')) {
return true;
}
return $user->service_id === $contrat->service_id;
}
public function delete(User $user, Contrat $contrat): bool
{
if ($user->hasRole('admin')) {
return true;
}
return $user->service_id === $contrat->service_id;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Policies;
use App\Models\Domaine;
use App\Models\User;
class DomainePolicy
{
public function viewAny(User $user): bool
{
return true;
}
public function view(User $user, Domaine $domaine): bool
{
return true;
}
private function canManage(User $user): bool
{
if ($user->hasRole('admin')) {
return true;
}
// Must belong to "Infrastructure" service
return $user->service && $user->service->nom === 'Infrastructure';
}
public function create(User $user): bool
{
return $this->canManage($user);
}
public function update(User $user, Domaine $domaine): bool
{
return $this->canManage($user);
}
public function delete(User $user, Domaine $domaine): bool
{
return $this->canManage($user);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Policies;
use App\Models\Fournisseur;
use App\Models\User;
class FournisseurPolicy
{
public function viewAny(User $user): bool
{
return true;
}
public function view(User $user, Fournisseur $fournisseur): bool
{
return true;
}
public function create(User $user): bool
{
return $user->hasAnyRole(['admin', 'responsable', 'acheteur']);
}
public function update(User $user, Fournisseur $fournisseur): bool
{
return $user->hasAnyRole(['admin', 'responsable', 'acheteur']);
}
public function delete(User $user, Fournisseur $fournisseur): bool
{
return $user->hasRole('admin');
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Policies;
use App\Models\PieceJointe;
use App\Models\User;
class PieceJointePolicy
{
public function delete(User $user, PieceJointe $pieceJointe): bool
{
// Admin ou celui qui a uploadé
return $user->hasRole('admin') || $pieceJointe->user_id === $user->id;
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Policies;
use App\Models\User;
class UserPolicy
{
public function viewAny(User $user): bool
{
return $user->hasRole('admin');
}
public function update(User $user, User $model): bool
{
return $user->hasRole('admin');
}
public function delete(User $user, User $model): bool
{
return $user->hasRole('admin') && $user->id !== $model->id;
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Providers;
use App\Models\Categorie;
use App\Models\Commande;
use App\Models\Fournisseur;
use App\Models\PieceJointe;
use App\Models\User;
use App\Policies\CommandePolicy;
use App\Policies\FournisseurPolicy;
use App\Policies\PieceJointePolicy;
use App\Policies\UserPolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Vite;
class AppServiceProvider extends ServiceProvider
{
protected $policies = [
Commande::class => CommandePolicy::class,
Fournisseur::class => FournisseurPolicy::class,
PieceJointe::class => PieceJointePolicy::class,
User::class => UserPolicy::class,
];
public function register(): void
{
//
}
public function boot(): void
{
$this->registerPolicies();
Vite::prefetch(concurrency: 3);
// Les admins passent toutes les gates sauf les policies de user
Gate::before(function (User $user, string $ability) {
if ($user->hasRole('admin')) {
return true;
}
});
// Gates pour catégories et services (pas de modèle dédié à une Policy)
Gate::define('manage-categories', fn (User $user) => $user->hasAnyRole(['admin', 'responsable']));
Gate::define('manage-services', fn (User $user) => $user->hasRole('admin'));
}
}