11 Commits
Author SHA1 Message Date
bastien e569c84c9d Merge branch 'release/0.0.2'
Laverie/backend/pipeline/tag This commit looks good
2026-07-05 01:41:21 +02:00
bastien 4ff8ff2aac version 0.0.2 2026-07-05 01:41:11 +02:00
bastien 80085a94d3 mise en place schedulers (crons laravel) 2026-07-05 01:40:31 +02:00
bastien c347796c29 Merge tag '0.0.1' into develop
0.0.1
2026-07-05 00:40:14 +02:00
bastien 6dbec68894 Merge branch 'release/0.0.1'
Laverie/backend/pipeline/head This commit looks good
Laverie/backend/pipeline/tag This commit looks good
2026-07-05 00:40:01 +02:00
bastien 661b4ebdb0 version 0.0.1 2026-07-05 00:39:49 +02:00
bastien ee51dc4dba Jenkinsfile
Laverie/backend/pipeline/head This commit looks good
2026-07-04 23:38:27 +02:00
bastien 635acf2b41 maj ignore
Laverie/backend/pipeline/head There was a failure building this commit
2026-07-04 23:21:25 +02:00
bastien 293f49adfa Ajout documentation 2026-07-04 22:47:55 +02:00
bastien d95bfa6c58 Jenkins file
Laverie/backend/pipeline/head There was a failure building this commit
2026-07-04 22:42:58 +02:00
bastien 10ee859602 Intégration fonctionnalites V1 2026-07-04 22:30:18 +02:00
265 changed files with 8247 additions and 13935 deletions
+16 -14
View File
@@ -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
View File
@@ -0,0 +1 @@
vitePipeline(name: 'laundry-backend', phpVersion: '8.3')
+1 -1
View File
@@ -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),
]);
}
}
-132
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+667
View File
@@ -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)
+843
View File
@@ -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.
+460
View File
@@ -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 : LunVen / SamDim / 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 % : 190%
- 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)
+396
View File
@@ -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.
+70
View File
@@ -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:
-3385
View File
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>
-137
View File
@@ -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>
-139
View File
@@ -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>
View File
+13 -2
View File
@@ -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
@@ -87,4 +92,10 @@ LAVERIE_BOOKING_PENALTY_AMOUNT=3.00
LAVERIE_MACHINE_OFFLINE_THRESHOLD_MINUTES=10 LAVERIE_MACHINE_OFFLINE_THRESHOLD_MINUTES=10
# 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=
View File
@@ -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(),
]); ]);
} }
@@ -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é.');
} }
@@ -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;
}
}
@@ -4,9 +4,17 @@ namespace App\Http\Controllers\Supervisor;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Controllers\Supervisor\Concerns\ScopesSupervisorResources; use App\Http\Controllers\Supervisor\Concerns\ScopesSupervisorResources;
use App\Models\Establishment;
use App\Models\Machine; use App\Models\Machine;
use App\Models\MachineCommand;
use App\Models\MachineEvent; 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\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -20,6 +28,7 @@ class MachinePageController extends Controller
->with('establishment:id,name,uuid'); ->with('establishment:id,name,uuid');
$this->scopeEstablishmentQuery($query); $this->scopeEstablishmentQuery($query);
$this->applyEstablishmentFilter($request, $query);
if ($request->filled('status')) { if ($request->filled('status')) {
$query->where('status', $request->string('status')); $query->where('status', $request->string('status'));
@@ -38,23 +47,100 @@ class MachinePageController extends Controller
} }
$machines = $query $machines = $query
->orderBy('name') ->tap(fn ($builder) => $this->applySort($request, $builder, [
->paginate(20) '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() ->withQueryString()
->through(fn (Machine $machine) => $this->formatMachineListItem($machine)); ->through(fn (Machine $machine) => $this->formatMachineListItem($machine));
return Inertia::render('Supervisor/Machines/Index', [ return Inertia::render('Supervisor/Machines/Index', [
'machines' => $machines, 'machines' => $machines,
'establishments' => $this->establishmentOptions(),
'canManageMachines' => $this->canManageMachines(),
'filters' => [ 'filters' => [
'establishment_id' => $request->filled('establishment_id') ? (int) $request->input('establishment_id') : null,
'status' => $request->string('status')->toString() ?: null, 'status' => $request->string('status')->toString() ?: null,
'type' => $request->string('type')->toString() ?: null, 'type' => $request->string('type')->toString() ?: null,
'search' => $request->string('search')->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(), 'statusOptions' => $this->statusOptions(),
'typeOptions' => $this->typeOptions(), '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';
$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']);
}
$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 public function show(string $uuid): Response
{ {
$query = Machine::query()->where('uuid', $uuid); $query = Machine::query()->where('uuid', $uuid);
@@ -115,9 +201,11 @@ class MachinePageController extends Controller
{ {
return [ return [
'uuid' => $machine->uuid, 'uuid' => $machine->uuid,
'establishment_id' => $machine->establishment_id,
'name' => $machine->name, 'name' => $machine->name,
'type' => $machine->type, 'type' => $machine->type,
'type_label' => $this->typeLabel($machine->type), 'type_label' => $this->typeLabel($machine->type),
'qr_code' => $machine->qr_code,
'status' => $machine->status, 'status' => $machine->status,
'status_label' => $this->statusLabel($machine->status), 'status_label' => $this->statusLabel($machine->status),
'establishment_name' => $machine->establishment?->name, 'establishment_name' => $machine->establishment?->name,
@@ -125,6 +213,68 @@ class MachinePageController extends Controller
]; ];
} }
/**
* @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> * @return array<string, string>
*/ */
@@ -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.',
]);
}
}
}
@@ -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>
*/ */
@@ -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>
*/ */
@@ -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(),
]); ]);
@@ -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'],
]; ];
@@ -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