2 Commits
Author SHA1 Message Date
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
258 changed files with 5659 additions and 1874 deletions
+14 -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,15 @@
/.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
_ide_helper.php _ide_helper.php
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml
Vendored
+1
View File
@@ -0,0 +1 @@
webPipeline(name: 'laundry-backend', phpVersion: '8.3')
@@ -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]);
}
});
}
}
@@ -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
+8 -1
View File
@@ -3,6 +3,7 @@ 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
@@ -87,4 +88,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;
} }
} }
@@ -17,7 +17,7 @@ class WalletTransactionResource extends JsonResource
'balance_after' => (float) $this->balance_after, 'balance_after' => (float) $this->balance_after,
'source_type' => $this->source_type, 'source_type' => $this->source_type,
'source_id' => $this->source_id, 'source_id' => $this->source_id,
'metadata' => $this->metadata, 'metadata' => $this->metadata ?? [],
'created_at' => $this->created_at?->toIso8601String(), 'created_at' => $this->created_at?->toIso8601String(),
]; ];
} }

Some files were not shown because too many files have changed in this diff Show More