Laravel

Un framework qui rend heureux

Voir cette catégorie
Vers le bas
Voir cette série
Shop : les commandes
Dimanche 2 février 2025 16:51

Dans notre précédent article, nous avons établi les fondations de notre tableau de bord d'administration. Cette étape cruciale a impliqué la création d'un nouveau layout et d'une barre de navigation latérale pour une navigation fluide. Soucieux de l'ergonomie et de la sécurité de notre code, nous avons également mis en place un middleware spécifique pour les administrateurs, garantissant ainsi un accès approprié aux différentes fonctionnalités.

Maintenant que cette infrastructure est solidement en place, nous pouvons nous concentrer sur l'ajout de fonctionnalités essentielles à notre boutique. Au cœur de notre système se trouvent les commandes, et leur gestion efficace est primordiale pour le bon fonctionnement de notre plateforme.

Vous pouvez trouver le code dans ce dépôt Github.

Conception du tableau de gestion des commandes

Le tableau

Pour faciliter la gestion des commandes, nous allons créer un tableau intuitif et complet. Ce tableau rassemblera les informations clés de chaque commande en un coup d'œil, permettant aux administrateurs de gérer efficacement le contenu. Voici les éléments que nous inclurons dans notre tableau :

  • Référence
  • Nom du client (ou entité)
  • Prix total
  • Date de création
  • État
  • Facture

Fonctionnalités avancées

En plus de ces informations de base, nous allons envisager d'ajouter des fonctionnalités supplémentaires pour améliorer l'expérience utilisateur :

  • Recherche rapide : intégrer un contrôle de recherche pour trouver rapidement une commande spécifique
  • Pagination : pour gérer efficacement un grand nombre de commandes

Une vue partielle pour le tableau

Dans l'idée d'afficher aussi un tableau des commandes allégé au niveau du tableau de bord, on va créer la partie tableau avec une vue partielle :

Avec ce code :

<x-card>
    <x-table 
        striped 
        :headers="$headersOrders" 
        :rows="$orders" 
        :sort-by="$sortBy" 
        per-page="perPage"
        :with-pagination="$paginationOrders"
        link="/admin/orders/{id}"
    >
        @scope('cell_user', $order)
            {{ $order->addresses->first()->company? $order->addresses->first()->company :  $order->user->name . ' ' . $order->user->firstname }}
        @endscope
        @scope('cell_created_at', $order)
            <span class="whitespace-nowrap">
                {{ $order->created_at->isoFormat('LL') }}
                @if(!$order->created_at->isSameDay($order->updated_at))
                    <p>@lang('Change') : {{ $order->updated_at->isoFormat('LL') }}</p>
                @endif
            </span>
        @endscope
        @scope('cell_total', $order)
            <span class="whitespace-nowrap">
                {{ $order->total }} €
            </span>
        @endscope
        @scope('cell_state', $order)
            <x-badge value="{{ $order->state->name }}" class="p-3 bg-{{ $order->state->color }}-400 whitespace-nowrap" />
        @endscope
        @scope('cell_reference', $order)
            <strong>{{ $order->reference }}</strong>
        @endscope
        @scope('cell_invoice_id', $order)
            @if ($order->invoice_id)
                <x-icon name="o-check-circle" />
            @endif
        @endscope
    </x-table>
</x-card>

Un trait

Pour la même raison de partage du code, on va aussi ajouter un trait :

Avec ce code :

<?php

namespace App\Traits;

trait ManageOrders 
{
    public array $sortBy = [
        'column' => 'created_at',
        'direction' => 'desc',
    ];
    
    public function headersOrders(): array
    {
        return [
            ['key' => 'id', 'label' => __('Id')],
            ['key' => 'reference', 'label' => __('Reference')], 
            ['key' => 'user', 'label' => __('Customer'), 'sortable' => false],            
            ['key' => 'total', 'label' => __('Total price')],
            ['key' => 'created_at', 'label' => __('Date')],
            ['key' => 'state', 'label' => __('Etat'), 'sortable' => false],
            ['key' => 'invoice_id', 'label' => __('Invoice')],
        ];
    }

}

Avec ces traductions :

"Customer": "Client",
"Total price": "Prix total",
"Invoice": "Facture",

Un composant pour le tableau

Il nous faut maintenant un composant Volt pour afficher le tableau :

php artisan make:volt admin/orders/index --class

Avec ce code :

<?php

use Livewire\Volt\Component;
use Livewire\Attributes\{Layout, Title};
use App\Models\Order;
use Mary\Traits\Toast;
use Livewire\WithPagination;
use Illuminate\Database\Eloquent\Builder;
use App\Traits\ManageOrders;

new 
#[Title('Orders')] 
#[Layout('components.layouts.admin')] 
class extends Component
{
    use Toast, WithPagination, ManageOrders;

    public int $perPage = 10;
    public string $search = '';
    public bool $paginationOrders = true;

    public function deleteOrder(Order $order): void
    {
        $order->delete();
        $this->success(__('Order deleted successfully.'));
    }

    public function with(): array
	{
		return [
            'orders' => Order::with('user', 'state', 'addresses')
                ->orderBy(...array_values($this->sortBy))
                ->when($this->search, function (Builder $q)
                {
                    $q->where('reference', 'like', "%{$this->search}%")
                        ->orWhereRelation('addresses', 'company', 'like', "%{$this->search}%")
                        ->orWhereRelation('state', 'name', 'like', "%{$this->search}%");
                })
                ->paginate($this->perPage),
			'headersOrders' => $this->headersOrders(),
		];
	}
   
}; ?>

<div>
    <x-header title="{{ __('Orders') }}" separator progress-indicator >
        <x-slot:actions>
            <x-input 
                placeholder="{{ __('Search...') }}" 
                wire:model.live.debounce="search" 
                clearable
                icon="o-magnifying-glass" 
            />
            <x-button 
                icon="s-building-office-2" 
                label="{{ __('Dashboard') }}" 
                class="btn-outline lg:hidden" 
                link="{{ route('admin') }}" 
            />
        </x-slot:actions>
    </x-header>

    @include('livewire.admin.orders.table')

 </div>

On ajoute cette traduction :

"Search...": "Rechercher...",

La route

On ajoute une route pour l'atteindre :

Route::middleware('auth')->group(function () {
	...
	Route::middleware(IsAdmin::class)->prefix('admin')->group(function ()
	{
		...
		Volt::route('/orders', 'admin.orders.index')->name('admin.orders.index');
	});

La navigation

On ajoute un lien dans la barre latérale de l'administration :

<x-menu-item title="{{ __('Orders') }}" icon="s-shopping-bag" link="{{ route('admin.orders.index') }}" />

Et on a enfin notre tableau :

Avec la pagination en partie inférieure :

Vérifiez que cette pagination fonctionne, ainsi que la recherche qui s'effectue sur les références, les adresses et les états. On peut aussi trier par référence, par pris et par date. 

Le détail d'une commande

Lorsqu'on clique sur la ligne d'une commande dans le tableau, on doit ouvrir une page avec le détail de la commande concernée. Il nous faut un composant Volt pour afficher ce détail :

php artisan make:volt admin/orders/show --class

Avec un code un peu fourni :

<?php

use Livewire\Volt\Component;
use Livewire\Attributes\{Layout, Title};
use App\Models\{ State, Order };
use Mary\Traits\Toast;
use Illuminate\Support\Collection;
use App\Services\Invoice;

new 
#[Title('Order')] 
#[Layout('components.layouts.admin')] 
class extends Component
{
    use Toast;
    
    public Order $order;

    public bool $paid = false;
    public int $annule_indice = 0;    
    public Collection $states;
    public ?string $purchase_order = null;
    public string $state = '';

    public function mount(Order $order): void
    {
        $this->order = $order;
        $this->purchase_order = $order->purchase_order?? null;
        $this->state = $order->state_id;
        $this->order->load('addresses', 'state', 'products', 'payment_infos', 'user', 'user.orders');
        $this->paid = $order->state->indice > 3;

        // Cas du mandat administratif
        $this->annule_indice = State::whereSlug('annule')->first()->indice;
        $this->states = $order->payment === 'mandat' && !$order->purchase_order ?
          State::where('indice', '<=', $this->annule_indice)
              ->where('indice', '>', 0)
              ->get() :
          State::where('indice', '>=', $order->state->indice)->get();
    }

    public function generateInvoice(Invoice $invoice): void
    {
        $response = $invoice->create($this->order, true);  
        if($response->successful()) {
            $data = json_decode($response->body());
            $this->order->invoice_id = $data->id;
            $this->order->invoice_number = $data->number;
            $this->order->save();
            $this->success(__('Invoice generated successfully.'));        
        }
    }

    public function savePurchaseOrder(Invoice $invoice): void
    {
        $this->order->purchase_order = $this->purchase_order == '' ? null : $this->purchase_order;
        $this->order->save();
        $this->success(__('Purchase order numberupdated successfully.'));
    }

    public function updatedState($value): void
    {
        $this->order->state_id = $value;

        $state = $this->states->find($value);

        if($state->indice === 1) {
            $this->order->payment = $state->slug;
        }

        $this->order->save();
        $this->order->refresh();

        $this->success(__('State updated successfully.'));
    }
   
}; ?>

<div>
    <x-header title="{!! __('Order management') !!}" separator progress-indicator>
        <x-slot:actions>
            <x-button icon="s-building-office-2" label="{{ __('Dashboard') }}" class="btn-outline lg:hidden"
                link="{{ route('admin') }}" />
        </x-slot:actions>
    </x-header>

    <x-card 
        title="{{ __('References') }}" 
        shadow 
    >
        <div class="space-y-4">
            <div>
                <strong>@lang('Number') :</strong> 
                <x-badge value="{{ $order->id }}" class="badge-info" />
            </div>
            <div>
                <strong>@lang('Reference') :</strong> 
                <x-badge value="{{ $order->reference }}" class="badge-info" />
            </div>
        </div>
    </x-card>
    <br>
    
    <x-card 
        title="{{ __('Mode of payment') }}" 
        shadow 
    >
        <div class="space-y-4">
            <div>
                {{ $order->payment_text }}
            </div>
            @if($order->payment_infos)
                <div>
                    <strong>@lang('Payment ID') :</strong> 
                    <x-badge value="{{ $order->payment_infos->payment_id }}" class="badge-info" />
                </div>
            @endif
        </div>
    </x-card>
    <br>

    @if($shop->invoice && ($order->invoice_id || $order->state->indice > $annule_indice))
        <x-card 
            title="{{ __('Invoice') }}" 
            shadow 
        >
            @if($order->invoice_id)
                <p>@lang('The invoice was generated with id') <strong>{{ $order->invoice_id }}</strong> @lang('and the number') <strong>{{ $order->invoice_number }}</strong>.</p>
            @else
                <div class="flex flex-row justify-between">
                    <x-checkbox label="{{ __('Payment has been made') }}" wire:model="paid" hint="{!! __('Tick this box if the invoice has actually been paid.') !!}" />
                    <x-button label="{{ __('Generate invoice') }}" wire:click="generateInvoice" class="btn-primary" />
                </div>
            @endif
        </x-card>
        <br>
    @endif

    @if($order->payment === 'mandat')
        <x-card 
            title="{{ __('Order form') }}" 
            shadow 
            progress-indicator
        >
            <x-form wire:submit="savePurchaseOrder">
                <x-input label="{{ __('Purchase order number') }}" wire:model="purchase_order" type="text" />
                <x-slot:actions>
                    <x-button label="{{ __('Update purchase order number') }}" icon="o-paper-airplane" spinner="save" type="submit" class="btn-primary" />
                </x-slot:actions>
            </x-form>
        </x-card>
        <br>
    @endif

    <x-card 
        title="{{ __('State') }}" 
        shadow 
    >    
        <x-slot:menu>
            <x-badge value="{{ $order->state->name }}" class="bg-{{ $order->state->color }}-400 mb-4 p-3" />
        </x-slot:menu>
        <x-select label="{!! __('Change state') !!}" :options="$states" wire:model="state" wire:change="$refresh"  />
    </x-card>
    <br>

    <x-card 
        title="{{ __('Products') }}" 
        shadow
    >
        <x-details 
            :content="$order->products" 
            :shipping="$order->shipping" 
            :tax="$order->tax" 
            :total="$order->total"
            :pick="$order->pick"
        />    
    </x-card>
    <br>

    <x-card 
        title="{{ __('Customer') }}" 
        shadow 
    >
        <div class="space-y-4">
            <div class="flex items-center">
                <x-icon name="o-user" class="mr-3 w-6 h-6 text-primary" />
                <span>
                    <strong>@lang('Name') :</strong> 
                    {{ $order->user->name }} {{ $order->user->firstname }}
                </span>
            </div>
            <div class="flex items-center">
                <x-button icon="o-envelope" link="mailto:{{ $order->user->email }}" no-wire-navigate spinner
                    class="mr-3 w-6 h-6 text-primary btn-ghost btn-sm" tooltip="{{ __('Send an email') }}" />
                <span>
                    <strong>@lang('Email') :</strong> 
                    {{ $order->user->email }}
                </span>
            </div>
            <div class="flex items-center">
                <x-icon name="o-calendar" class="mr-3 w-6 h-6 text-primary" />
                <span>
                    <strong>@lang('Date of registration') :</strong> 
                    {{ $order->user->created_at->isoFormat('LL') }}
                </span>
            </div>
            <div class="flex items-center">
                <x-icon name="o-shopping-cart" class="mr-3 w-6 h-6 text-primary" />
                <span>
                    <strong>@lang('Validated orders') :</strong> 
                    <x-badge value="{{ $order->user->orders->where('state_id', '>', 5)->count() }}" class="badge-success" />
                </span>
            </div>
        </div>
        <x-slot:actions>
            <x-button label="{{ __('View customer details') }}" class="btn-primary" link="/" />
        </x-slot:actions>
    </x-card>
    <br>

    <x-card 
        title="{{ __('Addresses') }}" 
        shadow 
    >
        <x-card title="{{ __('Billing address') }} {{ $this->order->addresses->count() === 1 && !$this->order->pick ? __('and delivery') : '' }}" shadow >
            <x-address :address="$this->order->addresses->first()" />
        </x-card>
        @if($this->order->pick)
            <x-alert title="{!! __('The customer has chosen to collect the order on site.') !!}" icon="o-exclamation-triangle" class="alert-info" />
        @else
            @if($this->order->addresses->count() === 2)
                <x-card class="w-full sm:min-w-[50vw]" title="{{ __('Delivery address') }}" shadow separator >
                    <x-address :address="$this->order->addresses->get(1)" />
                </x-card>
            @endif              
        @endif
    </x-card>

</div>

On a besoin de nouvelles traductions :

"Order management": "Gestion d'une commande",
"Number": "N°",
"Payment ID": "ID paiement",
"The invoice was generated with id": "La facture a été générée avec l'id",
"and the number": "et le n°",
"Payment has been made": "Le paiement a été effectué",
"Generate invoice": "Générer la facture",
"Invoice generated successfully.": "Facture génerée avec succès.",
"Order form": "Bon de commande",
"Purchase order number": "Numéro du bon de commande",
"Update purchase order number": "Mettre à jour le numéro du bon de commande",
"Purchase order numberupdated successfully.": "Numéro de bon de commande mis à jour avec succès.",
"Change state": "Changement de l'état",
"Update state": "Mise à jour de l'état",
"The customer has chosen to pick up the order on site.": "Le client a choisi de venir chercher sa commande sur place.",
"Normal Colissimo delivery.": "Livraison Colissimo standard.",
"Tick this box if the invoice has actually been paid.": "Cochez cette case si la facture a effectivement été payée.",
"View customer details": "Voir les détails du client",
"Date of registration": "Date d'inscription",
"The customer has chosen to collect the order on site.": "Le client a choisi de venir chercher sa commande sur place.",

On ajoute la route :

Volt::route('/orders/{order}', 'admin.orders.show')->name('admin.orders.show');

Dans la partie supérieure, on a les références :

Puis le moyen de paiement utilisé :

Ensuite une zone optionnelle selon la situation de la commande pour la facture :

Si le paiement a été effectivement effectué, on peut générer la facture correspondante. Si la facture existe déjà, on obtient sa référence dans cette zone :

On a ensuite l'état de paiement, que l'on peut évidemment changer avec les valeurs possibles :

Vient ensuite le détail de la commande :

Ensuite les données principales du client :

Pour le moment le bouton pour voir les autres données ne fonctionne pas, on le complètera plus tard.

Et on a finalement la zone des adresses :

Cette page constitue sans doute la plus importante dans l'administration.

Les dernières commandes dans le tableau de bord

Il semble judicieux d'afficher le tableau des dernières commandes passées sur le tableau de bord alors, on va compléter le composant admin.index (on en profite pour ajouter le lien vers les commandes) :

<?php

use App\Models\{ Order, Product, User };
use Livewire\Attributes\{Layout, Title};
use Livewire\Volt\Component;
use App\Traits\ManageOrders;

new 
#[Title('Dashboard')] 
#[Layout('components.layouts.admin')] 
class extends Component
{
	use ManageOrders;

    public bool $openGlance = true;
    public bool $openOrders = true;
    public bool $paginationOrders = false;
 
    public function with(): array
	{
		return [
			'productsCount' => Product::count(),
			'ordersCount'   => Order::whereRelation('state', 'indice', '>', 3)
                                    ->whereRelation('state', 'indice', '<', 6)
                                    ->count(),
			'usersCount'    => User::count(),
            'orders'        => Order::with('user', 'state', 'addresses')
                                    ->orderBy(...array_values($this->sortBy))
                                    ->take(6)
                                    ->get(),
            'headersOrders' => $this->headersOrders(),
		];
	}

}; ?>

<div>
    <x-collapse wire:model="openGlance" class="shadow-md">
        <x-slot:heading>
            @lang('In a glance')
        </x-slot:heading>
        <x-slot:content class="flex flex-wrap gap-4">
            <a href="/" class="flex-grow">
                <x-stat 
                    title="{{ __('Active products') }}" 
                    description="" 
                    value="{{ $productsCount }}" 
                    icon="s-shopping-bag"
                    class="shadow-hover" />
            </a>
            <a href="{{ route('admin.orders.index') }}" class="flex-grow">
                <x-stat 
                    title="{{ __('Successful orders') }}" 
                    description="" 
                    value="{{ $ordersCount }}" 
                    icon="s-shopping-cart"
                    class="shadow-hover" />
            </a>
            <a href="/" class="flex-grow">
                <x-stat 
                    title="{{ __('Customers') }}" 
                    description="" 
                    value="{{ $usersCount }}" 
                    icon="s-user"
                    class="shadow-hover" />
            </a>
        </x-slot:content>
    </x-collapse>
    <br>

    <x-collapse wire:model="openOrders" class="shadow-md">
        <x-slot:heading>
            @lang('Latest orders')
        </x-slot:heading>
        <x-slot:content>
            <x-card class="mt-6" title="" shadow separator >
                @include('livewire.admin.orders.table')
                <x-slot:actions>
                    <x-button 
                        label="{{ __('See all orders') }}" 
                        class="btn-primary" 
                        link="{{ route('admin.orders.index') }}" />
                </x-slot:actions>
            </x-card>
        </x-slot:content>
    </x-collapse>

</div>

Il nous manque deux traductions :

"Latest orders": "Dernières commandes",
"See all orders": "Voir toutes les commandes",

Maintenant, on a les dernières commandes passées directement sur le tableau de bord :

Et évidemment, un clic sur la ligne d'une commande conduit directement sur le détail correspondant.

Conclusion

Cet article est un peu chargé, mais je voulais terminer la gestion des commandes. Dans la prochaine étape, on codera la gestion des clients.



Par bestmomo

Aucun commentaire

Article précédent : Shop : l'administration
Article suivant : Shop : les clients