Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64275cbccb | ||
|
|
a58647a67d | ||
|
|
47bed5a5ab | ||
|
|
bac9b0a1e5 | ||
|
|
9be2aa3468 | ||
|
|
e569c84c9d | ||
|
|
4ff8ff2aac | ||
|
|
80085a94d3 | ||
|
|
c347796c29 | ||
|
|
6dbec68894 | ||
|
|
661b4ebdb0 | ||
|
|
ee51dc4dba | ||
|
|
635acf2b41 | ||
|
|
293f49adfa | ||
|
|
d95bfa6c58 | ||
|
|
10ee859602 |
+16
-14
@@ -1,10 +1,10 @@
|
|||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env
|
src/.env
|
||||||
.env.backup
|
src/.env.backup
|
||||||
.env.production
|
src/.env.production
|
||||||
.phpactor.json
|
src/.phpactor.json
|
||||||
.phpunit.result.cache
|
src/.phpunit.result.cache
|
||||||
/.codex
|
/.codex
|
||||||
/.cursor/
|
/.cursor/
|
||||||
/.idea
|
/.idea
|
||||||
@@ -12,15 +12,17 @@
|
|||||||
/.phpunit.cache
|
/.phpunit.cache
|
||||||
/.vscode
|
/.vscode
|
||||||
/.zed
|
/.zed
|
||||||
/auth.json
|
src/auth.json
|
||||||
/node_modules
|
src/node_modules
|
||||||
/public/build
|
src/public/build
|
||||||
/public/fonts-manifest.dev.json
|
src/public/fonts-manifest.dev.json
|
||||||
/public/hot
|
src/public/hot
|
||||||
/public/storage
|
src/public/storage
|
||||||
/storage/*.key
|
src/storage/*.key
|
||||||
/storage/pail
|
src/storage/pail
|
||||||
/vendor
|
src/vendor
|
||||||
|
src/package-lock.json
|
||||||
|
src/composer.lock
|
||||||
_ide_helper.php
|
_ide_helper.php
|
||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
|
|||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
vitePipeline(name: 'laundry-backend', phpVersion: '8.3')
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
```cmd
|
```cmd
|
||||||
/c/wamp64/bin/php/php8.3.28/php.exe artisan serve
|
/c/wamp64/bin/php/php8.3.28/php.exe artisan serve
|
||||||
vite
|
npm run dev
|
||||||
/c/wamp64/bin/php/php8.3.28/php.exe artisan queue:work
|
/c/wamp64/bin/php/php8.3.28/php.exe artisan queue:work
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<?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),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
<?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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services;
|
|
||||||
|
|
||||||
use App\Models\PaymentTransaction;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class PaymentService
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly WalletService $walletService,
|
|
||||||
private readonly AuditService $auditService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function initiateTopUp(
|
|
||||||
User $user,
|
|
||||||
float $amount,
|
|
||||||
string $idempotencyKey,
|
|
||||||
?string $returnUrl = null,
|
|
||||||
): PaymentTransaction {
|
|
||||||
if ($amount <= 0) {
|
|
||||||
throw new \InvalidArgumentException('Le montant doit être strictement positif.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$existing = PaymentTransaction::query()
|
|
||||||
->where('idempotency_key', $idempotencyKey)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($existing !== null) {
|
|
||||||
return $existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
$payment = PaymentTransaction::query()->create([
|
|
||||||
'uuid' => (string) Str::uuid(),
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'provider' => config('laverie.payment.default_provider', 'simulated'),
|
|
||||||
'provider_payment_id' => 'sim-'.Str::random(16),
|
|
||||||
'amount' => $amount,
|
|
||||||
'currency' => 'EUR',
|
|
||||||
'status' => 'initiated',
|
|
||||||
'idempotency_key' => $idempotencyKey,
|
|
||||||
'return_url' => $returnUrl,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->auditService->log($user, 'payment.initiated', $payment, null, $payment->toArray());
|
|
||||||
|
|
||||||
return $payment;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function confirmTopUp(string $paymentUuid): PaymentTransaction
|
|
||||||
{
|
|
||||||
return DB::transaction(function () use ($paymentUuid) {
|
|
||||||
$payment = PaymentTransaction::query()
|
|
||||||
->where('uuid', $paymentUuid)
|
|
||||||
->lockForUpdate()
|
|
||||||
->firstOrFail();
|
|
||||||
|
|
||||||
if ($payment->status === 'succeeded') {
|
|
||||||
return $payment;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! in_array($payment->status, ['initiated', 'pending'], true)) {
|
|
||||||
throw new \RuntimeException("Paiement non confirmable (statut : {$payment->status}).");
|
|
||||||
}
|
|
||||||
|
|
||||||
$payment->update(['status' => 'succeeded']);
|
|
||||||
|
|
||||||
$this->walletService->credit(
|
|
||||||
$payment->user,
|
|
||||||
(float) $payment->amount,
|
|
||||||
PaymentTransaction::class,
|
|
||||||
$payment->id,
|
|
||||||
"payment-credit:{$payment->uuid}",
|
|
||||||
['provider' => $payment->provider],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->auditService->log(
|
|
||||||
$payment->user,
|
|
||||||
'payment.confirmed',
|
|
||||||
$payment,
|
|
||||||
null,
|
|
||||||
$payment->fresh()->toArray(),
|
|
||||||
);
|
|
||||||
|
|
||||||
return $payment->fresh();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handleWebhook(string $provider, array $payload): void
|
|
||||||
{
|
|
||||||
$providerPaymentId = $payload['provider_payment_id'] ?? $payload['payment_id'] ?? null;
|
|
||||||
$idempotencyKey = $payload['idempotency_key'] ?? null;
|
|
||||||
$status = $payload['status'] ?? 'succeeded';
|
|
||||||
|
|
||||||
if ($providerPaymentId === null && $idempotencyKey === null) {
|
|
||||||
throw new \InvalidArgumentException('Webhook paiement invalide.');
|
|
||||||
}
|
|
||||||
|
|
||||||
DB::transaction(function () use ($provider, $payload, $providerPaymentId, $idempotencyKey, $status) {
|
|
||||||
$query = PaymentTransaction::query()->lockForUpdate();
|
|
||||||
|
|
||||||
if ($idempotencyKey !== null) {
|
|
||||||
$payment = $query->where('idempotency_key', $idempotencyKey)->first();
|
|
||||||
} else {
|
|
||||||
$payment = $query
|
|
||||||
->where('provider', $provider)
|
|
||||||
->where('provider_payment_id', $providerPaymentId)
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($payment === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($payment->status === 'succeeded') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$payment->update([
|
|
||||||
'raw_payload' => $payload,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($status === 'succeeded') {
|
|
||||||
$this->confirmTopUp($payment->uuid);
|
|
||||||
} elseif (in_array($status, ['failed', 'cancelled', 'refunded'], true)) {
|
|
||||||
$payment->update(['status' => $status]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
-8672
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,667 @@
|
|||||||
|
# Cahier des Charges — API & Base de Données
|
||||||
|
## Projet : Laverie Connectée — Backend
|
||||||
|
**Version :** 1.0
|
||||||
|
**Date :** 2026-06-27
|
||||||
|
**Stack principale :** Laravel 11, MySQL 8, Redis, Docker
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Contexte & Objectifs
|
||||||
|
|
||||||
|
### 1.1 Contexte
|
||||||
|
Développement d'une API REST centrale servant :
|
||||||
|
- Une application mobile multi-plateforme (Flutter : Android / iOS / Web)
|
||||||
|
- Un back-office d'administration (Vue.js)
|
||||||
|
- Des automates de laverie connectés via passerelle LLDP
|
||||||
|
|
||||||
|
L'API est le **seul point d'entrée** pour toute la logique métier. Aucun client ne communique directement avec la base de données.
|
||||||
|
|
||||||
|
### 1.2 Objectifs
|
||||||
|
- Exposer des endpoints REST sécurisés (JWT) pour tous les clients
|
||||||
|
- Gérer les utilisateurs, l'authentification, les porte-monnaie et les paiements
|
||||||
|
- Orchestrer les communications avec les automates (commandes machines)
|
||||||
|
- Automatiser les notifications (cron, push, événementiel)
|
||||||
|
- Garantir la conformité RGPD sur toutes les données personnelles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Stack Technique
|
||||||
|
|
||||||
|
| Composant | Technologie | Version | Justification |
|
||||||
|
|-----------|-------------|---------|---------------|
|
||||||
|
| Framework | Laravel | 11.x | Robustesse, écosystème, ORM Eloquent |
|
||||||
|
| Base de données principale | MySQL | 8.0 | Transactions ACID, maturité |
|
||||||
|
| Cache & Queues | Redis | 7.x | Queues asynchrones, cache sessions |
|
||||||
|
| Auth | Laravel Sanctum | 4.x | JWT stateless multi-guard |
|
||||||
|
| ORM | Eloquent | (Laravel) | Relations, scopes, casting |
|
||||||
|
| Queues | Laravel Horizon | 5.x | Monitoring des workers Redis |
|
||||||
|
| Scheduler | Laravel Scheduler | (Laravel) | Cron jobs natifs |
|
||||||
|
| Notifications Push | Firebase FCM | HTTP v1 | Android + Web |
|
||||||
|
| Notifications Push | APNs (Apple) | HTTP/2 | iOS |
|
||||||
|
| Paiement | Abstraction PaymentProvider | — | Multi-partenaire |
|
||||||
|
| Tests | PHPUnit + Pest | — | TDD sur logique métier critique |
|
||||||
|
| Documentation API | Scramble (L5-Swagger) | — | OpenAPI 3.0 auto-générée |
|
||||||
|
| Conteneurisation | Docker + Docker Compose | — | Dev/staging/prod reproductibles |
|
||||||
|
| CI/CD | GitHub Actions | — | Tests + déploiement automatisé |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architecture
|
||||||
|
|
||||||
|
### 3.1 Structure du projet Laravel
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── Http/
|
||||||
|
│ ├── Controllers/
|
||||||
|
│ │ ├── Auth/
|
||||||
|
│ │ │ ├── UserAuthController.php
|
||||||
|
│ │ │ └── SupervisorAuthController.php
|
||||||
|
│ │ ├── User/
|
||||||
|
│ │ │ ├── WalletController.php
|
||||||
|
│ │ │ ├── BookingController.php
|
||||||
|
│ │ │ └── WashController.php
|
||||||
|
│ │ ├── Supervisor/
|
||||||
|
│ │ │ ├── DashboardController.php
|
||||||
|
│ │ │ ├── MachineController.php
|
||||||
|
│ │ │ └── PricingController.php
|
||||||
|
│ │ └── Webhook/
|
||||||
|
│ │ └── PaymentWebhookController.php
|
||||||
|
│ ├── Middleware/
|
||||||
|
│ │ ├── EnsureEmailVerified.php
|
||||||
|
│ │ ├── CheckWalletBalance.php
|
||||||
|
│ │ └── RateLimitPayment.php
|
||||||
|
│ └── Requests/ # Form Requests (validation)
|
||||||
|
├── Models/
|
||||||
|
│ ├── User.php
|
||||||
|
│ ├── Supervisor.php
|
||||||
|
│ ├── Establishment.php
|
||||||
|
│ ├── Machine.php
|
||||||
|
│ ├── Booking.php
|
||||||
|
│ ├── Wash.php
|
||||||
|
│ ├── WalletTransaction.php
|
||||||
|
│ ├── PricingRule.php
|
||||||
|
│ ├── Promotion.php
|
||||||
|
│ ├── Notification.php
|
||||||
|
│ └── GdprConsent.php
|
||||||
|
├── Services/
|
||||||
|
│ ├── WalletService.php # Logique porte-monnaie (atomique)
|
||||||
|
│ ├── BookingService.php # Réservation + débit auto no-show
|
||||||
|
│ ├── MachineCommandService.php # Abstraction commandes LLDP
|
||||||
|
│ ├── NotificationService.php # FCM + APNs
|
||||||
|
│ ├── Payment/
|
||||||
|
│ │ ├── PaymentProviderInterface.php
|
||||||
|
│ │ ├── StripeProvider.php
|
||||||
|
│ │ └── LydiProvider.php # Exemple partenaire alternatif
|
||||||
|
│ └── PricingService.php # Calcul tarif dynamique
|
||||||
|
├── Jobs/
|
||||||
|
│ ├── SendPushNotificationJob.php
|
||||||
|
│ ├── DebitNoShowBookingJob.php
|
||||||
|
│ └── SyncMachineStatusJob.php
|
||||||
|
├── Events/ & Listeners/
|
||||||
|
├── Policies/ # Autorisation par modèle
|
||||||
|
└── Console/Commands/ # Artisan custom
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Guards d'authentification
|
||||||
|
|
||||||
|
```
|
||||||
|
sanctum guards :
|
||||||
|
├── user → token scope [user] → app mobile
|
||||||
|
├── supervisor → token scope [supervisor] → back-office
|
||||||
|
└── machine → token scope [machine] → automates (optionnel)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Flux de données principal
|
||||||
|
|
||||||
|
```
|
||||||
|
App Flutter → HTTPS → API Laravel → MySQL
|
||||||
|
↓
|
||||||
|
Redis Queue → Job Workers
|
||||||
|
↓
|
||||||
|
FCM / APNs / LLDP Gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Base de Données — Schéma Complet
|
||||||
|
|
||||||
|
### 4.1 Table `users`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE users (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL, -- Identifiant public exposé dans l'API
|
||||||
|
first_name VARCHAR(100) NOT NULL,
|
||||||
|
last_name VARCHAR(100) NOT NULL,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
phone VARCHAR(20) UNIQUE,
|
||||||
|
birthdate DATE,
|
||||||
|
email_verified_at TIMESTAMP NULL,
|
||||||
|
password VARCHAR(255) NOT NULL,
|
||||||
|
wallet_balance DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||||
|
referral_code VARCHAR(20) UNIQUE,
|
||||||
|
referred_by BIGINT UNSIGNED NULL REFERENCES users(id),
|
||||||
|
loyalty_points INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
fcm_token VARCHAR(255) NULL, -- Token push Android/Web
|
||||||
|
apns_token VARCHAR(255) NULL, -- Token push iOS
|
||||||
|
locale VARCHAR(10) DEFAULT 'fr',
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
anonymized_at TIMESTAMP NULL, -- RGPD : suppression logique
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP NULL -- Soft delete
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Table `gdpr_consents`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE gdpr_consents (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL REFERENCES users(id),
|
||||||
|
type ENUM('data_processing','marketing','analytics') NOT NULL,
|
||||||
|
accepted BOOLEAN NOT NULL,
|
||||||
|
ip_address VARCHAR(45),
|
||||||
|
user_agent TEXT,
|
||||||
|
accepted_at TIMESTAMP NOT NULL,
|
||||||
|
revoked_at TIMESTAMP NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Table `supervisors`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE supervisors (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
establishment_id BIGINT UNSIGNED NOT NULL REFERENCES establishments(id),
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password VARCHAR(255) NOT NULL,
|
||||||
|
role ENUM('admin','manager','viewer') DEFAULT 'manager',
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Table `establishments`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE establishments (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
city VARCHAR(100),
|
||||||
|
zip_code VARCHAR(10),
|
||||||
|
latitude DECIMAL(10,8),
|
||||||
|
longitude DECIMAL(11,8),
|
||||||
|
timezone VARCHAR(50) DEFAULT 'Europe/Paris',
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 Table `machines`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE machines (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
establishment_id BIGINT UNSIGNED NOT NULL REFERENCES establishments(id),
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
type ENUM('washer_small','washer_large','dryer_small','dryer_large') NOT NULL,
|
||||||
|
qr_code VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
lldp_device_id VARCHAR(100), -- Identifiant côté passerelle LLDP
|
||||||
|
status ENUM('available','running','reserved','maintenance','offline') DEFAULT 'available',
|
||||||
|
current_user_id BIGINT UNSIGNED NULL REFERENCES users(id),
|
||||||
|
cycle_started_at TIMESTAMP NULL,
|
||||||
|
cycle_ends_at TIMESTAMP NULL,
|
||||||
|
last_heartbeat TIMESTAMP NULL, -- Dernier ping de l'automate
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.6 Table `pricing_rules`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE pricing_rules (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
machine_id BIGINT UNSIGNED NOT NULL REFERENCES machines(id),
|
||||||
|
day_type ENUM('weekday','weekend','holiday','all') DEFAULT 'all',
|
||||||
|
slot_start TIME NOT NULL,
|
||||||
|
slot_end TIME NOT NULL,
|
||||||
|
price DECIMAL(6,2) NOT NULL,
|
||||||
|
label VARCHAR(100), -- Ex: "Heure creuse", "Heure pleine"
|
||||||
|
requires_app BOOLEAN DEFAULT FALSE, -- Tarif exclusif app
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.7 Table `promotions`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE promotions (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
establishment_id BIGINT UNSIGNED NOT NULL REFERENCES establishments(id),
|
||||||
|
machine_type ENUM('washer_small','washer_large','dryer_small','dryer_large','all') DEFAULT 'all',
|
||||||
|
discount_type ENUM('percent','fixed') NOT NULL,
|
||||||
|
discount_value DECIMAL(6,2) NOT NULL,
|
||||||
|
starts_at TIMESTAMP NOT NULL,
|
||||||
|
ends_at TIMESTAMP NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.8 Table `bookings`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE bookings (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL REFERENCES users(id),
|
||||||
|
machine_id BIGINT UNSIGNED NOT NULL REFERENCES machines(id),
|
||||||
|
slot_start TIMESTAMP NOT NULL,
|
||||||
|
slot_end TIMESTAMP NOT NULL,
|
||||||
|
amount_reserved DECIMAL(6,2) NOT NULL, -- Montant débité au moment de la réservation
|
||||||
|
amount_penalty DECIMAL(6,2) DEFAULT 0.00, -- Pénalité no-show
|
||||||
|
status ENUM('pending','confirmed','active','completed','cancelled','no_show') DEFAULT 'pending',
|
||||||
|
cancelled_at TIMESTAMP NULL,
|
||||||
|
penalty_applied_at TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.9 Table `washes`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE washes (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL REFERENCES users(id),
|
||||||
|
machine_id BIGINT UNSIGNED NOT NULL REFERENCES machines(id),
|
||||||
|
booking_id BIGINT UNSIGNED NULL REFERENCES bookings(id),
|
||||||
|
trigger_method ENUM('qr_code','booking','terminal') NOT NULL,
|
||||||
|
started_at TIMESTAMP NOT NULL,
|
||||||
|
ended_at TIMESTAMP NULL,
|
||||||
|
duration_minutes INT UNSIGNED,
|
||||||
|
cost DECIMAL(6,2) NOT NULL,
|
||||||
|
pricing_rule_id BIGINT UNSIGNED NULL REFERENCES pricing_rules(id),
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.10 Table `wallet_transactions`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE wallet_transactions (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL REFERENCES users(id),
|
||||||
|
type ENUM('credit','debit') NOT NULL,
|
||||||
|
amount DECIMAL(10,2) NOT NULL,
|
||||||
|
balance_after DECIMAL(10,2) NOT NULL,
|
||||||
|
source ENUM('top_up','wash','booking','penalty','refund','referral_bonus') NOT NULL,
|
||||||
|
reference_id BIGINT UNSIGNED NULL, -- ID du wash ou booking associé
|
||||||
|
reference_type VARCHAR(100) NULL, -- Morph polymorphique
|
||||||
|
payment_provider VARCHAR(50) NULL,
|
||||||
|
payment_ref VARCHAR(255) NULL, -- Référence transaction externe
|
||||||
|
created_at TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.11 Table `push_notifications`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE push_notifications (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT UNSIGNED NULL REFERENCES users(id), -- NULL = broadcast
|
||||||
|
type VARCHAR(100) NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
data JSON NULL,
|
||||||
|
channel ENUM('fcm','apns','both') DEFAULT 'both',
|
||||||
|
status ENUM('pending','sent','failed') DEFAULT 'pending',
|
||||||
|
sent_at TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Endpoints API — Spécification Complète
|
||||||
|
|
||||||
|
### 5.1 Authentification utilisateur
|
||||||
|
|
||||||
|
| Méthode | Endpoint | Description | Auth |
|
||||||
|
|---------|----------|-------------|------|
|
||||||
|
| POST | `/api/v1/auth/register` | Inscription + collecte consentements RGPD | Public |
|
||||||
|
| POST | `/api/v1/auth/login` | Connexion, retourne access_token + refresh_token | Public |
|
||||||
|
| POST | `/api/v1/auth/refresh` | Renouvelle le token | Public |
|
||||||
|
| POST | `/api/v1/auth/logout` | Révoque le token | User |
|
||||||
|
| POST | `/api/v1/auth/forgot-password` | Envoi email reset | Public |
|
||||||
|
| POST | `/api/v1/auth/reset-password` | Réinitialisation | Public |
|
||||||
|
| GET | `/api/v1/auth/verify-email/{token}` | Vérification email | Public |
|
||||||
|
|
||||||
|
**Payload register :**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"first_name": "Jean",
|
||||||
|
"last_name": "Dupont",
|
||||||
|
"email": "jean@example.com",
|
||||||
|
"phone": "+33612345678",
|
||||||
|
"password": "••••••••",
|
||||||
|
"birthdate": "1990-01-15",
|
||||||
|
"referral_code": "ABC123",
|
||||||
|
"consents": {
|
||||||
|
"data_processing": true,
|
||||||
|
"marketing": false,
|
||||||
|
"analytics": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réponse login :**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access_token": "eyJ...",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 3600,
|
||||||
|
"refresh_token": "def50200...",
|
||||||
|
"user": {
|
||||||
|
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"first_name": "Jean",
|
||||||
|
"wallet_balance": "12.50"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Porte-monnaie
|
||||||
|
|
||||||
|
| Méthode | Endpoint | Description | Auth |
|
||||||
|
|---------|----------|-------------|------|
|
||||||
|
| GET | `/api/v1/wallet` | Solde actuel | User |
|
||||||
|
| GET | `/api/v1/wallet/transactions` | Historique paginé | User |
|
||||||
|
| POST | `/api/v1/wallet/top-up/initiate` | Initier rechargement | User |
|
||||||
|
| POST | `/api/v1/wallet/top-up/confirm` | Confirmer après paiement | User |
|
||||||
|
| GET | `/api/v1/wallet/top-up/providers` | Liste partenaires paiement disponibles | User |
|
||||||
|
|
||||||
|
**Payload top-up initiate :**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"amount": 20.00,
|
||||||
|
"provider": "stripe",
|
||||||
|
"return_url": "laverie://wallet/topup-result"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réponse :**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"payment_intent_id": "pi_xxx",
|
||||||
|
"redirect_url": "https://checkout.stripe.com/...",
|
||||||
|
"expires_at": "2026-06-27T15:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Établissements & Machines
|
||||||
|
|
||||||
|
| Méthode | Endpoint | Description | Auth |
|
||||||
|
|---------|----------|-------------|------|
|
||||||
|
| GET | `/api/v1/establishments` | Liste avec coordonnées GPS | User |
|
||||||
|
| GET | `/api/v1/establishments/{uuid}` | Détail + machines + disponibilités | User |
|
||||||
|
| GET | `/api/v1/machines/{uuid}` | Détail machine + tarif actuel | User |
|
||||||
|
| GET | `/api/v1/machines/{uuid}/availability` | Créneaux disponibles sur N jours | User |
|
||||||
|
| GET | `/api/v1/machines/{uuid}/pricing` | Grille tarifaire active | User |
|
||||||
|
| POST | `/api/v1/machines/{uuid}/heartbeat` | Ping de l'automate (machine scope) | Machine |
|
||||||
|
|
||||||
|
### 5.4 Réservations
|
||||||
|
|
||||||
|
| Méthode | Endpoint | Description | Auth |
|
||||||
|
|---------|----------|-------------|------|
|
||||||
|
| POST | `/api/v1/bookings` | Créer une réservation | User |
|
||||||
|
| GET | `/api/v1/bookings` | Mes réservations | User |
|
||||||
|
| GET | `/api/v1/bookings/{uuid}` | Détail réservation | User |
|
||||||
|
| PATCH | `/api/v1/bookings/{uuid}/cancel` | Annuler | User |
|
||||||
|
| PATCH | `/api/v1/bookings/{uuid}/move` | Déplacer le créneau | User |
|
||||||
|
|
||||||
|
**Payload POST /bookings :**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"machine_uuid": "550e8400-...",
|
||||||
|
"slot_start": "2026-06-28T09:00:00Z",
|
||||||
|
"slot_end": "2026-06-28T10:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Règles métier :**
|
||||||
|
- Vérification atomique du solde (mutex Redis sur `wallet:user:{id}`)
|
||||||
|
- Débit immédiat du montant + supplément réservation (1€)
|
||||||
|
- Si annulation > 2h avant : remboursement intégral
|
||||||
|
- Si annulation < 2h ou no-show : pénalité définie par `machine.no_show_penalty`
|
||||||
|
- Cron toutes les 5 minutes : détection no-show → `DebitNoShowBookingJob`
|
||||||
|
|
||||||
|
### 5.5 Lavages
|
||||||
|
|
||||||
|
| Méthode | Endpoint | Description | Auth |
|
||||||
|
|---------|----------|-------------|------|
|
||||||
|
| POST | `/api/v1/washes/start` | Démarrer via QR code | User |
|
||||||
|
| GET | `/api/v1/washes/{uuid}` | Statut en cours | User |
|
||||||
|
| GET | `/api/v1/washes` | Historique | User |
|
||||||
|
|
||||||
|
**Payload POST /washes/start :**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"qr_code": "MACHINE_QR_ABC123",
|
||||||
|
"program": "60C_standard"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Flux :**
|
||||||
|
1. Valider le QR → identifier la machine
|
||||||
|
2. Vérifier statut machine (`available`)
|
||||||
|
3. Vérifier solde suffisant
|
||||||
|
4. Calculer prix (tarif actuel + promo éventuelle)
|
||||||
|
5. Débit atomique du porte-monnaie
|
||||||
|
6. Envoyer commande à la passerelle LLDP via `MachineCommandService`
|
||||||
|
7. Mettre à jour le statut machine (`running`)
|
||||||
|
8. Retourner confirmation + durée estimée
|
||||||
|
|
||||||
|
### 5.6 Authentification superviseur
|
||||||
|
|
||||||
|
| Méthode | Endpoint | Description | Auth |
|
||||||
|
|---------|----------|-------------|------|
|
||||||
|
| POST | `/api/v1/supervisor/auth/login` | Connexion superviseur | Public |
|
||||||
|
| POST | `/api/v1/supervisor/auth/logout` | Déconnexion | Supervisor |
|
||||||
|
| GET | `/api/v1/supervisor/dashboard` | Statistiques + alertes | Supervisor |
|
||||||
|
| GET | `/api/v1/supervisor/machines` | Parc machines de l'établissement | Supervisor |
|
||||||
|
| PATCH | `/api/v1/supervisor/machines/{uuid}` | Mise à jour machine | Supervisor |
|
||||||
|
| GET | `/api/v1/supervisor/pricing` | Grille tarifaire | Supervisor |
|
||||||
|
| POST | `/api/v1/supervisor/pricing` | Créer règle tarifaire | Supervisor |
|
||||||
|
| PUT | `/api/v1/supervisor/pricing/{id}` | Modifier règle | Supervisor |
|
||||||
|
| DELETE | `/api/v1/supervisor/pricing/{id}` | Supprimer règle | Supervisor |
|
||||||
|
| GET | `/api/v1/supervisor/promotions` | Promotions actives | Supervisor |
|
||||||
|
| POST | `/api/v1/supervisor/promotions` | Créer promotion | Supervisor |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Services Métier Critiques
|
||||||
|
|
||||||
|
### 6.1 WalletService — Opérations atomiques
|
||||||
|
|
||||||
|
Toute opération sur le porte-monnaie passe par ce service. Les débits/crédits sont encapsulés dans des transactions MySQL + verrou Redis pour éviter les race conditions.
|
||||||
|
|
||||||
|
```
|
||||||
|
WalletService::credit(user, amount, source, referenceId)
|
||||||
|
WalletService::debit(user, amount, source, referenceId) ← lance WalletInsufficientFundsException si insuffisant
|
||||||
|
WalletService::reserve(user, amount, bookingId) ← débit préventif réservation
|
||||||
|
WalletService::refund(user, walletTransactionId)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Règle absolue :** Aucun controller ne doit modifier `users.wallet_balance` directement. Tout passe par `WalletService`.
|
||||||
|
|
||||||
|
### 6.2 PricingService — Tarif dynamique
|
||||||
|
|
||||||
|
```
|
||||||
|
PricingService::getCurrentPrice(machine, datetime)
|
||||||
|
→ Cherche la PricingRule active pour (machine_id, day_type, heure)
|
||||||
|
→ Applique Promotion active si existante
|
||||||
|
→ Retourne { base_price, discount, final_price, label, requires_app }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Priorité des règles :** Règle spécifique machine > Règle par type machine > Tarif défaut établissement.
|
||||||
|
|
||||||
|
### 6.3 MachineCommandService — Abstraction LLDP
|
||||||
|
|
||||||
|
```
|
||||||
|
MachineCommandService::startWash(machine, program)
|
||||||
|
MachineCommandService::stopWash(machine)
|
||||||
|
MachineCommandService::getStatus(machine)
|
||||||
|
```
|
||||||
|
|
||||||
|
L'implémentation concrète (`LldpGatewayAdapter`) communique avec la passerelle LLDP via HTTP interne. En cas d'indisponibilité de la passerelle, la commande est mise en queue Redis et retentée 3 fois (backoff exponentiel).
|
||||||
|
|
||||||
|
### 6.4 PaymentProviderInterface
|
||||||
|
|
||||||
|
```php
|
||||||
|
interface PaymentProviderInterface {
|
||||||
|
public function initiateTopUp(User $user, float $amount, string $returnUrl): TopUpIntent;
|
||||||
|
public function confirmTopUp(string $paymentRef): TopUpConfirmation;
|
||||||
|
public function handleWebhook(Request $request): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Implémentations : `StripeProvider`, `LydiProvider`, `SumUpProvider` (extensible).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Tâches Planifiées (Cron)
|
||||||
|
|
||||||
|
| Fréquence | Job | Description |
|
||||||
|
|-----------|-----|-------------|
|
||||||
|
| Toutes les 5 min | `CheckNoShowBookings` | Détecter et débiter les no-shows |
|
||||||
|
| Toutes les 5 min | `SyncMachineStatuses` | Interroger la passerelle LLDP |
|
||||||
|
| Toutes les heures | `SendScheduledNotifications` | Notifications planifiées en attente |
|
||||||
|
| Chaque jour 02:00 | `GenerateDailyStats` | Agrégation statistiques pour dashboard |
|
||||||
|
| Chaque jour 03:00 | `CleanExpiredTokens` | Purge tokens révoqués |
|
||||||
|
| Chaque semaine | `AnonymizeInactiveUsers` | RGPD : users inactifs > 3 ans |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Notifications — Architecture
|
||||||
|
|
||||||
|
### 8.1 Déclencheurs
|
||||||
|
| Événement | Canal | Timing |
|
||||||
|
|-----------|-------|--------|
|
||||||
|
| Rechargement confirmé | Push | Immédiat |
|
||||||
|
| Réservation confirmée | Push | Immédiat |
|
||||||
|
| Rappel créneau | Push | 30 min avant |
|
||||||
|
| Lavage terminé | Push | Immédiat |
|
||||||
|
| Solde faible (< 5€) | Push | À chaque débit |
|
||||||
|
| Promotion disponible | Push | Planifié par superviseur |
|
||||||
|
| No-show détecté | Push | À la détection |
|
||||||
|
|
||||||
|
### 8.2 Implémentation
|
||||||
|
- Les notifications sont toujours créées en base (`push_notifications`) avant envoi
|
||||||
|
- L'envoi passe par un Job Redis (`SendPushNotificationJob`) → résilience
|
||||||
|
- FCM HTTP v1 pour Android/Web, APNs HTTP/2 pour iOS
|
||||||
|
- En cas d'échec, 3 retries avec backoff (1min, 5min, 15min)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Sécurité
|
||||||
|
|
||||||
|
### 9.1 Authentification
|
||||||
|
- Tokens JWT via Laravel Sanctum (stateless)
|
||||||
|
- Refresh token rotation (invalidation à chaque renouvellement)
|
||||||
|
- Rate limiting : 5 tentatives de login / 10 minutes / IP
|
||||||
|
- Rate limiting paiements : 3 initiations / minute / user
|
||||||
|
|
||||||
|
### 9.2 Données
|
||||||
|
- Mots de passe hashés : `bcrypt` (cost 12)
|
||||||
|
- Données sensibles (IBAN, tokens paiement) : chiffrées `AES-256-CBC` en base
|
||||||
|
- HTTPS obligatoire (TLS 1.3 minimum)
|
||||||
|
- UUID exposés dans l'API (jamais les auto-increment IDs)
|
||||||
|
- Soft deletes sur `users` — anonymisation RGPD séparée
|
||||||
|
|
||||||
|
### 9.3 API
|
||||||
|
- Validation stricte de tous les inputs (`FormRequest`)
|
||||||
|
- Politique CORS restrictive (origines whitelist)
|
||||||
|
- Headers sécurité : `Strict-Transport-Security`, `X-Frame-Options`, `Content-Security-Policy`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Tests
|
||||||
|
|
||||||
|
| Type | Outil | Couverture cible |
|
||||||
|
|------|-------|-----------------|
|
||||||
|
| Tests unitaires | Pest | WalletService, PricingService, BookingService |
|
||||||
|
| Tests d'intégration | Pest + SQLite in-memory | Tous les endpoints API |
|
||||||
|
| Tests de contrats | — | Webhooks paiement |
|
||||||
|
| Tests de charge | k6 | Endpoints critiques (start wash, top-up) |
|
||||||
|
|
||||||
|
**Cas critiques à tester :**
|
||||||
|
- Race condition débit simultané du porte-monnaie
|
||||||
|
- Timeout passerelle LLDP → comportement de la queue
|
||||||
|
- Webhook paiement reçu en doublon (idempotence)
|
||||||
|
- No-show avec annulation simultanée
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Variables d'environnement requises
|
||||||
|
|
||||||
|
```env
|
||||||
|
APP_ENV=production
|
||||||
|
APP_KEY=base64:...
|
||||||
|
|
||||||
|
DB_HOST=
|
||||||
|
DB_DATABASE=laverie
|
||||||
|
DB_USERNAME=
|
||||||
|
DB_PASSWORD=
|
||||||
|
|
||||||
|
REDIS_HOST=
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
|
||||||
|
# Paiement
|
||||||
|
STRIPE_SECRET_KEY=
|
||||||
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
# Notifications
|
||||||
|
FIREBASE_PROJECT_ID=
|
||||||
|
FIREBASE_PRIVATE_KEY=
|
||||||
|
FIREBASE_CLIENT_EMAIL=
|
||||||
|
APNS_KEY_ID=
|
||||||
|
APNS_TEAM_ID=
|
||||||
|
APNS_PRIVATE_KEY_PATH=
|
||||||
|
|
||||||
|
# LLDP Gateway
|
||||||
|
LLDP_GATEWAY_URL=
|
||||||
|
LLDP_GATEWAY_TOKEN=
|
||||||
|
|
||||||
|
# URLs
|
||||||
|
FRONTEND_URL=
|
||||||
|
APP_URL=
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Livrables attendus
|
||||||
|
|
||||||
|
- [ ] Code source Laravel complet (GitHub)
|
||||||
|
- [ ] Migrations SQL versionnées
|
||||||
|
- [ ] Seeders (données de démo)
|
||||||
|
- [ ] Documentation OpenAPI 3.0 auto-générée (`/api/documentation`)
|
||||||
|
- [ ] Collection Postman exportée
|
||||||
|
- [ ] `docker-compose.yml` pour dev local
|
||||||
|
- [ ] `.env.example` documenté
|
||||||
|
- [ ] README avec instructions d'installation
|
||||||
|
- [ ] Suite de tests (couverture > 80% sur les services critiques)
|
||||||
@@ -0,0 +1,843 @@
|
|||||||
|
# Cahier des Charges — API & Base de Données
|
||||||
|
## Projet : Laverie Connectée — Backend
|
||||||
|
**Version :** 2.0
|
||||||
|
**Stack principale :** Laravel 11, MySQL 8, Redis, Docker
|
||||||
|
**Positionnement :** V1 démontrable, extensible vers V2 sans refonte majeure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Contexte & Objectifs
|
||||||
|
|
||||||
|
### 1.1 Contexte
|
||||||
|
Développement d'une API centrale servant :
|
||||||
|
- une application utilisateur multi-plateforme,
|
||||||
|
- un back-office exploitant,
|
||||||
|
- une couche d'intégration avec les systèmes machines fournis par le client ou ses partenaires techniques.
|
||||||
|
|
||||||
|
L'API est le **point d'entrée unique de la logique métier**. Les clients applicatifs ne communiquent jamais directement avec la base de données.
|
||||||
|
|
||||||
|
### 1.2 Objectifs V1
|
||||||
|
- authentifier les utilisateurs finaux et les exploitants,
|
||||||
|
- gérer plusieurs laveries et plusieurs exploitants dans une même plateforme,
|
||||||
|
- exposer les données établissements / machines / disponibilités,
|
||||||
|
- gérer un porte-monnaie électronique avec traçabilité,
|
||||||
|
- permettre la réservation d'un créneau,
|
||||||
|
- permettre le démarrage d'un lavage via intégration machine,
|
||||||
|
- historiser les événements machines,
|
||||||
|
- fournir un socle d'audit, de sécurité et de statistiques simples.
|
||||||
|
|
||||||
|
### 1.3 Principes de conception
|
||||||
|
- **MVP strict** : seules les fonctionnalités nécessaires à la démonstration et à la première mise en service sont incluses en V1.
|
||||||
|
- **Extensibilité** : fidélité, abonnements, parrainage, campagnes marketing et analytics avancées sont prévues mais non implémentées en V1.
|
||||||
|
- **Traçabilité** : tout flux critique doit être rejouable et auditable.
|
||||||
|
- **Isolation métier** : chaque exploitant ne voit que ses établissements, ses machines et ses chiffres.
|
||||||
|
- **Intégration externe souple** : la couche machine doit supporter aussi bien un modèle où notre système appelle une API partenaire qu'un modèle où le partenaire pousse des événements vers notre API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Périmètre fonctionnel
|
||||||
|
|
||||||
|
### 2.1 MVP V1
|
||||||
|
|
||||||
|
#### Utilisateur final
|
||||||
|
- inscription / connexion,
|
||||||
|
- consultation des laveries,
|
||||||
|
- consultation des machines et de leur statut,
|
||||||
|
- consultation du solde wallet,
|
||||||
|
- historique simple des transactions,
|
||||||
|
- rechargement du wallet,
|
||||||
|
- réservation d'un créneau,
|
||||||
|
- démarrage d'un lavage,
|
||||||
|
- historique des lavages,
|
||||||
|
- notifications transactionnelles.
|
||||||
|
|
||||||
|
#### Exploitant
|
||||||
|
- connexion superviseur,
|
||||||
|
- consultation du parc machines,
|
||||||
|
- consultation d'un tableau de bord simple,
|
||||||
|
- consultation des réservations et lavages,
|
||||||
|
- gestion simple de la tarification,
|
||||||
|
- visualisation des alertes techniques de base.
|
||||||
|
|
||||||
|
#### Plateforme
|
||||||
|
- multi-exploitants,
|
||||||
|
- audit logs,
|
||||||
|
- agrégats journaliers simples,
|
||||||
|
- intégration machine simulable pour démonstration.
|
||||||
|
|
||||||
|
### 2.2 V2 prévue
|
||||||
|
- fidélité,
|
||||||
|
- parrainage,
|
||||||
|
- abonnements,
|
||||||
|
- campagnes marketing,
|
||||||
|
- promotions avancées,
|
||||||
|
- exports RGPD complets,
|
||||||
|
- analytics détaillées,
|
||||||
|
- règles tarifaires complexes,
|
||||||
|
- exports métier avancés.
|
||||||
|
|
||||||
|
### 2.3 Hors périmètre V1
|
||||||
|
- chat,
|
||||||
|
- avis,
|
||||||
|
- FAQ dynamique,
|
||||||
|
- recommandation de cycle par photo,
|
||||||
|
- moteur prédictif / deep learning,
|
||||||
|
- orchestration matérielle bas niveau.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Stack Technique
|
||||||
|
|
||||||
|
| Composant | Technologie | Version | Commentaire |
|
||||||
|
|-----------|-------------|---------|-------------|
|
||||||
|
| Framework API | Laravel | 11.x | Coeur métier principal |
|
||||||
|
| Base de données | MySQL | 8.0 | Référentiel transactionnel |
|
||||||
|
| Cache / queues | Redis | 7.x | Queues, verrous applicatifs, cache court |
|
||||||
|
| Workers | Laravel Horizon | 5.x | Supervision des jobs |
|
||||||
|
| Scheduler | Laravel Scheduler | natif | Cron applicatif |
|
||||||
|
| Documentation API | Scramble ou L5-Swagger | — | OpenAPI |
|
||||||
|
| Conteneurisation | Docker | — | Environnements reproductibles |
|
||||||
|
| CI/CD | GitHub Actions | — | Tests + build |
|
||||||
|
| Monitoring erreurs | Sentry | — | Backend |
|
||||||
|
| Logs | JSON structurés | — | Corrélation et exploitation |
|
||||||
|
|
||||||
|
### 3.1 Authentification recommandée
|
||||||
|
- **App mobile / web utilisateur** : access token court + refresh token.
|
||||||
|
- **Back-office exploitant** : session cookie Laravel.
|
||||||
|
- **Intégrations machines / partenaires** : API key technique ou signature HMAC.
|
||||||
|
|
||||||
|
Cette séparation évite de mélanger les contraintes des apps utilisateurs, du back-office et des systèmes techniques.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Architecture logique
|
||||||
|
|
||||||
|
### 4.1 Domaines métier principaux
|
||||||
|
- Authentification
|
||||||
|
- Organisations / exploitants
|
||||||
|
- Établissements
|
||||||
|
- Machines
|
||||||
|
- Tarification
|
||||||
|
- Wallet & paiements
|
||||||
|
- Réservations
|
||||||
|
- Lavages
|
||||||
|
- Notifications
|
||||||
|
- Intégrations machines
|
||||||
|
- Audit & statistiques
|
||||||
|
|
||||||
|
### 4.2 Multi-tenant logique
|
||||||
|
Le système n'est pas multi-base ni multi-schémas. L'isolation est assurée au niveau applicatif par :
|
||||||
|
- `organization_id`,
|
||||||
|
- policies Laravel,
|
||||||
|
- query scopes explicites,
|
||||||
|
- ressources d'API filtrées selon l'utilisateur connecté.
|
||||||
|
|
||||||
|
### 4.3 Structure recommandée Laravel
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── Domain/
|
||||||
|
│ ├── Auth/
|
||||||
|
│ ├── Organization/
|
||||||
|
│ ├── Machine/
|
||||||
|
│ ├── Wallet/
|
||||||
|
│ ├── Booking/
|
||||||
|
│ ├── Wash/
|
||||||
|
│ ├── Pricing/
|
||||||
|
│ ├── Notification/
|
||||||
|
│ └── Integration/
|
||||||
|
├── Http/
|
||||||
|
│ ├── Controllers/
|
||||||
|
│ ├── Middleware/
|
||||||
|
│ └── Requests/
|
||||||
|
├── Jobs/
|
||||||
|
├── Events/
|
||||||
|
├── Listeners/
|
||||||
|
├── Policies/
|
||||||
|
├── Models/
|
||||||
|
└── Support/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Base de Données — Schéma révisé
|
||||||
|
|
||||||
|
## 5.0 Connexion bdd de test
|
||||||
|
|
||||||
|
hostname: localhost
|
||||||
|
port: 3306
|
||||||
|
user: root
|
||||||
|
password:
|
||||||
|
shema: laverie
|
||||||
|
|
||||||
|
## 5.1 Exploitants et périmètre
|
||||||
|
|
||||||
|
### Table `organizations`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE organizations (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
code VARCHAR(50) UNIQUE NULL,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `establishments`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE establishments (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
organization_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
city VARCHAR(100) NULL,
|
||||||
|
zip_code VARCHAR(10) NULL,
|
||||||
|
latitude DECIMAL(10,8) NULL,
|
||||||
|
longitude DECIMAL(11,8) NULL,
|
||||||
|
timezone VARCHAR(50) NOT NULL DEFAULT 'Europe/Paris',
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
CONSTRAINT fk_establishments_organization FOREIGN KEY (organization_id) REFERENCES organizations(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `supervisors`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE supervisors (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
organization_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
establishment_id BIGINT UNSIGNED NULL,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
first_name VARCHAR(100) NOT NULL,
|
||||||
|
last_name VARCHAR(100) NOT NULL,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password VARCHAR(255) NOT NULL,
|
||||||
|
role ENUM('platform_admin','owner','manager','viewer') NOT NULL DEFAULT 'manager',
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
last_login_at TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
CONSTRAINT fk_supervisors_organization FOREIGN KEY (organization_id) REFERENCES organizations(id),
|
||||||
|
CONSTRAINT fk_supervisors_establishment FOREIGN KEY (establishment_id) REFERENCES establishments(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5.2 Utilisateurs finaux
|
||||||
|
|
||||||
|
### Table `users`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE users (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
first_name VARCHAR(100) NOT NULL,
|
||||||
|
last_name VARCHAR(100) NOT NULL,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
phone VARCHAR(20) UNIQUE NULL,
|
||||||
|
birthdate DATE NULL,
|
||||||
|
email_verified_at TIMESTAMP NULL,
|
||||||
|
password VARCHAR(255) NOT NULL,
|
||||||
|
locale VARCHAR(10) NOT NULL DEFAULT 'fr',
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
anonymized_at TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
deleted_at TIMESTAMP NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `gdpr_consents`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE gdpr_consents (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
type ENUM('data_processing','marketing','analytics') NOT NULL,
|
||||||
|
accepted BOOLEAN NOT NULL,
|
||||||
|
policy_version VARCHAR(50) NOT NULL,
|
||||||
|
policy_text_hash VARCHAR(255) NOT NULL,
|
||||||
|
source ENUM('mobile','web','backoffice') NOT NULL,
|
||||||
|
consent_language VARCHAR(10) NOT NULL DEFAULT 'fr',
|
||||||
|
ip_address VARCHAR(45) NULL,
|
||||||
|
user_agent TEXT NULL,
|
||||||
|
accepted_at TIMESTAMP NOT NULL,
|
||||||
|
revoked_at TIMESTAMP NULL,
|
||||||
|
CONSTRAINT fk_gdpr_consents_user FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `user_devices`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE user_devices (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
platform ENUM('android','ios','web') NOT NULL,
|
||||||
|
push_token VARCHAR(255) NOT NULL,
|
||||||
|
app_version VARCHAR(50) NULL,
|
||||||
|
device_name VARCHAR(100) NULL,
|
||||||
|
last_seen_at TIMESTAMP NULL,
|
||||||
|
revoked_at TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
UNIQUE KEY uniq_user_device_token (push_token),
|
||||||
|
CONSTRAINT fk_user_devices_user FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `notification_preferences`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE notification_preferences (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
transaction_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
reminder_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
marketing_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
system_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
UNIQUE KEY uniq_notification_preferences_user (user_id),
|
||||||
|
CONSTRAINT fk_notification_preferences_user FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5.3 Machines et intégrations
|
||||||
|
|
||||||
|
### Table `machines`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE machines (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
establishment_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
type ENUM('washer_small','washer_large','dryer_small','dryer_large') NOT NULL,
|
||||||
|
qr_code VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
status ENUM('available','reserved','running','maintenance','offline','error') NOT NULL DEFAULT 'available',
|
||||||
|
current_user_id BIGINT UNSIGNED NULL,
|
||||||
|
cycle_started_at TIMESTAMP NULL,
|
||||||
|
cycle_ends_at TIMESTAMP NULL,
|
||||||
|
last_heartbeat_at TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
CONSTRAINT fk_machines_establishment FOREIGN KEY (establishment_id) REFERENCES establishments(id),
|
||||||
|
CONSTRAINT fk_machines_current_user FOREIGN KEY (current_user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `machine_integrations`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE machine_integrations (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
machine_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
provider VARCHAR(100) NOT NULL,
|
||||||
|
external_machine_id VARCHAR(100) NOT NULL,
|
||||||
|
external_site_id VARCHAR(100) NULL,
|
||||||
|
mode ENUM('pull','push','hybrid','simulated') NOT NULL DEFAULT 'simulated',
|
||||||
|
config JSON NULL,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
UNIQUE KEY uniq_machine_provider_external (provider, external_machine_id),
|
||||||
|
CONSTRAINT fk_machine_integrations_machine FOREIGN KEY (machine_id) REFERENCES machines(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `machine_commands`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE machine_commands (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
machine_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
provider VARCHAR(100) NOT NULL,
|
||||||
|
command_type ENUM('start_cycle','stop_cycle','refresh_status') NOT NULL,
|
||||||
|
payload JSON NULL,
|
||||||
|
status ENUM('pending','sent','acknowledged','failed','timeout','cancelled') NOT NULL DEFAULT 'pending',
|
||||||
|
external_reference VARCHAR(255) NULL,
|
||||||
|
correlation_id VARCHAR(255) NULL,
|
||||||
|
requested_by_user_id BIGINT UNSIGNED NULL,
|
||||||
|
requested_by_supervisor_id BIGINT UNSIGNED NULL,
|
||||||
|
sent_at TIMESTAMP NULL,
|
||||||
|
responded_at TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
CONSTRAINT fk_machine_commands_machine FOREIGN KEY (machine_id) REFERENCES machines(id),
|
||||||
|
CONSTRAINT fk_machine_commands_user FOREIGN KEY (requested_by_user_id) REFERENCES users(id),
|
||||||
|
CONSTRAINT fk_machine_commands_supervisor FOREIGN KEY (requested_by_supervisor_id) REFERENCES supervisors(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `machine_events`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE machine_events (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
machine_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
provider VARCHAR(100) NOT NULL,
|
||||||
|
external_event_id VARCHAR(255) NULL,
|
||||||
|
event_type ENUM('heartbeat','machine_online','machine_offline','cycle_started','cycle_completed','cycle_failed','error_reported','status_changed') NOT NULL,
|
||||||
|
payload JSON NULL,
|
||||||
|
occurred_at TIMESTAMP NOT NULL,
|
||||||
|
received_at TIMESTAMP NOT NULL,
|
||||||
|
processed_at TIMESTAMP NULL,
|
||||||
|
processing_status ENUM('pending','processed','failed','ignored') NOT NULL DEFAULT 'pending',
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
CONSTRAINT fk_machine_events_machine FOREIGN KEY (machine_id) REFERENCES machines(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `machine_status_history`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE machine_status_history (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
machine_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
previous_status VARCHAR(50) NULL,
|
||||||
|
new_status VARCHAR(50) NOT NULL,
|
||||||
|
source VARCHAR(100) NOT NULL,
|
||||||
|
reason VARCHAR(255) NULL,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
CONSTRAINT fk_machine_status_history_machine FOREIGN KEY (machine_id) REFERENCES machines(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5.4 Tarification
|
||||||
|
|
||||||
|
### Table `pricing_rules`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE pricing_rules (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
establishment_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
machine_id BIGINT UNSIGNED NULL,
|
||||||
|
machine_type ENUM('washer_small','washer_large','dryer_small','dryer_large') NULL,
|
||||||
|
day_type ENUM('weekday','weekend','holiday','all') NOT NULL DEFAULT 'all',
|
||||||
|
slot_start TIME NOT NULL,
|
||||||
|
slot_end TIME NOT NULL,
|
||||||
|
price DECIMAL(8,2) NOT NULL,
|
||||||
|
label VARCHAR(100) NULL,
|
||||||
|
requires_app BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
priority SMALLINT NOT NULL DEFAULT 100,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
CONSTRAINT fk_pricing_rules_establishment FOREIGN KEY (establishment_id) REFERENCES establishments(id),
|
||||||
|
CONSTRAINT fk_pricing_rules_machine FOREIGN KEY (machine_id) REFERENCES machines(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `promotions`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE promotions (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
establishment_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
machine_type ENUM('washer_small','washer_large','dryer_small','dryer_large','all') NOT NULL DEFAULT 'all',
|
||||||
|
discount_type ENUM('percent','fixed') NOT NULL,
|
||||||
|
discount_value DECIMAL(8,2) NOT NULL,
|
||||||
|
starts_at TIMESTAMP NOT NULL,
|
||||||
|
ends_at TIMESTAMP NOT NULL,
|
||||||
|
description TEXT NULL,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
CONSTRAINT fk_promotions_establishment FOREIGN KEY (establishment_id) REFERENCES establishments(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5.5 Wallet et paiements
|
||||||
|
|
||||||
|
### Table `wallets`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE wallets (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
currency CHAR(3) NOT NULL DEFAULT 'EUR',
|
||||||
|
current_balance DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||||
|
status ENUM('active','blocked','closed') NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
UNIQUE KEY uniq_wallets_user (user_id),
|
||||||
|
CONSTRAINT fk_wallets_user FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `payment_transactions`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE payment_transactions (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
provider VARCHAR(50) NOT NULL,
|
||||||
|
provider_payment_id VARCHAR(255) NULL,
|
||||||
|
amount DECIMAL(10,2) NOT NULL,
|
||||||
|
currency CHAR(3) NOT NULL DEFAULT 'EUR',
|
||||||
|
status ENUM('initiated','pending','succeeded','failed','cancelled','refunded') NOT NULL DEFAULT 'initiated',
|
||||||
|
idempotency_key VARCHAR(100) NOT NULL,
|
||||||
|
return_url VARCHAR(255) NULL,
|
||||||
|
raw_payload JSON NULL,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
UNIQUE KEY uniq_payment_idempotency (idempotency_key),
|
||||||
|
CONSTRAINT fk_payment_transactions_user FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `wallet_transactions`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE wallet_transactions (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
wallet_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
type ENUM('credit','debit','hold','release','refund','adjustment') NOT NULL,
|
||||||
|
amount DECIMAL(10,2) NOT NULL,
|
||||||
|
balance_before DECIMAL(10,2) NOT NULL,
|
||||||
|
balance_after DECIMAL(10,2) NOT NULL,
|
||||||
|
source_type VARCHAR(100) NOT NULL,
|
||||||
|
source_id BIGINT UNSIGNED NULL,
|
||||||
|
idempotency_key VARCHAR(100) NULL,
|
||||||
|
metadata JSON NULL,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
CONSTRAINT fk_wallet_transactions_wallet FOREIGN KEY (wallet_id) REFERENCES wallets(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5.6 Réservations et lavages
|
||||||
|
|
||||||
|
### Table `bookings`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE bookings (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
machine_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
slot_start TIMESTAMP NOT NULL,
|
||||||
|
slot_end TIMESTAMP NOT NULL,
|
||||||
|
booking_fee DECIMAL(8,2) NOT NULL DEFAULT 0.00,
|
||||||
|
reserved_amount DECIMAL(8,2) NOT NULL DEFAULT 0.00,
|
||||||
|
penalty_amount DECIMAL(8,2) NOT NULL DEFAULT 0.00,
|
||||||
|
status ENUM('pending','confirmed','cancelled','expired','active','completed','no_show') NOT NULL DEFAULT 'pending',
|
||||||
|
cancelled_at TIMESTAMP NULL,
|
||||||
|
penalty_applied_at TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
CONSTRAINT fk_bookings_user FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
CONSTRAINT fk_bookings_machine FOREIGN KEY (machine_id) REFERENCES machines(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `washes`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE washes (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uuid CHAR(36) UNIQUE NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
machine_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
booking_id BIGINT UNSIGNED NULL,
|
||||||
|
machine_command_id BIGINT UNSIGNED NULL,
|
||||||
|
trigger_method ENUM('qr_code','booking','supervisor','system') NOT NULL,
|
||||||
|
status ENUM('pending_start','running','completed','failed','cancelled') NOT NULL DEFAULT 'pending_start',
|
||||||
|
program VARCHAR(100) NULL,
|
||||||
|
started_at TIMESTAMP NULL,
|
||||||
|
ended_at TIMESTAMP NULL,
|
||||||
|
duration_minutes INT UNSIGNED NULL,
|
||||||
|
cost DECIMAL(8,2) NOT NULL,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
CONSTRAINT fk_washes_user FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
CONSTRAINT fk_washes_machine FOREIGN KEY (machine_id) REFERENCES machines(id),
|
||||||
|
CONSTRAINT fk_washes_booking FOREIGN KEY (booking_id) REFERENCES bookings(id),
|
||||||
|
CONSTRAINT fk_washes_command FOREIGN KEY (machine_command_id) REFERENCES machine_commands(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5.7 Notifications, audit et statistiques
|
||||||
|
|
||||||
|
### Table `push_notifications`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE push_notifications (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT UNSIGNED NULL,
|
||||||
|
type VARCHAR(100) NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
data JSON NULL,
|
||||||
|
status ENUM('pending','sent','failed') NOT NULL DEFAULT 'pending',
|
||||||
|
sent_at TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
CONSTRAINT fk_push_notifications_user FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `audit_logs`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE audit_logs (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
actor_type VARCHAR(50) NOT NULL,
|
||||||
|
actor_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
organization_id BIGINT UNSIGNED NULL,
|
||||||
|
establishment_id BIGINT UNSIGNED NULL,
|
||||||
|
action VARCHAR(100) NOT NULL,
|
||||||
|
target_type VARCHAR(100) NOT NULL,
|
||||||
|
target_id BIGINT UNSIGNED NULL,
|
||||||
|
before_data JSON NULL,
|
||||||
|
after_data JSON NULL,
|
||||||
|
ip_address VARCHAR(45) NULL,
|
||||||
|
created_at TIMESTAMP NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table `daily_establishment_stats`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE daily_establishment_stats (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
establishment_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
stat_date DATE NOT NULL,
|
||||||
|
total_washes INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
total_revenue DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||||
|
bookings_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
no_show_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
top_up_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||||
|
occupancy_rate DECIMAL(5,2) NOT NULL DEFAULT 0.00,
|
||||||
|
created_at TIMESTAMP NULL,
|
||||||
|
updated_at TIMESTAMP NULL,
|
||||||
|
UNIQUE KEY uniq_daily_establishment_stats (establishment_id, stat_date),
|
||||||
|
CONSTRAINT fk_daily_establishment_stats_establishment FOREIGN KEY (establishment_id) REFERENCES establishments(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Contrat d'intégration machine
|
||||||
|
|
||||||
|
### 6.1 Principe
|
||||||
|
Le backend doit supporter deux scénarios :
|
||||||
|
- **pull** : notre système appelle une API partenaire pour envoyer une commande ou lire un statut,
|
||||||
|
- **push** : le partenaire pousse des événements vers notre système,
|
||||||
|
- **hybrid** : combinaison des deux,
|
||||||
|
- **simulated** : mode démonstration / sandbox.
|
||||||
|
|
||||||
|
### 6.2 Abstraction applicative
|
||||||
|
|
||||||
|
```php
|
||||||
|
interface MachineProviderInterface {
|
||||||
|
public function startCycle(Machine $machine, array $payload): MachineCommandResult;
|
||||||
|
public function stopCycle(Machine $machine, array $payload = []): MachineCommandResult;
|
||||||
|
public function refreshStatus(Machine $machine): MachineStatusSnapshot;
|
||||||
|
public function handleInboundEvent(array $payload): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Événements entrants attendus
|
||||||
|
- `heartbeat`
|
||||||
|
- `machine_online`
|
||||||
|
- `machine_offline`
|
||||||
|
- `cycle_started`
|
||||||
|
- `cycle_completed`
|
||||||
|
- `cycle_failed`
|
||||||
|
- `error_reported`
|
||||||
|
- `status_changed`
|
||||||
|
|
||||||
|
### 6.4 Endpoints d'intégration proposés
|
||||||
|
|
||||||
|
| Méthode | Endpoint | Usage |
|
||||||
|
|---------|----------|-------|
|
||||||
|
| POST | `/api/v1/integrations/machines/events` | Réception d'événements machine |
|
||||||
|
| POST | `/api/v1/integrations/machines/heartbeat` | Heartbeat passerelle / machine |
|
||||||
|
| POST | `/api/v1/integrations/machines/commands/{uuid}/ack` | Accusé de réception optionnel |
|
||||||
|
|
||||||
|
### 6.5 Payload minimal d'événement
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"provider": "client_gateway",
|
||||||
|
"event_type": "cycle_completed",
|
||||||
|
"occurred_at": "2026-06-27T10:15:00Z",
|
||||||
|
"machine_external_id": "MACH-001",
|
||||||
|
"correlation_id": "cmd_12345",
|
||||||
|
"data": {
|
||||||
|
"status": "available",
|
||||||
|
"duration_minutes": 42,
|
||||||
|
"error_code": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.6 Exigences minimales côté partenaire
|
||||||
|
- identifiant machine externe stable,
|
||||||
|
- horodatage fiable,
|
||||||
|
- type d'événement,
|
||||||
|
- état courant de la machine,
|
||||||
|
- identifiant de corrélation si une commande a été envoyée par notre système,
|
||||||
|
- stratégie d'authentification technique,
|
||||||
|
- documentation des erreurs métiers.
|
||||||
|
|
||||||
|
### 6.7 Mode démonstration
|
||||||
|
Un provider simulé doit permettre :
|
||||||
|
- démarrage immédiat d'un cycle,
|
||||||
|
- passage en statut `running`,
|
||||||
|
- émission d'un faux événement `cycle_completed` après un délai configurable,
|
||||||
|
- retour en statut `available`.
|
||||||
|
|
||||||
|
Ce mode permet une démo sans matériel réel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Endpoints API — V1
|
||||||
|
|
||||||
|
### 7.1 Auth utilisateur
|
||||||
|
| Méthode | Endpoint | Description |
|
||||||
|
|---------|----------|-------------|
|
||||||
|
| POST | `/api/v1/auth/register` | Inscription |
|
||||||
|
| POST | `/api/v1/auth/login` | Connexion utilisateur |
|
||||||
|
| POST | `/api/v1/auth/refresh` | Renouvellement token |
|
||||||
|
| POST | `/api/v1/auth/logout` | Déconnexion |
|
||||||
|
| GET | `/api/v1/auth/me` | Profil courant |
|
||||||
|
|
||||||
|
### 7.2 Wallet
|
||||||
|
| Méthode | Endpoint | Description |
|
||||||
|
|---------|----------|-------------|
|
||||||
|
| GET | `/api/v1/wallet` | Solde |
|
||||||
|
| GET | `/api/v1/wallet/transactions` | Historique |
|
||||||
|
| POST | `/api/v1/wallet/top-up/initiate` | Démarrer un rechargement |
|
||||||
|
| POST | `/api/v1/wallet/top-up/confirm` | Confirmation de rechargement |
|
||||||
|
| POST | `/api/v1/wallet/top-up/webhook/{provider}` | Webhook PSP |
|
||||||
|
|
||||||
|
### 7.3 Établissements & machines
|
||||||
|
| Méthode | Endpoint | Description |
|
||||||
|
|---------|----------|-------------|
|
||||||
|
| GET | `/api/v1/establishments` | Liste établissements |
|
||||||
|
| GET | `/api/v1/establishments/{uuid}` | Détail établissement |
|
||||||
|
| GET | `/api/v1/machines/{uuid}` | Détail machine |
|
||||||
|
| GET | `/api/v1/machines/{uuid}/availability` | Créneaux disponibles |
|
||||||
|
| GET | `/api/v1/machines/{uuid}/pricing` | Tarif actif |
|
||||||
|
|
||||||
|
### 7.4 Réservations
|
||||||
|
| Méthode | Endpoint | Description |
|
||||||
|
|---------|----------|-------------|
|
||||||
|
| POST | `/api/v1/bookings` | Créer une réservation |
|
||||||
|
| GET | `/api/v1/bookings` | Mes réservations |
|
||||||
|
| GET | `/api/v1/bookings/{uuid}` | Détail réservation |
|
||||||
|
| PATCH | `/api/v1/bookings/{uuid}/cancel` | Annuler |
|
||||||
|
| PATCH | `/api/v1/bookings/{uuid}/move` | Déplacer |
|
||||||
|
|
||||||
|
### 7.5 Lavages
|
||||||
|
| Méthode | Endpoint | Description |
|
||||||
|
|---------|----------|-------------|
|
||||||
|
| POST | `/api/v1/washes/start` | Démarrer un lavage |
|
||||||
|
| GET | `/api/v1/washes` | Historique |
|
||||||
|
| GET | `/api/v1/washes/{uuid}` | Détail |
|
||||||
|
|
||||||
|
### 7.6 Superviseur
|
||||||
|
| Méthode | Endpoint | Description |
|
||||||
|
|---------|----------|-------------|
|
||||||
|
| POST | `/api/v1/supervisor/auth/login` | Connexion superviseur |
|
||||||
|
| POST | `/api/v1/supervisor/auth/logout` | Déconnexion |
|
||||||
|
| GET | `/api/v1/supervisor/dashboard` | Dashboard |
|
||||||
|
| GET | `/api/v1/supervisor/machines` | Parc machines |
|
||||||
|
| GET | `/api/v1/supervisor/bookings` | Réservations |
|
||||||
|
| GET | `/api/v1/supervisor/washes` | Lavages |
|
||||||
|
| GET | `/api/v1/supervisor/pricing` | Tarifs |
|
||||||
|
| POST | `/api/v1/supervisor/pricing` | Création tarif |
|
||||||
|
| PUT | `/api/v1/supervisor/pricing/{id}` | Modification tarif |
|
||||||
|
| GET | `/api/v1/supervisor/promotions` | Promotions |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Règles métier critiques
|
||||||
|
|
||||||
|
### 8.1 Wallet
|
||||||
|
- aucune écriture directe de solde hors `WalletService`,
|
||||||
|
- toute opération critique porte une clé d'idempotence,
|
||||||
|
- séparation stricte entre transaction de paiement externe et mouvement de wallet interne.
|
||||||
|
|
||||||
|
### 8.2 Réservations
|
||||||
|
- un créneau machine ne peut jamais être réservé deux fois,
|
||||||
|
- création de réservation sous transaction avec verrou logique,
|
||||||
|
- pénalité et remboursement centralisés dans `BookingService`.
|
||||||
|
|
||||||
|
### 8.3 Lavages
|
||||||
|
- un lavage ne peut démarrer que si la machine est éligible,
|
||||||
|
- toute commande machine doit être historisée,
|
||||||
|
- toute fin de cycle doit être confirmée par événement ou simulation contrôlée.
|
||||||
|
|
||||||
|
### 8.4 Scoping exploitant
|
||||||
|
- toute requête superviseur doit être filtrée par `organization_id`,
|
||||||
|
- si `establishment_id` est renseigné sur le superviseur, la visibilité est limitée à cet établissement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Tâches planifiées
|
||||||
|
|
||||||
|
| Fréquence | Job | Description |
|
||||||
|
|-----------|-----|-------------|
|
||||||
|
| Toutes les 5 min | `CheckNoShowBookings` | Détection des no-shows |
|
||||||
|
| Toutes les 5 min | `RefreshOfflineMachines` | Contrôle heartbeat / statuts |
|
||||||
|
| Toutes les heures | `SendScheduledNotifications` | Notifications en attente |
|
||||||
|
| Chaque nuit | `GenerateDailyStats` | Agrégats journaliers |
|
||||||
|
| Chaque nuit | `PurgeRevokedTokens` | Nettoyage technique |
|
||||||
|
| Chaque semaine | `AnonymizeInactiveUsers` | Politique RGPD |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Sécurité
|
||||||
|
|
||||||
|
### 10.1 Général
|
||||||
|
- HTTPS obligatoire,
|
||||||
|
- validation stricte des entrées,
|
||||||
|
- rate limiting sur auth, paiements et endpoints techniques,
|
||||||
|
- headers de sécurité côté web,
|
||||||
|
- UUID publics dans l'API.
|
||||||
|
|
||||||
|
### 10.2 Paiements
|
||||||
|
- vérification systématique des webhooks,
|
||||||
|
- aucune confiance dans le retour client seul,
|
||||||
|
- conservation du payload brut du PSP.
|
||||||
|
|
||||||
|
### 10.3 Intégrations machines
|
||||||
|
- API key dédiée ou signature HMAC,
|
||||||
|
- journalisation des appels,
|
||||||
|
- horodatage et contrôle anti-rejeu si possible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Observabilité
|
||||||
|
|
||||||
|
- Sentry backend,
|
||||||
|
- logs structurés JSON,
|
||||||
|
- suivi des jobs Laravel Horizon,
|
||||||
|
- métriques minimales :
|
||||||
|
- taux d'échec commandes machines,
|
||||||
|
- taux d'échec paiements,
|
||||||
|
- machines offline,
|
||||||
|
- jobs en échec,
|
||||||
|
- no-shows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Tests
|
||||||
|
|
||||||
|
| Type | Outil | Portée |
|
||||||
|
|------|-------|--------|
|
||||||
|
| Unitaires | Pest | Wallet, Booking, Pricing, Machine provider |
|
||||||
|
| Intégration | Pest + MySQL réel | Endpoints critiques |
|
||||||
|
| Contrat | Payloads partenaires | Intégration machine / paiement |
|
||||||
|
| Charge | k6 | Wallet, booking, start wash |
|
||||||
|
|
||||||
|
### Cas critiques obligatoires
|
||||||
|
- double webhook paiement,
|
||||||
|
- double réservation même créneau,
|
||||||
|
- timeout commande machine,
|
||||||
|
- événement machine reçu en doublon,
|
||||||
|
- annulation et no-show simultanés,
|
||||||
|
- scoping superviseur inter-organisation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Livrables attendus
|
||||||
|
|
||||||
|
- code source Laravel,
|
||||||
|
- migrations et seeders,
|
||||||
|
- documentation OpenAPI,
|
||||||
|
- `.env.example`,
|
||||||
|
- Docker pour dev,
|
||||||
|
- suite de tests critique,
|
||||||
|
- mode simulation machine,
|
||||||
|
- données de démonstration multi-laveries.
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
# Cahier des Charges — Back-Office Superviseur
|
||||||
|
## Projet : Laverie Connectée — Interface d'Administration
|
||||||
|
**Version :** 1.0
|
||||||
|
**Date :** 2026-06-27
|
||||||
|
**Stack principale :** Laravel 11 (API déjà existante) + Vue.js 3 + Inertia.js
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Contexte & Objectifs
|
||||||
|
|
||||||
|
### 1.1 Contexte
|
||||||
|
Interface web d'administration destinée aux **superviseurs** (exploitants de laveries). Elle s'appuie sur les endpoints `/api/v1/supervisor/*` de l'API Laravel et s'authentifie avec le guard `supervisor`.
|
||||||
|
|
||||||
|
Le back-office est une **SPA (Single Page Application)** rendue côté serveur via Inertia.js pour simplifier le déploiement (pas d'API séparée pour le back-office, utilisation directe de Laravel comme backend Inertia).
|
||||||
|
|
||||||
|
### 1.2 Utilisateurs cibles
|
||||||
|
| Rôle | Droits |
|
||||||
|
|------|--------|
|
||||||
|
| `admin` | Accès complet multi-établissements, gestion des superviseurs |
|
||||||
|
| `manager` | Accès complet à son établissement |
|
||||||
|
| `viewer` | Lecture seule (statistiques, tableau de bord) |
|
||||||
|
|
||||||
|
### 1.3 Objectifs fonctionnels
|
||||||
|
- Visualiser l'occupation en temps réel du parc de machines
|
||||||
|
- Consulter les statistiques d'utilisation et le chiffre d'affaires
|
||||||
|
- Gérer la tarification et les promotions
|
||||||
|
- Recevoir et gérer les alertes machines (pannes, hors ligne)
|
||||||
|
- (v2) Envoyer des campagnes de notifications aux utilisateurs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Stack Technique
|
||||||
|
|
||||||
|
| Composant | Technologie | Version | Justification |
|
||||||
|
|-----------|-------------|---------|---------------|
|
||||||
|
| Backend | Laravel 11 | (partagé avec l'API) | Inertia server-side rendering |
|
||||||
|
| Intégration SPA | Inertia.js | 2.x | SSR Laravel → Vue.js sans API REST dédiée |
|
||||||
|
| Framework UI | Vue.js | 3.x (Composition API) | Réactivité, écosystème riche |
|
||||||
|
| Build tool | Vite | 5.x | HMR rapide, bundling optimisé |
|
||||||
|
| UI Components | PrimeVue | 4.x | DataTable, Chart, Calendar, etc. |
|
||||||
|
| CSS | Tailwind CSS | 3.x | Utility-first, cohérence design |
|
||||||
|
| Graphiques | Chart.js via PrimeVue | 4.x | Courbes CA, camembert machines |
|
||||||
|
| State management | Pinia | 2.x | Store réactif Vue 3 |
|
||||||
|
| Temps réel | Laravel Echo + Pusher (ou Soketi self-hosted) | — | Mise à jour statut machines |
|
||||||
|
| Validation forms | VeeValidate + Zod | — | Validation côté client |
|
||||||
|
| Tables | PrimeVue DataTable | — | Tri, filtre, pagination, export |
|
||||||
|
| Auth | Laravel Sanctum (session cookie) | — | SPA same-domain |
|
||||||
|
| Tests back | Pest | — | Tests controllers superviseur |
|
||||||
|
| Tests front | Vitest + Vue Test Utils | — | Composants Vue |
|
||||||
|
| E2E | Playwright | — | Parcours critiques superviseur |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architecture
|
||||||
|
|
||||||
|
### 3.1 Structure des fichiers
|
||||||
|
|
||||||
|
```
|
||||||
|
resources/
|
||||||
|
├── js/
|
||||||
|
│ ├── app.js # Point d'entrée Inertia
|
||||||
|
│ ├── bootstrap.js # Echo, Axios
|
||||||
|
│ ├── Components/
|
||||||
|
│ │ ├── Layout/
|
||||||
|
│ │ │ ├── AppLayout.vue # Layout principal (sidebar + topbar)
|
||||||
|
│ │ │ ├── Sidebar.vue
|
||||||
|
│ │ │ └── Topbar.vue
|
||||||
|
│ │ ├── Charts/
|
||||||
|
│ │ │ ├── RevenueChart.vue
|
||||||
|
│ │ │ ├── OccupancyChart.vue
|
||||||
|
│ │ │ └── MachineStatusChart.vue
|
||||||
|
│ │ ├── Machines/
|
||||||
|
│ │ │ ├── MachineCard.vue # Carte statut en temps réel
|
||||||
|
│ │ │ └── MachineGrid.vue
|
||||||
|
│ │ └── UI/
|
||||||
|
│ │ ├── StatCard.vue
|
||||||
|
│ │ ├── AlertBadge.vue
|
||||||
|
│ │ └── ConfirmDialog.vue
|
||||||
|
│ │
|
||||||
|
│ └── Pages/
|
||||||
|
│ ├── Auth/
|
||||||
|
│ │ └── Login.vue
|
||||||
|
│ ├── Dashboard/
|
||||||
|
│ │ └── Index.vue
|
||||||
|
│ ├── Machines/
|
||||||
|
│ │ ├── Index.vue
|
||||||
|
│ │ └── Show.vue
|
||||||
|
│ ├── Pricing/
|
||||||
|
│ │ ├── Index.vue
|
||||||
|
│ │ └── Edit.vue
|
||||||
|
│ ├── Promotions/
|
||||||
|
│ │ ├── Index.vue
|
||||||
|
│ │ └── Create.vue
|
||||||
|
│ ├── Stats/
|
||||||
|
│ │ └── Index.vue
|
||||||
|
│ └── Settings/
|
||||||
|
│ └── Index.vue
|
||||||
|
│
|
||||||
|
app/
|
||||||
|
├── Http/Controllers/Supervisor/ # (déjà définis dans CDC API)
|
||||||
|
│ ├── DashboardController.php
|
||||||
|
│ ├── MachineController.php
|
||||||
|
│ ├── PricingController.php
|
||||||
|
│ ├── PromotionController.php
|
||||||
|
│ └── StatsController.php
|
||||||
|
└── Events/
|
||||||
|
└── MachineStatusUpdated.php # Broadcast temps réel
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Routage Laravel (Inertia)
|
||||||
|
|
||||||
|
```php
|
||||||
|
// routes/supervisor.php (middleware: auth:supervisor, inertia)
|
||||||
|
Route::prefix('supervisor')->name('supervisor.')->group(function () {
|
||||||
|
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
|
||||||
|
Route::resource('/machines', MachineController::class)->only(['index','show','update']);
|
||||||
|
Route::resource('/pricing', PricingController::class);
|
||||||
|
Route::resource('/promotions', PromotionController::class);
|
||||||
|
Route::get('/stats', StatsController::class)->name('stats');
|
||||||
|
Route::get('/settings', SettingsController::class)->name('settings');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Écrans & Fonctionnalités
|
||||||
|
|
||||||
|
### 4.1 Connexion (`/supervisor/login`)
|
||||||
|
|
||||||
|
**Formulaire :**
|
||||||
|
- Email + Mot de passe
|
||||||
|
- Bouton "Se connecter"
|
||||||
|
- Lien "Mot de passe oublié"
|
||||||
|
|
||||||
|
**Comportement :**
|
||||||
|
- Session cookie Sanctum (SPA same-domain)
|
||||||
|
- Redirection automatique vers `/supervisor/dashboard` si déjà connecté
|
||||||
|
- Rate limiting : 5 tentatives / 10 min
|
||||||
|
|
||||||
|
**Données Inertia passées :**
|
||||||
|
```php
|
||||||
|
// Aucune — page statique
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 Tableau de Bord (`/supervisor/dashboard`)
|
||||||
|
|
||||||
|
C'est l'écran central. Il se rafraîchit partiellement en temps réel via Laravel Echo.
|
||||||
|
|
||||||
|
#### 4.2.1 Bandeau de KPIs (haut de page)
|
||||||
|
|
||||||
|
| KPI | Description | Période |
|
||||||
|
|-----|-------------|---------|
|
||||||
|
| CA du jour | Somme des `washes.cost` du jour | Aujourd'hui |
|
||||||
|
| Lavages du jour | Nombre de `washes` terminés | Aujourd'hui |
|
||||||
|
| Taux d'occupation | % machines actives / total | Temps réel |
|
||||||
|
| Solde moyen rechargé | Moyenne des top-ups | Cette semaine |
|
||||||
|
|
||||||
|
Chaque KPI affiche la variation vs. la veille (flèche + couleur).
|
||||||
|
|
||||||
|
#### 4.2.2 Grille des Machines (temps réel)
|
||||||
|
|
||||||
|
- Grille de cartes, une carte par machine
|
||||||
|
- Mise à jour via **WebSocket** (Laravel Echo, channel `establishment.{id}`, event `MachineStatusUpdated`)
|
||||||
|
- Chaque carte affiche :
|
||||||
|
- Nom et type de machine (icône)
|
||||||
|
- Statut (badge coloré)
|
||||||
|
- Temps restant si en cours (countdown)
|
||||||
|
- Utilisateur en cours (avatar anonymisé : initiales)
|
||||||
|
- Dernière activité
|
||||||
|
|
||||||
|
**Couleurs statut :**
|
||||||
|
| Statut | Couleur |
|
||||||
|
|--------|---------|
|
||||||
|
| `available` | 🟢 Vert |
|
||||||
|
| `running` | 🔵 Bleu + animation pulse |
|
||||||
|
| `reserved` | 🟡 Jaune |
|
||||||
|
| `maintenance` | 🟠 Orange |
|
||||||
|
| `offline` | 🔴 Rouge |
|
||||||
|
|
||||||
|
**Actions rapides (rôle manager/admin) :**
|
||||||
|
- Mettre en maintenance
|
||||||
|
- Forcer disponible
|
||||||
|
- Voir le détail
|
||||||
|
|
||||||
|
#### 4.2.3 Alertes Actives
|
||||||
|
|
||||||
|
Panel latéral ou section dédié listant :
|
||||||
|
- Machines hors ligne (dernier heartbeat > 5 min)
|
||||||
|
- Machines en erreur signalée
|
||||||
|
- Réservations no-show récentes
|
||||||
|
|
||||||
|
Chaque alerte : icône, machine concernée, heure, bouton "Traité".
|
||||||
|
|
||||||
|
#### 4.2.4 Graphique d'occupation journalière
|
||||||
|
|
||||||
|
Courbe Chart.js (via PrimeVue) :
|
||||||
|
- Axe X : heures (00h → 23h)
|
||||||
|
- Axe Y : % d'occupation
|
||||||
|
- Deux courbes : Aujourd'hui vs Moyenne 30 jours
|
||||||
|
|
||||||
|
**Données Inertia passées :**
|
||||||
|
```php
|
||||||
|
Inertia::render('Dashboard/Index', [
|
||||||
|
'machines' => MachineResource::collection($machines),
|
||||||
|
'kpis' => $dashboardService->getTodayKpis($establishment),
|
||||||
|
'alerts' => AlertResource::collection($activeAlerts),
|
||||||
|
'occupancy_chart' => $statsService->getHourlyOccupancy($establishment, today()),
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 Statistiques (`/supervisor/stats`)
|
||||||
|
|
||||||
|
Écran dédié à l'analyse de données avec filtres temporels.
|
||||||
|
|
||||||
|
#### 4.3.1 Filtres
|
||||||
|
|
||||||
|
- Période : Aujourd'hui / 7 jours / 30 jours / 3 mois / Personnalisée (date picker)
|
||||||
|
- Machine : Toutes / Par type / Par machine spécifique
|
||||||
|
|
||||||
|
#### 4.3.2 Blocs de statistiques
|
||||||
|
|
||||||
|
**Chiffre d'affaires**
|
||||||
|
- Graphique en barres : CA par jour sur la période
|
||||||
|
- Ligne de tendance (moyenne mobile 7 jours)
|
||||||
|
- Total période, meilleur jour, pire jour
|
||||||
|
|
||||||
|
**Utilisation des machines**
|
||||||
|
- Graphique camembert : Répartition par type de machine
|
||||||
|
- Nombre de cycles par machine (tableau trié)
|
||||||
|
- Taux d'utilisation horaire moyen
|
||||||
|
|
||||||
|
**Utilisateurs**
|
||||||
|
- Nouveaux inscrits sur la période
|
||||||
|
- Utilisateurs actifs (au moins 1 lavage)
|
||||||
|
- Top 10 utilisateurs (anonymisés : "Utilisateur #XXX")
|
||||||
|
|
||||||
|
**Réservations**
|
||||||
|
- Nombre de réservations vs. lavages directs
|
||||||
|
- Taux de no-show
|
||||||
|
- Taux d'annulation
|
||||||
|
|
||||||
|
**Rechargements**
|
||||||
|
- CA par source de paiement (Stripe, etc.)
|
||||||
|
- Montant moyen rechargé
|
||||||
|
- Fréquence de rechargement
|
||||||
|
|
||||||
|
#### 4.3.3 Export
|
||||||
|
|
||||||
|
- Export CSV des données brutes de la période sélectionnée
|
||||||
|
- Export PDF du rapport (via impression navigateur, layout print-optimized)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.4 Gestion de la Tarification (`/supervisor/pricing`)
|
||||||
|
|
||||||
|
#### 4.4.1 Vue Liste
|
||||||
|
|
||||||
|
Tableau (PrimeVue DataTable) listant toutes les règles tarifaires :
|
||||||
|
|
||||||
|
| Machine | Jours | Horaire | Prix | Exclusive App | Actions |
|
||||||
|
|---------|-------|---------|------|---------------|---------|
|
||||||
|
| Lave-linge 7kg | Semaine | 08h-12h | 3.50€ | Non | ✏️ 🗑 |
|
||||||
|
| Lave-linge 7kg | Semaine | 18h-22h | 4.50€ | Oui | ✏️ 🗑 |
|
||||||
|
|
||||||
|
- Filtrable par machine, type de jour
|
||||||
|
- Tri par colonne
|
||||||
|
- Badge "Actif" / "Inactif" si les horaires sont hors plage
|
||||||
|
|
||||||
|
#### 4.4.2 Formulaire Création / Édition
|
||||||
|
|
||||||
|
**Champs :**
|
||||||
|
- Machine (select, avec recherche)
|
||||||
|
- Jours applicables (checkboxes : Lun–Ven / Sam–Dim / Jours fériés / Tous)
|
||||||
|
- Heure de début / fin (time pickers, validation : début < fin)
|
||||||
|
- Prix en euros (input numérique, 2 décimales, min 0.50€)
|
||||||
|
- Label (ex: "Heure creuse", "Heure pleine")
|
||||||
|
- Exclusive app ✓ (si coché, ce tarif n'est visible que via l'application)
|
||||||
|
|
||||||
|
**Validation :**
|
||||||
|
- Vérification de non-chevauchement avec les règles existantes sur la même machine
|
||||||
|
- Avertissement si la plage couvre toute la journée sans tarif de base restant
|
||||||
|
|
||||||
|
**Règle de priorité affichée :**
|
||||||
|
> "Une règle spécifique à une machine a priorité sur une règle par type de machine."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.5 Gestion des Promotions (`/supervisor/promotions`)
|
||||||
|
|
||||||
|
#### 4.5.1 Vue Liste
|
||||||
|
|
||||||
|
Tableau avec onglets : **Actives** / **À venir** / **Terminées**
|
||||||
|
|
||||||
|
Colonnes : Type machine, Réduction, Début, Fin, Description, Statut, Actions.
|
||||||
|
|
||||||
|
#### 4.5.2 Formulaire Création
|
||||||
|
|
||||||
|
**Champs :**
|
||||||
|
- Type de machine concernée (Lave-linge petit / grand / Sèche-linge / Tous)
|
||||||
|
- Type de réduction :
|
||||||
|
- Pourcentage (ex: -20%)
|
||||||
|
- Montant fixe (ex: -0.50€)
|
||||||
|
- Valeur de la réduction
|
||||||
|
- Date/heure de début (datetime picker)
|
||||||
|
- Date/heure de fin (datetime picker)
|
||||||
|
- Description interne (texte libre, visible uniquement dans le back-office)
|
||||||
|
|
||||||
|
**Validation :**
|
||||||
|
- Date de fin > Date de début
|
||||||
|
- Réduction % : 1–90%
|
||||||
|
- Réduction fixe : ne peut pas dépasser le prix minimum (0.50€ minimum après remise)
|
||||||
|
- Avertissement si chevauchement avec une promotion existante du même type
|
||||||
|
|
||||||
|
**Impact affiché en temps réel :**
|
||||||
|
Aperçu du nouveau prix après promotion pour chaque règle tarifaire concernée.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.6 Détail Machine (`/supervisor/machines/{uuid}`)
|
||||||
|
|
||||||
|
- Informations générales (nom, type, QR code, ID LLDP)
|
||||||
|
- Statut actuel avec boutons d'action (Maintenance / Disponible)
|
||||||
|
- Historique des 30 derniers cycles (tableau paginé)
|
||||||
|
- Grille tarifaire appliquée
|
||||||
|
- Uptime sur 30 jours (%)
|
||||||
|
- Historique des alertes / pannes (30 derniers jours)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Temps Réel — Laravel Echo
|
||||||
|
|
||||||
|
### 5.1 Configuration
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// bootstrap.js
|
||||||
|
import Echo from 'laravel-echo';
|
||||||
|
import Pusher from 'pusher-js';
|
||||||
|
|
||||||
|
window.Echo = new Echo({
|
||||||
|
broadcaster: 'pusher',
|
||||||
|
key: import.meta.env.VITE_PUSHER_APP_KEY,
|
||||||
|
cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER,
|
||||||
|
wsHost: import.meta.env.VITE_PUSHER_HOST, // Soketi en self-hosted
|
||||||
|
wsPort: 6001,
|
||||||
|
forceTLS: false,
|
||||||
|
enabledTransports: ['ws', 'wss'],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Channels & Events
|
||||||
|
|
||||||
|
| Channel | Event | Payload | Déclencheur |
|
||||||
|
|---------|-------|---------|-------------|
|
||||||
|
| `private-establishment.{id}` | `MachineStatusUpdated` | `{ machine_uuid, status, current_user_initials, cycle_ends_at }` | Heartbeat LLDP + fin de cycle |
|
||||||
|
| `private-establishment.{id}` | `NewAlert` | `{ type, machine_uuid, message, created_at }` | Offline détecté, erreur machine |
|
||||||
|
| `private-establishment.{id}` | `KpiUpdated` | `{ ca_today, washes_today, occupancy_rate }` | Fin de chaque lavage |
|
||||||
|
|
||||||
|
### 5.3 Implémentation Vue
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// DashboardController.vue (Composition API)
|
||||||
|
onMounted(() => {
|
||||||
|
window.Echo
|
||||||
|
.private(`establishment.${props.establishment.id}`)
|
||||||
|
.listen('MachineStatusUpdated', (event) => {
|
||||||
|
machineStore.updateMachineStatus(event.machine_uuid, event);
|
||||||
|
})
|
||||||
|
.listen('NewAlert', (event) => {
|
||||||
|
alertStore.addAlert(event);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.Echo.leave(`establishment.${props.establishment.id}`);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Sécurité
|
||||||
|
|
||||||
|
### 6.1 Authentification
|
||||||
|
- Session cookie Sanctum (SameSite=Lax, Secure en prod)
|
||||||
|
- CSRF token sur toutes les mutations
|
||||||
|
- Timeout de session : 8 heures d'inactivité
|
||||||
|
|
||||||
|
### 6.2 Autorisation (Policies Laravel)
|
||||||
|
```
|
||||||
|
MachinePolicy::update() → role manager ou admin ET même établissement
|
||||||
|
PricingPolicy::create() → role manager ou admin
|
||||||
|
PromotionPolicy::delete() → role admin seulement
|
||||||
|
StatsPolicy::view() → tous les rôles
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Données
|
||||||
|
- Les données utilisateurs affichées sont **anonymisées** (initiales, jamais nom complet ou email)
|
||||||
|
- Logs d'audit sur toutes les mutations (qui a modifié quoi et quand)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Responsive & Accessibilité
|
||||||
|
|
||||||
|
- Layout responsive : sidebar collapsible sur tablette, menu bottom sur mobile
|
||||||
|
- Taille minimale cible : tablette 768px (utilisation en mobilité sur le terrain)
|
||||||
|
- Contraste WCAG AA sur tous les textes
|
||||||
|
- Navigation clavier complète (focus visible)
|
||||||
|
- Attributs ARIA sur les graphiques (descriptions alternatives)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Tests
|
||||||
|
|
||||||
|
| Type | Outil | Portée |
|
||||||
|
|------|-------|--------|
|
||||||
|
| Tests unitaires | Pest | Services stats, services tarification |
|
||||||
|
| Tests controllers | Pest | Tous les controllers superviseur (mocks auth) |
|
||||||
|
| Tests composants | Vitest + VTU | StatCard, MachineCard, PricingForm |
|
||||||
|
| Tests E2E | Playwright | Login, modifier un tarif, créer une promo, consulter stats |
|
||||||
|
|
||||||
|
**Scénarios Playwright critiques :**
|
||||||
|
1. Login échoué (mauvais mot de passe) → message d'erreur
|
||||||
|
2. Login réussi → redirection dashboard
|
||||||
|
3. Créer une règle tarifaire → vérification dans la liste
|
||||||
|
4. Créer une promotion avec chevauchement → message d'avertissement
|
||||||
|
5. Mise en maintenance d'une machine → badge mis à jour en temps réel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Variables d'environnement spécifiques
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Pusher / Soketi (temps réel)
|
||||||
|
PUSHER_APP_ID=
|
||||||
|
PUSHER_APP_KEY=
|
||||||
|
PUSHER_APP_SECRET=
|
||||||
|
PUSHER_HOST=127.0.0.1
|
||||||
|
PUSHER_PORT=6001
|
||||||
|
PUSHER_SCHEME=http
|
||||||
|
PUSHER_APP_CLUSTER=mt1
|
||||||
|
|
||||||
|
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
|
||||||
|
VITE_PUSHER_HOST="${PUSHER_HOST}"
|
||||||
|
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Livrables attendus
|
||||||
|
|
||||||
|
- [ ] Code source Vue.js (dans le même dépôt Laravel ou dépôt séparé)
|
||||||
|
- [ ] Migrations et seeders pour les superviseurs de démo
|
||||||
|
- [ ] Documentation des rôles et permissions
|
||||||
|
- [ ] Tests Pest controllers (couverture > 80%)
|
||||||
|
- [ ] Tests Playwright (5 parcours critiques)
|
||||||
|
- [ ] Guide d'utilisation superviseur (PDF ou page dans le back-office)
|
||||||
|
- [ ] `README.md` avec instructions de déploiement front (Vite build + assets)
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
# Cahier des Charges — Back-Office Exploitant
|
||||||
|
## Projet : Laverie Connectée — Interface d'Administration
|
||||||
|
**Version :** 2.0
|
||||||
|
**Stack principale :** Laravel 11 + Vue.js 3 + Inertia.js
|
||||||
|
**Positionnement :** V1 simple, cloisonnée par exploitant, démontrable rapidement
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Contexte & Objectifs
|
||||||
|
|
||||||
|
### 1.1 Objectif
|
||||||
|
Le back-office permet à un exploitant de piloter ses laveries sans exposer les données des autres exploitants présents sur la plateforme.
|
||||||
|
|
||||||
|
### 1.2 Objectifs V1
|
||||||
|
- authentifier les exploitants,
|
||||||
|
- afficher les machines et leur état,
|
||||||
|
- afficher les indicateurs métier de base,
|
||||||
|
- consulter réservations et lavages,
|
||||||
|
- gérer les tarifs,
|
||||||
|
- consulter les alertes techniques simples,
|
||||||
|
- garantir un cloisonnement strict par exploitant.
|
||||||
|
|
||||||
|
### 1.3 Principes
|
||||||
|
- une organisation ne voit que ses établissements,
|
||||||
|
- un manager peut être restreint à un établissement,
|
||||||
|
- la V1 privilégie la lisibilité et la fiabilité plutôt que la richesse fonctionnelle,
|
||||||
|
- le temps réel peut être simulé ou remplacé par du polling si nécessaire.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Périmètre fonctionnel
|
||||||
|
|
||||||
|
### 2.1 MVP V1
|
||||||
|
- connexion superviseur,
|
||||||
|
- tableau de bord simple,
|
||||||
|
- liste des machines,
|
||||||
|
- détail machine,
|
||||||
|
- liste des réservations,
|
||||||
|
- liste des lavages,
|
||||||
|
- gestion simple des tarifs,
|
||||||
|
- consultation simple des promotions si activées en V1,
|
||||||
|
- affichage des alertes de base,
|
||||||
|
- audit minimal des actions sensibles.
|
||||||
|
|
||||||
|
### 2.2 V2 prévue
|
||||||
|
- campagnes de notifications,
|
||||||
|
- exports avancés,
|
||||||
|
- analytics détaillées,
|
||||||
|
- gestion multi-utilisateurs plus riche,
|
||||||
|
- segmentation marketing,
|
||||||
|
- vue consolidée plus poussée pour groupes de laveries.
|
||||||
|
|
||||||
|
### 2.3 Hors périmètre V1
|
||||||
|
- CRM,
|
||||||
|
- marketing automation,
|
||||||
|
- édition de rapports complexes,
|
||||||
|
- configuration technique profonde des intégrations machines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Utilisateurs et droits
|
||||||
|
|
||||||
|
| Rôle | Portée | Droits |
|
||||||
|
|------|--------|--------|
|
||||||
|
| `platform_admin` | plateforme complète | vision globale, administration complète |
|
||||||
|
| `owner` | organization complète | accès complet à ses laveries |
|
||||||
|
| `manager` | organization ou établissement | gestion opérationnelle |
|
||||||
|
| `viewer` | organization ou établissement | lecture seule |
|
||||||
|
|
||||||
|
### 3.1 Règle de cloisonnement
|
||||||
|
Toute donnée visible dans le back-office doit être filtrée au minimum par `organization_id`. Si le superviseur est attaché à un établissement précis, la visibilité est encore plus restreinte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Stack Technique
|
||||||
|
|
||||||
|
| Composant | Technologie | Version | Commentaire |
|
||||||
|
|-----------|-------------|---------|-------------|
|
||||||
|
| Backend | Laravel 11 | — | Backend partagé avec l'API |
|
||||||
|
| Rendu SPA | Inertia.js | 2.x | Intégration Laravel / Vue |
|
||||||
|
| UI | Vue.js | 3.x | Composition API |
|
||||||
|
| UI kit | PrimeVue | 4.x | Rapide pour dashboard / tables |
|
||||||
|
| Styles | Tailwind CSS | 3.x | Mise en forme rapide |
|
||||||
|
| State | Pinia | 2.x | Si besoin de stores front |
|
||||||
|
| Graphiques | Chart.js | 4.x | KPIs simples |
|
||||||
|
| Tests front | Vitest + Vue Test Utils | — | Composants critiques |
|
||||||
|
| Tests E2E | Playwright | — | Flux exploitant |
|
||||||
|
|
||||||
|
### 4.1 Temps réel
|
||||||
|
Le temps réel n'est pas un prérequis absolu de la V1. Deux stratégies possibles :
|
||||||
|
- **V1 rapide** : polling 15 à 30 secondes,
|
||||||
|
- **V1 enrichie** : Laravel Echo / Soketi ou Pusher.
|
||||||
|
|
||||||
|
Si le délai est serré, le polling est acceptable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Architecture recommandée
|
||||||
|
|
||||||
|
```
|
||||||
|
resources/js/
|
||||||
|
├── Components/
|
||||||
|
│ ├── Layout/
|
||||||
|
│ ├── Dashboard/
|
||||||
|
│ ├── Machines/
|
||||||
|
│ ├── Pricing/
|
||||||
|
│ ├── Bookings/
|
||||||
|
│ ├── Washes/
|
||||||
|
│ └── UI/
|
||||||
|
├── Pages/
|
||||||
|
│ ├── Auth/
|
||||||
|
│ ├── Dashboard/
|
||||||
|
│ ├── Machines/
|
||||||
|
│ ├── Pricing/
|
||||||
|
│ ├── Bookings/
|
||||||
|
│ ├── Washes/
|
||||||
|
│ └── Settings/
|
||||||
|
└── app.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.1 Principe de navigation
|
||||||
|
- menu latéral simple,
|
||||||
|
- accès rapide au dashboard,
|
||||||
|
- pages data-centric,
|
||||||
|
- peu de modales complexes en V1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Écrans V1
|
||||||
|
|
||||||
|
## 6.1 Connexion
|
||||||
|
|
||||||
|
### Fonctionnalités
|
||||||
|
- email,
|
||||||
|
- mot de passe,
|
||||||
|
- message d'erreur clair,
|
||||||
|
- redirection vers dashboard si session active.
|
||||||
|
|
||||||
|
### Sécurité
|
||||||
|
- session cookie,
|
||||||
|
- CSRF,
|
||||||
|
- rate limiting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.2 Tableau de bord
|
||||||
|
|
||||||
|
### Objectif
|
||||||
|
Donner à l'exploitant une vue immédiate de l'état de ses laveries.
|
||||||
|
|
||||||
|
### KPIs V1
|
||||||
|
- chiffre d'affaires du jour,
|
||||||
|
- nombre de lavages du jour,
|
||||||
|
- réservations du jour,
|
||||||
|
- taux d'occupation courant,
|
||||||
|
- nombre de machines offline / en erreur.
|
||||||
|
|
||||||
|
### Sections recommandées
|
||||||
|
- bandeau KPI,
|
||||||
|
- liste des alertes,
|
||||||
|
- aperçu du parc machines,
|
||||||
|
- graphique simple de CA ou occupation.
|
||||||
|
|
||||||
|
### Données affichées
|
||||||
|
Toujours filtrées sur le périmètre du superviseur connecté.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.3 Liste des machines
|
||||||
|
|
||||||
|
### Colonnes minimales
|
||||||
|
- nom,
|
||||||
|
- établissement,
|
||||||
|
- type,
|
||||||
|
- statut,
|
||||||
|
- dernier heartbeat,
|
||||||
|
- utilisateur courant anonymisé si pertinent,
|
||||||
|
- actions.
|
||||||
|
|
||||||
|
### Filtres
|
||||||
|
- établissement,
|
||||||
|
- statut,
|
||||||
|
- type.
|
||||||
|
|
||||||
|
### Actions V1
|
||||||
|
- voir détail,
|
||||||
|
- basculer maintenance si autorisé,
|
||||||
|
- forcer disponibilité seulement si besoin réel et journalisé.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.4 Détail machine
|
||||||
|
|
||||||
|
### Contenu
|
||||||
|
- informations générales,
|
||||||
|
- statut actuel,
|
||||||
|
- historique récent des cycles,
|
||||||
|
- historique récent des événements machines,
|
||||||
|
- tarification appliquée,
|
||||||
|
- alertes récentes,
|
||||||
|
- uptime simple si disponible.
|
||||||
|
|
||||||
|
Cette page est très utile pour la démo car elle montre la profondeur du produit sans nécessiter trop d'écrans.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.5 Réservations
|
||||||
|
|
||||||
|
### Vue liste
|
||||||
|
- utilisateur anonymisé,
|
||||||
|
- machine,
|
||||||
|
- établissement,
|
||||||
|
- créneau,
|
||||||
|
- statut,
|
||||||
|
- montant réservé,
|
||||||
|
- pénalité éventuelle.
|
||||||
|
|
||||||
|
### Filtres
|
||||||
|
- période,
|
||||||
|
- établissement,
|
||||||
|
- machine,
|
||||||
|
- statut.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.6 Lavages
|
||||||
|
|
||||||
|
### Vue liste
|
||||||
|
- utilisateur anonymisé,
|
||||||
|
- machine,
|
||||||
|
- établissement,
|
||||||
|
- heure de démarrage,
|
||||||
|
- heure de fin,
|
||||||
|
- coût,
|
||||||
|
- statut.
|
||||||
|
|
||||||
|
### Intérêt
|
||||||
|
Permet à l'exploitant de relier l'activité terrain au chiffre d'affaires.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.7 Tarification
|
||||||
|
|
||||||
|
### Vue liste
|
||||||
|
- établissement,
|
||||||
|
- machine ou type de machine,
|
||||||
|
- plage horaire,
|
||||||
|
- prix,
|
||||||
|
- libellé,
|
||||||
|
- actif / inactif.
|
||||||
|
|
||||||
|
### Formulaire V1
|
||||||
|
- machine ou type,
|
||||||
|
- jours applicables,
|
||||||
|
- heure début / fin,
|
||||||
|
- prix,
|
||||||
|
- libellé,
|
||||||
|
- tarif exclusif app si retenu.
|
||||||
|
|
||||||
|
### Validations
|
||||||
|
- pas de plage inversée,
|
||||||
|
- pas de conflit de règles non géré,
|
||||||
|
- audit de toute modification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.8 Promotions
|
||||||
|
|
||||||
|
Si activé en V1, les promotions restent simples :
|
||||||
|
- portée établissement,
|
||||||
|
- type de machine,
|
||||||
|
- réduction fixe ou pourcentage,
|
||||||
|
- date début / fin.
|
||||||
|
|
||||||
|
Si le timing est trop serré, cette page peut être préparée mais non activée en démonstration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.9 Paramètres
|
||||||
|
|
||||||
|
### Contenu minimal
|
||||||
|
- profil superviseur,
|
||||||
|
- établissement ou organisation associés,
|
||||||
|
- informations de session,
|
||||||
|
- éventuellement préférences simples.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Cloisonnement des données
|
||||||
|
|
||||||
|
### Règles obligatoires
|
||||||
|
- `platform_admin` : accès global,
|
||||||
|
- `owner` : toutes les laveries de son organization,
|
||||||
|
- `manager` : selon son scope,
|
||||||
|
- `viewer` : lecture seule.
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
- policies Laravel,
|
||||||
|
- query scopes,
|
||||||
|
- tests dédiés au cloisonnement.
|
||||||
|
|
||||||
|
### Interdiction
|
||||||
|
Aucune page ne doit faire remonter des agrégats globaux non filtrés à un exploitant local.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Audit
|
||||||
|
|
||||||
|
### Actions à journaliser
|
||||||
|
- connexion superviseur,
|
||||||
|
- modification de tarif,
|
||||||
|
- création / modification de promotion,
|
||||||
|
- changement manuel de statut machine,
|
||||||
|
- toute action d'administration sensible.
|
||||||
|
|
||||||
|
### Utilité
|
||||||
|
- sécurité,
|
||||||
|
- compréhension métier,
|
||||||
|
- support,
|
||||||
|
- preuve en cas de litige.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Temps réel / polling
|
||||||
|
|
||||||
|
### Option 1 - Polling V1 recommandé si délai serré
|
||||||
|
- refresh du dashboard toutes les 30 secondes,
|
||||||
|
- refresh détail machine toutes les 15 à 30 secondes.
|
||||||
|
|
||||||
|
### Option 2 - Temps réel enrichi
|
||||||
|
- Echo,
|
||||||
|
- Soketi ou Pusher,
|
||||||
|
- mise à jour machine / alertes / KPIs.
|
||||||
|
|
||||||
|
### Recommandation
|
||||||
|
Pour la démo de fin de mois, le polling propre est souvent suffisant.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Sécurité
|
||||||
|
|
||||||
|
### 10.1 Authentification
|
||||||
|
- session cookie sécurisée,
|
||||||
|
- CSRF,
|
||||||
|
- timeout de session,
|
||||||
|
- rate limiting login.
|
||||||
|
|
||||||
|
### 10.2 Autorisation
|
||||||
|
- policies explicites par rôle,
|
||||||
|
- filtre systématique des établissements.
|
||||||
|
|
||||||
|
### 10.3 Données utilisateur
|
||||||
|
- anonymisation des noms dans les écrans exploitants si non nécessaire,
|
||||||
|
- pas d'affichage d'email complet côté exploitation terrain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Dashboard de démo
|
||||||
|
|
||||||
|
Le dashboard de démo doit montrer visuellement :
|
||||||
|
- plusieurs laveries sur la plateforme,
|
||||||
|
- un exploitant qui ne voit que les siennes,
|
||||||
|
- des machines dans plusieurs états,
|
||||||
|
- un lavage qui démarre puis se termine,
|
||||||
|
- un KPI qui évolue,
|
||||||
|
- une tarification modifiable.
|
||||||
|
|
||||||
|
C'est le meilleur compromis entre crédibilité et temps de développement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Tests
|
||||||
|
|
||||||
|
| Type | Outil | Portée |
|
||||||
|
|------|-------|--------|
|
||||||
|
| Controllers | Pest | Accès dashboard, pricing, machines |
|
||||||
|
| Components | Vitest | KPIs, listes, formulaires |
|
||||||
|
| E2E | Playwright | Connexion, dashboard, tarif, machine |
|
||||||
|
|
||||||
|
### Cas critiques
|
||||||
|
1. un exploitant A ne voit pas les données de B,
|
||||||
|
2. un viewer ne peut pas modifier un tarif,
|
||||||
|
3. un manager voit les bons KPI,
|
||||||
|
4. une machine passe de disponible à en cours,
|
||||||
|
5. une modification de tarif est auditée.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Livrables attendus
|
||||||
|
|
||||||
|
- code source back-office,
|
||||||
|
- pages Inertia principales,
|
||||||
|
- dataset de démonstration multi-exploitants,
|
||||||
|
- audit minimal,
|
||||||
|
- tests critiques,
|
||||||
|
- guide court de démonstration.
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# Cadrage global — Laverie Connectée
|
||||||
|
|
||||||
|
## 1. Vision produit
|
||||||
|
|
||||||
|
Le produit vise à centraliser la gestion de plusieurs laveries dans une même plateforme, avec deux populations principales :
|
||||||
|
|
||||||
|
- **les utilisateurs finaux**, qui consultent les laveries, rechargent leur wallet, réservent un créneau et lancent des lavages ;
|
||||||
|
- **les exploitants**, qui pilotent uniquement leurs propres établissements, leurs machines et leurs indicateurs.
|
||||||
|
|
||||||
|
La première version doit être **démontrable rapidement**, avec une base technique suffisamment propre pour permettre une V2 sans refonte majeure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Périmètre
|
||||||
|
|
||||||
|
### MVP V1
|
||||||
|
|
||||||
|
- authentification utilisateur
|
||||||
|
- authentification exploitant
|
||||||
|
- multi-laveries
|
||||||
|
- cloisonnement des exploitants par organization
|
||||||
|
- consultation des établissements et machines
|
||||||
|
- wallet + rechargement
|
||||||
|
- réservation de créneau
|
||||||
|
- lancement d'un lavage
|
||||||
|
- historique simple des transactions, réservations et lavages
|
||||||
|
- dashboard exploitant simple
|
||||||
|
- tarification simple
|
||||||
|
- intégration machine simulable
|
||||||
|
- audit minimal
|
||||||
|
- statistiques journalières simples
|
||||||
|
|
||||||
|
### V2
|
||||||
|
|
||||||
|
- fidélité
|
||||||
|
- parrainage
|
||||||
|
- abonnements
|
||||||
|
- promotions avancées
|
||||||
|
- campagnes marketing
|
||||||
|
- exports avancés
|
||||||
|
- analytics détaillées
|
||||||
|
- RGPD enrichi
|
||||||
|
- modes d'intégration machine plus complets
|
||||||
|
|
||||||
|
### Hors périmètre V1
|
||||||
|
|
||||||
|
- chat
|
||||||
|
- avis
|
||||||
|
- FAQ dynamique
|
||||||
|
- IA / lecture photo étiquette
|
||||||
|
- moteur prédictif
|
||||||
|
- interfaçage matériel bas niveau
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architecture métier cible
|
||||||
|
|
||||||
|
### Entités principales
|
||||||
|
|
||||||
|
- `organizations`
|
||||||
|
- `establishments`
|
||||||
|
- `supervisors`
|
||||||
|
- `users`
|
||||||
|
- `machines`
|
||||||
|
- `machine_integrations`
|
||||||
|
- `machine_commands`
|
||||||
|
- `machine_events`
|
||||||
|
- `wallets`
|
||||||
|
- `payment_transactions`
|
||||||
|
- `wallet_transactions`
|
||||||
|
- `bookings`
|
||||||
|
- `washes`
|
||||||
|
- `pricing_rules`
|
||||||
|
- `promotions`
|
||||||
|
- `push_notifications`
|
||||||
|
- `audit_logs`
|
||||||
|
- `daily_establishment_stats`
|
||||||
|
|
||||||
|
### Cloisonnement
|
||||||
|
|
||||||
|
- un exploitant ne voit que les données de son `organization_id`
|
||||||
|
- un manager peut être limité à un seul établissement
|
||||||
|
- un `platform_admin` peut avoir une vue globale
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Stratégie d'intégration machine
|
||||||
|
|
||||||
|
Le système doit être pensé comme une **couche d'intégration** entre le métier laverie et un fournisseur technique externe.
|
||||||
|
|
||||||
|
### Modes supportés
|
||||||
|
|
||||||
|
- **push** : le partenaire envoie des événements à notre API
|
||||||
|
- **pull** : notre backend appelle son API
|
||||||
|
- **hybrid** : combinaison des deux
|
||||||
|
- **simulated** : mode démo
|
||||||
|
|
||||||
|
### Principe recommandé
|
||||||
|
|
||||||
|
Pour la V1 et la démo, implémenter d'abord un **provider simulé**.
|
||||||
|
|
||||||
|
Cela permet de démontrer :
|
||||||
|
- le lancement d'un lavage,
|
||||||
|
- le passage machine en cours,
|
||||||
|
- la fin de cycle,
|
||||||
|
- les mises à jour du dashboard,
|
||||||
|
- les notifications,
|
||||||
|
|
||||||
|
sans dépendre du matériel réel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Démo de fin de mois
|
||||||
|
|
||||||
|
### Parcours utilisateur
|
||||||
|
|
||||||
|
1. connexion
|
||||||
|
2. affichage des laveries
|
||||||
|
3. consultation des machines
|
||||||
|
4. rechargement wallet
|
||||||
|
5. réservation d'un créneau
|
||||||
|
6. lancement d'un lavage
|
||||||
|
7. cycle simulé
|
||||||
|
8. notification de fin
|
||||||
|
9. historique mis à jour
|
||||||
|
|
||||||
|
### Parcours exploitant
|
||||||
|
|
||||||
|
1. connexion exploitant
|
||||||
|
2. dashboard limité à ses laveries
|
||||||
|
3. visualisation d'une machine qui passe en `running`
|
||||||
|
4. évolution d'un KPI
|
||||||
|
5. modification d'un tarif
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Backlog priorisé
|
||||||
|
|
||||||
|
### Sprint 1 — Fondations
|
||||||
|
|
||||||
|
- modèle organizations / establishments / supervisors
|
||||||
|
- auth utilisateur
|
||||||
|
- auth exploitant
|
||||||
|
- seeders de démo
|
||||||
|
- policies de cloisonnement
|
||||||
|
|
||||||
|
### Sprint 2 — Coeur métier
|
||||||
|
|
||||||
|
- machines
|
||||||
|
- wallet
|
||||||
|
- rechargement
|
||||||
|
- réservation
|
||||||
|
- lavages
|
||||||
|
- provider machine simulé
|
||||||
|
|
||||||
|
### Sprint 3 — Interfaces
|
||||||
|
|
||||||
|
- écrans Flutter MVP
|
||||||
|
- back-office MVP
|
||||||
|
- dashboard simple
|
||||||
|
- historique simple
|
||||||
|
|
||||||
|
### Sprint 4 — Démo et stabilisation
|
||||||
|
|
||||||
|
- audit minimal
|
||||||
|
- notifications
|
||||||
|
- agrégats journaliers
|
||||||
|
- nettoyage UX
|
||||||
|
- dataset final de démonstration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Risques à surveiller
|
||||||
|
|
||||||
|
- dérive de périmètre côté client
|
||||||
|
- ambiguïté sur l'intégration machine réelle
|
||||||
|
- sous-estimation de la partie paiement
|
||||||
|
- dette technique si trop d'optimisation UI inutile en V1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Règle de pilotage recommandée
|
||||||
|
|
||||||
|
Toute fonctionnalité V1 doit répondre à au moins un de ces critères :
|
||||||
|
|
||||||
|
- utile à la démonstration,
|
||||||
|
- nécessaire au fonctionnement métier minimal,
|
||||||
|
- nécessaire à la sécurité / traçabilité,
|
||||||
|
- nécessaire pour éviter une refonte en V2.
|
||||||
|
|
||||||
|
Si elle ne remplit aucun de ces critères, elle doit être repoussée.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Stack de développement Laverie Connectée
|
||||||
|
# Usage : docker compose up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: laverie-mysql
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${MYSQL_PORT:-3306}:3306"
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:-secret}"
|
||||||
|
MYSQL_DATABASE: laverie
|
||||||
|
MYSQL_USER: laverie
|
||||||
|
MYSQL_PASSWORD: "${MYSQL_PASSWORD:-laverie}"
|
||||||
|
volumes:
|
||||||
|
- laverie_mysql_data:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-secret}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: laverie-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${REDIS_PORT:-6379}:6379"
|
||||||
|
volumes:
|
||||||
|
- laverie_redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# Service applicatif Laravel (optionnel — décommenter pour lancer l'API dans Docker)
|
||||||
|
# app:
|
||||||
|
# build:
|
||||||
|
# context: ./backend
|
||||||
|
# dockerfile: Dockerfile
|
||||||
|
# container_name: laverie-app
|
||||||
|
# restart: unless-stopped
|
||||||
|
# ports:
|
||||||
|
# - "${APP_PORT:-8000}:8000"
|
||||||
|
# environment:
|
||||||
|
# APP_ENV: local
|
||||||
|
# APP_DEBUG: "true"
|
||||||
|
# DB_CONNECTION: mysql
|
||||||
|
# DB_HOST: mysql
|
||||||
|
# DB_PORT: 3306
|
||||||
|
# DB_DATABASE: laverie
|
||||||
|
# DB_USERNAME: laverie
|
||||||
|
# DB_PASSWORD: "${MYSQL_PASSWORD:-laverie}"
|
||||||
|
# REDIS_HOST: redis
|
||||||
|
# REDIS_PORT: 6379
|
||||||
|
# LAVERIE_MACHINE_API_KEY: "${LAVERIE_MACHINE_API_KEY:-demo-machine-api-key}"
|
||||||
|
# volumes:
|
||||||
|
# - ./backend:/var/www/html
|
||||||
|
# depends_on:
|
||||||
|
# mysql:
|
||||||
|
# condition: service_healthy
|
||||||
|
# redis:
|
||||||
|
# condition: service_healthy
|
||||||
|
# command: php artisan serve --host=0.0.0.0 --port=8000
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
laverie_mysql_data:
|
||||||
|
laverie_redis_data:
|
||||||
Generated
-3385
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
<button
|
|
||||||
class="inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 active:bg-gray-900"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
defineProps({
|
|
||||||
type: {
|
|
||||||
type: String,
|
|
||||||
default: 'button',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<button
|
|
||||||
:type="type"
|
|
||||||
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-xs font-semibold uppercase tracking-widest text-gray-700 shadow-sm transition duration-150 ease-in-out hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-25"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import ApplicationLogo from '@/Components/ApplicationLogo.vue';
|
|
||||||
import { Link, usePage } from '@inertiajs/vue3';
|
|
||||||
import { computed } from 'vue';
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const page = usePage();
|
|
||||||
const supervisor = computed(() => page.props.auth.supervisor);
|
|
||||||
|
|
||||||
const navItems = [
|
|
||||||
{ label: 'Tableau de bord', route: 'supervisor.dashboard', icon: 'dashboard' },
|
|
||||||
{ label: 'Machines', route: 'supervisor.machines.index', icon: 'machines' },
|
|
||||||
{ label: 'Réservations', route: 'supervisor.bookings.index', icon: 'bookings' },
|
|
||||||
{ label: 'Lavages', route: 'supervisor.washes.index', icon: 'washes' },
|
|
||||||
{ label: 'Tarifs', route: 'supervisor.pricing.index', icon: 'pricing' },
|
|
||||||
{ label: 'Promotions', route: 'supervisor.promotions.index', icon: 'promotions' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const isActive = (routeName) => {
|
|
||||||
if (routeName === 'supervisor.machines.index') {
|
|
||||||
return route().current('supervisor.machines.*');
|
|
||||||
}
|
|
||||||
return route().current(routeName);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="min-h-screen bg-gray-100">
|
|
||||||
<div class="flex min-h-screen">
|
|
||||||
<!-- Sidebar -->
|
|
||||||
<aside class="hidden w-64 flex-shrink-0 bg-indigo-900 lg:flex lg:flex-col">
|
|
||||||
<div class="flex h-16 items-center px-6">
|
|
||||||
<Link :href="route('supervisor.dashboard')" class="flex items-center gap-2">
|
|
||||||
<ApplicationLogo class="h-8 w-auto fill-current text-white" />
|
|
||||||
<span class="text-lg font-semibold text-white">Laverie</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="mt-4 flex-1 space-y-1 px-3">
|
|
||||||
<Link
|
|
||||||
v-for="item in navItems"
|
|
||||||
:key="item.route"
|
|
||||||
:href="route(item.route)"
|
|
||||||
class="flex items-center rounded-md px-3 py-2 text-sm font-medium transition"
|
|
||||||
:class="
|
|
||||||
isActive(item.route)
|
|
||||||
? 'bg-indigo-800 text-white'
|
|
||||||
: 'text-indigo-100 hover:bg-indigo-800 hover:text-white'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ item.label }}
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="border-t border-indigo-800 p-4">
|
|
||||||
<p class="truncate text-sm font-medium text-white">
|
|
||||||
{{ supervisor?.name }}
|
|
||||||
</p>
|
|
||||||
<p class="truncate text-xs text-indigo-300">
|
|
||||||
{{ supervisor?.email }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Main content -->
|
|
||||||
<div class="flex flex-1 flex-col">
|
|
||||||
<!-- Header -->
|
|
||||||
<header class="border-b border-gray-200 bg-white shadow-sm">
|
|
||||||
<div class="flex h-16 items-center justify-between px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<Link
|
|
||||||
:href="route('supervisor.dashboard')"
|
|
||||||
class="text-lg font-semibold text-gray-800 lg:hidden"
|
|
||||||
>
|
|
||||||
Laverie
|
|
||||||
</Link>
|
|
||||||
<h1 v-if="title" class="text-lg font-semibold text-gray-800">
|
|
||||||
{{ title }}
|
|
||||||
</h1>
|
|
||||||
<slot name="header" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<span class="hidden text-sm text-gray-600 sm:inline">
|
|
||||||
{{ supervisor?.name }}
|
|
||||||
</span>
|
|
||||||
<Link
|
|
||||||
:href="route('logout')"
|
|
||||||
method="post"
|
|
||||||
as="button"
|
|
||||||
class="rounded-md bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-200"
|
|
||||||
>
|
|
||||||
Déconnexion
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mobile nav -->
|
|
||||||
<nav class="flex gap-1 overflow-x-auto border-t border-gray-100 px-4 py-2 lg:hidden">
|
|
||||||
<Link
|
|
||||||
v-for="item in navItems"
|
|
||||||
:key="item.route"
|
|
||||||
:href="route(item.route)"
|
|
||||||
class="whitespace-nowrap rounded-md px-3 py-1.5 text-xs font-medium"
|
|
||||||
:class="
|
|
||||||
isActive(item.route)
|
|
||||||
? 'bg-indigo-100 text-indigo-800'
|
|
||||||
: 'text-gray-600 hover:bg-gray-100'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ item.label }}
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Flash message -->
|
|
||||||
<div
|
|
||||||
v-if="page.props.flash?.success"
|
|
||||||
class="mx-4 mt-4 rounded-md bg-green-50 px-4 py-3 text-sm text-green-800 sm:mx-6 lg:mx-8"
|
|
||||||
>
|
|
||||||
{{ page.props.flash.success }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Page content -->
|
|
||||||
<main class="flex-1 p-4 sm:p-6 lg:p-8">
|
|
||||||
<slot />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import Checkbox from '@/Components/Checkbox.vue';
|
|
||||||
import InputError from '@/Components/InputError.vue';
|
|
||||||
import InputLabel from '@/Components/InputLabel.vue';
|
|
||||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
|
||||||
import TextInput from '@/Components/TextInput.vue';
|
|
||||||
import { Head, useForm } from '@inertiajs/vue3';
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
status: {
|
|
||||||
type: String,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
email: '',
|
|
||||||
password: '',
|
|
||||||
remember: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const submit = () => {
|
|
||||||
form.post(route('login.store'), {
|
|
||||||
onFinish: () => form.reset('password'),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Head title="Connexion superviseur" />
|
|
||||||
|
|
||||||
<div class="flex min-h-screen flex-col items-center justify-center bg-gray-100 px-4">
|
|
||||||
<div class="mb-8 text-center">
|
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Laverie — Back-office</h1>
|
|
||||||
<p class="mt-1 text-sm text-gray-600">Connexion exploitant</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full max-w-md overflow-hidden rounded-lg bg-white px-6 py-8 shadow-md">
|
|
||||||
<div v-if="status" class="mb-4 text-sm font-medium text-green-600">
|
|
||||||
{{ status }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form @submit.prevent="submit">
|
|
||||||
<div>
|
|
||||||
<InputLabel for="email" value="Adresse e-mail" />
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
class="mt-1 block w-full"
|
|
||||||
v-model="form.email"
|
|
||||||
required
|
|
||||||
autofocus
|
|
||||||
autocomplete="username"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<InputError class="mt-2" :message="form.errors.email" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
|
||||||
<InputLabel for="password" value="Mot de passe" />
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
class="mt-1 block w-full"
|
|
||||||
v-model="form.password"
|
|
||||||
required
|
|
||||||
autocomplete="current-password"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<InputError class="mt-2" :message="form.errors.password" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
|
||||||
<label class="flex items-center">
|
|
||||||
<Checkbox name="remember" v-model:checked="form.remember" />
|
|
||||||
<span class="ms-2 text-sm text-gray-600">Se souvenir de moi</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6">
|
|
||||||
<PrimaryButton
|
|
||||||
class="w-full justify-center"
|
|
||||||
:class="{ 'opacity-25': form.processing }"
|
|
||||||
:disabled="form.processing"
|
|
||||||
>
|
|
||||||
Se connecter
|
|
||||||
</PrimaryButton>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import SupervisorLayout from '@/Layouts/SupervisorLayout.vue';
|
|
||||||
import { Head, Link, router } from '@inertiajs/vue3';
|
|
||||||
import { reactive, watch } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
bookings: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
filters: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
statusOptions: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const localFilters = reactive({
|
|
||||||
status: props.filters.status ?? '',
|
|
||||||
date: props.filters.date ?? '',
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(localFilters, () => {
|
|
||||||
router.get(route('supervisor.bookings.index'), localFilters, {
|
|
||||||
preserveState: true,
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatDate = (iso) => {
|
|
||||||
if (!iso) return '—';
|
|
||||||
return new Intl.DateTimeFormat('fr-FR', {
|
|
||||||
dateStyle: 'short',
|
|
||||||
timeStyle: 'short',
|
|
||||||
}).format(new Date(iso));
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (value) =>
|
|
||||||
new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value ?? 0);
|
|
||||||
|
|
||||||
const statusColor = (status) => {
|
|
||||||
const colors = {
|
|
||||||
pending: 'bg-gray-100 text-gray-800',
|
|
||||||
confirmed: 'bg-blue-100 text-blue-800',
|
|
||||||
expired: 'bg-orange-100 text-orange-800',
|
|
||||||
active: 'bg-green-100 text-green-800',
|
|
||||||
completed: 'bg-indigo-100 text-indigo-800',
|
|
||||||
cancelled: 'bg-yellow-100 text-yellow-800',
|
|
||||||
no_show: 'bg-red-100 text-red-800',
|
|
||||||
};
|
|
||||||
return colors[status] ?? 'bg-gray-100 text-gray-800';
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Head title="Réservations" />
|
|
||||||
|
|
||||||
<SupervisorLayout title="Réservations">
|
|
||||||
<div class="mb-6 flex flex-wrap gap-4 rounded-lg bg-white p-4 shadow">
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-gray-500">Statut</label>
|
|
||||||
<select
|
|
||||||
v-model="localFilters.status"
|
|
||||||
class="mt-1 block rounded-md border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
|
||||||
>
|
|
||||||
<option value="">Tous</option>
|
|
||||||
<option v-for="(label, value) in statusOptions" :key="value" :value="value">
|
|
||||||
{{ label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-gray-500">Date</label>
|
|
||||||
<input
|
|
||||||
v-model="localFilters.date"
|
|
||||||
type="date"
|
|
||||||
class="mt-1 block rounded-md border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="overflow-hidden rounded-lg bg-white shadow">
|
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead class="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Créneau</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Utilisateur</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Machine</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Statut</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Frais</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-gray-100">
|
|
||||||
<tr v-if="bookings.data.length === 0">
|
|
||||||
<td colspan="5" class="px-4 py-8 text-center text-sm text-gray-500">
|
|
||||||
Aucune réservation trouvée.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-for="booking in bookings.data" :key="booking.uuid" class="hover:bg-gray-50">
|
|
||||||
<td class="whitespace-nowrap px-4 py-3 text-sm">
|
|
||||||
<div>{{ formatDate(booking.slot_start) }}</div>
|
|
||||||
<div class="text-xs text-gray-500">→ {{ formatDate(booking.slot_end) }}</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm">
|
|
||||||
<div class="font-medium text-gray-800">{{ booking.user?.name ?? '—' }}</div>
|
|
||||||
<div class="text-xs text-gray-500">{{ booking.user?.email }}</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm">
|
|
||||||
<Link
|
|
||||||
v-if="booking.machine"
|
|
||||||
:href="route('supervisor.machines.show', booking.machine.uuid)"
|
|
||||||
class="text-indigo-600 hover:text-indigo-800"
|
|
||||||
>
|
|
||||||
{{ booking.machine.name }}
|
|
||||||
</Link>
|
|
||||||
<span v-else>—</span>
|
|
||||||
<div v-if="booking.machine?.establishment_name" class="text-xs text-gray-500">
|
|
||||||
{{ booking.machine.establishment_name }}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<span
|
|
||||||
class="rounded-full px-2.5 py-0.5 text-xs font-medium"
|
|
||||||
:class="statusColor(booking.status)"
|
|
||||||
>
|
|
||||||
{{ booking.status_label }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-600">
|
|
||||||
{{ formatCurrency(booking.booking_fee) }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div v-if="bookings.links.length > 3" class="flex items-center justify-between border-t px-4 py-3">
|
|
||||||
<p class="text-sm text-gray-600">
|
|
||||||
{{ bookings.from ?? 0 }}–{{ bookings.to ?? 0 }} sur {{ bookings.total }}
|
|
||||||
</p>
|
|
||||||
<div class="flex gap-1">
|
|
||||||
<Link
|
|
||||||
v-for="link in bookings.links"
|
|
||||||
:key="link.label"
|
|
||||||
:href="link.url"
|
|
||||||
v-html="link.label"
|
|
||||||
class="rounded px-3 py-1 text-sm"
|
|
||||||
:class="
|
|
||||||
link.active
|
|
||||||
? 'bg-indigo-600 text-white'
|
|
||||||
: link.url
|
|
||||||
? 'text-gray-600 hover:bg-gray-100'
|
|
||||||
: 'cursor-not-allowed text-gray-300'
|
|
||||||
"
|
|
||||||
preserve-state
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SupervisorLayout>
|
|
||||||
</template>
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import SupervisorLayout from '@/Layouts/SupervisorLayout.vue';
|
|
||||||
import { Head, Link } from '@inertiajs/vue3';
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
kpis: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
alerts: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
machinesOverview: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatCurrency = (value) =>
|
|
||||||
new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value ?? 0);
|
|
||||||
|
|
||||||
const formatDate = (iso) => {
|
|
||||||
if (!iso) return '—';
|
|
||||||
return new Intl.DateTimeFormat('fr-FR', {
|
|
||||||
dateStyle: 'short',
|
|
||||||
timeStyle: 'short',
|
|
||||||
}).format(new Date(iso));
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusColor = (status) => {
|
|
||||||
const colors = {
|
|
||||||
available: 'bg-green-100 text-green-800',
|
|
||||||
reserved: 'bg-yellow-100 text-yellow-800',
|
|
||||||
running: 'bg-blue-100 text-blue-800',
|
|
||||||
maintenance: 'bg-orange-100 text-orange-800',
|
|
||||||
offline: 'bg-gray-100 text-gray-800',
|
|
||||||
error: 'bg-red-100 text-red-800',
|
|
||||||
};
|
|
||||||
return colors[status] ?? 'bg-gray-100 text-gray-800';
|
|
||||||
};
|
|
||||||
|
|
||||||
const severityColor = (severity) => {
|
|
||||||
return severity === 'high' ? 'border-red-400 bg-red-50' : 'border-yellow-400 bg-yellow-50';
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Head title="Tableau de bord" />
|
|
||||||
|
|
||||||
<SupervisorLayout title="Tableau de bord">
|
|
||||||
<!-- KPI cards -->
|
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<div class="rounded-lg bg-white p-5 shadow">
|
|
||||||
<p class="text-sm font-medium text-gray-500">Chiffre d'affaires aujourd'hui</p>
|
|
||||||
<p class="mt-2 text-2xl font-bold text-gray-900">
|
|
||||||
{{ formatCurrency(kpis.revenue_today) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-lg bg-white p-5 shadow">
|
|
||||||
<p class="text-sm font-medium text-gray-500">Lavages aujourd'hui</p>
|
|
||||||
<p class="mt-2 text-2xl font-bold text-gray-900">{{ kpis.washes_today }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-lg bg-white p-5 shadow">
|
|
||||||
<p class="text-sm font-medium text-gray-500">Réservations aujourd'hui</p>
|
|
||||||
<p class="mt-2 text-2xl font-bold text-gray-900">{{ kpis.bookings_today }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-lg bg-white p-5 shadow">
|
|
||||||
<p class="text-sm font-medium text-gray-500">Machines hors ligne</p>
|
|
||||||
<p class="mt-2 text-2xl font-bold" :class="kpis.offline_machines > 0 ? 'text-red-600' : 'text-gray-900'">
|
|
||||||
{{ kpis.offline_machines }}
|
|
||||||
<span class="text-sm font-normal text-gray-500">/ {{ kpis.machines_total }}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
||||||
<!-- Alerts -->
|
|
||||||
<div class="rounded-lg bg-white p-5 shadow">
|
|
||||||
<h2 class="mb-4 text-lg font-semibold text-gray-800">Alertes</h2>
|
|
||||||
<div v-if="alerts.length === 0" class="text-sm text-gray-500">
|
|
||||||
Aucune alerte pour le moment.
|
|
||||||
</div>
|
|
||||||
<ul v-else class="space-y-3">
|
|
||||||
<li
|
|
||||||
v-for="(alert, index) in alerts"
|
|
||||||
:key="index"
|
|
||||||
class="rounded-md border-l-4 px-3 py-2 text-sm"
|
|
||||||
:class="severityColor(alert.severity)"
|
|
||||||
>
|
|
||||||
<p class="font-medium text-gray-800">{{ alert.message }}</p>
|
|
||||||
<p class="mt-1 text-xs text-gray-500">{{ formatDate(alert.occurred_at) }}</p>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Machines overview -->
|
|
||||||
<div class="rounded-lg bg-white p-5 shadow">
|
|
||||||
<div class="mb-4 flex items-center justify-between">
|
|
||||||
<h2 class="text-lg font-semibold text-gray-800">Parc machines</h2>
|
|
||||||
<Link
|
|
||||||
:href="route('supervisor.machines.index')"
|
|
||||||
class="text-sm text-indigo-600 hover:text-indigo-800"
|
|
||||||
>
|
|
||||||
Voir tout →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div v-if="machinesOverview.length === 0" class="text-sm text-gray-500">
|
|
||||||
Aucune machine dans votre périmètre.
|
|
||||||
</div>
|
|
||||||
<ul v-else class="divide-y divide-gray-100">
|
|
||||||
<li
|
|
||||||
v-for="machine in machinesOverview"
|
|
||||||
:key="machine.uuid"
|
|
||||||
class="flex items-center justify-between py-3"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<Link
|
|
||||||
:href="route('supervisor.machines.show', machine.uuid)"
|
|
||||||
class="font-medium text-gray-800 hover:text-indigo-600"
|
|
||||||
>
|
|
||||||
{{ machine.name }}
|
|
||||||
</Link>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
{{ machine.establishment_name }} · {{ machine.type_label }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="rounded-full px-2.5 py-0.5 text-xs font-medium"
|
|
||||||
:class="statusColor(machine.status)"
|
|
||||||
>
|
|
||||||
{{ machine.status_label }}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SupervisorLayout>
|
|
||||||
</template>
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import SupervisorLayout from '@/Layouts/SupervisorLayout.vue';
|
|
||||||
import { Head, Link, router } from '@inertiajs/vue3';
|
|
||||||
import { reactive, watch } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
machines: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
filters: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
statusOptions: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
typeOptions: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const localFilters = reactive({
|
|
||||||
search: props.filters.search ?? '',
|
|
||||||
status: props.filters.status ?? '',
|
|
||||||
type: props.filters.type ?? '',
|
|
||||||
});
|
|
||||||
|
|
||||||
let debounceTimer = null;
|
|
||||||
|
|
||||||
watch(localFilters, () => {
|
|
||||||
clearTimeout(debounceTimer);
|
|
||||||
debounceTimer = setTimeout(() => {
|
|
||||||
router.get(route('supervisor.machines.index'), localFilters, {
|
|
||||||
preserveState: true,
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
|
|
||||||
const statusColor = (status) => {
|
|
||||||
const colors = {
|
|
||||||
available: 'bg-green-100 text-green-800',
|
|
||||||
reserved: 'bg-yellow-100 text-yellow-800',
|
|
||||||
running: 'bg-blue-100 text-blue-800',
|
|
||||||
maintenance: 'bg-orange-100 text-orange-800',
|
|
||||||
offline: 'bg-gray-100 text-gray-800',
|
|
||||||
error: 'bg-red-100 text-red-800',
|
|
||||||
};
|
|
||||||
return colors[status] ?? 'bg-gray-100 text-gray-800';
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (iso) => {
|
|
||||||
if (!iso) return '—';
|
|
||||||
return new Intl.DateTimeFormat('fr-FR', {
|
|
||||||
dateStyle: 'short',
|
|
||||||
timeStyle: 'short',
|
|
||||||
}).format(new Date(iso));
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Head title="Machines" />
|
|
||||||
|
|
||||||
<SupervisorLayout title="Machines">
|
|
||||||
<!-- Filters -->
|
|
||||||
<div class="mb-6 flex flex-wrap gap-4 rounded-lg bg-white p-4 shadow">
|
|
||||||
<div class="min-w-[200px] flex-1">
|
|
||||||
<label class="block text-xs font-medium text-gray-500">Recherche</label>
|
|
||||||
<input
|
|
||||||
v-model="localFilters.search"
|
|
||||||
type="text"
|
|
||||||
placeholder="Nom ou QR code…"
|
|
||||||
class="mt-1 block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-gray-500">Statut</label>
|
|
||||||
<select
|
|
||||||
v-model="localFilters.status"
|
|
||||||
class="mt-1 block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
|
||||||
>
|
|
||||||
<option value="">Tous</option>
|
|
||||||
<option v-for="(label, value) in statusOptions" :key="value" :value="value">
|
|
||||||
{{ label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-gray-500">Type</label>
|
|
||||||
<select
|
|
||||||
v-model="localFilters.type"
|
|
||||||
class="mt-1 block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
|
||||||
>
|
|
||||||
<option value="">Tous</option>
|
|
||||||
<option v-for="(label, value) in typeOptions" :key="value" :value="value">
|
|
||||||
{{ label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Table -->
|
|
||||||
<div class="overflow-hidden rounded-lg bg-white shadow">
|
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead class="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
|
||||||
Machine
|
|
||||||
</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
|
||||||
Établissement
|
|
||||||
</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
|
||||||
Type
|
|
||||||
</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
|
||||||
Statut
|
|
||||||
</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
|
||||||
Dernier signal
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-gray-100 bg-white">
|
|
||||||
<tr v-if="machines.data.length === 0">
|
|
||||||
<td colspan="5" class="px-4 py-8 text-center text-sm text-gray-500">
|
|
||||||
Aucune machine trouvée.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-for="machine in machines.data" :key="machine.uuid" class="hover:bg-gray-50">
|
|
||||||
<td class="whitespace-nowrap px-4 py-3">
|
|
||||||
<Link
|
|
||||||
:href="route('supervisor.machines.show', machine.uuid)"
|
|
||||||
class="font-medium text-indigo-600 hover:text-indigo-800"
|
|
||||||
>
|
|
||||||
{{ machine.name }}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-600">
|
|
||||||
{{ machine.establishment_name ?? '—' }}
|
|
||||||
</td>
|
|
||||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-600">
|
|
||||||
{{ machine.type_label }}
|
|
||||||
</td>
|
|
||||||
<td class="whitespace-nowrap px-4 py-3">
|
|
||||||
<span
|
|
||||||
class="rounded-full px-2.5 py-0.5 text-xs font-medium"
|
|
||||||
:class="statusColor(machine.status)"
|
|
||||||
>
|
|
||||||
{{ machine.status_label }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
|
|
||||||
{{ formatDate(machine.last_heartbeat_at) }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
<div v-if="machines.links.length > 3" class="flex items-center justify-between border-t px-4 py-3">
|
|
||||||
<p class="text-sm text-gray-600">
|
|
||||||
{{ machines.from ?? 0 }}–{{ machines.to ?? 0 }} sur {{ machines.total }}
|
|
||||||
</p>
|
|
||||||
<div class="flex gap-1">
|
|
||||||
<Link
|
|
||||||
v-for="link in machines.links"
|
|
||||||
:key="link.label"
|
|
||||||
:href="link.url"
|
|
||||||
v-html="link.label"
|
|
||||||
class="rounded px-3 py-1 text-sm"
|
|
||||||
:class="
|
|
||||||
link.active
|
|
||||||
? 'bg-indigo-600 text-white'
|
|
||||||
: link.url
|
|
||||||
? 'text-gray-600 hover:bg-gray-100'
|
|
||||||
: 'cursor-not-allowed text-gray-300'
|
|
||||||
"
|
|
||||||
preserve-state
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SupervisorLayout>
|
|
||||||
</template>
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import SupervisorLayout from '@/Layouts/SupervisorLayout.vue';
|
|
||||||
import { Head, Link } from '@inertiajs/vue3';
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
machine: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
recentEvents: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatDate = (iso) => {
|
|
||||||
if (!iso) return '—';
|
|
||||||
return new Intl.DateTimeFormat('fr-FR', {
|
|
||||||
dateStyle: 'medium',
|
|
||||||
timeStyle: 'short',
|
|
||||||
}).format(new Date(iso));
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusColor = (status) => {
|
|
||||||
const colors = {
|
|
||||||
available: 'bg-green-100 text-green-800',
|
|
||||||
reserved: 'bg-yellow-100 text-yellow-800',
|
|
||||||
running: 'bg-blue-100 text-blue-800',
|
|
||||||
maintenance: 'bg-orange-100 text-orange-800',
|
|
||||||
offline: 'bg-gray-100 text-gray-800',
|
|
||||||
error: 'bg-red-100 text-red-800',
|
|
||||||
};
|
|
||||||
return colors[status] ?? 'bg-gray-100 text-gray-800';
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Head :title="machine.name" />
|
|
||||||
|
|
||||||
<SupervisorLayout>
|
|
||||||
<template #header>
|
|
||||||
<Link
|
|
||||||
:href="route('supervisor.machines.index')"
|
|
||||||
class="text-sm text-indigo-600 hover:text-indigo-800"
|
|
||||||
>
|
|
||||||
← Retour aux machines
|
|
||||||
</Link>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<h1 class="text-2xl font-bold text-gray-900">{{ machine.name }}</h1>
|
|
||||||
<p class="text-sm text-gray-500">{{ machine.establishment?.name }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
||||||
<!-- Details -->
|
|
||||||
<div class="rounded-lg bg-white p-6 shadow">
|
|
||||||
<h2 class="mb-4 text-lg font-semibold text-gray-800">Informations</h2>
|
|
||||||
<dl class="space-y-3 text-sm">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<dt class="text-gray-500">Type</dt>
|
|
||||||
<dd class="font-medium text-gray-800">{{ machine.type_label }}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<dt class="text-gray-500">Statut</dt>
|
|
||||||
<dd>
|
|
||||||
<span
|
|
||||||
class="rounded-full px-2.5 py-0.5 text-xs font-medium"
|
|
||||||
:class="statusColor(machine.status)"
|
|
||||||
>
|
|
||||||
{{ machine.status_label }}
|
|
||||||
</span>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<dt class="text-gray-500">QR code</dt>
|
|
||||||
<dd class="font-mono text-gray-800">{{ machine.qr_code }}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<dt class="text-gray-500">Dernier signal</dt>
|
|
||||||
<dd class="text-gray-800">{{ formatDate(machine.last_heartbeat_at) }}</dd>
|
|
||||||
</div>
|
|
||||||
<div v-if="machine.cycle_started_at" class="flex justify-between">
|
|
||||||
<dt class="text-gray-500">Cycle démarré</dt>
|
|
||||||
<dd class="text-gray-800">{{ formatDate(machine.cycle_started_at) }}</dd>
|
|
||||||
</div>
|
|
||||||
<div v-if="machine.cycle_ends_at" class="flex justify-between">
|
|
||||||
<dt class="text-gray-500">Fin de cycle prévue</dt>
|
|
||||||
<dd class="text-gray-800">{{ formatDate(machine.cycle_ends_at) }}</dd>
|
|
||||||
</div>
|
|
||||||
<div v-if="machine.current_user" class="flex justify-between">
|
|
||||||
<dt class="text-gray-500">Utilisateur actuel</dt>
|
|
||||||
<dd class="text-gray-800">
|
|
||||||
{{ machine.current_user.name }}
|
|
||||||
<span class="text-gray-500">({{ machine.current_user.email }})</span>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div v-if="machine.establishment" class="mt-6 border-t pt-4">
|
|
||||||
<h3 class="mb-2 text-sm font-semibold text-gray-700">Établissement</h3>
|
|
||||||
<p class="text-sm text-gray-600">{{ machine.establishment.address }}</p>
|
|
||||||
<p v-if="machine.establishment.city" class="text-sm text-gray-600">
|
|
||||||
{{ machine.establishment.city }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="machine.integration" class="mt-6 border-t pt-4">
|
|
||||||
<h3 class="mb-2 text-sm font-semibold text-gray-700">Intégration</h3>
|
|
||||||
<dl class="space-y-2 text-sm">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<dt class="text-gray-500">Fournisseur</dt>
|
|
||||||
<dd>{{ machine.integration.provider }}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<dt class="text-gray-500">Mode</dt>
|
|
||||||
<dd>{{ machine.integration.mode }}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<dt class="text-gray-500">Active</dt>
|
|
||||||
<dd>{{ machine.integration.is_active ? 'Oui' : 'Non' }}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Events -->
|
|
||||||
<div class="rounded-lg bg-white p-6 shadow">
|
|
||||||
<h2 class="mb-4 text-lg font-semibold text-gray-800">Événements récents</h2>
|
|
||||||
<div v-if="recentEvents.length === 0" class="text-sm text-gray-500">
|
|
||||||
Aucun événement enregistré.
|
|
||||||
</div>
|
|
||||||
<ul v-else class="divide-y divide-gray-100">
|
|
||||||
<li v-for="(event, index) in recentEvents" :key="index" class="py-3">
|
|
||||||
<p class="text-sm font-medium text-gray-800">{{ event.event_type_label }}</p>
|
|
||||||
<p class="text-xs text-gray-500">{{ formatDate(event.occurred_at) }}</p>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SupervisorLayout>
|
|
||||||
</template>
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
|
||||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
|
||||||
import SupervisorLayout from '@/Layouts/SupervisorLayout.vue';
|
|
||||||
import { Head, useForm } from '@inertiajs/vue3';
|
|
||||||
import { ref } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
rules: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
establishments: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
machineTypes: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
dayTypes: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const editingId = ref(null);
|
|
||||||
const showForm = ref(false);
|
|
||||||
|
|
||||||
const emptyForm = () => ({
|
|
||||||
establishment_id: props.establishments[0]?.id ?? '',
|
|
||||||
machine_id: null,
|
|
||||||
machine_type: '',
|
|
||||||
day_type: 'all',
|
|
||||||
slot_start: '08:00',
|
|
||||||
slot_end: '22:00',
|
|
||||||
price: '',
|
|
||||||
label: '',
|
|
||||||
requires_app: false,
|
|
||||||
priority: 100,
|
|
||||||
is_active: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = useForm(emptyForm());
|
|
||||||
|
|
||||||
const startCreate = () => {
|
|
||||||
editingId.value = null;
|
|
||||||
form.defaults(emptyForm());
|
|
||||||
form.reset();
|
|
||||||
showForm.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const startEdit = (rule) => {
|
|
||||||
editingId.value = rule.id;
|
|
||||||
form.defaults({
|
|
||||||
establishment_id: rule.establishment_id,
|
|
||||||
machine_id: rule.machine_id,
|
|
||||||
machine_type: rule.machine_type ?? '',
|
|
||||||
day_type: rule.day_type,
|
|
||||||
slot_start: rule.slot_start,
|
|
||||||
slot_end: rule.slot_end,
|
|
||||||
price: rule.price,
|
|
||||||
label: rule.label ?? '',
|
|
||||||
requires_app: rule.requires_app,
|
|
||||||
priority: rule.priority,
|
|
||||||
is_active: rule.is_active,
|
|
||||||
});
|
|
||||||
form.reset();
|
|
||||||
showForm.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelForm = () => {
|
|
||||||
showForm.value = false;
|
|
||||||
editingId.value = null;
|
|
||||||
form.reset();
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = () => {
|
|
||||||
const payload = {
|
|
||||||
...form.data(),
|
|
||||||
machine_id: form.machine_id || null,
|
|
||||||
machine_type: form.machine_type || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (editingId.value) {
|
|
||||||
form.transform(() => payload).put(route('supervisor.pricing.update', editingId.value), {
|
|
||||||
onSuccess: cancelForm,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
form.transform(() => payload).post(route('supervisor.pricing.store'), {
|
|
||||||
onSuccess: cancelForm,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const destroy = (id) => {
|
|
||||||
if (confirm('Supprimer cette règle tarifaire ?')) {
|
|
||||||
useForm({}).delete(route('supervisor.pricing.destroy', id));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (value) =>
|
|
||||||
new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value ?? 0);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Head title="Tarifs" />
|
|
||||||
|
|
||||||
<SupervisorLayout title="Tarifs">
|
|
||||||
<div class="mb-4 flex justify-end">
|
|
||||||
<PrimaryButton v-if="!showForm" @click="startCreate">
|
|
||||||
Nouvelle règle
|
|
||||||
</PrimaryButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Inline form -->
|
|
||||||
<div v-if="showForm" class="mb-6 rounded-lg border border-indigo-200 bg-white p-6 shadow">
|
|
||||||
<h2 class="mb-4 text-lg font-semibold text-gray-800">
|
|
||||||
{{ editingId ? 'Modifier la règle' : 'Nouvelle règle tarifaire' }}
|
|
||||||
</h2>
|
|
||||||
<form @submit.prevent="submit" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-gray-500">Établissement</label>
|
|
||||||
<select
|
|
||||||
v-model="form.establishment_id"
|
|
||||||
required
|
|
||||||
class="mt-1 block w-full rounded-md border-gray-300 text-sm shadow-sm"
|
|
||||||
>
|
|
||||||
<option v-for="est in establishments" :key="est.id" :value="est.id">
|
|
||||||
{{ est.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-gray-500">Type de machine</label>
|
|
||||||
<select v-model="form.machine_type" class="mt-1 block w-full rounded-md border-gray-300 text-sm shadow-sm">
|
|
||||||
<option value="">Tous types</option>
|
|
||||||
<option v-for="(label, value) in machineTypes" :key="value" :value="value">
|
|
||||||
{{ label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-gray-500">Jour</label>
|
|
||||||
<select v-model="form.day_type" required class="mt-1 block w-full rounded-md border-gray-300 text-sm shadow-sm">
|
|
||||||
<option v-for="(label, value) in dayTypes" :key="value" :value="value">
|
|
||||||
{{ label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-gray-500">Début créneau</label>
|
|
||||||
<input v-model="form.slot_start" type="time" required class="mt-1 block w-full rounded-md border-gray-300 text-sm shadow-sm" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-gray-500">Fin créneau</label>
|
|
||||||
<input v-model="form.slot_end" type="time" required class="mt-1 block w-full rounded-md border-gray-300 text-sm shadow-sm" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-gray-500">Prix (€)</label>
|
|
||||||
<input v-model="form.price" type="number" step="0.01" min="0" required class="mt-1 block w-full rounded-md border-gray-300 text-sm shadow-sm" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-gray-500">Libellé</label>
|
|
||||||
<input v-model="form.label" type="text" class="mt-1 block w-full rounded-md border-gray-300 text-sm shadow-sm" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-gray-500">Priorité</label>
|
|
||||||
<input v-model="form.priority" type="number" min="0" class="mt-1 block w-full rounded-md border-gray-300 text-sm shadow-sm" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-end gap-4">
|
|
||||||
<label class="flex items-center gap-2 text-sm">
|
|
||||||
<input v-model="form.requires_app" type="checkbox" class="rounded border-gray-300 text-indigo-600" />
|
|
||||||
App requise
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 text-sm">
|
|
||||||
<input v-model="form.is_active" type="checkbox" class="rounded border-gray-300 text-indigo-600" />
|
|
||||||
Active
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2 sm:col-span-2 lg:col-span-3">
|
|
||||||
<PrimaryButton type="submit" :disabled="form.processing">
|
|
||||||
{{ editingId ? 'Enregistrer' : 'Créer' }}
|
|
||||||
</PrimaryButton>
|
|
||||||
<SecondaryButton type="button" @click="cancelForm">Annuler</SecondaryButton>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Rules list -->
|
|
||||||
<div class="overflow-hidden rounded-lg bg-white shadow">
|
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead class="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Établissement</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Créneau</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Type</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Prix</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Statut</th>
|
|
||||||
<th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-gray-100">
|
|
||||||
<tr v-if="rules.length === 0">
|
|
||||||
<td colspan="6" class="px-4 py-8 text-center text-sm text-gray-500">
|
|
||||||
Aucune règle tarifaire configurée.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-for="rule in rules" :key="rule.id" class="hover:bg-gray-50">
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-800">{{ rule.establishment_name }}</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-600">
|
|
||||||
{{ rule.slot_start }} – {{ rule.slot_end }}
|
|
||||||
<span class="text-xs text-gray-400">({{ rule.day_type_label }})</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-600">
|
|
||||||
{{ rule.machine_type_label ?? 'Tous' }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm font-medium text-gray-800">
|
|
||||||
{{ formatCurrency(rule.price) }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<span
|
|
||||||
class="rounded-full px-2.5 py-0.5 text-xs font-medium"
|
|
||||||
:class="rule.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'"
|
|
||||||
>
|
|
||||||
{{ rule.is_active ? 'Active' : 'Inactive' }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-right text-sm">
|
|
||||||
<button @click="startEdit(rule)" class="text-indigo-600 hover:text-indigo-800">
|
|
||||||
Modifier
|
|
||||||
</button>
|
|
||||||
<button @click="destroy(rule.id)" class="ms-3 text-red-600 hover:text-red-800">
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</SupervisorLayout>
|
|
||||||
</template>
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
|
||||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
|
||||||
import SupervisorLayout from '@/Layouts/SupervisorLayout.vue';
|
|
||||||
import { Head, useForm } from '@inertiajs/vue3';
|
|
||||||
import { ref } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
promotions: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
establishments: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
machineTypes: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
discountTypes: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const editingId = ref(null);
|
|
||||||
const showForm = ref(false);
|
|
||||||
|
|
||||||
const emptyForm = () => ({
|
|
||||||
establishment_id: props.establishments[0]?.id ?? '',
|
|
||||||
machine_type: 'all',
|
|
||||||
discount_type: 'percent',
|
|
||||||
discount_value: '',
|
|
||||||
starts_at: '',
|
|
||||||
ends_at: '',
|
|
||||||
description: '',
|
|
||||||
is_active: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = useForm(emptyForm());
|
|
||||||
|
|
||||||
const startCreate = () => {
|
|
||||||
editingId.value = null;
|
|
||||||
form.defaults(emptyForm());
|
|
||||||
form.reset();
|
|
||||||
showForm.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const startEdit = (promotion) => {
|
|
||||||
editingId.value = promotion.id;
|
|
||||||
form.defaults({
|
|
||||||
establishment_id: promotion.establishment_id,
|
|
||||||
machine_type: promotion.machine_type,
|
|
||||||
discount_type: promotion.discount_type,
|
|
||||||
discount_value: promotion.discount_value,
|
|
||||||
starts_at: promotion.starts_at,
|
|
||||||
ends_at: promotion.ends_at,
|
|
||||||
description: promotion.description ?? '',
|
|
||||||
is_active: promotion.is_active,
|
|
||||||
});
|
|
||||||
form.reset();
|
|
||||||
showForm.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelForm = () => {
|
|
||||||
showForm.value = false;
|
|
||||||
editingId.value = null;
|
|
||||||
form.reset();
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = () => {
|
|
||||||
if (editingId.value) {
|
|
||||||
form.put(route('supervisor.promotions.update', editingId.value), {
|
|
||||||
onSuccess: cancelForm,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
form.post(route('supervisor.promotions.store'), {
|
|
||||||
onSuccess: cancelForm,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const destroy = (id) => {
|
|
||||||
if (confirm('Supprimer cette promotion ?')) {
|
|
||||||
useForm({}).delete(route('supervisor.promotions.destroy', id));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (iso) => {
|
|
||||||
if (!iso) return '—';
|
|
||||||
return new Intl.DateTimeFormat('fr-FR', {
|
|
||||||
dateStyle: 'short',
|
|
||||||
timeStyle: 'short',
|
|
||||||
}).format(new Date(iso));
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDiscount = (promotion) => {
|
|
||||||
if (promotion.discount_type === 'percent') {
|
|
||||||
return `-${promotion.discount_value} %`;
|
|
||||||
}
|
|
||||||
return `-${new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(promotion.discount_value)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isCurrentlyActive = (promotion) => {
|
|
||||||
const now = new Date();
|
|
||||||
return (
|
|
||||||
promotion.is_active &&
|
|
||||||
new Date(promotion.starts_at) <= now &&
|
|
||||||
new Date(promotion.ends_at) >= now
|
|
||||||
);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Head title="Promotions" />
|
|
||||||
|
|
||||||
<SupervisorLayout title="Promotions">
|
|
||||||
<div class="mb-4 flex justify-end">
|
|
||||||
<PrimaryButton v-if="!showForm" @click="startCreate">
|
|
||||||
Nouvelle promotion
|
|
||||||
</PrimaryButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="showForm" class="mb-6 rounded-lg border border-indigo-200 bg-white p-6 shadow">
|
|
||||||
<h2 class="mb-4 text-lg font-semibold text-gray-800">
|
|
||||||
{{ editingId ? 'Modifier la promotion' : 'Nouvelle promotion' }}
|
|
||||||
</h2>
|
|
||||||
<form @submit.prevent="submit" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-gray-500">Établissement</label>
|
|
||||||
<select v-model="form.establishment_id" required class="mt-1 block w-full rounded-md border-gray-300 text-sm shadow-sm">
|
|
||||||
<option v-for="est in establishments" :key="est.id" :value="est.id">
|
|
||||||
{{ est.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-gray-500">Type de machine</label>
|
|
||||||
<select v-model="form.machine_type" required class="mt-1 block w-full rounded-md border-gray-300 text-sm shadow-sm">
|
|
||||||
<option v-for="(label, value) in machineTypes" :key="value" :value="value">
|
|
||||||
{{ label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-gray-500">Type de remise</label>
|
|
||||||
<select v-model="form.discount_type" required class="mt-1 block w-full rounded-md border-gray-300 text-sm shadow-sm">
|
|
||||||
<option v-for="(label, value) in discountTypes" :key="value" :value="value">
|
|
||||||
{{ label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-gray-500">Valeur</label>
|
|
||||||
<input v-model="form.discount_value" type="number" step="0.01" min="0" required class="mt-1 block w-full rounded-md border-gray-300 text-sm shadow-sm" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-gray-500">Début</label>
|
|
||||||
<input v-model="form.starts_at" type="datetime-local" required class="mt-1 block w-full rounded-md border-gray-300 text-sm shadow-sm" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-gray-500">Fin</label>
|
|
||||||
<input v-model="form.ends_at" type="datetime-local" required class="mt-1 block w-full rounded-md border-gray-300 text-sm shadow-sm" />
|
|
||||||
</div>
|
|
||||||
<div class="sm:col-span-2 lg:col-span-3">
|
|
||||||
<label class="block text-xs font-medium text-gray-500">Description</label>
|
|
||||||
<textarea v-model="form.description" rows="2" class="mt-1 block w-full rounded-md border-gray-300 text-sm shadow-sm" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<label class="flex items-center gap-2 text-sm">
|
|
||||||
<input v-model="form.is_active" type="checkbox" class="rounded border-gray-300 text-indigo-600" />
|
|
||||||
Active
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2 sm:col-span-2 lg:col-span-3">
|
|
||||||
<PrimaryButton type="submit" :disabled="form.processing">
|
|
||||||
{{ editingId ? 'Enregistrer' : 'Créer' }}
|
|
||||||
</PrimaryButton>
|
|
||||||
<SecondaryButton type="button" @click="cancelForm">Annuler</SecondaryButton>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="overflow-hidden rounded-lg bg-white shadow">
|
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead class="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Établissement</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Remise</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Période</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Machines</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Statut</th>
|
|
||||||
<th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-gray-100">
|
|
||||||
<tr v-if="promotions.length === 0">
|
|
||||||
<td colspan="6" class="px-4 py-8 text-center text-sm text-gray-500">
|
|
||||||
Aucune promotion configurée.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-for="promotion in promotions" :key="promotion.id" class="hover:bg-gray-50">
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-800">{{ promotion.establishment_name }}</td>
|
|
||||||
<td class="px-4 py-3 text-sm font-medium text-green-700">
|
|
||||||
{{ formatDiscount(promotion) }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-600">
|
|
||||||
<div>{{ formatDate(promotion.starts_at) }}</div>
|
|
||||||
<div class="text-xs text-gray-400">→ {{ formatDate(promotion.ends_at) }}</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-600">{{ promotion.machine_type_label }}</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<span
|
|
||||||
class="rounded-full px-2.5 py-0.5 text-xs font-medium"
|
|
||||||
:class="
|
|
||||||
isCurrentlyActive(promotion)
|
|
||||||
? 'bg-green-100 text-green-800'
|
|
||||||
: promotion.is_active
|
|
||||||
? 'bg-yellow-100 text-yellow-800'
|
|
||||||
: 'bg-gray-100 text-gray-600'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
isCurrentlyActive(promotion)
|
|
||||||
? 'En cours'
|
|
||||||
: promotion.is_active
|
|
||||||
? 'Programmée'
|
|
||||||
: 'Inactive'
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-right text-sm">
|
|
||||||
<button @click="startEdit(promotion)" class="text-indigo-600 hover:text-indigo-800">
|
|
||||||
Modifier
|
|
||||||
</button>
|
|
||||||
<button @click="destroy(promotion.id)" class="ms-3 text-red-600 hover:text-red-800">
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</SupervisorLayout>
|
|
||||||
</template>
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import SupervisorLayout from '@/Layouts/SupervisorLayout.vue';
|
|
||||||
import { Head, Link, router } from '@inertiajs/vue3';
|
|
||||||
import { reactive, watch } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
washes: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
filters: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
statusOptions: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const localFilters = reactive({
|
|
||||||
status: props.filters.status ?? '',
|
|
||||||
date: props.filters.date ?? '',
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(localFilters, () => {
|
|
||||||
router.get(route('supervisor.washes.index'), localFilters, {
|
|
||||||
preserveState: true,
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatDate = (iso) => {
|
|
||||||
if (!iso) return '—';
|
|
||||||
return new Intl.DateTimeFormat('fr-FR', {
|
|
||||||
dateStyle: 'short',
|
|
||||||
timeStyle: 'short',
|
|
||||||
}).format(new Date(iso));
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (value) =>
|
|
||||||
new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(value ?? 0);
|
|
||||||
|
|
||||||
const statusColor = (status) => {
|
|
||||||
const colors = {
|
|
||||||
pending_start: 'bg-gray-100 text-gray-800',
|
|
||||||
running: 'bg-blue-100 text-blue-800',
|
|
||||||
completed: 'bg-green-100 text-green-800',
|
|
||||||
failed: 'bg-red-100 text-red-800',
|
|
||||||
cancelled: 'bg-yellow-100 text-yellow-800',
|
|
||||||
};
|
|
||||||
return colors[status] ?? 'bg-gray-100 text-gray-800';
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Head title="Lavages" />
|
|
||||||
|
|
||||||
<SupervisorLayout title="Lavages">
|
|
||||||
<div class="mb-6 flex flex-wrap gap-4 rounded-lg bg-white p-4 shadow">
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-gray-500">Statut</label>
|
|
||||||
<select
|
|
||||||
v-model="localFilters.status"
|
|
||||||
class="mt-1 block rounded-md border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
|
||||||
>
|
|
||||||
<option value="">Tous</option>
|
|
||||||
<option v-for="(label, value) in statusOptions" :key="value" :value="value">
|
|
||||||
{{ label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-gray-500">Date</label>
|
|
||||||
<input
|
|
||||||
v-model="localFilters.date"
|
|
||||||
type="date"
|
|
||||||
class="mt-1 block rounded-md border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="overflow-hidden rounded-lg bg-white shadow">
|
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead class="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Début</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Utilisateur</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Machine</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Statut</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Durée</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Coût</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-gray-100">
|
|
||||||
<tr v-if="washes.data.length === 0">
|
|
||||||
<td colspan="6" class="px-4 py-8 text-center text-sm text-gray-500">
|
|
||||||
Aucun lavage trouvé.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-for="wash in washes.data" :key="wash.uuid" class="hover:bg-gray-50">
|
|
||||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800">
|
|
||||||
{{ formatDate(wash.started_at) }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm">
|
|
||||||
<div class="font-medium text-gray-800">{{ wash.user?.name ?? '—' }}</div>
|
|
||||||
<div class="text-xs text-gray-500">{{ wash.user?.email }}</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm">
|
|
||||||
<Link
|
|
||||||
v-if="wash.machine"
|
|
||||||
:href="route('supervisor.machines.show', wash.machine.uuid)"
|
|
||||||
class="text-indigo-600 hover:text-indigo-800"
|
|
||||||
>
|
|
||||||
{{ wash.machine.name }}
|
|
||||||
</Link>
|
|
||||||
<span v-else>—</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<span
|
|
||||||
class="rounded-full px-2.5 py-0.5 text-xs font-medium"
|
|
||||||
:class="statusColor(wash.status)"
|
|
||||||
>
|
|
||||||
{{ wash.status_label }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-600">
|
|
||||||
{{ wash.duration_minutes ? `${wash.duration_minutes} min` : '—' }}
|
|
||||||
</td>
|
|
||||||
<td class="whitespace-nowrap px-4 py-3 text-sm font-medium text-gray-800">
|
|
||||||
{{ formatCurrency(wash.cost) }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div v-if="washes.links.length > 3" class="flex items-center justify-between border-t px-4 py-3">
|
|
||||||
<p class="text-sm text-gray-600">
|
|
||||||
{{ washes.from ?? 0 }}–{{ washes.to ?? 0 }} sur {{ washes.total }}
|
|
||||||
</p>
|
|
||||||
<div class="flex gap-1">
|
|
||||||
<Link
|
|
||||||
v-for="link in washes.links"
|
|
||||||
:key="link.label"
|
|
||||||
:href="link.url"
|
|
||||||
v-html="link.label"
|
|
||||||
class="rounded px-3 py-1 text-sm"
|
|
||||||
:class="
|
|
||||||
link.active
|
|
||||||
? 'bg-indigo-600 text-white'
|
|
||||||
: link.url
|
|
||||||
? 'text-gray-600 hover:bg-gray-100'
|
|
||||||
: 'cursor-not-allowed text-gray-300'
|
|
||||||
"
|
|
||||||
preserve-state
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SupervisorLayout>
|
|
||||||
</template>
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
APP_NAME=Laverie
|
APP_NAME=laundry-backend
|
||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=http://localhost:8000
|
APP_URL=http://localhost:8000
|
||||||
|
APP_TIMEZONE=Europe/Paris
|
||||||
|
|
||||||
APP_LOCALE=fr
|
APP_LOCALE=fr
|
||||||
APP_FALLBACK_LOCALE=fr
|
APP_FALLBACK_LOCALE=fr
|
||||||
@@ -37,8 +38,12 @@ SESSION_DOMAIN=null
|
|||||||
BROADCAST_CONNECTION=log
|
BROADCAST_CONNECTION=log
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
QUEUE_CONNECTION=redis
|
QUEUE_CONNECTION=redis
|
||||||
|
# En production sans Redis (VPS Ansible) : database
|
||||||
|
# QUEUE_CONNECTION=database
|
||||||
|
|
||||||
CACHE_STORE=redis
|
CACHE_STORE=redis
|
||||||
|
# En production sans Redis (VPS Ansible) : database
|
||||||
|
# CACHE_STORE=database
|
||||||
# CACHE_PREFIX=
|
# CACHE_PREFIX=
|
||||||
|
|
||||||
MEMCACHED_HOST=127.0.0.1
|
MEMCACHED_HOST=127.0.0.1
|
||||||
@@ -85,6 +90,14 @@ LAVERIE_BOOKING_PENALTY_AMOUNT=3.00
|
|||||||
|
|
||||||
# Machines
|
# Machines
|
||||||
LAVERIE_MACHINE_OFFLINE_THRESHOLD_MINUTES=10
|
LAVERIE_MACHINE_OFFLINE_THRESHOLD_MINUTES=10
|
||||||
|
# Désactiver le passage automatique en hors ligne (démo / dev sans matériel)
|
||||||
|
LAVERIE_MACHINE_OFFLINE_CHECK_ENABLED=false
|
||||||
|
|
||||||
# Paiements
|
# Paiements
|
||||||
LAVERIE_PAYMENT_PROVIDER=simulated
|
LAVERIE_PAYMENT_PROVIDER=stripe
|
||||||
|
LAVERIE_TOP_UP_MIN=5
|
||||||
|
LAVERIE_TOP_UP_MAX=150
|
||||||
|
|
||||||
|
STRIPE_KEY=pk_test_51TpANRJRUgjTIwfBR9PoU4Lu201yD5R0JzvOv8Nmyva7ISX3GJPJ3IX4lSqnkg13siYwi3B9Qq0tIpEj6VCzeVFB00PSt9o0OA
|
||||||
|
STRIPE_SECRET=sk_test_51TpANRJRUgjTIwfB2jUa5nr7IpolnWNwwi0MQHbCG6JERu7npCmBrJPm6wbyTfo0lkZUh7PYyuHQ8qeecEfcfSXa0007BAKC7T
|
||||||
|
STRIPE_WEBHOOK_SECRET=
|
||||||
+1
@@ -17,6 +17,7 @@ class HealthController extends Controller
|
|||||||
return $this->success([
|
return $this->success([
|
||||||
'status' => 'ok',
|
'status' => 'ok',
|
||||||
'service' => 'laverie-api',
|
'service' => 'laverie-api',
|
||||||
|
'timezone' => config('app.timezone'),
|
||||||
'timestamp' => now()->toIso8601String(),
|
'timestamp' => now()->toIso8601String(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
+17
-7
@@ -22,7 +22,6 @@ class WalletController extends Controller
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly WalletService $walletService,
|
private readonly WalletService $walletService,
|
||||||
private readonly PaymentService $paymentService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
@@ -63,9 +62,9 @@ class WalletController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function initiateTopUp(InitiateTopUpRequest $request): JsonResponse
|
public function initiateTopUp(InitiateTopUpRequest $request, PaymentService $paymentService): JsonResponse
|
||||||
{
|
{
|
||||||
$payment = $this->paymentService->initiateTopUp(
|
$payment = $paymentService->initiateTopUp(
|
||||||
$request->user(),
|
$request->user(),
|
||||||
(float) $request->input('amount'),
|
(float) $request->input('amount'),
|
||||||
$request->string('idempotency_key')->toString(),
|
$request->string('idempotency_key')->toString(),
|
||||||
@@ -77,9 +76,9 @@ class WalletController extends Controller
|
|||||||
], 'Rechargement initié.');
|
], 'Rechargement initié.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function confirmTopUp(ConfirmTopUpRequest $request): JsonResponse
|
public function confirmTopUp(ConfirmTopUpRequest $request, PaymentService $paymentService): JsonResponse
|
||||||
{
|
{
|
||||||
$payment = $this->paymentService->confirmTopUp(
|
$payment = $paymentService->confirmTopUp(
|
||||||
$request->string('payment_uuid')->toString(),
|
$request->string('payment_uuid')->toString(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -93,9 +92,20 @@ class WalletController extends Controller
|
|||||||
], 'Rechargement confirmé.');
|
], 'Rechargement confirmé.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function webhook(Request $request, string $provider): JsonResponse
|
public function webhook(Request $request, string $provider, PaymentService $paymentService): JsonResponse
|
||||||
{
|
{
|
||||||
$this->paymentService->handleWebhook($provider, $request->all());
|
if ($provider === 'stripe') {
|
||||||
|
try {
|
||||||
|
$paymentService->handleStripeWebhook(
|
||||||
|
$request->getContent(),
|
||||||
|
$request->header('Stripe-Signature', ''),
|
||||||
|
);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return $this->error($e->getMessage(), 400);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$paymentService->handleWebhook($provider, $request->all());
|
||||||
|
}
|
||||||
|
|
||||||
return $this->success(message: 'Webhook traité.');
|
return $this->success(message: 'Webhook traité.');
|
||||||
}
|
}
|
||||||
+16
-2
@@ -19,6 +19,8 @@ class BookingPageController extends Controller
|
|||||||
->with(['user:id,first_name,last_name,email', 'machine:id,uuid,name,establishment_id', 'machine.establishment:id,name']);
|
->with(['user:id,first_name,last_name,email', 'machine:id,uuid,name,establishment_id', 'machine.establishment:id,name']);
|
||||||
|
|
||||||
$this->scopeMachineRelationQuery($query);
|
$this->scopeMachineRelationQuery($query);
|
||||||
|
$this->applyEstablishmentFilter($request, $query, 'machine');
|
||||||
|
$this->applyUserSearchFilter($request, $query);
|
||||||
|
|
||||||
if ($request->filled('status')) {
|
if ($request->filled('status')) {
|
||||||
$query->where('status', $request->string('status'));
|
$query->where('status', $request->string('status'));
|
||||||
@@ -29,8 +31,14 @@ class BookingPageController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$bookings = $query
|
$bookings = $query
|
||||||
->orderByDesc('slot_start')
|
->tap(fn ($builder) => $this->applySort($request, $builder, [
|
||||||
->paginate(20)
|
'slot_start' => 'bookings.slot_start',
|
||||||
|
'user' => fn ($builder, string $direction) => $this->sortByRelatedUser($builder, 'bookings', 'user_id', $direction),
|
||||||
|
'machine' => fn ($builder, string $direction) => $this->sortByRelatedMachine($builder, 'bookings', $direction),
|
||||||
|
'status' => 'bookings.status',
|
||||||
|
'booking_fee' => 'bookings.booking_fee',
|
||||||
|
], 'slot_start', 'desc'))
|
||||||
|
->paginate($this->supervisorListPerPage($request))
|
||||||
->withQueryString()
|
->withQueryString()
|
||||||
->through(fn (Booking $booking) => [
|
->through(fn (Booking $booking) => [
|
||||||
'uuid' => $booking->uuid,
|
'uuid' => $booking->uuid,
|
||||||
@@ -54,9 +62,15 @@ class BookingPageController extends Controller
|
|||||||
|
|
||||||
return Inertia::render('Supervisor/Bookings/Index', [
|
return Inertia::render('Supervisor/Bookings/Index', [
|
||||||
'bookings' => $bookings,
|
'bookings' => $bookings,
|
||||||
|
'establishments' => $this->establishmentOptions(),
|
||||||
'filters' => [
|
'filters' => [
|
||||||
|
'establishment_id' => $request->filled('establishment_id') ? (int) $request->input('establishment_id') : null,
|
||||||
|
'user' => $request->string('user')->toString() ?: null,
|
||||||
'status' => $request->string('status')->toString() ?: null,
|
'status' => $request->string('status')->toString() ?: null,
|
||||||
'date' => $request->string('date')->toString() ?: null,
|
'date' => $request->string('date')->toString() ?: null,
|
||||||
|
'sort' => $request->string('sort')->toString() ?: 'slot_start',
|
||||||
|
'direction' => $request->string('direction')->toString() === 'asc' ? 'asc' : 'desc',
|
||||||
|
'per_page' => $this->supervisorListPerPage($request),
|
||||||
],
|
],
|
||||||
'statusOptions' => $this->statusOptions(),
|
'statusOptions' => $this->statusOptions(),
|
||||||
]);
|
]);
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Supervisor\Concerns;
|
||||||
|
|
||||||
|
use App\Models\Establishment;
|
||||||
|
use App\Models\Supervisor;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
trait ScopesSupervisorResources
|
||||||
|
{
|
||||||
|
protected function supervisor(): Supervisor
|
||||||
|
{
|
||||||
|
/** @var Supervisor $supervisor */
|
||||||
|
$supervisor = Auth::guard('supervisor')->user();
|
||||||
|
|
||||||
|
return $supervisor;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function supervisorListPerPage(Request $request): int
|
||||||
|
{
|
||||||
|
$perPage = $request->integer('per_page', 10);
|
||||||
|
|
||||||
|
return in_array($perPage, $this->supervisorPerPageOptions(), true) ? $perPage : 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, int>
|
||||||
|
*/
|
||||||
|
protected function supervisorPerPageOptions(): array
|
||||||
|
{
|
||||||
|
return [10, 25, 50];
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function applyEstablishmentFilter(Request $request, Builder $query, string $mode = 'direct'): void
|
||||||
|
{
|
||||||
|
if (! $request->filled('establishment_id')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$establishmentId = (int) $request->input('establishment_id');
|
||||||
|
$this->assertEstablishmentAccessible($establishmentId);
|
||||||
|
|
||||||
|
if ($mode === 'machine') {
|
||||||
|
$query->whereHas('machine', fn (Builder $query) => $query->where('establishment_id', $establishmentId));
|
||||||
|
} else {
|
||||||
|
$query->where('establishment_id', $establishmentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function applyUserSearchFilter(Request $request, Builder $query): void
|
||||||
|
{
|
||||||
|
if (! $request->filled('user')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$search = $request->string('user');
|
||||||
|
|
||||||
|
$query->whereHas('user', function (Builder $query) use ($search) {
|
||||||
|
$query->where(function (Builder $query) use ($search) {
|
||||||
|
$query->where('first_name', 'like', "%{$search}%")
|
||||||
|
->orWhere('last_name', 'like', "%{$search}%")
|
||||||
|
->orWhere('email', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected 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 ($query) => $query->where('id', $supervisor->establishment_id))
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
abort_unless($exists, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{id: int, name: string}>
|
||||||
|
*/
|
||||||
|
protected function establishmentOptions(): array
|
||||||
|
{
|
||||||
|
$supervisor = $this->supervisor();
|
||||||
|
|
||||||
|
return Establishment::query()
|
||||||
|
->where('organization_id', $supervisor->organization_id)
|
||||||
|
->when($supervisor->establishment_id, fn ($query) => $query->where('id', $supervisor->establishment_id))
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name'])
|
||||||
|
->map(fn (Establishment $establishment) => [
|
||||||
|
'id' => $establishment->id,
|
||||||
|
'name' => $establishment->name,
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string|callable(Builder, string): void> $sortableColumns
|
||||||
|
*/
|
||||||
|
protected function applySort(
|
||||||
|
Request $request,
|
||||||
|
Builder $query,
|
||||||
|
array $sortableColumns,
|
||||||
|
string $defaultColumn,
|
||||||
|
string $defaultDirection = 'asc',
|
||||||
|
): void {
|
||||||
|
$sort = $request->string('sort')->toString();
|
||||||
|
$direction = strtolower($request->string('direction')->toString()) === 'desc' ? 'desc' : 'asc';
|
||||||
|
|
||||||
|
if ($sort === '' || ! array_key_exists($sort, $sortableColumns)) {
|
||||||
|
$sort = $defaultColumn;
|
||||||
|
$direction = $defaultDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
$handler = $sortableColumns[$sort];
|
||||||
|
|
||||||
|
if (is_callable($handler)) {
|
||||||
|
$handler($query, $direction);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->orderBy($handler, $direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function sortByRelatedUser(Builder $query, string $parentTable, string $foreignKey, string $direction): void
|
||||||
|
{
|
||||||
|
$query->leftJoin('users', "{$parentTable}.{$foreignKey}", '=', 'users.id')
|
||||||
|
->orderBy('users.last_name', $direction)
|
||||||
|
->orderBy('users.first_name', $direction)
|
||||||
|
->select("{$parentTable}.*");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function sortByRelatedMachine(Builder $query, string $parentTable, string $direction): void
|
||||||
|
{
|
||||||
|
$query->leftJoin('machines', "{$parentTable}.machine_id", '=', 'machines.id')
|
||||||
|
->orderBy('machines.name', $direction)
|
||||||
|
->select("{$parentTable}.*");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function sortByRelatedEstablishment(
|
||||||
|
Builder $query,
|
||||||
|
string $parentTable,
|
||||||
|
string $foreignKeyColumn,
|
||||||
|
string $direction,
|
||||||
|
): void {
|
||||||
|
$query->leftJoin('establishments', "{$parentTable}.{$foreignKeyColumn}", '=', 'establishments.id')
|
||||||
|
->orderBy('establishments.name', $direction)
|
||||||
|
->select("{$parentTable}.*");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Supervisor;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Supervisor\Concerns\ScopesSupervisorResources;
|
||||||
|
use App\Models\Supervisor;
|
||||||
|
use App\Services\DashboardService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class DashboardController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesSupervisorResources;
|
||||||
|
|
||||||
|
public function index(Request $request, DashboardService $dashboardService): Response
|
||||||
|
{
|
||||||
|
$supervisor = $this->supervisor();
|
||||||
|
$establishmentFilter = $this->resolveDashboardEstablishmentFilter($request, $supervisor);
|
||||||
|
$establishments = $this->establishmentOptions();
|
||||||
|
|
||||||
|
return Inertia::render('Supervisor/Dashboard', [
|
||||||
|
'kpis' => $dashboardService->getKpis($supervisor, $establishmentFilter),
|
||||||
|
'alerts' => $dashboardService->getAlerts($supervisor, $establishmentFilter),
|
||||||
|
'machinesOverview' => $dashboardService->getMachinesOverview($supervisor, $establishmentFilter),
|
||||||
|
'machineStatusBreakdown' => $dashboardService->getMachineStatusBreakdown($supervisor, $establishmentFilter),
|
||||||
|
'recentWashes' => $dashboardService->getRecentWashes($supervisor, $establishmentFilter),
|
||||||
|
'context' => $dashboardService->getContext($supervisor, $establishmentFilter),
|
||||||
|
'establishments' => $establishments,
|
||||||
|
'showEstablishmentFilter' => $supervisor->establishment_id === null && count($establishments) > 1,
|
||||||
|
'filters' => [
|
||||||
|
'establishment_id' => $establishmentFilter,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveDashboardEstablishmentFilter(Request $request, Supervisor $supervisor): ?int
|
||||||
|
{
|
||||||
|
if ($supervisor->establishment_id !== null) {
|
||||||
|
return $supervisor->establishment_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $request->filled('establishment_id')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$establishmentId = (int) $request->input('establishment_id');
|
||||||
|
$this->assertEstablishmentAccessible($establishmentId);
|
||||||
|
|
||||||
|
return $establishmentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
<?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\Machine;
|
||||||
|
use App\Models\MachineCommand;
|
||||||
|
use App\Models\MachineEvent;
|
||||||
|
use App\Models\MachineIntegration;
|
||||||
|
use App\Models\MachineStatusHistory;
|
||||||
|
use App\Models\PricingRule;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
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);
|
||||||
|
$this->applyEstablishmentFilter($request, $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
|
||||||
|
->tap(fn ($builder) => $this->applySort($request, $builder, [
|
||||||
|
'name' => 'machines.name',
|
||||||
|
'establishment' => fn ($builder, string $direction) => $builder
|
||||||
|
->leftJoin('establishments', 'machines.establishment_id', '=', 'establishments.id')
|
||||||
|
->orderBy('establishments.name', $direction)
|
||||||
|
->select('machines.*'),
|
||||||
|
'type' => 'machines.type',
|
||||||
|
'status' => 'machines.status',
|
||||||
|
'last_heartbeat_at' => 'machines.last_heartbeat_at',
|
||||||
|
], 'name', 'asc'))
|
||||||
|
->paginate($this->supervisorListPerPage($request))
|
||||||
|
->withQueryString()
|
||||||
|
->through(fn (Machine $machine) => $this->formatMachineListItem($machine));
|
||||||
|
|
||||||
|
return Inertia::render('Supervisor/Machines/Index', [
|
||||||
|
'machines' => $machines,
|
||||||
|
'establishments' => $this->establishmentOptions(),
|
||||||
|
'canManageMachines' => $this->canManageMachines(),
|
||||||
|
'filters' => [
|
||||||
|
'establishment_id' => $request->filled('establishment_id') ? (int) $request->input('establishment_id') : null,
|
||||||
|
'status' => $request->string('status')->toString() ?: null,
|
||||||
|
'type' => $request->string('type')->toString() ?: null,
|
||||||
|
'search' => $request->string('search')->toString() ?: null,
|
||||||
|
'sort' => $request->string('sort')->toString() ?: 'name',
|
||||||
|
'direction' => $request->string('direction')->toString() === 'desc' ? 'desc' : 'asc',
|
||||||
|
'per_page' => $this->supervisorListPerPage($request),
|
||||||
|
],
|
||||||
|
'statusOptions' => $this->statusOptions(),
|
||||||
|
'typeOptions' => $this->typeOptions(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($this->canManageMachines(), 403);
|
||||||
|
|
||||||
|
$validated = $this->validateMachine($request);
|
||||||
|
$this->assertEstablishmentAccessible((int) $validated['establishment_id']);
|
||||||
|
|
||||||
|
$validated['qr_code'] = $this->resolveQrCode($validated['qr_code'] ?? null);
|
||||||
|
$validated['status'] = $validated['status'] ?? 'available';
|
||||||
|
|
||||||
|
if ($validated['status'] === 'available') {
|
||||||
|
$validated['last_heartbeat_at'] = now();
|
||||||
|
}
|
||||||
|
|
||||||
|
$machine = Machine::query()->create($validated);
|
||||||
|
$this->createSimulatedIntegration($machine);
|
||||||
|
|
||||||
|
return redirect()->route('supervisor.machines.index')
|
||||||
|
->with('success', 'Machine créée.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, string $uuid): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($this->canManageMachines(), 403);
|
||||||
|
|
||||||
|
$machine = $this->findAccessibleMachine($uuid);
|
||||||
|
$validated = $this->validateMachine($request, $machine);
|
||||||
|
$this->assertEstablishmentAccessible((int) $validated['establishment_id']);
|
||||||
|
|
||||||
|
if (array_key_exists('qr_code', $validated) && blank($validated['qr_code'])) {
|
||||||
|
unset($validated['qr_code']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
array_key_exists('status', $validated)
|
||||||
|
&& $validated['status'] === 'available'
|
||||||
|
&& $machine->status !== 'available'
|
||||||
|
) {
|
||||||
|
$validated['last_heartbeat_at'] = now();
|
||||||
|
}
|
||||||
|
|
||||||
|
$machine->update($validated);
|
||||||
|
|
||||||
|
return redirect()->route('supervisor.machines.index')
|
||||||
|
->with('success', 'Machine mise à jour.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(string $uuid): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($this->canManageMachines(), 403);
|
||||||
|
|
||||||
|
$machine = $this->findAccessibleMachine($uuid);
|
||||||
|
|
||||||
|
if (in_array($machine->status, ['running', 'reserved'], true)) {
|
||||||
|
return redirect()->route('supervisor.machines.index')
|
||||||
|
->withErrors(['delete' => 'Impossible de supprimer une machine en cours d\'utilisation.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($machine->bookings()->exists() || $machine->washes()->exists()) {
|
||||||
|
return redirect()->route('supervisor.machines.index')
|
||||||
|
->withErrors(['delete' => 'Impossible de supprimer une machine avec un historique de réservations ou de lavages.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
PricingRule::query()->where('machine_id', $machine->id)->update(['machine_id' => null]);
|
||||||
|
MachineCommand::query()->where('machine_id', $machine->id)->delete();
|
||||||
|
MachineEvent::query()->where('machine_id', $machine->id)->delete();
|
||||||
|
MachineStatusHistory::query()->where('machine_id', $machine->id)->delete();
|
||||||
|
$machine->machineIntegration()?->delete();
|
||||||
|
$machine->delete();
|
||||||
|
|
||||||
|
return redirect()->route('supervisor.machines.index')
|
||||||
|
->with('success', 'Machine supprimée.');
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
'establishment_id' => $machine->establishment_id,
|
||||||
|
'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),
|
||||||
|
'establishment_name' => $machine->establishment?->name,
|
||||||
|
'last_heartbeat_at' => $machine->last_heartbeat_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function validateMachine(Request $request, ?Machine $machine = null): array
|
||||||
|
{
|
||||||
|
return $request->validate([
|
||||||
|
'establishment_id' => ['required', 'integer', 'exists:establishments,id'],
|
||||||
|
'name' => ['required', 'string', 'max:100'],
|
||||||
|
'type' => ['required', 'in:washer_small,washer_large,dryer_small,dryer_large'],
|
||||||
|
'qr_code' => [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('machines', 'qr_code')->ignore($machine?->id),
|
||||||
|
],
|
||||||
|
'status' => ['required', 'in:available,reserved,running,maintenance,offline,error'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findAccessibleMachine(string $uuid): Machine
|
||||||
|
{
|
||||||
|
$query = Machine::query()->where('uuid', $uuid);
|
||||||
|
$this->scopeEstablishmentQuery($query);
|
||||||
|
|
||||||
|
return $query->firstOrFail();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveQrCode(?string $qrCode): string
|
||||||
|
{
|
||||||
|
if (filled($qrCode)) {
|
||||||
|
return $qrCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
$candidate = 'LAVERIE-'.strtoupper(Str::random(8));
|
||||||
|
} while (Machine::query()->where('qr_code', $candidate)->exists());
|
||||||
|
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createSimulatedIntegration(Machine $machine): void
|
||||||
|
{
|
||||||
|
$machine->load('establishment');
|
||||||
|
|
||||||
|
MachineIntegration::query()->create([
|
||||||
|
'machine_id' => $machine->id,
|
||||||
|
'provider' => config('laverie.simulation.provider_name', 'simulated'),
|
||||||
|
'external_machine_id' => 'SIM-'.$machine->uuid,
|
||||||
|
'external_site_id' => 'SITE-'.$machine->establishment?->uuid,
|
||||||
|
'mode' => 'simulated',
|
||||||
|
'config' => [
|
||||||
|
'cycle_duration_seconds' => config('laverie.simulation.cycle_duration_seconds', 30),
|
||||||
|
],
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canManageMachines(): bool
|
||||||
|
{
|
||||||
|
return $this->supervisor()->role !== 'viewer';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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,276 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Supervisor;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Supervisor\Concerns\ScopesSupervisorResources;
|
||||||
|
use App\Models\Establishment;
|
||||||
|
use App\Services\GeocodingService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class OrganizationPageController extends Controller
|
||||||
|
{
|
||||||
|
use ScopesSupervisorResources;
|
||||||
|
|
||||||
|
public function __construct(private GeocodingService $geocoding) {}
|
||||||
|
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$supervisor = $this->supervisor();
|
||||||
|
|
||||||
|
$baseQuery = Establishment::query()
|
||||||
|
->where('organization_id', $supervisor->organization_id);
|
||||||
|
|
||||||
|
if ($supervisor->establishment_id !== null) {
|
||||||
|
$baseQuery->where('id', $supervisor->establishment_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalCount = (clone $baseQuery)->count();
|
||||||
|
$viewMode = $totalCount === 1 ? 'single' : 'list';
|
||||||
|
|
||||||
|
$canManageEstablishments = $this->canManageEstablishments();
|
||||||
|
$canCreateEstablishments = $this->canCreateEstablishments();
|
||||||
|
|
||||||
|
if ($viewMode === 'single') {
|
||||||
|
$establishment = (clone $baseQuery)->withCount('machines')->first();
|
||||||
|
|
||||||
|
return Inertia::render('Supervisor/Organizations/Index', [
|
||||||
|
'establishments' => [$this->formatEstablishment($establishment)],
|
||||||
|
'viewMode' => 'single',
|
||||||
|
'canManageEstablishments' => $canManageEstablishments,
|
||||||
|
'canCreateEstablishments' => $canCreateEstablishments,
|
||||||
|
'filters' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = (clone $baseQuery)->withCount('machines');
|
||||||
|
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$search = $request->string('search');
|
||||||
|
$query->where(function ($builder) use ($search) {
|
||||||
|
$builder->where('name', 'like', "%{$search}%")
|
||||||
|
->orWhere('address', 'like', "%{$search}%")
|
||||||
|
->orWhere('city', 'like', "%{$search}%")
|
||||||
|
->orWhere('zip_code', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->has('is_active') && $request->string('is_active')->toString() !== '') {
|
||||||
|
$query->where('is_active', $request->boolean('is_active'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$establishments = $query
|
||||||
|
->tap(fn ($builder) => $this->applySort($request, $builder, [
|
||||||
|
'name' => 'name',
|
||||||
|
'city' => 'city',
|
||||||
|
'machines_count' => 'machines_count',
|
||||||
|
'is_active' => 'is_active',
|
||||||
|
], 'name', 'asc'))
|
||||||
|
->paginate($this->supervisorListPerPage($request))
|
||||||
|
->withQueryString()
|
||||||
|
->through(fn (Establishment $establishment) => $this->formatEstablishment($establishment));
|
||||||
|
|
||||||
|
return Inertia::render('Supervisor/Organizations/Index', [
|
||||||
|
'establishments' => $establishments,
|
||||||
|
'viewMode' => 'list',
|
||||||
|
'canManageEstablishments' => $canManageEstablishments,
|
||||||
|
'canCreateEstablishments' => $canCreateEstablishments,
|
||||||
|
'filters' => [
|
||||||
|
'search' => $request->string('search')->toString() ?: null,
|
||||||
|
'is_active' => $request->has('is_active') && $request->string('is_active')->toString() !== ''
|
||||||
|
? ($request->boolean('is_active') ? '1' : '0')
|
||||||
|
: null,
|
||||||
|
'sort' => $request->string('sort')->toString() ?: 'name',
|
||||||
|
'direction' => $request->string('direction')->toString() === 'desc' ? 'desc' : 'asc',
|
||||||
|
'per_page' => $this->supervisorListPerPage($request),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function searchAddresses(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
abort_unless($this->canManageEstablishments(), 403);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'q' => ['required', 'string', 'min:3', 'max:200'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(
|
||||||
|
$this->geocoding->search($validated['q']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeEstablishment(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($this->canManageEstablishments(), 403);
|
||||||
|
abort_if($this->supervisor()->establishment_id !== null, 403);
|
||||||
|
|
||||||
|
$validated = $this->prepareEstablishmentData(
|
||||||
|
$request->validate($this->establishmentRules()),
|
||||||
|
);
|
||||||
|
$validated['organization_id'] = $this->supervisor()->organization_id;
|
||||||
|
|
||||||
|
Establishment::query()->create($validated);
|
||||||
|
|
||||||
|
return redirect()->route('supervisor.organizations.index')
|
||||||
|
->with('success', 'Enseigne créée.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateEstablishment(Request $request, int $id): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($this->canManageEstablishments(), 403);
|
||||||
|
|
||||||
|
$establishment = $this->findAccessibleEstablishment($id);
|
||||||
|
$validated = $this->prepareEstablishmentData(
|
||||||
|
$request->validate($this->establishmentRules()),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! empty($validated['is_active']) && ! $establishment->is_active) {
|
||||||
|
$this->validateEstablishmentReadyForActivation($validated, $establishment);
|
||||||
|
}
|
||||||
|
|
||||||
|
$establishment->update($validated);
|
||||||
|
|
||||||
|
return redirect()->route('supervisor.organizations.index')
|
||||||
|
->with('success', 'Enseigne mise à jour.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleEstablishmentActive(int $id): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($this->canManageEstablishments(), 403);
|
||||||
|
|
||||||
|
$establishment = $this->findAccessibleEstablishment($id);
|
||||||
|
$isActive = ! $establishment->is_active;
|
||||||
|
|
||||||
|
if ($isActive) {
|
||||||
|
$this->validateEstablishmentReadyForActivation([
|
||||||
|
'name' => $establishment->name,
|
||||||
|
'address' => $establishment->address,
|
||||||
|
'timezone' => $establishment->timezone,
|
||||||
|
], $establishment);
|
||||||
|
}
|
||||||
|
|
||||||
|
$establishment->update(['is_active' => $isActive]);
|
||||||
|
|
||||||
|
return redirect()->route('supervisor.organizations.index')
|
||||||
|
->with('success', $isActive ? 'Enseigne réactivée.' : 'Enseigne désactivée.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function formatEstablishment(Establishment $establishment): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $establishment->id,
|
||||||
|
'uuid' => $establishment->uuid,
|
||||||
|
'name' => $establishment->name,
|
||||||
|
'address' => $establishment->address,
|
||||||
|
'city' => $establishment->city,
|
||||||
|
'zip_code' => $establishment->zip_code,
|
||||||
|
'latitude' => $establishment->latitude !== null ? (float) $establishment->latitude : null,
|
||||||
|
'longitude' => $establishment->longitude !== null ? (float) $establishment->longitude : null,
|
||||||
|
'timezone' => $establishment->timezone,
|
||||||
|
'is_active' => $establishment->is_active,
|
||||||
|
'machines_count' => $establishment->machines_count,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function establishmentRules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:200'],
|
||||||
|
'address' => ['required', 'string'],
|
||||||
|
'city' => ['nullable', 'string', 'max:100'],
|
||||||
|
'zip_code' => ['nullable', 'string', 'max:10'],
|
||||||
|
'timezone' => ['required', 'string', 'max:50'],
|
||||||
|
'is_active' => ['boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $validated
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function prepareEstablishmentData(array $validated): array
|
||||||
|
{
|
||||||
|
$coordinates = $this->geocoding->geocode(
|
||||||
|
$validated['address'],
|
||||||
|
$validated['city'] ?? null,
|
||||||
|
$validated['zip_code'] ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$validated['latitude'] = $coordinates['latitude'] ?? null;
|
||||||
|
$validated['longitude'] = $coordinates['longitude'] ?? null;
|
||||||
|
|
||||||
|
return $validated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findAccessibleEstablishment(int $id): Establishment
|
||||||
|
{
|
||||||
|
$supervisor = $this->supervisor();
|
||||||
|
|
||||||
|
return Establishment::query()
|
||||||
|
->where('id', $id)
|
||||||
|
->where('organization_id', $supervisor->organization_id)
|
||||||
|
->when($supervisor->establishment_id, fn ($query) => $query->where('id', $supervisor->establishment_id))
|
||||||
|
->firstOrFail();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canCreateEstablishments(): bool
|
||||||
|
{
|
||||||
|
$supervisor = $this->supervisor();
|
||||||
|
|
||||||
|
if ($supervisor->role === 'viewer') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($supervisor->role, ['owner', 'platform_admin'], true)
|
||||||
|
&& $supervisor->establishment_id === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canManageEstablishments(): bool
|
||||||
|
{
|
||||||
|
return $this->supervisor()->role !== 'viewer';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
private function validateEstablishmentReadyForActivation(array $data, Establishment $establishment): void
|
||||||
|
{
|
||||||
|
$validator = validator(
|
||||||
|
$data,
|
||||||
|
[
|
||||||
|
'name' => ['required', 'string', 'max:200'],
|
||||||
|
'address' => ['required', 'string'],
|
||||||
|
'timezone' => ['required', 'string', 'max:50'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name.required' => 'Le nom est requis pour réactiver l\'enseigne.',
|
||||||
|
'address.required' => 'L\'adresse est requise pour réactiver l\'enseigne.',
|
||||||
|
'timezone.required' => 'Le fuseau horaire est requis pour réactiver l\'enseigne.',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
throw ValidationException::withMessages($validator->errors()->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
$establishment->loadMissing('organization');
|
||||||
|
|
||||||
|
if (! $establishment->organization?->is_active) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'is_active' => 'L\'organisation doit être active pour réactiver cette enseigne.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
-20
@@ -21,18 +21,60 @@ class PricingPageController extends Controller
|
|||||||
->with(['establishment:id,name', 'machine:id,uuid,name']);
|
->with(['establishment:id,name', 'machine:id,uuid,name']);
|
||||||
|
|
||||||
$this->scopeEstablishmentQuery($query);
|
$this->scopeEstablishmentQuery($query);
|
||||||
|
$this->applyEstablishmentFilter($request, $query);
|
||||||
|
|
||||||
|
if ($request->filled('machine_type')) {
|
||||||
|
$query->where('machine_type', $request->string('machine_type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('day_type')) {
|
||||||
|
$query->where('day_type', $request->string('day_type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->has('is_active') && $request->string('is_active')->toString() !== '') {
|
||||||
|
$query->where('is_active', $request->boolean('is_active'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$search = $request->string('search');
|
||||||
|
$query->where('label', 'like', "%{$search}%");
|
||||||
|
}
|
||||||
|
|
||||||
$rules = $query
|
$rules = $query
|
||||||
->orderBy('priority')
|
->tap(fn ($builder) => $this->applySort($request, $builder, [
|
||||||
->orderBy('slot_start')
|
'establishment' => fn ($builder, string $direction) => $this->sortByRelatedEstablishment(
|
||||||
->get()
|
$builder,
|
||||||
->map(fn (PricingRule $rule) => $this->formatRule($rule));
|
'pricing_rules',
|
||||||
|
'establishment_id',
|
||||||
|
$direction,
|
||||||
|
),
|
||||||
|
'slot_start' => 'slot_start',
|
||||||
|
'machine_type' => 'machine_type',
|
||||||
|
'price' => 'price',
|
||||||
|
'priority' => 'priority',
|
||||||
|
'is_active' => 'is_active',
|
||||||
|
], 'priority', 'asc'))
|
||||||
|
->paginate($this->supervisorListPerPage($request))
|
||||||
|
->withQueryString()
|
||||||
|
->through(fn (PricingRule $rule) => $this->formatRule($rule));
|
||||||
|
|
||||||
return Inertia::render('Supervisor/Pricing/Index', [
|
return Inertia::render('Supervisor/Pricing/Index', [
|
||||||
'rules' => $rules,
|
'rules' => $rules,
|
||||||
'establishments' => $this->establishmentOptions(),
|
'establishments' => $this->establishmentOptions(),
|
||||||
'machineTypes' => $this->machineTypeOptions(),
|
'machineTypes' => $this->machineTypeOptions(),
|
||||||
'dayTypes' => $this->dayTypeOptions(),
|
'dayTypes' => $this->dayTypeOptions(),
|
||||||
|
'filters' => [
|
||||||
|
'establishment_id' => $request->filled('establishment_id') ? (int) $request->input('establishment_id') : null,
|
||||||
|
'machine_type' => $request->string('machine_type')->toString() ?: null,
|
||||||
|
'day_type' => $request->string('day_type')->toString() ?: null,
|
||||||
|
'is_active' => $request->has('is_active') && $request->string('is_active')->toString() !== ''
|
||||||
|
? ($request->boolean('is_active') ? '1' : '0')
|
||||||
|
: null,
|
||||||
|
'search' => $request->string('search')->toString() ?: null,
|
||||||
|
'sort' => $request->string('sort')->toString() ?: 'priority',
|
||||||
|
'direction' => $request->string('direction')->toString() === 'desc' ? 'desc' : 'asc',
|
||||||
|
'per_page' => $this->supervisorListPerPage($request),
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,22 +176,6 @@ class PricingPageController extends Controller
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @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>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
+42
-20
@@ -15,21 +15,59 @@ class PromotionPageController extends Controller
|
|||||||
{
|
{
|
||||||
use ScopesSupervisorResources;
|
use ScopesSupervisorResources;
|
||||||
|
|
||||||
public function index(): Response
|
public function index(Request $request): Response
|
||||||
{
|
{
|
||||||
$query = Promotion::query()->with('establishment:id,name');
|
$query = Promotion::query()->with('establishment:id,name');
|
||||||
$this->scopeEstablishmentQuery($query);
|
$this->scopeEstablishmentQuery($query);
|
||||||
|
$this->applyEstablishmentFilter($request, $query);
|
||||||
|
|
||||||
|
if ($request->filled('machine_type')) {
|
||||||
|
$query->where('machine_type', $request->string('machine_type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->has('is_active') && $request->string('is_active')->toString() !== '') {
|
||||||
|
$query->where('is_active', $request->boolean('is_active'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$search = $request->string('search');
|
||||||
|
$query->where('description', 'like', "%{$search}%");
|
||||||
|
}
|
||||||
|
|
||||||
$promotions = $query
|
$promotions = $query
|
||||||
->orderByDesc('starts_at')
|
->tap(fn ($builder) => $this->applySort($request, $builder, [
|
||||||
->get()
|
'establishment' => fn ($builder, string $direction) => $this->sortByRelatedEstablishment(
|
||||||
->map(fn (Promotion $promotion) => $this->formatPromotion($promotion));
|
$builder,
|
||||||
|
'promotions',
|
||||||
|
'establishment_id',
|
||||||
|
$direction,
|
||||||
|
),
|
||||||
|
'discount_value' => 'discount_value',
|
||||||
|
'starts_at' => 'starts_at',
|
||||||
|
'ends_at' => 'ends_at',
|
||||||
|
'machine_type' => 'machine_type',
|
||||||
|
'is_active' => 'is_active',
|
||||||
|
], 'starts_at', 'desc'))
|
||||||
|
->paginate($this->supervisorListPerPage($request))
|
||||||
|
->withQueryString()
|
||||||
|
->through(fn (Promotion $promotion) => $this->formatPromotion($promotion));
|
||||||
|
|
||||||
return Inertia::render('Supervisor/Promotions/Index', [
|
return Inertia::render('Supervisor/Promotions/Index', [
|
||||||
'promotions' => $promotions,
|
'promotions' => $promotions,
|
||||||
'establishments' => $this->establishmentOptions(),
|
'establishments' => $this->establishmentOptions(),
|
||||||
'machineTypes' => $this->machineTypeOptions(),
|
'machineTypes' => $this->machineTypeOptions(),
|
||||||
'discountTypes' => $this->discountTypeOptions(),
|
'discountTypes' => $this->discountTypeOptions(),
|
||||||
|
'filters' => [
|
||||||
|
'establishment_id' => $request->filled('establishment_id') ? (int) $request->input('establishment_id') : null,
|
||||||
|
'machine_type' => $request->string('machine_type')->toString() ?: null,
|
||||||
|
'is_active' => $request->has('is_active') && $request->string('is_active')->toString() !== ''
|
||||||
|
? ($request->boolean('is_active') ? '1' : '0')
|
||||||
|
: null,
|
||||||
|
'search' => $request->string('search')->toString() ?: null,
|
||||||
|
'sort' => $request->string('sort')->toString() ?: 'starts_at',
|
||||||
|
'direction' => $request->string('direction')->toString() === 'desc' ? 'desc' : 'asc',
|
||||||
|
'per_page' => $this->supervisorListPerPage($request),
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,22 +162,6 @@ class PromotionPageController extends Controller
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @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>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
+17
-2
@@ -19,6 +19,8 @@ class WashPageController extends Controller
|
|||||||
->with(['user:id,first_name,last_name,email', 'machine:id,uuid,name,establishment_id', 'machine.establishment:id,name']);
|
->with(['user:id,first_name,last_name,email', 'machine:id,uuid,name,establishment_id', 'machine.establishment:id,name']);
|
||||||
|
|
||||||
$this->scopeMachineRelationQuery($query);
|
$this->scopeMachineRelationQuery($query);
|
||||||
|
$this->applyEstablishmentFilter($request, $query, 'machine');
|
||||||
|
$this->applyUserSearchFilter($request, $query);
|
||||||
|
|
||||||
if ($request->filled('status')) {
|
if ($request->filled('status')) {
|
||||||
$query->where('status', $request->string('status'));
|
$query->where('status', $request->string('status'));
|
||||||
@@ -29,8 +31,15 @@ class WashPageController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$washes = $query
|
$washes = $query
|
||||||
->orderByDesc('started_at')
|
->tap(fn ($builder) => $this->applySort($request, $builder, [
|
||||||
->paginate(20)
|
'started_at' => 'washes.started_at',
|
||||||
|
'user' => fn ($builder, string $direction) => $this->sortByRelatedUser($builder, 'washes', 'user_id', $direction),
|
||||||
|
'machine' => fn ($builder, string $direction) => $this->sortByRelatedMachine($builder, 'washes', $direction),
|
||||||
|
'status' => 'washes.status',
|
||||||
|
'duration_minutes' => 'washes.duration_minutes',
|
||||||
|
'cost' => 'washes.cost',
|
||||||
|
], 'started_at', 'desc'))
|
||||||
|
->paginate($this->supervisorListPerPage($request))
|
||||||
->withQueryString()
|
->withQueryString()
|
||||||
->through(fn (Wash $wash) => [
|
->through(fn (Wash $wash) => [
|
||||||
'uuid' => $wash->uuid,
|
'uuid' => $wash->uuid,
|
||||||
@@ -56,9 +65,15 @@ class WashPageController extends Controller
|
|||||||
|
|
||||||
return Inertia::render('Supervisor/Washes/Index', [
|
return Inertia::render('Supervisor/Washes/Index', [
|
||||||
'washes' => $washes,
|
'washes' => $washes,
|
||||||
|
'establishments' => $this->establishmentOptions(),
|
||||||
'filters' => [
|
'filters' => [
|
||||||
|
'establishment_id' => $request->filled('establishment_id') ? (int) $request->input('establishment_id') : null,
|
||||||
|
'user' => $request->string('user')->toString() ?: null,
|
||||||
'status' => $request->string('status')->toString() ?: null,
|
'status' => $request->string('status')->toString() ?: null,
|
||||||
'date' => $request->string('date')->toString() ?: null,
|
'date' => $request->string('date')->toString() ?: null,
|
||||||
|
'sort' => $request->string('sort')->toString() ?: 'started_at',
|
||||||
|
'direction' => $request->string('direction')->toString() === 'asc' ? 'asc' : 'desc',
|
||||||
|
'per_page' => $this->supervisorListPerPage($request),
|
||||||
],
|
],
|
||||||
'statusOptions' => $this->statusOptions(),
|
'statusOptions' => $this->statusOptions(),
|
||||||
]);
|
]);
|
||||||
+1
-1
@@ -14,7 +14,7 @@ class InitiateTopUpRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'amount' => ['required', 'numeric', 'min:1', 'max:500'],
|
'amount' => ['required', 'numeric', 'min:5', 'max:150'],
|
||||||
'idempotency_key' => ['required', 'string', 'max:100'],
|
'idempotency_key' => ['required', 'string', 'max:100'],
|
||||||
'return_url' => ['nullable', 'url', 'max:255'],
|
'return_url' => ['nullable', 'url', 'max:255'],
|
||||||
];
|
];
|
||||||
+15
-1
@@ -9,7 +9,7 @@ class PaymentTransactionResource extends JsonResource
|
|||||||
{
|
{
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
return [
|
$data = [
|
||||||
'uuid' => $this->uuid,
|
'uuid' => $this->uuid,
|
||||||
'provider' => $this->provider,
|
'provider' => $this->provider,
|
||||||
'provider_payment_id' => $this->provider_payment_id,
|
'provider_payment_id' => $this->provider_payment_id,
|
||||||
@@ -19,5 +19,19 @@ class PaymentTransactionResource extends JsonResource
|
|||||||
'return_url' => $this->return_url,
|
'return_url' => $this->return_url,
|
||||||
'created_at' => $this->created_at?->toIso8601String(),
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if ($this->provider === 'stripe' && is_array($this->raw_payload)) {
|
||||||
|
$clientSecret = $this->raw_payload['client_secret'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($clientSecret) && $clientSecret !== '') {
|
||||||
|
$data['stripe'] = [
|
||||||
|
'payment_intent_id' => $this->provider_payment_id,
|
||||||
|
'client_secret' => $clientSecret,
|
||||||
|
'publishable_key' => config('services.stripe.key'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user