feat(v1.6): inline status change on orders list

Phase 44 complete:
- Clickable status badge opens dropdown with grouped statuses
- AJAX POST changes status without page reload (optimistic update)
- Fixed-position dropdown escapes table overflow:hidden
- updateStatus() returns JSON for AJAX, redirect for standard POST

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 09:40:06 +01:00
parent 3f072c5906
commit f2f1c44324
10 changed files with 652 additions and 47 deletions

View File

@@ -142,6 +142,8 @@ final class OrdersController
],
'stats' => $stats,
'statusPanel' => $statusPanel,
'allStatuses' => $this->buildAllStatusOptions($statusConfig),
'statusColorMap' => $statusColorMap,
'errorMessage' => (string) ($result['error'] ?? ''),
], 'layouts/app');
@@ -241,19 +243,28 @@ final class OrdersController
public function updateStatus(Request $request): Response
{
$isAjax = strtolower($request->header('X-Requested-With')) === 'xmlhttprequest';
$orderId = max(0, (int) $request->input('id', 0));
if ($orderId <= 0) {
return Response::html('Not found', 404);
return $isAjax
? Response::json(['success' => false, 'error' => 'Not found'], 404)
: Response::html('Not found', 404);
}
$csrfToken = (string) $request->input('_token', '');
if (!Csrf::validate($csrfToken)) {
if ($isAjax) {
return Response::json(['success' => false, 'error' => $this->translator->get('auth.errors.csrf_expired')], 403);
}
Flash::set('order.error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/orders/' . $orderId);
}
$newStatus = trim((string) $request->input('new_status', ''));
if ($newStatus === '') {
if ($isAjax) {
return Response::json(['success' => false, 'error' => $this->translator->get('orders.details.status_change.status_required')], 422);
}
Flash::set('order.error', $this->translator->get('orders.details.status_change.status_required'));
return Response::redirect('/orders/' . $orderId);
}
@@ -262,6 +273,23 @@ final class OrdersController
$actorName = is_array($user) ? trim((string) ($user['name'] ?? $user['email'] ?? '')) : null;
$success = $this->orders->updateOrderStatus($orderId, $newStatus, 'user', $actorName !== '' ? $actorName : null);
if ($isAjax) {
if (!$success) {
return Response::json(['success' => false, 'error' => $this->translator->get('orders.details.status_change.failed')], 500);
}
$statusConfig = $this->orders->statusPanelConfig();
$statusLabelMap = $this->statusLabelMap($statusConfig);
$statusColorMap = $this->statusColorMap($statusConfig);
$normalizedCode = strtolower(trim($newStatus));
return Response::json([
'success' => true,
'status_code' => $normalizedCode,
'status_label' => $this->statusLabel($normalizedCode, $statusLabelMap),
'status_color' => $statusColorMap[$normalizedCode] ?? '',
]);
}
if ($success) {
Flash::set('order.success', $this->translator->get('orders.details.status_change.success'));
} else {
@@ -317,7 +345,7 @@ final class OrdersController
. '<span>' . htmlspecialchars($buyerCity, ENT_QUOTES, 'UTF-8') . '</span>'
. '</div>'
. '</div>',
'status_badges' => '<div class="orders-status-wrap">'
'status_badges' => '<div class="orders-status-wrap" data-order-id="' . (int) ($row['id'] ?? 0) . '" data-current-status="' . htmlspecialchars($status, ENT_QUOTES, 'UTF-8') . '">'
. $this->statusBadge($status, $this->statusLabel($status, $statusLabelMap), $statusColorMap[strtolower(trim($status))] ?? '')
. '</div>',
'products' => $this->productsHtml($itemsPreview, $itemsCount, $itemsQty),