Merge branch 'develop' into trunk

This commit is contained in:
Jérémy Munsch 2026-10-29 21:16:37 +01:00
commit e8a6ab07de
No known key found for this signature in database
GPG key ID: 2AAECDA6B7330F91
10 changed files with 225 additions and 41 deletions

View file

@ -68,7 +68,13 @@ final readonly class GetBoPaginatedOrdersQuery implements Query
$query->where('status', $input->status);
}
$this->dataTable->sortQuery(Order::class, $query);
$this->dataTable->sortQuery(
Order::class,
$query,
defaultSortCol: 'date',
defaultSortWay: \SortDirection::Descending,
columnMap: ['date' => 'id'],
);
$paginator = $this->dataTable->paginateQuery($query, $input->pagination);
/** @var LengthAwarePaginator<int, OrderDto> $ordersDtoPaginator */

View file

@ -37,9 +37,15 @@ final readonly class GetBoTodayOrdersQuery implements Query
$query = Order::query()->with(['shop']);
$query->where('pickup_day', Date::now()->toDateString());
$query->whereCreatedAtBetween(Date::now(), Date::now());
$this->dataTable->sortQuery(Order::class, $query);
$this->dataTable->sortQuery(
Order::class,
$query,
defaultSortCol: 'date',
defaultSortWay: \SortDirection::Descending,
columnMap: ['date' => 'id'],
);
$paginator = $this->dataTable->paginateQuery($query, $input->pagination);

View file

@ -98,11 +98,15 @@ class DataTableService
* @param class-string<TModel> $modelClass
* @param TBuilder $query
* @param array<int, string> $ignore Columns to ignore for sorting.
* @param array<string, string> $columnMap Map virtual column names to database columns.
*/
public function sortQuery(
string $modelClass,
Builder $query,
array $ignore = []
array $ignore = [],
?string $defaultSortCol = null,
SortDirection $defaultSortWay = SortDirection::Ascending,
array $columnMap = [],
): Builder {
$table = (new $modelClass())->getTable();
$rName = $this->request->route()?->getName() ?? 'noroute';
@ -115,15 +119,22 @@ class DataTableService
$sort_col = $sort['sort_col'];
$sort_way = $sort['sort_way'];
if ((! $sort_col || $sort_col === 'undefined') && $defaultSortCol !== null) {
$sort_col = $defaultSortCol;
$sort_way = $defaultSortWay;
}
$this->persistSortSession($rName, $sort_col, $sort_way);
if ($sort_col && $sort_col !== 'undefined' && $sort_way && ! in_array($sort_col, $ignore) && $this->hasTableColumn($table, $sort_col)) {
$dbCol = $columnMap[$sort_col] ?? $sort_col;
if ($sort_col && $sort_col !== 'undefined' && $sort_way && ! in_array($sort_col, $ignore) && $this->hasTableColumn($table, $dbCol)) {
$direction = match ($sort_way) {
SortDirection::Descending => 'desc',
default => 'asc',
};
$query->orderBy("{$table}.{$sort_col}", $direction);
$query->orderBy("{$table}.{$dbCol}", $direction);
} elseif ($this->hasTableColumn($table, self::ORDER_COLUMN)) {
$query->orderBy(self::ORDER_COLUMN, 'asc');

View file

@ -1,6 +1,14 @@
#products-carousel {
position: relative;
&.has-controls,
&:has(.carousel-control-prev) {
@include media-breakpoint-up(lg) {
padding-left: 3.5rem;
padding-right: 3.5rem;
}
}
.carousel-inner {
display: flex !important;
flex-wrap: nowrap !important;
@ -18,6 +26,50 @@
transition: none !important;
}
.carousel-control-prev,
.carousel-control-next {
position: absolute;
top: 50%;
bottom: auto;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
color: var(--bs-dark);
cursor: pointer;
background-color: var(--bs-primary);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 50%;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
opacity: 1;
filter: none;
transform: translateY(-50%);
transition: background-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
&:hover,
&:focus-visible {
color: var(--bs-dark);
background-color: color-mix(in srgb, var(--bs-primary) 85%, black);
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.2);
opacity: 1;
transform: translateY(-50%) scale(1.08);
}
&:active {
transform: translateY(-50%) scale(0.96);
}
}
.carousel-control-prev {
left: 0.5rem;
}
.carousel-control-next {
right: 0.5rem;
}
}
.carousel-indicators {

View file

@ -2,9 +2,16 @@
// * Route vars
$routeName = request()->route()?->getName() ?? 'noroute';
// * Default sort vars
$defaultSortCol = $defaultSortCol ?? 'order';
$defaultSortWay = $defaultSortWay ?? 'asc';
$defaultSortWayStr = $defaultSortWay instanceof \SortDirection
? ($defaultSortWay === \SortDirection::Descending ? 'desc' : 'asc')
: (string) $defaultSortWay;
// * Request vars
$currentSortCol = session()->get("{$routeName}.sort_col");
$currentSortWay = session()->get("{$routeName}.sort_way");
$currentSortCol = session()->get("{$routeName}.sort_col", $defaultSortCol);
$currentSortWay = session()->get("{$routeName}.sort_way", $defaultSortWayStr);
$searchQuery = request()->input('search');
@ -18,7 +25,7 @@
View::share('noOrder', $noOrder);
// * Reset button
$hasCustomSort = ($currentSortCol && $currentSortCol !== 'order') || ($currentSortCol === 'order' && $currentSortWay === 'desc');
$hasCustomSort = ($currentSortCol && $currentSortCol !== $defaultSortCol) || ($currentSortCol === $defaultSortCol && $currentSortWay !== $defaultSortWayStr);
$showReset = !empty($searchQuery) || $hasCustomSort;
@endphp

View file

@ -16,6 +16,8 @@
'cols' => $cols,
'mobileHide' => [],
'ignore' => ['customer', 'pickup_date'],
'defaultSortCol' => 'date',
'defaultSortWay' => 'desc',
])
</thead>
<tbody>

View file

@ -1,10 +1,6 @@
@extends('bo.layouts.layout')
@if (request()->routeIs('bo.merch.starred'))
@section('title', __('crud.helpers.starred'))
@else
@section('title', Str::ucfirst(trans_choice('models.classes.orders', \INF)))
@endif
@section('title', __('Today\'s Orders'))
@section('breadcrumb', request()->route()->getName())
@ -14,7 +10,7 @@
<div class="row">
<div class="col">
<h1 class="h3">
{{ Str::ucfirst(trans_choice('models.classes.orders', \INF)) }}&nbsp;{{ Str::ucfirst(__('crud.actions.list')) }}
{{ __('Today\'s Orders') }}
</h1>
</div>
</div>

View file

@ -1,7 +1,7 @@
<div class="row">
<div class="col-12 position-relative overflow-hidden">
<div class="carousel slide carousel-dark" id="products-carousel" data-bs-ride="false" data-bs-touch="true" role="region"
<div class="carousel slide carousel-dark @if (count($starProductList) > 3) has-controls @endif" id="products-carousel" data-bs-ride="false" data-bs-touch="true" role="region"
aria-roledescription="carousel" aria-label="{{ __('Offers Slider') }}">
<div class="carousel-inner p-2">
@ -91,8 +91,7 @@
</small>
</li>
<li class="position-relative z-3 py-2 text-center">
<li class="d-flex justify-content-between flex-row py-2">
<li class="position-relative z-3 d-flex justify-content-between flex-row py-2">
@if ($productWithPricing->product->inco?->nutriscore)
<img class="object-fit-contain img-fluid mw-5" data-bs-toggle="tooltip"
src="{{ Vite::asset("resources/assets/images/nutriscore/nutriscore-{$productWithPricing->product->inco->nutriscore->value()}.png") }}"
@ -101,7 +100,7 @@
@endif
@can('addToBasket', $productWithPricing->product)
<div class="basket-buttons" data-json='@json(['id' => $productWithPricing->product->id])'></div>
<div class="basket-buttons ms-auto" data-json='@json(['id' => $productWithPricing->product->id])'></div>
@endcan
</li>
</ul>
@ -115,21 +114,15 @@
</div>
@if (count($starProductList) > 3)
<button class="carousel-control-prev d-none d-lg-flex justify-content-start opacity-100" data-bs-target="#products-carousel"
data-bs-slide="prev" type="button" style="filter: none; z-index: 10;">
<div class="btn btn-primary rounded-circle d-flex justify-content-center align-items-center ms-2 shadow-sm"
style="width: 45px; height: 45px; pointer-events: none;">
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
</div>
<button class="carousel-control-prev d-none d-lg-flex" data-bs-target="#products-carousel"
data-bs-slide="prev" type="button" aria-label="{{ __('pagination.previous') }}">
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
<span class="visually-hidden">{{ __('pagination.previous') }}</span>
</button>
<button class="carousel-control-next d-none d-lg-flex justify-content-end opacity-100" data-bs-target="#products-carousel"
data-bs-slide="next" type="button" style="filter: none; z-index: 10;">
<div class="btn btn-primary rounded-circle d-flex justify-content-center align-items-center me-2 shadow-sm"
style="width: 45px; height: 45px; pointer-events: none;">
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
</div>
<button class="carousel-control-next d-none d-lg-flex" data-bs-target="#products-carousel"
data-bs-slide="next" type="button" aria-label="{{ __('pagination.next') }}">
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
<span class="visually-hidden">{{ __('pagination.next') }}</span>
</button>
@endif

View file

@ -113,25 +113,72 @@ describe('Bo\OrdersController', function (): void {
// THEN
expect($response->status())->toBe(200);
$response->assertViewHas('orders', function ($orders) use ($ref): bool {
return $orders instanceof LengthAwarePaginator
&& $orders->total() === 1
&& (string) $orders->first()->reference === $ref;
$response->assertViewHas('orders', function (mixed $orders) use ($ref): bool {
/** @var \Illuminate\Pagination\LengthAwarePaginator<int, OrderDto> $orders */
return $orders instanceof \Illuminate\Pagination\LengthAwarePaginator
&& $orders->total() === 1
&& (string) $orders->first()?->reference === $ref;
});
});
it('can access today orders', function (): void {
it('sorts index orders by default by date from newest to oldest', function (): void {
/** @var \Tests\TestCase $this */
// GIVEN
$shop = Shop::factory()->create();
Order::forceCreate([
'id' => Uuid::uuid7()->toString(),
$shop = Shop::factory()->create();
$olderOrder = Order::forceCreate([
'id' => Uuid::uuid7(Date::now()->subHours(2))->toString(),
'shop_id' => $shop->id,
'reference' => new OrderReference(Uuid::uuid7()),
'status' => OrderStatus::VALIDATED->value,
'total' => new Money(1000, new Currency('EUR')),
'employee' => new OrderEmployee(new Matricule('1000198'), 'Old', 'Order', new ShopperEstablishmentCode('FAC01'), new ShopperSiteCode('SITE'), new ShopperServiceCode('101')),
'delivery_address' => new DeliveryAddress('Shop Name', 'Address', '75000', 'Paris'),
'pickup_day' => Date::now()->addDays(7)->toDateString(),
]);
$newerOrder = Order::forceCreate([
'id' => Uuid::uuid7(Date::now())->toString(),
'shop_id' => $shop->id,
'reference' => new OrderReference(Uuid::uuid7()),
'status' => OrderStatus::VALIDATED->value,
'total' => new Money(2000, new Currency('EUR')),
'employee' => new OrderEmployee(new Matricule('1000199'), 'New', 'Order', new ShopperEstablishmentCode('FAC01'), new ShopperSiteCode('SITE'), new ShopperServiceCode('101')),
'delivery_address' => new DeliveryAddress('Shop Name', 'Address', '75000', 'Paris'),
'pickup_day' => Date::now()->addDays(7)->toDateString(),
]);
// WHEN
$response = $this->get('/bo/orders');
// THEN
expect($response->status())->toBe(200);
/** @var \Illuminate\Pagination\LengthAwarePaginator<int, OrderDto> $orders */
$orders = $response->viewData('orders');
expect($orders->first()?->id)->toBe($newerOrder->id)
->and($orders->last()?->id)->toBe($olderOrder->id);
});
it('can access today orders and filters out past orders', function (): void {
/** @var \Tests\TestCase $this */
// GIVEN
$shop = Shop::factory()->create();
$todayOrder = Order::forceCreate([
'id' => Uuid::uuid7(Date::now())->toString(),
'shop_id' => $shop->id,
'reference' => new OrderReference(Uuid::uuid7()),
'status' => OrderStatus::VALIDATED->value,
'total' => new Money(3000, new Currency('EUR')),
'employee' => new OrderEmployee(new Matricule('1000103'), 'Charlie', 'Brown', new ShopperEstablishmentCode('FAC01'), new ShopperSiteCode('SITE'), new ShopperServiceCode('101')),
'delivery_address' => new DeliveryAddress('Shop Name', 'Address', '75000', 'Paris'),
'pickup_day' => Date::now()->addDays(7)->toDateString(),
]);
$pastOrder = Order::forceCreate([
'id' => Uuid::uuid7(Date::now()->subDays(3))->toString(),
'shop_id' => $shop->id,
'reference' => new OrderReference(Uuid::uuid7()),
'status' => OrderStatus::VALIDATED->value,
'total' => new Money(4000, new Currency('EUR')),
'employee' => new OrderEmployee(new Matricule('1000104'), 'Past', 'Order', new ShopperEstablishmentCode('FAC01'), new ShopperSiteCode('SITE'), new ShopperServiceCode('101')),
'delivery_address' => new DeliveryAddress('Shop Name', 'Address', '75000', 'Paris'),
'pickup_day' => Date::now()->toDateString(),
]);
@ -141,7 +188,10 @@ describe('Bo\OrdersController', function (): void {
// THEN
expect($response->status())->toBe(200);
$response->assertViewIs('bo.orders.index-today');
$response->assertViewHas('orders', fn ($orders) => $orders instanceof LengthAwarePaginator && $orders->total() >= 1);
/** @var \Illuminate\Pagination\LengthAwarePaginator<int, OrderDto> $orders */
$orders = $response->viewData('orders');
expect($orders->total())->toBe(1)
->and($orders->first()?->id)->toBe($todayOrder->id);
});
it('can access order show detail', function (): void {
@ -215,7 +265,7 @@ describe('Bo\OrdersController', function (): void {
$refreshedOrder = Order::query()->find($order->id);
expect($refreshedOrder)->not->toBeNull()
->and($refreshedOrder->status)->toBe(OrderStatus::CANCELED);
->and($refreshedOrder?->status)->toBe(OrderStatus::CANCELED);
});
it('rejects unauthenticated users', function (): void {

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Unit\Services;
use App\Models\Order;
use App\Models\Product;
use App\Services\DataTableService;
use Illuminate\Contracts\Database\Query\Builder;
@ -129,4 +130,64 @@ describe('DataTableService', function () {
// THEN
// Mockery verifies on teardown that orderBy('order', 'asc') was called once.
});
it('sorts by custom default column and way when no sort column is specified', function () {
// GIVEN
$request = Request::create('/bo/orders', 'GET');
$session = mock(Session::class);
$session->shouldReceive('get')->andReturn(null);
Schema::shouldReceive('getColumnListing')
->once()
->with('orders')
->andReturn(['id', 'reference', 'status', 'total']);
$query = mock(Builder::class);
$query->shouldReceive('orderBy')->once()->with('orders.id', 'desc');
$service = new DataTableService($request, $session);
// WHEN
$service->sortQuery(
Order::class,
$query,
defaultSortCol: 'date',
defaultSortWay: SortDirection::Descending,
columnMap: ['date' => 'id'],
);
// THEN
// Mockery verifies on teardown that orderBy('orders.id', 'desc') was called once.
});
it('uses columnMap when sorting by an explicit virtual column', function () {
// GIVEN
$request = Request::create('/bo/orders', 'GET', [
'sort_col' => 'date',
'sort_way' => 'asc',
]);
$session = mock(Session::class);
$session->shouldReceive('get')->andReturn(null);
$session->shouldReceive('put')->twice();
Schema::shouldReceive('getColumnListing')
->once()
->with('orders')
->andReturn(['id', 'reference', 'status', 'total']);
$query = mock(Builder::class);
$query->shouldReceive('orderBy')->once()->with('orders.id', 'asc');
$service = new DataTableService($request, $session);
// WHEN
$service->sortQuery(
Order::class,
$query,
columnMap: ['date' => 'id'],
);
// THEN
// Mockery verifies on teardown that orderBy('orders.id', 'asc') was called once.
});
});