initial commit

This commit is contained in:
bastien
2026-06-28 12:29:53 +02:00
parent 03e85d2f9d
commit b2443b654c
220 changed files with 24969 additions and 1 deletions
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Supervisor;
use App\Http\Controllers\Controller;
use App\Http\Requests\Supervisor\LoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Inertia\Response;
class AuthController extends Controller
{
public function create(): Response
{
return Inertia::render('Supervisor/Auth/Login', [
'status' => session('status'),
]);
}
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
$supervisor = Auth::guard('supervisor')->user();
$supervisor?->update(['last_login_at' => now()]);
return redirect()->intended(route('supervisor.dashboard'));
}
public function destroy(Request $request): RedirectResponse
{
Auth::guard('supervisor')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login');
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers\Supervisor;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Supervisor\Concerns\ScopesSupervisorResources;
use App\Models\Booking;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class BookingPageController extends Controller
{
use ScopesSupervisorResources;
public function index(Request $request): Response
{
$query = Booking::query()
->with(['user:id,first_name,last_name,email', 'machine:id,uuid,name,establishment_id', 'machine.establishment:id,name']);
$this->scopeMachineRelationQuery($query);
if ($request->filled('status')) {
$query->where('status', $request->string('status'));
}
if ($request->filled('date')) {
$query->whereDate('slot_start', $request->string('date'));
}
$bookings = $query
->orderByDesc('slot_start')
->paginate(20)
->withQueryString()
->through(fn (Booking $booking) => [
'uuid' => $booking->uuid,
'status' => $booking->status,
'status_label' => $this->statusLabel($booking->status),
'slot_start' => $booking->slot_start?->toIso8601String(),
'slot_end' => $booking->slot_end?->toIso8601String(),
'booking_fee' => (float) $booking->booking_fee,
'reserved_amount' => (float) $booking->reserved_amount,
'penalty_amount' => (float) $booking->penalty_amount,
'user' => $booking->user ? [
'name' => trim($booking->user->first_name.' '.$booking->user->last_name),
'email' => $booking->user->email,
] : null,
'machine' => $booking->machine ? [
'uuid' => $booking->machine->uuid,
'name' => $booking->machine->name,
'establishment_name' => $booking->machine->establishment?->name,
] : null,
]);
return Inertia::render('Supervisor/Bookings/Index', [
'bookings' => $bookings,
'filters' => [
'status' => $request->string('status')->toString() ?: null,
'date' => $request->string('date')->toString() ?: null,
],
'statusOptions' => $this->statusOptions(),
]);
}
/**
* @return array<string, string>
*/
private function statusOptions(): array
{
return [
'pending' => 'En attente',
'confirmed' => 'Confirmée',
'cancelled' => 'Annulée',
'expired' => 'Expirée',
'active' => 'Active',
'completed' => 'Terminée',
'no_show' => 'Absent',
];
}
private function statusLabel(string $status): string
{
return $this->statusOptions()[$status] ?? $status;
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Supervisor\Concerns;
use App\Models\Supervisor;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
trait ScopesSupervisorResources
{
protected function supervisor(): Supervisor
{
/** @var Supervisor $supervisor */
$supervisor = Auth::guard('supervisor')->user();
return $supervisor;
}
protected function scopeEstablishmentQuery(Builder $query, string $establishmentColumn = 'establishment_id'): Builder
{
$supervisor = $this->supervisor();
$query->whereHas('establishment', function (Builder $query) use ($supervisor) {
$query->where('organization_id', $supervisor->organization_id);
if ($supervisor->establishment_id !== null) {
$query->where('id', $supervisor->establishment_id);
}
});
if ($supervisor->establishment_id !== null && $establishmentColumn !== '') {
$query->where($establishmentColumn, $supervisor->establishment_id);
}
return $query;
}
protected function scopeMachineRelationQuery(Builder $query): Builder
{
$supervisor = $this->supervisor();
return $query->whereHas('machine.establishment', function (Builder $query) use ($supervisor) {
$query->where('organization_id', $supervisor->organization_id);
if ($supervisor->establishment_id !== null) {
$query->where('id', $supervisor->establishment_id);
}
});
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers\Supervisor;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Supervisor\Concerns\ScopesSupervisorResources;
use App\Services\DashboardService;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
use ScopesSupervisorResources;
public function index(DashboardService $dashboardService): Response
{
$supervisor = $this->supervisor();
return Inertia::render('Supervisor/Dashboard', [
'kpis' => $dashboardService->getKpis($supervisor),
'alerts' => $dashboardService->getAlerts($supervisor),
'machinesOverview' => $dashboardService->getMachinesOverview($supervisor),
]);
}
}
@@ -0,0 +1,180 @@
<?php
namespace App\Http\Controllers\Supervisor;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Supervisor\Concerns\ScopesSupervisorResources;
use App\Models\Machine;
use App\Models\MachineEvent;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class MachinePageController extends Controller
{
use ScopesSupervisorResources;
public function index(Request $request): Response
{
$query = Machine::query()
->with('establishment:id,name,uuid');
$this->scopeEstablishmentQuery($query);
if ($request->filled('status')) {
$query->where('status', $request->string('status'));
}
if ($request->filled('type')) {
$query->where('type', $request->string('type'));
}
if ($request->filled('search')) {
$search = $request->string('search');
$query->where(function ($query) use ($search) {
$query->where('name', 'like', "%{$search}%")
->orWhere('qr_code', 'like', "%{$search}%");
});
}
$machines = $query
->orderBy('name')
->paginate(20)
->withQueryString()
->through(fn (Machine $machine) => $this->formatMachineListItem($machine));
return Inertia::render('Supervisor/Machines/Index', [
'machines' => $machines,
'filters' => [
'status' => $request->string('status')->toString() ?: null,
'type' => $request->string('type')->toString() ?: null,
'search' => $request->string('search')->toString() ?: null,
],
'statusOptions' => $this->statusOptions(),
'typeOptions' => $this->typeOptions(),
]);
}
public function show(string $uuid): Response
{
$query = Machine::query()->where('uuid', $uuid);
$this->scopeEstablishmentQuery($query);
$machine = $query
->with(['establishment:id,name,uuid,address,city', 'machineIntegration', 'currentUser:id,first_name,last_name,email'])
->firstOrFail();
$recentEvents = MachineEvent::query()
->where('machine_id', $machine->id)
->orderByDesc('occurred_at')
->limit(20)
->get()
->map(fn (MachineEvent $event) => [
'event_type' => $event->event_type,
'event_type_label' => $this->eventTypeLabel($event->event_type),
'occurred_at' => $event->occurred_at?->toIso8601String(),
'processing_status' => $event->processing_status,
]);
return Inertia::render('Supervisor/Machines/Show', [
'machine' => [
'uuid' => $machine->uuid,
'name' => $machine->name,
'type' => $machine->type,
'type_label' => $this->typeLabel($machine->type),
'qr_code' => $machine->qr_code,
'status' => $machine->status,
'status_label' => $this->statusLabel($machine->status),
'cycle_started_at' => $machine->cycle_started_at?->toIso8601String(),
'cycle_ends_at' => $machine->cycle_ends_at?->toIso8601String(),
'last_heartbeat_at' => $machine->last_heartbeat_at?->toIso8601String(),
'establishment' => $machine->establishment ? [
'uuid' => $machine->establishment->uuid,
'name' => $machine->establishment->name,
'address' => $machine->establishment->address,
'city' => $machine->establishment->city,
] : null,
'current_user' => $machine->currentUser ? [
'name' => trim($machine->currentUser->first_name.' '.$machine->currentUser->last_name),
'email' => $machine->currentUser->email,
] : null,
'integration' => $machine->machineIntegration ? [
'provider' => $machine->machineIntegration->provider,
'mode' => $machine->machineIntegration->mode,
'is_active' => $machine->machineIntegration->is_active,
] : null,
],
'recentEvents' => $recentEvents,
]);
}
/**
* @return array<string, mixed>
*/
private function formatMachineListItem(Machine $machine): array
{
return [
'uuid' => $machine->uuid,
'name' => $machine->name,
'type' => $machine->type,
'type_label' => $this->typeLabel($machine->type),
'status' => $machine->status,
'status_label' => $this->statusLabel($machine->status),
'establishment_name' => $machine->establishment?->name,
'last_heartbeat_at' => $machine->last_heartbeat_at?->toIso8601String(),
];
}
/**
* @return array<string, string>
*/
private function statusOptions(): array
{
return [
'available' => 'Disponible',
'reserved' => 'Réservée',
'running' => 'En cours',
'maintenance' => 'Maintenance',
'offline' => 'Hors ligne',
'error' => 'Erreur',
];
}
/**
* @return array<string, string>
*/
private function typeOptions(): array
{
return [
'washer_small' => 'Lave-linge petit',
'washer_large' => 'Lave-linge grand',
'dryer_small' => 'Sèche-linge petit',
'dryer_large' => 'Sèche-linge grand',
];
}
private function statusLabel(string $status): string
{
return $this->statusOptions()[$status] ?? $status;
}
private function typeLabel(string $type): string
{
return $this->typeOptions()[$type] ?? $type;
}
private function eventTypeLabel(string $type): string
{
return match ($type) {
'heartbeat' => 'Signal de vie',
'machine_online' => 'En ligne',
'machine_offline' => 'Hors ligne',
'cycle_started' => 'Cycle démarré',
'cycle_completed' => 'Cycle terminé',
'cycle_failed' => 'Cycle échoué',
'error_reported' => 'Erreur signalée',
'status_changed' => 'Changement de statut',
default => $type,
};
}
}
@@ -0,0 +1,178 @@
<?php
namespace App\Http\Controllers\Supervisor;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Supervisor\Concerns\ScopesSupervisorResources;
use App\Models\Establishment;
use App\Models\PricingRule;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class PricingPageController extends Controller
{
use ScopesSupervisorResources;
public function index(Request $request): Response
{
$query = PricingRule::query()
->with(['establishment:id,name', 'machine:id,uuid,name']);
$this->scopeEstablishmentQuery($query);
$rules = $query
->orderBy('priority')
->orderBy('slot_start')
->get()
->map(fn (PricingRule $rule) => $this->formatRule($rule));
return Inertia::render('Supervisor/Pricing/Index', [
'rules' => $rules,
'establishments' => $this->establishmentOptions(),
'machineTypes' => $this->machineTypeOptions(),
'dayTypes' => $this->dayTypeOptions(),
]);
}
public function store(Request $request): RedirectResponse
{
$validated = $this->validateRule($request);
$this->assertEstablishmentAccessible((int) $validated['establishment_id']);
PricingRule::query()->create($validated);
return redirect()->route('supervisor.pricing.index')
->with('success', 'Règle tarifaire créée.');
}
public function update(Request $request, int $id): RedirectResponse
{
$rule = $this->findAccessibleRule($id);
$validated = $this->validateRule($request);
$this->assertEstablishmentAccessible((int) $validated['establishment_id']);
$rule->update($validated);
return redirect()->route('supervisor.pricing.index')
->with('success', 'Règle tarifaire mise à jour.');
}
public function destroy(int $id): RedirectResponse
{
$rule = $this->findAccessibleRule($id);
$rule->delete();
return redirect()->route('supervisor.pricing.index')
->with('success', 'Règle tarifaire supprimée.');
}
/**
* @return array<string, mixed>
*/
private function validateRule(Request $request): array
{
return $request->validate([
'establishment_id' => ['required', 'integer', 'exists:establishments,id'],
'machine_id' => ['nullable', 'integer', 'exists:machines,id'],
'machine_type' => ['nullable', 'in:washer_small,washer_large,dryer_small,dryer_large'],
'day_type' => ['required', 'in:weekday,weekend,holiday,all'],
'slot_start' => ['required', 'date_format:H:i'],
'slot_end' => ['required', 'date_format:H:i', 'after:slot_start'],
'price' => ['required', 'numeric', 'min:0'],
'label' => ['nullable', 'string', 'max:100'],
'requires_app' => ['boolean'],
'priority' => ['integer', 'min:0'],
'is_active' => ['boolean'],
]);
}
private function findAccessibleRule(int $id): PricingRule
{
$query = PricingRule::query()->where('id', $id);
$this->scopeEstablishmentQuery($query);
return $query->firstOrFail();
}
private function assertEstablishmentAccessible(int $establishmentId): void
{
$supervisor = $this->supervisor();
$exists = Establishment::query()
->where('id', $establishmentId)
->where('organization_id', $supervisor->organization_id)
->when($supervisor->establishment_id, fn ($q) => $q->where('id', $supervisor->establishment_id))
->exists();
abort_unless($exists, 403);
}
/**
* @return array<string, mixed>
*/
private function formatRule(PricingRule $rule): array
{
return [
'id' => $rule->id,
'establishment_id' => $rule->establishment_id,
'establishment_name' => $rule->establishment?->name,
'machine_id' => $rule->machine_id,
'machine_name' => $rule->machine?->name,
'machine_type' => $rule->machine_type,
'machine_type_label' => $rule->machine_type ? ($this->machineTypeOptions()[$rule->machine_type] ?? $rule->machine_type) : null,
'day_type' => $rule->day_type,
'day_type_label' => $this->dayTypeOptions()[$rule->day_type] ?? $rule->day_type,
'slot_start' => $rule->slot_start ? substr((string) $rule->slot_start, 0, 5) : null,
'slot_end' => $rule->slot_end ? substr((string) $rule->slot_end, 0, 5) : null,
'price' => (float) $rule->price,
'label' => $rule->label,
'requires_app' => $rule->requires_app,
'priority' => $rule->priority,
'is_active' => $rule->is_active,
];
}
/**
* @return array<int, array{id: int, name: string}>
*/
private function establishmentOptions(): array
{
$supervisor = $this->supervisor();
return Establishment::query()
->where('organization_id', $supervisor->organization_id)
->when($supervisor->establishment_id, fn ($q) => $q->where('id', $supervisor->establishment_id))
->orderBy('name')
->get(['id', 'name'])
->map(fn (Establishment $e) => ['id' => $e->id, 'name' => $e->name])
->all();
}
/**
* @return array<string, string>
*/
private function machineTypeOptions(): array
{
return [
'washer_small' => 'Lave-linge petit',
'washer_large' => 'Lave-linge grand',
'dryer_small' => 'Sèche-linge petit',
'dryer_large' => 'Sèche-linge grand',
];
}
/**
* @return array<string, string>
*/
private function dayTypeOptions(): array
{
return [
'all' => 'Tous les jours',
'weekday' => 'Semaine',
'weekend' => 'Week-end',
'holiday' => 'Jours fériés',
];
}
}
@@ -0,0 +1,167 @@
<?php
namespace App\Http\Controllers\Supervisor;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Supervisor\Concerns\ScopesSupervisorResources;
use App\Models\Establishment;
use App\Models\Promotion;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class PromotionPageController extends Controller
{
use ScopesSupervisorResources;
public function index(): Response
{
$query = Promotion::query()->with('establishment:id,name');
$this->scopeEstablishmentQuery($query);
$promotions = $query
->orderByDesc('starts_at')
->get()
->map(fn (Promotion $promotion) => $this->formatPromotion($promotion));
return Inertia::render('Supervisor/Promotions/Index', [
'promotions' => $promotions,
'establishments' => $this->establishmentOptions(),
'machineTypes' => $this->machineTypeOptions(),
'discountTypes' => $this->discountTypeOptions(),
]);
}
public function store(Request $request): RedirectResponse
{
$validated = $this->validatePromotion($request);
$this->assertEstablishmentAccessible((int) $validated['establishment_id']);
Promotion::query()->create($validated);
return redirect()->route('supervisor.promotions.index')
->with('success', 'Promotion créée.');
}
public function update(Request $request, int $id): RedirectResponse
{
$promotion = $this->findAccessiblePromotion($id);
$validated = $this->validatePromotion($request);
$this->assertEstablishmentAccessible((int) $validated['establishment_id']);
$promotion->update($validated);
return redirect()->route('supervisor.promotions.index')
->with('success', 'Promotion mise à jour.');
}
public function destroy(int $id): RedirectResponse
{
$promotion = $this->findAccessiblePromotion($id);
$promotion->delete();
return redirect()->route('supervisor.promotions.index')
->with('success', 'Promotion supprimée.');
}
/**
* @return array<string, mixed>
*/
private function validatePromotion(Request $request): array
{
return $request->validate([
'establishment_id' => ['required', 'integer', 'exists:establishments,id'],
'machine_type' => ['required', 'in:washer_small,washer_large,dryer_small,dryer_large,all'],
'discount_type' => ['required', 'in:percent,fixed'],
'discount_value' => ['required', 'numeric', 'min:0'],
'starts_at' => ['required', 'date'],
'ends_at' => ['required', 'date', 'after:starts_at'],
'description' => ['nullable', 'string', 'max:500'],
'is_active' => ['boolean'],
]);
}
private function findAccessiblePromotion(int $id): Promotion
{
$query = Promotion::query()->where('id', $id);
$this->scopeEstablishmentQuery($query);
return $query->firstOrFail();
}
private function assertEstablishmentAccessible(int $establishmentId): void
{
$supervisor = $this->supervisor();
$exists = Establishment::query()
->where('id', $establishmentId)
->where('organization_id', $supervisor->organization_id)
->when($supervisor->establishment_id, fn ($q) => $q->where('id', $supervisor->establishment_id))
->exists();
abort_unless($exists, 403);
}
/**
* @return array<string, mixed>
*/
private function formatPromotion(Promotion $promotion): array
{
return [
'id' => $promotion->id,
'establishment_id' => $promotion->establishment_id,
'establishment_name' => $promotion->establishment?->name,
'machine_type' => $promotion->machine_type,
'machine_type_label' => $this->machineTypeOptions()[$promotion->machine_type] ?? $promotion->machine_type,
'discount_type' => $promotion->discount_type,
'discount_type_label' => $this->discountTypeOptions()[$promotion->discount_type] ?? $promotion->discount_type,
'discount_value' => (float) $promotion->discount_value,
'starts_at' => $promotion->starts_at?->format('Y-m-d\TH:i'),
'ends_at' => $promotion->ends_at?->format('Y-m-d\TH:i'),
'description' => $promotion->description,
'is_active' => $promotion->is_active,
];
}
/**
* @return array<int, array{id: int, name: string}>
*/
private function establishmentOptions(): array
{
$supervisor = $this->supervisor();
return Establishment::query()
->where('organization_id', $supervisor->organization_id)
->when($supervisor->establishment_id, fn ($q) => $q->where('id', $supervisor->establishment_id))
->orderBy('name')
->get(['id', 'name'])
->map(fn (Establishment $e) => ['id' => $e->id, 'name' => $e->name])
->all();
}
/**
* @return array<string, string>
*/
private function machineTypeOptions(): array
{
return [
'all' => 'Toutes les machines',
'washer_small' => 'Lave-linge petit',
'washer_large' => 'Lave-linge grand',
'dryer_small' => 'Sèche-linge petit',
'dryer_large' => 'Sèche-linge grand',
];
}
/**
* @return array<string, string>
*/
private function discountTypeOptions(): array
{
return [
'percent' => 'Pourcentage',
'fixed' => 'Montant fixe',
];
}
}
@@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers\Supervisor;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Supervisor\Concerns\ScopesSupervisorResources;
use App\Models\Wash;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class WashPageController extends Controller
{
use ScopesSupervisorResources;
public function index(Request $request): Response
{
$query = Wash::query()
->with(['user:id,first_name,last_name,email', 'machine:id,uuid,name,establishment_id', 'machine.establishment:id,name']);
$this->scopeMachineRelationQuery($query);
if ($request->filled('status')) {
$query->where('status', $request->string('status'));
}
if ($request->filled('date')) {
$query->whereDate('started_at', $request->string('date'));
}
$washes = $query
->orderByDesc('started_at')
->paginate(20)
->withQueryString()
->through(fn (Wash $wash) => [
'uuid' => $wash->uuid,
'status' => $wash->status,
'status_label' => $this->statusLabel($wash->status),
'program' => $wash->program,
'trigger_method' => $wash->trigger_method,
'trigger_method_label' => $this->triggerMethodLabel($wash->trigger_method),
'started_at' => $wash->started_at?->toIso8601String(),
'ended_at' => $wash->ended_at?->toIso8601String(),
'duration_minutes' => $wash->duration_minutes,
'cost' => (float) $wash->cost,
'user' => $wash->user ? [
'name' => trim($wash->user->first_name.' '.$wash->user->last_name),
'email' => $wash->user->email,
] : null,
'machine' => $wash->machine ? [
'uuid' => $wash->machine->uuid,
'name' => $wash->machine->name,
'establishment_name' => $wash->machine->establishment?->name,
] : null,
]);
return Inertia::render('Supervisor/Washes/Index', [
'washes' => $washes,
'filters' => [
'status' => $request->string('status')->toString() ?: null,
'date' => $request->string('date')->toString() ?: null,
],
'statusOptions' => $this->statusOptions(),
]);
}
/**
* @return array<string, string>
*/
private function statusOptions(): array
{
return [
'pending_start' => 'En attente',
'running' => 'En cours',
'completed' => 'Terminé',
'failed' => 'Échoué',
'cancelled' => 'Annulé',
];
}
private function statusLabel(string $status): string
{
return $this->statusOptions()[$status] ?? $status;
}
private function triggerMethodLabel(string $method): string
{
return match ($method) {
'qr_code' => 'QR code',
'booking' => 'Réservation',
'supervisor' => 'Superviseur',
'system' => 'Système',
default => $method,
};
}
}