Merge pull request 'Refactor admin fulfillment into focused modules.' (#8) from refactor/admin-fulfillment into master
Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/8
This commit is contained in:
commit
fa7e48fe02
26 changed files with 1285 additions and 933 deletions
|
|
@ -9,11 +9,13 @@ import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import se.bilhalsning.dto.AdminOrderMapper;
|
||||||
import se.bilhalsning.dto.AdminOrderResponse;
|
import se.bilhalsning.dto.AdminOrderResponse;
|
||||||
import se.bilhalsning.dto.RegisterShipmentRequest;
|
import se.bilhalsning.dto.RegisterShipmentRequest;
|
||||||
import se.bilhalsning.dto.UpdateAdminNotesRequest;
|
import se.bilhalsning.dto.UpdateAdminNotesRequest;
|
||||||
import se.bilhalsning.dto.UpdateStatusRequest;
|
import se.bilhalsning.dto.UpdateStatusRequest;
|
||||||
import se.bilhalsning.entity.Order;
|
import se.bilhalsning.entity.Order;
|
||||||
|
import se.bilhalsning.service.AdminOrderWorkflowService;
|
||||||
import se.bilhalsning.service.OrderService;
|
import se.bilhalsning.service.OrderService;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
@ -25,11 +27,12 @@ import java.util.UUID;
|
||||||
public class AdminController {
|
public class AdminController {
|
||||||
|
|
||||||
private final OrderService orderService;
|
private final OrderService orderService;
|
||||||
|
private final AdminOrderWorkflowService adminOrderWorkflowService;
|
||||||
|
|
||||||
@GetMapping("/orders")
|
@GetMapping("/orders")
|
||||||
public ResponseEntity<List<AdminOrderResponse>> listAllOrders() {
|
public ResponseEntity<List<AdminOrderResponse>> listAllOrders() {
|
||||||
List<AdminOrderResponse> orders = orderService.getAllOrders().stream()
|
List<AdminOrderResponse> orders = orderService.getAllOrders().stream()
|
||||||
.map(this::toAdminResponse)
|
.map(AdminOrderMapper::toResponse)
|
||||||
.toList();
|
.toList();
|
||||||
return ResponseEntity.ok(orders);
|
return ResponseEntity.ok(orders);
|
||||||
}
|
}
|
||||||
|
|
@ -38,42 +41,26 @@ public class AdminController {
|
||||||
public ResponseEntity<AdminOrderResponse> updateStatus(
|
public ResponseEntity<AdminOrderResponse> updateStatus(
|
||||||
@PathVariable UUID id,
|
@PathVariable UUID id,
|
||||||
@Valid @RequestBody UpdateStatusRequest request) {
|
@Valid @RequestBody UpdateStatusRequest request) {
|
||||||
Order order = orderService.updateOrderStatus(id, request.status());
|
Order order = adminOrderWorkflowService.updateOrderStatus(id, request.status());
|
||||||
return ResponseEntity.ok(toAdminResponse(order));
|
return ResponseEntity.ok(AdminOrderMapper.toResponse(order));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PatchMapping("/orders/{id}/register-shipment")
|
@PatchMapping("/orders/{id}/register-shipment")
|
||||||
public ResponseEntity<AdminOrderResponse> registerShipment(
|
public ResponseEntity<AdminOrderResponse> registerShipment(
|
||||||
@PathVariable UUID id,
|
@PathVariable UUID id,
|
||||||
@Valid @RequestBody RegisterShipmentRequest request) {
|
@Valid @RequestBody RegisterShipmentRequest request) {
|
||||||
Order order = orderService.registerShipment(
|
Order order = adminOrderWorkflowService.registerShipment(
|
||||||
id,
|
id,
|
||||||
request.trackingInput(),
|
request.trackingInput(),
|
||||||
request.notifyCustomerOrDefault());
|
request.notifyCustomerOrDefault());
|
||||||
return ResponseEntity.ok(toAdminResponse(order));
|
return ResponseEntity.ok(AdminOrderMapper.toResponse(order));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PatchMapping("/orders/{id}/notes")
|
@PatchMapping("/orders/{id}/notes")
|
||||||
public ResponseEntity<AdminOrderResponse> updateNotes(
|
public ResponseEntity<AdminOrderResponse> updateNotes(
|
||||||
@PathVariable UUID id,
|
@PathVariable UUID id,
|
||||||
@RequestBody UpdateAdminNotesRequest request) {
|
@RequestBody UpdateAdminNotesRequest request) {
|
||||||
Order order = orderService.updateAdminNotes(id, request.adminNotes());
|
Order order = adminOrderWorkflowService.updateAdminNotes(id, request.adminNotes());
|
||||||
return ResponseEntity.ok(toAdminResponse(order));
|
return ResponseEntity.ok(AdminOrderMapper.toResponse(order));
|
||||||
}
|
|
||||||
|
|
||||||
private AdminOrderResponse toAdminResponse(Order order) {
|
|
||||||
String email = order.getUser() != null ? order.getUser().getEmail() : "";
|
|
||||||
return new AdminOrderResponse(
|
|
||||||
order.getId(),
|
|
||||||
email,
|
|
||||||
order.getPlate(),
|
|
||||||
order.getLetterText(),
|
|
||||||
order.getStatus().getValue(),
|
|
||||||
order.getTrackingId(),
|
|
||||||
order.getAmountPaid(),
|
|
||||||
order.getShippedAt(),
|
|
||||||
order.getAdminNotes(),
|
|
||||||
order.getCreatedAt()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package se.bilhalsning.dto;
|
||||||
|
|
||||||
|
import se.bilhalsning.entity.Order;
|
||||||
|
import se.bilhalsning.service.AdminOrderStatusRules;
|
||||||
|
|
||||||
|
public final class AdminOrderMapper {
|
||||||
|
|
||||||
|
private AdminOrderMapper() {}
|
||||||
|
|
||||||
|
public static AdminOrderResponse toResponse(Order order) {
|
||||||
|
String email = order.getUser() != null ? order.getUser().getEmail() : "";
|
||||||
|
return new AdminOrderResponse(
|
||||||
|
order.getId(),
|
||||||
|
email,
|
||||||
|
order.getPlate(),
|
||||||
|
order.getLetterText(),
|
||||||
|
order.getStatus().getValue(),
|
||||||
|
order.getTrackingId(),
|
||||||
|
order.getAmountPaid(),
|
||||||
|
order.getShippedAt(),
|
||||||
|
order.getAdminNotes(),
|
||||||
|
order.getCreatedAt(),
|
||||||
|
AdminOrderStatusRules.allowedStatusValues(order),
|
||||||
|
AdminOrderStatusRules.canRegisterShipment(order));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ package se.bilhalsning.dto;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public record AdminOrderResponse(
|
public record AdminOrderResponse(
|
||||||
|
|
@ -14,5 +15,7 @@ public record AdminOrderResponse(
|
||||||
BigDecimal amountPaid,
|
BigDecimal amountPaid,
|
||||||
Instant shippedAt,
|
Instant shippedAt,
|
||||||
String adminNotes,
|
String adminNotes,
|
||||||
Instant createdAt
|
Instant createdAt,
|
||||||
|
List<String> allowedStatuses,
|
||||||
|
boolean canRegisterShipment
|
||||||
) {}
|
) {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
package se.bilhalsning.service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import se.bilhalsning.entity.Order;
|
||||||
|
import se.bilhalsning.entity.OrderStatus;
|
||||||
|
import se.bilhalsning.exception.InvalidOrderStateException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin status transitions and UI affordances. Single source of truth for
|
||||||
|
* {@link AdminOrderResponse#allowedStatuses()} and {@link AdminOrderResponse#canRegisterShipment()}.
|
||||||
|
*/
|
||||||
|
public final class AdminOrderStatusRules {
|
||||||
|
|
||||||
|
private AdminOrderStatusRules() {}
|
||||||
|
|
||||||
|
public static List<String> allowedStatusValues(Order order) {
|
||||||
|
OrderStatus current = order.getStatus();
|
||||||
|
LinkedHashSet<OrderStatus> options = new LinkedHashSet<>();
|
||||||
|
options.add(current);
|
||||||
|
for (OrderStatus target : allowedTargets(current, order)) {
|
||||||
|
options.add(target);
|
||||||
|
}
|
||||||
|
List<String> values = new ArrayList<>();
|
||||||
|
for (OrderStatus status : options) {
|
||||||
|
values.add(status.getValue());
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean canRegisterShipment(Order order) {
|
||||||
|
OrderStatus status = order.getStatus();
|
||||||
|
if (status == OrderStatus.PROCESSING
|
||||||
|
|| status == OrderStatus.SENT
|
||||||
|
|| status == OrderStatus.DELIVERED) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return status == OrderStatus.FAILED && order.getAmountPaid() != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void validateTransition(Order order, OrderStatus to) {
|
||||||
|
OrderStatus from = order.getStatus();
|
||||||
|
if (from == to) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!allowedTargets(from, order).contains(to)) {
|
||||||
|
throw new InvalidOrderStateException(
|
||||||
|
"Status kan inte ändras från " + from.getValue() + " till " + to.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<OrderStatus> allowedTargets(OrderStatus from, Order order) {
|
||||||
|
return switch (from) {
|
||||||
|
case PENDING_PAYMENT -> List.of(OrderStatus.FAILED);
|
||||||
|
case PROCESSING -> List.of(OrderStatus.FAILED);
|
||||||
|
case SENT -> List.of(OrderStatus.DELIVERED, OrderStatus.FAILED);
|
||||||
|
case DELIVERED -> List.of(OrderStatus.FAILED);
|
||||||
|
case FAILED -> allowedTargetsFromFailed(order);
|
||||||
|
default -> List.of();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<OrderStatus> allowedTargetsFromFailed(Order order) {
|
||||||
|
if (hasTrackingId(order)) {
|
||||||
|
return List.of(
|
||||||
|
OrderStatus.PROCESSING,
|
||||||
|
OrderStatus.SENT,
|
||||||
|
OrderStatus.DELIVERED);
|
||||||
|
}
|
||||||
|
if (order.getAmountPaid() == null) {
|
||||||
|
return List.of(OrderStatus.PENDING_PAYMENT);
|
||||||
|
}
|
||||||
|
return List.of(OrderStatus.PROCESSING, OrderStatus.SENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasTrackingId(Order order) {
|
||||||
|
String trackingId = order.getTrackingId();
|
||||||
|
return trackingId != null && !trackingId.isBlank();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
package se.bilhalsning.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import se.bilhalsning.entity.Order;
|
||||||
|
import se.bilhalsning.entity.OrderStatus;
|
||||||
|
import se.bilhalsning.exception.InvalidOrderStateException;
|
||||||
|
import se.bilhalsning.exception.OrderNotFoundException;
|
||||||
|
import se.bilhalsning.repository.OrderRepository;
|
||||||
|
import se.bilhalsning.util.PostNordTrackingNormalizer;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AdminOrderWorkflowService {
|
||||||
|
|
||||||
|
private final OrderRepository orderRepository;
|
||||||
|
private final OrderNotificationService orderNotificationService;
|
||||||
|
|
||||||
|
public Order updateOrderStatus(UUID orderId, String statusString) {
|
||||||
|
Order order = requireOrder(orderId);
|
||||||
|
OrderStatus newStatus = parseStatus(statusString);
|
||||||
|
OrderStatus previousStatus = order.getStatus();
|
||||||
|
AdminOrderStatusRules.validateTransition(order, newStatus);
|
||||||
|
order.setStatus(newStatus);
|
||||||
|
if (newStatus == OrderStatus.SENT
|
||||||
|
&& previousStatus == OrderStatus.FAILED
|
||||||
|
&& order.getShippedAt() == null) {
|
||||||
|
order.setShippedAt(Instant.now());
|
||||||
|
}
|
||||||
|
Order saved = orderRepository.save(order);
|
||||||
|
if (newStatus == OrderStatus.FAILED && previousStatus != OrderStatus.FAILED) {
|
||||||
|
orderNotificationService.notifyOrderFailed(saved);
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Order registerShipment(UUID orderId, String rawTrackingInput, boolean notifyCustomer) {
|
||||||
|
String trackingId = PostNordTrackingNormalizer.normalize(rawTrackingInput);
|
||||||
|
Order order = requireOrder(orderId);
|
||||||
|
OrderStatus previousStatus = order.getStatus();
|
||||||
|
|
||||||
|
if (!AdminOrderStatusRules.canRegisterShipment(order)) {
|
||||||
|
throw new InvalidOrderStateException(
|
||||||
|
"Beställningen kan inte registreras som utskickad i detta tillstånd");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousStatus == OrderStatus.FAILED && order.getAmountPaid() == null) {
|
||||||
|
throw new InvalidOrderStateException(
|
||||||
|
"Obetalda misslyckade beställningar kan inte registreras som utskickade");
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean firstShipment = previousStatus == OrderStatus.PROCESSING
|
||||||
|
|| previousStatus == OrderStatus.FAILED;
|
||||||
|
order.setTrackingId(trackingId);
|
||||||
|
if (firstShipment) {
|
||||||
|
order.setStatus(OrderStatus.SENT);
|
||||||
|
order.setShippedAt(Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
Order saved = orderRepository.save(order);
|
||||||
|
if (notifyCustomer && firstShipment) {
|
||||||
|
orderNotificationService.notifyOrderSent(saved, trackingId);
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Order updateAdminNotes(UUID orderId, String adminNotes) {
|
||||||
|
Order order = requireOrder(orderId);
|
||||||
|
order.setAdminNotes(adminNotes);
|
||||||
|
return orderRepository.save(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Order requireOrder(UUID orderId) {
|
||||||
|
return orderRepository.findWithUserById(orderId)
|
||||||
|
.orElseThrow(() -> new OrderNotFoundException(orderId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OrderStatus parseStatus(String statusString) {
|
||||||
|
try {
|
||||||
|
return OrderStatus.valueOf(statusString.toUpperCase());
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
throw new IllegalArgumentException("Ogiltig status");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,11 +7,8 @@ import se.bilhalsning.entity.OrderStatus;
|
||||||
import se.bilhalsning.exception.InvalidOrderStateException;
|
import se.bilhalsning.exception.InvalidOrderStateException;
|
||||||
import se.bilhalsning.exception.OrderNotFoundException;
|
import se.bilhalsning.exception.OrderNotFoundException;
|
||||||
import se.bilhalsning.repository.OrderRepository;
|
import se.bilhalsning.repository.OrderRepository;
|
||||||
import se.bilhalsning.util.PostNordTrackingNormalizer;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
|
|
@ -43,63 +40,6 @@ public class OrderService {
|
||||||
return orderRepository.findAllByOrderByCreatedAtDesc();
|
return orderRepository.findAllByOrderByCreatedAtDesc();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Order updateOrderStatus(UUID orderId, String statusString) {
|
|
||||||
Order order = requireOrder(orderId);
|
|
||||||
OrderStatus newStatus = parseStatus(statusString);
|
|
||||||
OrderStatus previousStatus = order.getStatus();
|
|
||||||
validateAdminStatusTransition(order, newStatus);
|
|
||||||
order.setStatus(newStatus);
|
|
||||||
if (newStatus == OrderStatus.SENT
|
|
||||||
&& previousStatus == OrderStatus.FAILED
|
|
||||||
&& order.getShippedAt() == null) {
|
|
||||||
order.setShippedAt(Instant.now());
|
|
||||||
}
|
|
||||||
Order saved = orderRepository.save(order);
|
|
||||||
if (newStatus == OrderStatus.FAILED && previousStatus != OrderStatus.FAILED) {
|
|
||||||
orderNotificationService.notifyOrderFailed(saved);
|
|
||||||
}
|
|
||||||
return saved;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Order registerShipment(UUID orderId, String rawTrackingInput, boolean notifyCustomer) {
|
|
||||||
String trackingId = PostNordTrackingNormalizer.normalize(rawTrackingInput);
|
|
||||||
Order order = requireOrder(orderId);
|
|
||||||
OrderStatus previousStatus = order.getStatus();
|
|
||||||
|
|
||||||
if (previousStatus == OrderStatus.FAILED && order.getAmountPaid() == null) {
|
|
||||||
throw new InvalidOrderStateException(
|
|
||||||
"Obetalda misslyckade beställningar kan inte registreras som utskickade");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (previousStatus != OrderStatus.PROCESSING
|
|
||||||
&& previousStatus != OrderStatus.SENT
|
|
||||||
&& previousStatus != OrderStatus.DELIVERED
|
|
||||||
&& previousStatus != OrderStatus.FAILED) {
|
|
||||||
throw new InvalidOrderStateException(
|
|
||||||
"Beställningen kan inte registreras som utskickad i detta tillstånd");
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean firstShipment = previousStatus == OrderStatus.PROCESSING
|
|
||||||
|| previousStatus == OrderStatus.FAILED;
|
|
||||||
order.setTrackingId(trackingId);
|
|
||||||
if (firstShipment) {
|
|
||||||
order.setStatus(OrderStatus.SENT);
|
|
||||||
order.setShippedAt(Instant.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
Order saved = orderRepository.save(order);
|
|
||||||
if (notifyCustomer && firstShipment) {
|
|
||||||
orderNotificationService.notifyOrderSent(saved, trackingId);
|
|
||||||
}
|
|
||||||
return saved;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Order updateAdminNotes(UUID orderId, String adminNotes) {
|
|
||||||
Order order = requireOrder(orderId);
|
|
||||||
order.setAdminNotes(adminNotes);
|
|
||||||
return orderRepository.save(order);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Order confirmPayment(UUID orderId, UUID userId) {
|
public Order confirmPayment(UUID orderId, UUID userId) {
|
||||||
Order order = requirePendingOwnedBy(orderId, userId);
|
Order order = requirePendingOwnedBy(orderId, userId);
|
||||||
order.setStatus(OrderStatus.PROCESSING);
|
order.setStatus(OrderStatus.PROCESSING);
|
||||||
|
|
@ -120,11 +60,6 @@ public class OrderService {
|
||||||
return orderRepository.save(order);
|
return orderRepository.save(order);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Order requireOrder(UUID orderId) {
|
|
||||||
return orderRepository.findWithUserById(orderId)
|
|
||||||
.orElseThrow(() -> new OrderNotFoundException(orderId));
|
|
||||||
}
|
|
||||||
|
|
||||||
private Order requirePendingOwnedBy(UUID orderId, UUID userId) {
|
private Order requirePendingOwnedBy(UUID orderId, UUID userId) {
|
||||||
Order order = orderRepository.findById(orderId)
|
Order order = orderRepository.findById(orderId)
|
||||||
.orElseThrow(() -> new OrderNotFoundException(orderId));
|
.orElseThrow(() -> new OrderNotFoundException(orderId));
|
||||||
|
|
@ -140,53 +75,4 @@ public class OrderService {
|
||||||
|
|
||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static OrderStatus parseStatus(String statusString) {
|
|
||||||
try {
|
|
||||||
return OrderStatus.valueOf(statusString.toUpperCase());
|
|
||||||
} catch (IllegalArgumentException ex) {
|
|
||||||
throw new IllegalArgumentException("Ogiltig status");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void validateAdminStatusTransition(Order order, OrderStatus to) {
|
|
||||||
OrderStatus from = order.getStatus();
|
|
||||||
if (from == to) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Set<OrderStatus> allowed = switch (from) {
|
|
||||||
case PENDING_PAYMENT -> Set.of(OrderStatus.FAILED);
|
|
||||||
case PROCESSING -> Set.of(OrderStatus.FAILED);
|
|
||||||
case SENT -> Set.of(OrderStatus.DELIVERED, OrderStatus.FAILED);
|
|
||||||
case DELIVERED -> Set.of(OrderStatus.FAILED);
|
|
||||||
case FAILED -> allowedTargetsFromFailed(order);
|
|
||||||
default -> Set.of();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!allowed.contains(to)) {
|
|
||||||
throw new InvalidOrderStateException(
|
|
||||||
"Status kan inte ändras från " + from.getValue() + " till " + to.getValue());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Set<OrderStatus> allowedTargetsFromFailed(Order order) {
|
|
||||||
Set<OrderStatus> allowed = new java.util.HashSet<>();
|
|
||||||
if (hasTrackingId(order)) {
|
|
||||||
allowed.add(OrderStatus.PROCESSING);
|
|
||||||
allowed.add(OrderStatus.SENT);
|
|
||||||
allowed.add(OrderStatus.DELIVERED);
|
|
||||||
} else if (order.getAmountPaid() == null) {
|
|
||||||
allowed.add(OrderStatus.PENDING_PAYMENT);
|
|
||||||
} else {
|
|
||||||
allowed.add(OrderStatus.PROCESSING);
|
|
||||||
allowed.add(OrderStatus.SENT);
|
|
||||||
}
|
|
||||||
return allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean hasTrackingId(Order order) {
|
|
||||||
String trackingId = order.getTrackingId();
|
|
||||||
return trackingId != null && !trackingId.isBlank();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import se.bilhalsning.entity.OrderStatus;
|
||||||
import se.bilhalsning.entity.User;
|
import se.bilhalsning.entity.User;
|
||||||
import se.bilhalsning.exception.InvalidOrderStateException;
|
import se.bilhalsning.exception.InvalidOrderStateException;
|
||||||
import se.bilhalsning.exception.OrderNotFoundException;
|
import se.bilhalsning.exception.OrderNotFoundException;
|
||||||
|
import se.bilhalsning.service.AdminOrderWorkflowService;
|
||||||
import se.bilhalsning.service.OrderService;
|
import se.bilhalsning.service.OrderService;
|
||||||
|
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
|
|
@ -37,6 +38,9 @@ class AdminControllerTest {
|
||||||
@MockitoBean
|
@MockitoBean
|
||||||
private OrderService orderService;
|
private OrderService orderService;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
private AdminOrderWorkflowService adminOrderWorkflowService;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldReturn403WhenNotAuthenticated() throws Exception {
|
void shouldReturn403WhenNotAuthenticated() throws Exception {
|
||||||
mockMvc.perform(get("/api/admin/orders"))
|
mockMvc.perform(get("/api/admin/orders"))
|
||||||
|
|
@ -62,7 +66,9 @@ class AdminControllerTest {
|
||||||
.andExpect(jsonPath("$[0].id").value(order.getId().toString()))
|
.andExpect(jsonPath("$[0].id").value(order.getId().toString()))
|
||||||
.andExpect(jsonPath("$[0].email").value("test@bilhej.se"))
|
.andExpect(jsonPath("$[0].email").value("test@bilhej.se"))
|
||||||
.andExpect(jsonPath("$[0].plate").value("ABC123"))
|
.andExpect(jsonPath("$[0].plate").value("ABC123"))
|
||||||
.andExpect(jsonPath("$[0].status").value("sent"));
|
.andExpect(jsonPath("$[0].status").value("sent"))
|
||||||
|
.andExpect(jsonPath("$[0].allowedStatuses").isArray())
|
||||||
|
.andExpect(jsonPath("$[0].canRegisterShipment").value(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -71,7 +77,8 @@ class AdminControllerTest {
|
||||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||||
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.FAILED);
|
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.FAILED);
|
||||||
|
|
||||||
when(orderService.updateOrderStatus(eq(orderId), eq("failed"))).thenReturn(order);
|
when(adminOrderWorkflowService.updateOrderStatus(eq(orderId), eq("failed")))
|
||||||
|
.thenReturn(order);
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/admin/orders/{id}/status", orderId)
|
mockMvc.perform(patch("/api/admin/orders/{id}/status", orderId)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
|
@ -84,7 +91,7 @@ class AdminControllerTest {
|
||||||
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
||||||
void shouldReturn409WhenStatusTransitionInvalid() throws Exception {
|
void shouldReturn409WhenStatusTransitionInvalid() throws Exception {
|
||||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||||
when(orderService.updateOrderStatus(eq(orderId), eq("delivered")))
|
when(adminOrderWorkflowService.updateOrderStatus(eq(orderId), eq("delivered")))
|
||||||
.thenThrow(new InvalidOrderStateException("Ogiltig övergång"));
|
.thenThrow(new InvalidOrderStateException("Ogiltig övergång"));
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/admin/orders/{id}/status", orderId)
|
mockMvc.perform(patch("/api/admin/orders/{id}/status", orderId)
|
||||||
|
|
@ -101,7 +108,7 @@ class AdminControllerTest {
|
||||||
order.setTrackingId("PN123456789");
|
order.setTrackingId("PN123456789");
|
||||||
order.setShippedAt(Instant.parse("2026-05-13T12:00:00Z"));
|
order.setShippedAt(Instant.parse("2026-05-13T12:00:00Z"));
|
||||||
|
|
||||||
when(orderService.registerShipment(eq(orderId), eq("PN123456789"), eq(true)))
|
when(adminOrderWorkflowService.registerShipment(eq(orderId), eq("PN123456789"), eq(true)))
|
||||||
.thenReturn(order);
|
.thenReturn(order);
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/admin/orders/{id}/register-shipment", orderId)
|
mockMvc.perform(patch("/api/admin/orders/{id}/register-shipment", orderId)
|
||||||
|
|
@ -130,7 +137,7 @@ class AdminControllerTest {
|
||||||
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.PROCESSING);
|
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.PROCESSING);
|
||||||
order.setAdminNotes("Kontaktat TS");
|
order.setAdminNotes("Kontaktat TS");
|
||||||
|
|
||||||
when(orderService.updateAdminNotes(orderId, "Kontaktat TS")).thenReturn(order);
|
when(adminOrderWorkflowService.updateAdminNotes(orderId, "Kontaktat TS")).thenReturn(order);
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/admin/orders/{id}/notes", orderId)
|
mockMvc.perform(patch("/api/admin/orders/{id}/notes", orderId)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
|
@ -143,7 +150,7 @@ class AdminControllerTest {
|
||||||
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
|
||||||
void shouldReturn404WhenOrderNotFoundForRegisterShipment() throws Exception {
|
void shouldReturn404WhenOrderNotFoundForRegisterShipment() throws Exception {
|
||||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||||
when(orderService.registerShipment(eq(orderId), eq("PN123"), anyBoolean()))
|
when(adminOrderWorkflowService.registerShipment(eq(orderId), eq("PN123"), anyBoolean()))
|
||||||
.thenThrow(new OrderNotFoundException(orderId));
|
.thenThrow(new OrderNotFoundException(orderId));
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/admin/orders/{id}/register-shipment", orderId)
|
mockMvc.perform(patch("/api/admin/orders/{id}/register-shipment", orderId)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
package se.bilhalsning.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import se.bilhalsning.entity.Order;
|
||||||
|
import se.bilhalsning.entity.OrderStatus;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class AdminOrderStatusRulesTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldIncludeCurrentAndTargetsForSentOrder() {
|
||||||
|
Order order = orderWithStatus(OrderStatus.SENT);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
java.util.List.of("sent", "delivered", "failed"),
|
||||||
|
AdminOrderStatusRules.allowedStatusValues(order));
|
||||||
|
assertTrue(AdminOrderStatusRules.canRegisterShipment(order));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldAllowOnlyFailedFromPendingPayment() {
|
||||||
|
Order order = orderWithStatus(OrderStatus.PENDING_PAYMENT);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
java.util.List.of("pending_payment", "failed"),
|
||||||
|
AdminOrderStatusRules.allowedStatusValues(order));
|
||||||
|
assertFalse(AdminOrderStatusRules.canRegisterShipment(order));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldExposeFailedRecoveryOptionsWhenTrackingExists() {
|
||||||
|
Order order = orderWithStatus(OrderStatus.FAILED);
|
||||||
|
order.setTrackingId("PN123");
|
||||||
|
order.setAmountPaid(new BigDecimal("49.00"));
|
||||||
|
|
||||||
|
assertTrue(AdminOrderStatusRules.allowedStatusValues(order).contains("sent"));
|
||||||
|
assertTrue(AdminOrderStatusRules.canRegisterShipment(order));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Order orderWithStatus(OrderStatus status) {
|
||||||
|
Order order = new Order();
|
||||||
|
order.setStatus(status);
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
package se.bilhalsning.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import se.bilhalsning.entity.Order;
|
||||||
|
import se.bilhalsning.entity.OrderStatus;
|
||||||
|
import se.bilhalsning.exception.InvalidOrderStateException;
|
||||||
|
import se.bilhalsning.repository.OrderRepository;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class AdminOrderWorkflowServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private OrderRepository orderRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private OrderNotificationService orderNotificationService;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private AdminOrderWorkflowService adminOrderWorkflowService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRegisterShipmentFromProcessingAndSetSent() {
|
||||||
|
UUID orderId = UUID.randomUUID();
|
||||||
|
Order order = new Order();
|
||||||
|
order.setId(orderId);
|
||||||
|
order.setStatus(OrderStatus.PROCESSING);
|
||||||
|
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||||
|
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
Order result = adminOrderWorkflowService.registerShipment(orderId, "PN123456789", true);
|
||||||
|
|
||||||
|
assertEquals(OrderStatus.SENT, result.getStatus());
|
||||||
|
assertEquals("PN123456789", result.getTrackingId());
|
||||||
|
assertNotNull(result.getShippedAt());
|
||||||
|
verify(orderNotificationService).notifyOrderSent(result, "PN123456789");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectRegisterShipmentWhenPendingPayment() {
|
||||||
|
UUID orderId = UUID.randomUUID();
|
||||||
|
Order order = new Order();
|
||||||
|
order.setId(orderId);
|
||||||
|
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
||||||
|
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||||
|
|
||||||
|
assertThrows(InvalidOrderStateException.class,
|
||||||
|
() -> adminOrderWorkflowService.registerShipment(orderId, "PN123", true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectInvalidAdminStatusTransition() {
|
||||||
|
UUID orderId = UUID.randomUUID();
|
||||||
|
Order order = new Order();
|
||||||
|
order.setId(orderId);
|
||||||
|
order.setStatus(OrderStatus.PROCESSING);
|
||||||
|
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||||
|
|
||||||
|
assertThrows(InvalidOrderStateException.class,
|
||||||
|
() -> adminOrderWorkflowService.updateOrderStatus(orderId, "delivered"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldMarkPendingPaymentAsFailed() {
|
||||||
|
UUID orderId = UUID.randomUUID();
|
||||||
|
Order order = new Order();
|
||||||
|
order.setId(orderId);
|
||||||
|
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
||||||
|
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||||
|
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
Order result = adminOrderWorkflowService.updateOrderStatus(orderId, "failed");
|
||||||
|
|
||||||
|
assertEquals(OrderStatus.FAILED, result.getStatus());
|
||||||
|
verify(orderNotificationService).notifyOrderFailed(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRevertFailedToPendingPaymentWhenUnpaid() {
|
||||||
|
UUID orderId = UUID.randomUUID();
|
||||||
|
Order order = new Order();
|
||||||
|
order.setId(orderId);
|
||||||
|
order.setStatus(OrderStatus.FAILED);
|
||||||
|
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||||
|
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
Order result = adminOrderWorkflowService.updateOrderStatus(orderId, "pending_payment");
|
||||||
|
|
||||||
|
assertEquals(OrderStatus.PENDING_PAYMENT, result.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRevertFailedToProcessingWhenPaidWithoutTracking() {
|
||||||
|
UUID orderId = UUID.randomUUID();
|
||||||
|
Order order = new Order();
|
||||||
|
order.setId(orderId);
|
||||||
|
order.setStatus(OrderStatus.FAILED);
|
||||||
|
order.setAmountPaid(new BigDecimal("49.00"));
|
||||||
|
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||||
|
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
Order result = adminOrderWorkflowService.updateOrderStatus(orderId, "processing");
|
||||||
|
|
||||||
|
assertEquals(OrderStatus.PROCESSING, result.getStatus());
|
||||||
|
verify(orderNotificationService, never()).notifyOrderFailed(any(Order.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRevertFailedToSentWhenPaidWithoutTracking() {
|
||||||
|
UUID orderId = UUID.randomUUID();
|
||||||
|
Order order = new Order();
|
||||||
|
order.setId(orderId);
|
||||||
|
order.setStatus(OrderStatus.FAILED);
|
||||||
|
order.setAmountPaid(new BigDecimal("49.00"));
|
||||||
|
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||||
|
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
Order result = adminOrderWorkflowService.updateOrderStatus(orderId, "sent");
|
||||||
|
|
||||||
|
assertEquals(OrderStatus.SENT, result.getStatus());
|
||||||
|
assertNotNull(result.getShippedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRegisterShipmentFromFailedWhenPaid() {
|
||||||
|
UUID orderId = UUID.randomUUID();
|
||||||
|
Order order = new Order();
|
||||||
|
order.setId(orderId);
|
||||||
|
order.setStatus(OrderStatus.FAILED);
|
||||||
|
order.setAmountPaid(new BigDecimal("49.00"));
|
||||||
|
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||||
|
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
Order result = adminOrderWorkflowService.registerShipment(orderId, "PN888", true);
|
||||||
|
|
||||||
|
assertEquals(OrderStatus.SENT, result.getStatus());
|
||||||
|
assertEquals("PN888", result.getTrackingId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRevertFailedToSentWhenTrackingExists() {
|
||||||
|
UUID orderId = UUID.randomUUID();
|
||||||
|
Order order = new Order();
|
||||||
|
order.setId(orderId);
|
||||||
|
order.setStatus(OrderStatus.FAILED);
|
||||||
|
order.setTrackingId("PN123");
|
||||||
|
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||||
|
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
Order result = adminOrderWorkflowService.updateOrderStatus(orderId, "sent");
|
||||||
|
|
||||||
|
assertEquals(OrderStatus.SENT, result.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldNotifyCustomerOnFailedStatusFromProcessing() {
|
||||||
|
UUID orderId = UUID.randomUUID();
|
||||||
|
Order order = new Order();
|
||||||
|
order.setId(orderId);
|
||||||
|
order.setStatus(OrderStatus.PROCESSING);
|
||||||
|
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
||||||
|
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
adminOrderWorkflowService.updateOrderStatus(orderId, "failed");
|
||||||
|
|
||||||
|
verify(orderNotificationService).notifyOrderFailed(any(Order.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -253,150 +253,4 @@ class OrderServiceTest {
|
||||||
() -> orderService.confirmPayment(orderId, otherUserId));
|
() -> orderService.confirmPayment(orderId, otherUserId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldRegisterShipmentFromProcessingAndSetSent() {
|
|
||||||
UUID orderId = UUID.randomUUID();
|
|
||||||
Order order = new Order();
|
|
||||||
order.setId(orderId);
|
|
||||||
order.setStatus(OrderStatus.PROCESSING);
|
|
||||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
|
||||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
Order result = orderService.registerShipment(orderId, "PN123456789", true);
|
|
||||||
|
|
||||||
assertEquals(OrderStatus.SENT, result.getStatus());
|
|
||||||
assertEquals("PN123456789", result.getTrackingId());
|
|
||||||
assertNotNull(result.getShippedAt());
|
|
||||||
verify(orderNotificationService).notifyOrderSent(result, "PN123456789");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldRejectRegisterShipmentWhenPendingPayment() {
|
|
||||||
UUID orderId = UUID.randomUUID();
|
|
||||||
Order order = new Order();
|
|
||||||
order.setId(orderId);
|
|
||||||
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
|
||||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
|
||||||
|
|
||||||
assertThrows(InvalidOrderStateException.class,
|
|
||||||
() -> orderService.registerShipment(orderId, "PN123", true));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldRejectInvalidAdminStatusTransition() {
|
|
||||||
UUID orderId = UUID.randomUUID();
|
|
||||||
Order order = new Order();
|
|
||||||
order.setId(orderId);
|
|
||||||
order.setStatus(OrderStatus.PROCESSING);
|
|
||||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
|
||||||
|
|
||||||
assertThrows(InvalidOrderStateException.class,
|
|
||||||
() -> orderService.updateOrderStatus(orderId, "delivered"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldMarkPendingPaymentAsFailed() {
|
|
||||||
UUID orderId = UUID.randomUUID();
|
|
||||||
Order order = new Order();
|
|
||||||
order.setId(orderId);
|
|
||||||
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
|
||||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
|
||||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
Order result = orderService.updateOrderStatus(orderId, "failed");
|
|
||||||
|
|
||||||
assertEquals(OrderStatus.FAILED, result.getStatus());
|
|
||||||
verify(orderNotificationService).notifyOrderFailed(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldRevertFailedToPendingPaymentWhenUnpaid() {
|
|
||||||
UUID orderId = UUID.randomUUID();
|
|
||||||
Order order = new Order();
|
|
||||||
order.setId(orderId);
|
|
||||||
order.setStatus(OrderStatus.FAILED);
|
|
||||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
|
||||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
Order result = orderService.updateOrderStatus(orderId, "pending_payment");
|
|
||||||
|
|
||||||
assertEquals(OrderStatus.PENDING_PAYMENT, result.getStatus());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldRevertFailedToProcessingWhenPaidWithoutTracking() {
|
|
||||||
UUID orderId = UUID.randomUUID();
|
|
||||||
Order order = new Order();
|
|
||||||
order.setId(orderId);
|
|
||||||
order.setStatus(OrderStatus.FAILED);
|
|
||||||
order.setAmountPaid(new java.math.BigDecimal("49.00"));
|
|
||||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
|
||||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
Order result = orderService.updateOrderStatus(orderId, "processing");
|
|
||||||
|
|
||||||
assertEquals(OrderStatus.PROCESSING, result.getStatus());
|
|
||||||
verify(orderNotificationService, never()).notifyOrderFailed(any(Order.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldRevertFailedToSentWhenPaidWithoutTracking() {
|
|
||||||
UUID orderId = UUID.randomUUID();
|
|
||||||
Order order = new Order();
|
|
||||||
order.setId(orderId);
|
|
||||||
order.setStatus(OrderStatus.FAILED);
|
|
||||||
order.setAmountPaid(new java.math.BigDecimal("49.00"));
|
|
||||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
|
||||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
Order result = orderService.updateOrderStatus(orderId, "sent");
|
|
||||||
|
|
||||||
assertEquals(OrderStatus.SENT, result.getStatus());
|
|
||||||
assertNotNull(result.getShippedAt());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldRegisterShipmentFromFailedWhenPaid() {
|
|
||||||
UUID orderId = UUID.randomUUID();
|
|
||||||
Order order = new Order();
|
|
||||||
order.setId(orderId);
|
|
||||||
order.setStatus(OrderStatus.FAILED);
|
|
||||||
order.setAmountPaid(new java.math.BigDecimal("49.00"));
|
|
||||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
|
||||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
Order result = orderService.registerShipment(orderId, "PN888", true);
|
|
||||||
|
|
||||||
assertEquals(OrderStatus.SENT, result.getStatus());
|
|
||||||
assertEquals("PN888", result.getTrackingId());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldRevertFailedToSentWhenTrackingExists() {
|
|
||||||
UUID orderId = UUID.randomUUID();
|
|
||||||
Order order = new Order();
|
|
||||||
order.setId(orderId);
|
|
||||||
order.setStatus(OrderStatus.FAILED);
|
|
||||||
order.setTrackingId("PN123");
|
|
||||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
|
||||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
Order result = orderService.updateOrderStatus(orderId, "sent");
|
|
||||||
|
|
||||||
assertEquals(OrderStatus.SENT, result.getStatus());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldNotifyCustomerOnFailedStatusFromProcessing() {
|
|
||||||
UUID orderId = UUID.randomUUID();
|
|
||||||
Order order = new Order();
|
|
||||||
order.setId(orderId);
|
|
||||||
order.setStatus(OrderStatus.PROCESSING);
|
|
||||||
when(orderRepository.findWithUserById(orderId)).thenReturn(Optional.of(order));
|
|
||||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
orderService.updateOrderStatus(orderId, "failed");
|
|
||||||
|
|
||||||
verify(orderNotificationService).notifyOrderFailed(any(Order.class));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { test, expect } from '@playwright/test'
|
import { test, expect } from '@playwright/test'
|
||||||
|
import { loginAsAdmin } from './helpers/admin'
|
||||||
|
|
||||||
const SEEDED_ORDER_ID = 'c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'
|
const SEEDED_ORDER_ID = 'c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'
|
||||||
const SEEDED_ORDER_SHORT_ID = SEEDED_ORDER_ID.slice(0, 8)
|
const SEEDED_ORDER_SHORT_ID = SEEDED_ORDER_ID.slice(0, 8)
|
||||||
|
|
@ -12,11 +13,7 @@ function rowByPlate(page: import('@playwright/test').Page, plate: string) {
|
||||||
|
|
||||||
test.describe('Admin dashboard', () => {
|
test.describe('Admin dashboard', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto('/logga-in')
|
await loginAsAdmin(page)
|
||||||
await page.getByLabel('E-postadress').fill('admin@bilhalsning.se')
|
|
||||||
await page.getByLabel('Lösenord').fill('test1234')
|
|
||||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
|
||||||
await page.waitForURL('/')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('admin can navigate to admin page', async ({ page }) => {
|
test('admin can navigate to admin page', async ({ page }) => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { test, expect } from '@playwright/test'
|
import { test, expect } from '@playwright/test'
|
||||||
|
import { loginAsAdmin, openAdminDashboard } from './helpers/admin'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin order status and shipment flows (serial — mutates seeded orders).
|
* Admin order status and shipment flows (serial — mutates seeded orders).
|
||||||
|
|
@ -10,19 +11,6 @@ const PENDING_ORDER_SHORT_ID = 'c2eebc99'
|
||||||
const PROCESSING_PLATE = 'JKL012'
|
const PROCESSING_PLATE = 'JKL012'
|
||||||
const SENT_ORDER_SHORT_ID = 'c1eebc99'
|
const SENT_ORDER_SHORT_ID = 'c1eebc99'
|
||||||
|
|
||||||
async function loginAsAdmin(page: import('@playwright/test').Page) {
|
|
||||||
await page.goto('/logga-in')
|
|
||||||
await page.getByLabel('E-postadress').fill('admin@bilhalsning.se')
|
|
||||||
await page.getByLabel('Lösenord').fill('test1234')
|
|
||||||
await page.getByRole('button', { name: 'Logga in' }).click()
|
|
||||||
await page.waitForURL('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openAdmin(page: import('@playwright/test').Page) {
|
|
||||||
await page.goto('/admin')
|
|
||||||
await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 })
|
|
||||||
}
|
|
||||||
|
|
||||||
function orderRowByPlate(page: import('@playwright/test').Page, plate: string) {
|
function orderRowByPlate(page: import('@playwright/test').Page, plate: string) {
|
||||||
return page.locator('.admin__row').filter({
|
return page.locator('.admin__row').filter({
|
||||||
has: page.locator('.admin__plate', { hasText: plate }),
|
has: page.locator('.admin__plate', { hasText: plate }),
|
||||||
|
|
@ -39,7 +27,7 @@ function orderRowByShortId(
|
||||||
test.describe('Admin fulfillment flows', () => {
|
test.describe('Admin fulfillment flows', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await loginAsAdmin(page)
|
await loginAsAdmin(page)
|
||||||
await openAdmin(page)
|
await openAdminDashboard(page)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('can mark unpaid order as failed', async ({ page }) => {
|
test('can mark unpaid order as failed', async ({ page }) => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { test, expect } from '@playwright/test'
|
import { test, expect } from '@playwright/test'
|
||||||
|
import { loginAsAdmin, openAdminDashboard } from './helpers/admin'
|
||||||
|
|
||||||
test.describe.configure({ mode: 'serial' })
|
test.describe.configure({ mode: 'serial' })
|
||||||
|
|
||||||
|
|
@ -40,8 +41,7 @@ test.describe('Deferred payment and admin lookup', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openAdminTodoBoard(page: import('@playwright/test').Page) {
|
async function openAdminTodoBoard(page: import('@playwright/test').Page) {
|
||||||
await page.goto('/admin')
|
await openAdminDashboard(page)
|
||||||
await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 })
|
|
||||||
await page.getByRole('button', { name: /Att göra/ }).click()
|
await page.getByRole('button', { name: /Att göra/ }).click()
|
||||||
await expect(page.locator('.admin__stat--active')).toContainText('Att göra')
|
await expect(page.locator('.admin__stat--active')).toContainText('Att göra')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
frontend/e2e/helpers/admin.ts
Normal file
15
frontend/e2e/helpers/admin.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import type { Page } from '@playwright/test'
|
||||||
|
import { expect } from '@playwright/test'
|
||||||
|
|
||||||
|
export async function loginAsAdmin(page: Page) {
|
||||||
|
await page.goto('/logga-in')
|
||||||
|
await page.getByLabel('E-postadress').fill('admin@bilhalsning.se')
|
||||||
|
await page.getByLabel('Lösenord').fill('test1234')
|
||||||
|
await page.getByRole('button', { name: 'Logga in' }).click()
|
||||||
|
await page.waitForURL('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openAdminDashboard(page: Page) {
|
||||||
|
await page.goto('/admin')
|
||||||
|
await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 })
|
||||||
|
}
|
||||||
|
|
@ -46,6 +46,8 @@ const mockOrders = [
|
||||||
shippedAt: '2026-05-13T12:00:00Z',
|
shippedAt: '2026-05-13T12:00:00Z',
|
||||||
adminNotes: null,
|
adminNotes: null,
|
||||||
createdAt: '2026-05-11T12:00:00Z',
|
createdAt: '2026-05-11T12:00:00Z',
|
||||||
|
allowedStatuses: ['sent', 'delivered', 'failed'],
|
||||||
|
canRegisterShipment: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
|
id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
|
||||||
|
|
@ -58,6 +60,8 @@ const mockOrders = [
|
||||||
shippedAt: null,
|
shippedAt: null,
|
||||||
adminNotes: null,
|
adminNotes: null,
|
||||||
createdAt: '2026-05-14T13:00:00Z',
|
createdAt: '2026-05-14T13:00:00Z',
|
||||||
|
allowedStatuses: ['processing', 'failed'],
|
||||||
|
canRegisterShipment: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'c3eebc99-9c0b-4ef8-bb6d-6bb9bd380a13',
|
id: 'c3eebc99-9c0b-4ef8-bb6d-6bb9bd380a13',
|
||||||
|
|
@ -70,6 +74,8 @@ const mockOrders = [
|
||||||
shippedAt: null,
|
shippedAt: null,
|
||||||
adminNotes: null,
|
adminNotes: null,
|
||||||
createdAt: '2026-05-15T14:00:00Z',
|
createdAt: '2026-05-15T14:00:00Z',
|
||||||
|
allowedStatuses: ['pending_payment', 'failed'],
|
||||||
|
canRegisterShipment: false,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ export interface AdminOrder {
|
||||||
shippedAt: string | null
|
shippedAt: string | null
|
||||||
adminNotes: string | null
|
adminNotes: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
allowedStatuses: string[]
|
||||||
|
canRegisterShipment: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchAllOrders(): Promise<AdminOrder[]> {
|
export function fetchAllOrders(): Promise<AdminOrder[]> {
|
||||||
|
|
|
||||||
133
frontend/src/components/admin/AdminOrderDetailPanel.vue
Normal file
133
frontend/src/components/admin/AdminOrderDetailPanel.vue
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { AdminOrder } from '@/api/admin'
|
||||||
|
import { postNordTrackingUrl } from '@/constants/orderStatus'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
order: AdminOrder
|
||||||
|
trackingInput: string
|
||||||
|
adminNotes: string
|
||||||
|
notifyCustomer: boolean
|
||||||
|
trackingError: string
|
||||||
|
notesError: string
|
||||||
|
registering: boolean
|
||||||
|
savingNotes: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:trackingInput': [value: string]
|
||||||
|
'update:adminNotes': [value: string]
|
||||||
|
'update:notifyCustomer': [value: boolean]
|
||||||
|
registerShipment: []
|
||||||
|
saveNotes: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="admin__expanded-inner">
|
||||||
|
<ol v-if="order.status === 'processing'" class="admin__checklist">
|
||||||
|
<li>Hämta ägaradress via Transportstyrelsen</li>
|
||||||
|
<li>Skriv ut brevet och lägg i kuvert</li>
|
||||||
|
<li>Skicka med PostNord och få spårnings-ID</li>
|
||||||
|
<li>Registrera utskick nedan</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div v-if="order.canRegisterShipment" class="admin__section">
|
||||||
|
<div class="admin__section-header">
|
||||||
|
<span class="admin__section-label">Registrera utskick</span>
|
||||||
|
<a
|
||||||
|
v-if="order.trackingId"
|
||||||
|
class="admin__tracking-link"
|
||||||
|
:href="postNordTrackingUrl(order.trackingId)"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
Spåra hos PostNord
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="admin__section-hint">
|
||||||
|
Klistra in spårnings-ID eller PostNord-länk. Vid beställningar som
|
||||||
|
hanteras markeras brevet som skickat och kunden kan få e-post.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="trackingError"
|
||||||
|
class="message message--error admin__tracking-error"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{{ trackingError }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="admin__tracking-row">
|
||||||
|
<label :for="`tracking-${order.id}`" class="visually-hidden"
|
||||||
|
>Spårnings-ID</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:id="`tracking-${order.id}`"
|
||||||
|
class="admin__tracking-input"
|
||||||
|
type="text"
|
||||||
|
:value="trackingInput"
|
||||||
|
placeholder="PN... eller PostNord-länk"
|
||||||
|
@input="
|
||||||
|
emit(
|
||||||
|
'update:trackingInput',
|
||||||
|
($event.target as HTMLInputElement).value,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="btn btn--primary btn--sm"
|
||||||
|
:disabled="registering"
|
||||||
|
@click.stop="emit('registerShipment')"
|
||||||
|
>
|
||||||
|
{{ registering ? 'Registrerar...' : 'Registrera utskick' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label v-if="order.status === 'processing'" class="admin__notify">
|
||||||
|
<input
|
||||||
|
:checked="notifyCustomer"
|
||||||
|
type="checkbox"
|
||||||
|
@change="
|
||||||
|
emit(
|
||||||
|
'update:notifyCustomer',
|
||||||
|
($event.target as HTMLInputElement).checked,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
Skicka e-post till kund
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin__section">
|
||||||
|
<span class="admin__section-label">Interna anteckningar</span>
|
||||||
|
<p v-if="notesError" class="message message--error" role="alert">
|
||||||
|
{{ notesError }}
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
:id="`notes-${order.id}`"
|
||||||
|
class="admin__notes-input"
|
||||||
|
rows="3"
|
||||||
|
placeholder="T.ex. TS-begäran skickad..."
|
||||||
|
:value="adminNotes"
|
||||||
|
@input="
|
||||||
|
emit(
|
||||||
|
'update:adminNotes',
|
||||||
|
($event.target as HTMLTextAreaElement).value,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="btn btn--ghost btn--sm admin__notes-save"
|
||||||
|
:disabled="savingNotes"
|
||||||
|
@click.stop="emit('saveNotes')"
|
||||||
|
>
|
||||||
|
{{ savingNotes ? 'Sparar...' : 'Spara anteckningar' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
55
frontend/src/components/admin/AdminOrderMessageModal.vue
Normal file
55
frontend/src/components/admin/AdminOrderMessageModal.vue
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { AdminOrder } from '@/api/admin'
|
||||||
|
import { shortOrderId } from '@/utils/orderDisplay'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
order: AdminOrder | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="order" class="admin-modal-overlay" @click.self="emit('close')">
|
||||||
|
<div
|
||||||
|
class="admin-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="admin-message-modal-title"
|
||||||
|
>
|
||||||
|
<div class="admin-modal__header">
|
||||||
|
<h2 id="admin-message-modal-title" class="admin-modal__title">
|
||||||
|
Brevtext
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="admin-modal__close"
|
||||||
|
aria-label="Stäng"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="admin-modal__meta">
|
||||||
|
{{ order.plate }} · {{ shortOrderId(order.id) }}
|
||||||
|
</p>
|
||||||
|
<div class="admin-modal__body">
|
||||||
|
{{ order.letterText }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
176
frontend/src/components/admin/AdminOrdersTable.vue
Normal file
176
frontend/src/components/admin/AdminOrdersTable.vue
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { AdminOrder } from '@/api/admin'
|
||||||
|
import {
|
||||||
|
ORDER_STATUS_BADGE,
|
||||||
|
ORDER_STATUS_LABELS,
|
||||||
|
} from '@/constants/orderStatus'
|
||||||
|
import { formatOrderDate, shortOrderId } from '@/utils/orderDisplay'
|
||||||
|
import AdminOrderDetailPanel from '@/components/admin/AdminOrderDetailPanel.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
orders: AdminOrder[]
|
||||||
|
expandedOrderId: string | null
|
||||||
|
statusError: string
|
||||||
|
trackingError: string
|
||||||
|
notesError: string
|
||||||
|
trackingInputValues: Record<string, string>
|
||||||
|
adminNotesValues: Record<string, string>
|
||||||
|
notifyCustomerValues: Record<string, boolean>
|
||||||
|
savingNotesId: string | null
|
||||||
|
registeringId: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
toggleExpand: [orderId: string]
|
||||||
|
openMessage: [order: AdminOrder]
|
||||||
|
statusChange: [orderId: string, status: string]
|
||||||
|
registerShipment: [orderId: string]
|
||||||
|
saveNotes: [orderId: string]
|
||||||
|
'update:trackingInput': [orderId: string, value: string]
|
||||||
|
'update:adminNotes': [orderId: string, value: string]
|
||||||
|
'update:notifyCustomer': [orderId: string, value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function isStatusDropdownDisabled(order: AdminOrder): boolean {
|
||||||
|
return order.allowedStatuses.length <= 1
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p
|
||||||
|
v-if="statusError"
|
||||||
|
class="message message--error admin__status-error"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{{ statusError }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="admin__table-wrap">
|
||||||
|
<table class="admin__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="admin__th-expand" scope="col">
|
||||||
|
<span class="visually-hidden">Visa detaljer</span>
|
||||||
|
</th>
|
||||||
|
<th class="admin__th-date">Datum</th>
|
||||||
|
<th class="admin__th-id" title="Beställnings-ID">ID</th>
|
||||||
|
<th class="admin__th-email">E-post</th>
|
||||||
|
<th class="admin__th-plate">Regnr</th>
|
||||||
|
<th class="admin__th-message" title="Meddelande">Brev</th>
|
||||||
|
<th class="admin__th-status">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template v-for="order in orders" :key="order.id">
|
||||||
|
<tr
|
||||||
|
class="admin__row"
|
||||||
|
:class="{
|
||||||
|
'admin__row--expanded': expandedOrderId === order.id,
|
||||||
|
'admin__row--todo': order.status === 'processing',
|
||||||
|
}"
|
||||||
|
:aria-expanded="expandedOrderId === order.id"
|
||||||
|
:title="
|
||||||
|
expandedOrderId === order.id
|
||||||
|
? 'Klicka för att dölja detaljer'
|
||||||
|
: 'Klicka för att visa utskick och detaljer'
|
||||||
|
"
|
||||||
|
@click="emit('toggleExpand', order.id)"
|
||||||
|
>
|
||||||
|
<td class="admin__expand-cell">
|
||||||
|
<span
|
||||||
|
class="admin__expand-icon"
|
||||||
|
:class="{
|
||||||
|
'admin__expand-icon--open': expandedOrderId === order.id,
|
||||||
|
}"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<polyline
|
||||||
|
:points="
|
||||||
|
expandedOrderId === order.id
|
||||||
|
? '6 9 12 15 18 9'
|
||||||
|
: '9 6 15 12 9 18'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="admin__td-date">
|
||||||
|
{{ formatOrderDate(order.createdAt) }}
|
||||||
|
</td>
|
||||||
|
<td class="admin__order-id" :title="order.id">
|
||||||
|
{{ shortOrderId(order.id) }}
|
||||||
|
</td>
|
||||||
|
<td class="admin__email" :title="order.email">{{ order.email }}</td>
|
||||||
|
<td class="admin__plate">{{ order.plate }}</td>
|
||||||
|
<td class="admin__message-cell">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn--ghost btn--sm admin__message-btn"
|
||||||
|
@click.stop="emit('openMessage', order)"
|
||||||
|
>
|
||||||
|
Visa meddelande
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td class="admin__status-cell">
|
||||||
|
<select
|
||||||
|
class="admin__status-select"
|
||||||
|
:class="ORDER_STATUS_BADGE[order.status] || 'badge--muted'"
|
||||||
|
:value="order.status"
|
||||||
|
:disabled="isStatusDropdownDisabled(order)"
|
||||||
|
@change="
|
||||||
|
emit(
|
||||||
|
'statusChange',
|
||||||
|
order.id,
|
||||||
|
($event.target as HTMLSelectElement).value,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<option v-for="s in order.allowedStatuses" :key="s" :value="s">
|
||||||
|
{{ ORDER_STATUS_LABELS[s] }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="expandedOrderId === order.id" class="admin__expanded-row">
|
||||||
|
<td :colspan="7">
|
||||||
|
<AdminOrderDetailPanel
|
||||||
|
:order="order"
|
||||||
|
:tracking-input="
|
||||||
|
trackingInputValues[order.id] ?? order.trackingId ?? ''
|
||||||
|
"
|
||||||
|
:admin-notes="adminNotesValues[order.id] ?? ''"
|
||||||
|
:notify-customer="notifyCustomerValues[order.id] ?? true"
|
||||||
|
:tracking-error="trackingError"
|
||||||
|
:notes-error="notesError"
|
||||||
|
:registering="registeringId === order.id"
|
||||||
|
:saving-notes="savingNotesId === order.id"
|
||||||
|
@update:tracking-input="
|
||||||
|
emit('update:trackingInput', order.id, $event)
|
||||||
|
"
|
||||||
|
@update:admin-notes="
|
||||||
|
emit('update:adminNotes', order.id, $event)
|
||||||
|
"
|
||||||
|
@update:notify-customer="
|
||||||
|
emit('update:notifyCustomer', order.id, $event)
|
||||||
|
"
|
||||||
|
@register-shipment="emit('registerShipment', order.id)"
|
||||||
|
@save-notes="emit('saveNotes', order.id)"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
71
frontend/src/components/admin/AdminStatsBar.vue
Normal file
71
frontend/src/components/admin/AdminStatsBar.vue
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { AdminOrderFilter } from '@/composables/useAdminOrders'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
total: number
|
||||||
|
todo: number
|
||||||
|
paid: number
|
||||||
|
pending: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const activeFilterModel = defineModel<AdminOrderFilter>('activeFilter', {
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
const searchQuery = defineModel<string>('searchQuery', { required: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="admin__stats">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="admin__stat"
|
||||||
|
:class="{ 'admin__stat--active': activeFilterModel === 'all' }"
|
||||||
|
@click="activeFilterModel = 'all'"
|
||||||
|
>
|
||||||
|
<span class="admin__stat-value">{{ total }}</span>
|
||||||
|
<span class="admin__stat-label">Totalt</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="admin__stat"
|
||||||
|
:class="{ 'admin__stat--active': activeFilterModel === 'processing' }"
|
||||||
|
@click="activeFilterModel = 'processing'"
|
||||||
|
>
|
||||||
|
<span class="admin__stat-value">{{ todo }}</span>
|
||||||
|
<span class="admin__stat-label">Att göra</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="admin__stat"
|
||||||
|
:class="{ 'admin__stat--active': activeFilterModel === 'paid_group' }"
|
||||||
|
@click="activeFilterModel = 'paid_group'"
|
||||||
|
>
|
||||||
|
<span class="admin__stat-value">{{ paid }}</span>
|
||||||
|
<span class="admin__stat-label">Betalda</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="admin__stat"
|
||||||
|
:class="{
|
||||||
|
'admin__stat--active': activeFilterModel === 'pending_payment',
|
||||||
|
}"
|
||||||
|
@click="activeFilterModel = 'pending_payment'"
|
||||||
|
>
|
||||||
|
<span class="admin__stat-value">{{ pending }}</span>
|
||||||
|
<span class="admin__stat-label">Väntar</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin__toolbar">
|
||||||
|
<label for="admin-order-search" class="admin__search-label"
|
||||||
|
>Sök beställnings-ID eller regnr</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="admin-order-search"
|
||||||
|
v-model="searchQuery"
|
||||||
|
class="admin__search-input"
|
||||||
|
type="search"
|
||||||
|
placeholder="t.ex. c1eebc99 eller ABC123"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
153
frontend/src/composables/useAdminOrderActions.ts
Normal file
153
frontend/src/composables/useAdminOrderActions.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
import { ref, reactive, type Ref } from 'vue'
|
||||||
|
import { ApiError } from '@/api/client'
|
||||||
|
import {
|
||||||
|
updateOrderStatus,
|
||||||
|
registerShipment,
|
||||||
|
updateAdminNotes,
|
||||||
|
type AdminOrder,
|
||||||
|
} from '@/api/admin'
|
||||||
|
|
||||||
|
export function useAdminOrderActions(
|
||||||
|
orders: Ref<AdminOrder[]>,
|
||||||
|
replaceOrder: (updated: AdminOrder) => void,
|
||||||
|
) {
|
||||||
|
const expandedOrderId = ref<string | null>(null)
|
||||||
|
const statusError = ref('')
|
||||||
|
const trackingError = ref('')
|
||||||
|
const notesError = ref('')
|
||||||
|
const savingNotesId = ref<string | null>(null)
|
||||||
|
const registeringId = ref<string | null>(null)
|
||||||
|
const messageModalOrder = ref<AdminOrder | null>(null)
|
||||||
|
|
||||||
|
const trackingInputValues = reactive<Record<string, string>>({})
|
||||||
|
const adminNotesValues = reactive<Record<string, string>>({})
|
||||||
|
const notifyCustomerValues = reactive<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
function findOrder(orderId: string): AdminOrder | undefined {
|
||||||
|
return orders.value.find((o) => o.id === orderId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMessageModal(order: AdminOrder) {
|
||||||
|
messageModalOrder.value = order
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMessageModal() {
|
||||||
|
messageModalOrder.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExpand(orderId: string) {
|
||||||
|
if (expandedOrderId.value === orderId) {
|
||||||
|
expandedOrderId.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expandedOrderId.value = orderId
|
||||||
|
const order = findOrder(orderId)
|
||||||
|
if (!order) return
|
||||||
|
|
||||||
|
if (!(orderId in trackingInputValues)) {
|
||||||
|
trackingInputValues[orderId] = order.trackingId ?? ''
|
||||||
|
}
|
||||||
|
if (!(orderId in adminNotesValues)) {
|
||||||
|
adminNotesValues[orderId] = order.adminNotes ?? ''
|
||||||
|
}
|
||||||
|
if (!(orderId in notifyCustomerValues)) {
|
||||||
|
notifyCustomerValues[orderId] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStatusChange(orderId: string, newStatus: string) {
|
||||||
|
const order = findOrder(orderId)
|
||||||
|
if (!order) return
|
||||||
|
|
||||||
|
const previousStatus = order.status
|
||||||
|
order.status = newStatus
|
||||||
|
statusError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await updateOrderStatus(orderId, newStatus)
|
||||||
|
replaceOrder(updated)
|
||||||
|
} catch (err) {
|
||||||
|
order.status = previousStatus
|
||||||
|
statusError.value =
|
||||||
|
err instanceof ApiError && err.message
|
||||||
|
? err.message
|
||||||
|
: 'Kunde inte uppdatera status. Försök igen.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegisterShipment(orderId: string) {
|
||||||
|
const trackingInput = trackingInputValues[orderId]?.trim()
|
||||||
|
if (!trackingInput) {
|
||||||
|
trackingError.value = 'Ange ett spårnings-ID eller en PostNord-länk.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = findOrder(orderId)
|
||||||
|
if (!order) return
|
||||||
|
|
||||||
|
const previousStatus = order.status
|
||||||
|
const previousTrackingId = order.trackingId
|
||||||
|
const notifyCustomer = notifyCustomerValues[orderId] ?? true
|
||||||
|
trackingError.value = ''
|
||||||
|
registeringId.value = orderId
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await registerShipment(
|
||||||
|
orderId,
|
||||||
|
trackingInput,
|
||||||
|
notifyCustomer,
|
||||||
|
)
|
||||||
|
replaceOrder(updated)
|
||||||
|
trackingInputValues[orderId] = updated.trackingId ?? trackingInput
|
||||||
|
} catch {
|
||||||
|
order.status = previousStatus
|
||||||
|
order.trackingId = previousTrackingId
|
||||||
|
trackingError.value =
|
||||||
|
'Kunde inte registrera utskick. Kontrollera spårnings-ID och försök igen.'
|
||||||
|
} finally {
|
||||||
|
registeringId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNotesSave(orderId: string) {
|
||||||
|
const order = findOrder(orderId)
|
||||||
|
if (!order) return
|
||||||
|
|
||||||
|
const notes = adminNotesValues[orderId]?.trim() || null
|
||||||
|
const previousNotes = order.adminNotes
|
||||||
|
notesError.value = ''
|
||||||
|
savingNotesId.value = orderId
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await updateAdminNotes(orderId, notes)
|
||||||
|
replaceOrder(updated)
|
||||||
|
adminNotesValues[orderId] = updated.adminNotes ?? ''
|
||||||
|
} catch {
|
||||||
|
order.adminNotes = previousNotes
|
||||||
|
adminNotesValues[orderId] = previousNotes ?? ''
|
||||||
|
notesError.value = 'Kunde inte spara anteckningar. Försök igen.'
|
||||||
|
} finally {
|
||||||
|
savingNotesId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
expandedOrderId,
|
||||||
|
statusError,
|
||||||
|
trackingError,
|
||||||
|
notesError,
|
||||||
|
savingNotesId,
|
||||||
|
registeringId,
|
||||||
|
messageModalOrder,
|
||||||
|
trackingInputValues,
|
||||||
|
adminNotesValues,
|
||||||
|
notifyCustomerValues,
|
||||||
|
openMessageModal,
|
||||||
|
closeMessageModal,
|
||||||
|
toggleExpand,
|
||||||
|
handleStatusChange,
|
||||||
|
handleRegisterShipment,
|
||||||
|
handleNotesSave,
|
||||||
|
}
|
||||||
|
}
|
||||||
89
frontend/src/composables/useAdminOrders.ts
Normal file
89
frontend/src/composables/useAdminOrders.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { fetchAllOrders, type AdminOrder } from '@/api/admin'
|
||||||
|
import { PAID_GROUP_STATUSES } from '@/constants/orderStatus'
|
||||||
|
|
||||||
|
export type AdminOrderFilter =
|
||||||
|
| 'all'
|
||||||
|
| 'processing'
|
||||||
|
| 'paid_group'
|
||||||
|
| 'pending_payment'
|
||||||
|
|
||||||
|
export function useAdminOrders() {
|
||||||
|
const orders = ref<AdminOrder[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const activeFilter = ref<AdminOrderFilter>('all')
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
const stats = computed(() => {
|
||||||
|
const total = orders.value.length
|
||||||
|
const todo = orders.value.filter((o) => o.status === 'processing').length
|
||||||
|
const paid = orders.value.filter((o) =>
|
||||||
|
PAID_GROUP_STATUSES.includes(
|
||||||
|
o.status as (typeof PAID_GROUP_STATUSES)[number],
|
||||||
|
),
|
||||||
|
).length
|
||||||
|
const pending = orders.value.filter(
|
||||||
|
(o) => o.status === 'pending_payment',
|
||||||
|
).length
|
||||||
|
return { total, todo, paid, pending }
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredOrders = computed(() => {
|
||||||
|
let result = orders.value
|
||||||
|
|
||||||
|
if (activeFilter.value === 'processing') {
|
||||||
|
result = result.filter((o) => o.status === 'processing')
|
||||||
|
} else if (activeFilter.value === 'paid_group') {
|
||||||
|
result = result.filter((o) =>
|
||||||
|
PAID_GROUP_STATUSES.includes(
|
||||||
|
o.status as (typeof PAID_GROUP_STATUSES)[number],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else if (activeFilter.value === 'pending_payment') {
|
||||||
|
result = result.filter((o) => o.status === 'pending_payment')
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = searchQuery.value.trim().toLowerCase()
|
||||||
|
if (query) {
|
||||||
|
result = result.filter(
|
||||||
|
(o) =>
|
||||||
|
o.id.toLowerCase().includes(query) ||
|
||||||
|
o.plate.toLowerCase().includes(query),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadOrders() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
orders.value = await fetchAllOrders()
|
||||||
|
} catch {
|
||||||
|
error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceOrder(updated: AdminOrder) {
|
||||||
|
const index = orders.value.findIndex((o) => o.id === updated.id)
|
||||||
|
if (index !== -1) {
|
||||||
|
orders.value[index] = updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
orders,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
activeFilter,
|
||||||
|
searchQuery,
|
||||||
|
stats,
|
||||||
|
filteredOrders,
|
||||||
|
loadOrders,
|
||||||
|
replaceOrder,
|
||||||
|
}
|
||||||
|
}
|
||||||
30
frontend/src/constants/orderStatus.ts
Normal file
30
frontend/src/constants/orderStatus.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
export const ORDER_STATUS_LABELS: Record<string, string> = {
|
||||||
|
pending_payment: 'Väntar på betalning',
|
||||||
|
paid: 'Betalad',
|
||||||
|
processing: 'Hanteras',
|
||||||
|
sent: 'Skickat',
|
||||||
|
delivered: 'Levererat',
|
||||||
|
failed: 'Misslyckad',
|
||||||
|
cancelled: 'Avbruten',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ORDER_STATUS_BADGE: Record<string, string> = {
|
||||||
|
pending_payment: 'badge--muted',
|
||||||
|
paid: 'badge--success',
|
||||||
|
processing: 'badge--primary',
|
||||||
|
sent: 'badge--success',
|
||||||
|
delivered: 'badge--success',
|
||||||
|
failed: 'badge--danger',
|
||||||
|
cancelled: 'badge--muted',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PAID_GROUP_STATUSES = [
|
||||||
|
'processing',
|
||||||
|
'paid',
|
||||||
|
'sent',
|
||||||
|
'delivered',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export function postNordTrackingUrl(trackingId: string): string {
|
||||||
|
return `https://www.postnord.se/verktyg/spara/?id=${trackingId}`
|
||||||
|
}
|
||||||
|
|
@ -1,108 +1,41 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, reactive, computed } from 'vue'
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
import { ApiError } from '@/api/client'
|
import { useAdminOrders } from '@/composables/useAdminOrders'
|
||||||
import {
|
import { useAdminOrderActions } from '@/composables/useAdminOrderActions'
|
||||||
fetchAllOrders,
|
import AdminStatsBar from '@/components/admin/AdminStatsBar.vue'
|
||||||
updateOrderStatus,
|
import AdminOrdersTable from '@/components/admin/AdminOrdersTable.vue'
|
||||||
registerShipment,
|
import AdminOrderMessageModal from '@/components/admin/AdminOrderMessageModal.vue'
|
||||||
updateAdminNotes,
|
|
||||||
type AdminOrder,
|
|
||||||
} from '@/api/admin'
|
|
||||||
|
|
||||||
const orders = ref<AdminOrder[]>([])
|
const {
|
||||||
const expandedOrderId = ref<string | null>(null)
|
orders,
|
||||||
const loading = ref(true)
|
loading,
|
||||||
const error = ref('')
|
error,
|
||||||
const statusError = ref('')
|
activeFilter,
|
||||||
const trackingError = ref('')
|
searchQuery,
|
||||||
const activeFilter = ref<
|
stats,
|
||||||
'all' | 'processing' | 'paid_group' | 'pending_payment'
|
filteredOrders,
|
||||||
>('all')
|
loadOrders,
|
||||||
const searchQuery = ref('')
|
replaceOrder,
|
||||||
const trackingInputValues = reactive<Record<string, string>>({})
|
} = useAdminOrders()
|
||||||
const adminNotesValues = reactive<Record<string, string>>({})
|
|
||||||
const notifyCustomerValues = reactive<Record<string, boolean>>({})
|
|
||||||
const messageModalOrder = ref<AdminOrder | null>(null)
|
|
||||||
const notesError = ref('')
|
|
||||||
const savingNotesId = ref<string | null>(null)
|
|
||||||
const registeringId = ref<string | null>(null)
|
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
const {
|
||||||
pending_payment: 'Väntar på betalning',
|
expandedOrderId,
|
||||||
paid: 'Betalad',
|
statusError,
|
||||||
processing: 'Hanteras',
|
trackingError,
|
||||||
sent: 'Skickat',
|
notesError,
|
||||||
delivered: 'Levererat',
|
savingNotesId,
|
||||||
failed: 'Misslyckad',
|
registeringId,
|
||||||
cancelled: 'Avbruten',
|
messageModalOrder,
|
||||||
}
|
trackingInputValues,
|
||||||
|
adminNotesValues,
|
||||||
const statusBadge: Record<string, string> = {
|
notifyCustomerValues,
|
||||||
pending_payment: 'badge--muted',
|
openMessageModal,
|
||||||
paid: 'badge--success',
|
closeMessageModal,
|
||||||
processing: 'badge--primary',
|
toggleExpand,
|
||||||
sent: 'badge--success',
|
handleStatusChange,
|
||||||
delivered: 'badge--success',
|
handleRegisterShipment,
|
||||||
failed: 'badge--danger',
|
handleNotesSave,
|
||||||
cancelled: 'badge--muted',
|
} = useAdminOrderActions(orders, replaceOrder)
|
||||||
}
|
|
||||||
|
|
||||||
const stats = computed(() => {
|
|
||||||
const total = orders.value.length
|
|
||||||
const todo = orders.value.filter((o) => o.status === 'processing').length
|
|
||||||
const paid = orders.value.filter((o) =>
|
|
||||||
['processing', 'paid', 'sent', 'delivered'].includes(o.status),
|
|
||||||
).length
|
|
||||||
const pending = orders.value.filter(
|
|
||||||
(o) => o.status === 'pending_payment',
|
|
||||||
).length
|
|
||||||
return { total, todo, paid, pending }
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredOrders = computed(() => {
|
|
||||||
let result = orders.value
|
|
||||||
|
|
||||||
if (activeFilter.value === 'processing') {
|
|
||||||
result = result.filter((o) => o.status === 'processing')
|
|
||||||
} else if (activeFilter.value === 'paid_group') {
|
|
||||||
result = result.filter((o) =>
|
|
||||||
['processing', 'paid', 'sent', 'delivered'].includes(o.status),
|
|
||||||
)
|
|
||||||
} else if (activeFilter.value === 'pending_payment') {
|
|
||||||
result = result.filter((o) => o.status === 'pending_payment')
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = searchQuery.value.trim().toLowerCase()
|
|
||||||
if (query) {
|
|
||||||
result = result.filter(
|
|
||||||
(o) =>
|
|
||||||
o.id.toLowerCase().includes(query) ||
|
|
||||||
o.plate.toLowerCase().includes(query),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
function shortOrderId(id: string): string {
|
|
||||||
return id.slice(0, 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
|
||||||
return new Date(iso).toLocaleDateString('sv-SE', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function openMessageModal(order: AdminOrder) {
|
|
||||||
messageModalOrder.value = order
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeMessageModal() {
|
|
||||||
messageModalOrder.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleModalKeydown(event: KeyboardEvent) {
|
function handleModalKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === 'Escape' && messageModalOrder.value) {
|
if (event.key === 'Escape' && messageModalOrder.value) {
|
||||||
|
|
@ -110,149 +43,9 @@ function handleModalKeydown(event: KeyboardEvent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function allowedStatuses(order: AdminOrder): string[] {
|
onMounted(() => {
|
||||||
switch (order.status) {
|
|
||||||
case 'pending_payment':
|
|
||||||
return ['pending_payment', 'failed']
|
|
||||||
case 'processing':
|
|
||||||
return ['processing', 'failed']
|
|
||||||
case 'sent':
|
|
||||||
return ['sent', 'delivered', 'failed']
|
|
||||||
case 'delivered':
|
|
||||||
return ['delivered', 'failed']
|
|
||||||
case 'failed': {
|
|
||||||
const options = ['failed']
|
|
||||||
if (order.trackingId) {
|
|
||||||
options.push('processing', 'sent', 'delivered')
|
|
||||||
} else if (order.amountPaid == null) {
|
|
||||||
options.push('pending_payment')
|
|
||||||
} else {
|
|
||||||
options.push('processing', 'sent')
|
|
||||||
}
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return [order.status]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isStatusDropdownDisabled(order: AdminOrder): boolean {
|
|
||||||
return allowedStatuses(order).length <= 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function canRegisterShipment(order: AdminOrder): boolean {
|
|
||||||
if (['processing', 'sent', 'delivered'].includes(order.status)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return order.status === 'failed' && order.amountPaid != null
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleExpand(orderId: string) {
|
|
||||||
if (expandedOrderId.value === orderId) {
|
|
||||||
expandedOrderId.value = null
|
|
||||||
} else {
|
|
||||||
expandedOrderId.value = orderId
|
|
||||||
const order = orders.value.find((o) => o.id === orderId)
|
|
||||||
if (order) {
|
|
||||||
if (!(orderId in trackingInputValues)) {
|
|
||||||
trackingInputValues[orderId] = order.trackingId ?? ''
|
|
||||||
}
|
|
||||||
if (!(orderId in adminNotesValues)) {
|
|
||||||
adminNotesValues[orderId] = order.adminNotes ?? ''
|
|
||||||
}
|
|
||||||
if (!(orderId in notifyCustomerValues)) {
|
|
||||||
notifyCustomerValues[orderId] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStatusChange(orderId: string, newStatus: string) {
|
|
||||||
const order = orders.value.find((o) => o.id === orderId)
|
|
||||||
if (!order) return
|
|
||||||
|
|
||||||
const previousStatus = order.status
|
|
||||||
order.status = newStatus
|
|
||||||
statusError.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateOrderStatus(orderId, newStatus)
|
|
||||||
} catch (err) {
|
|
||||||
order.status = previousStatus
|
|
||||||
statusError.value =
|
|
||||||
err instanceof ApiError && err.message
|
|
||||||
? err.message
|
|
||||||
: 'Kunde inte uppdatera status. Försök igen.'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRegisterShipment(orderId: string) {
|
|
||||||
const trackingInput = trackingInputValues[orderId]?.trim()
|
|
||||||
if (!trackingInput) {
|
|
||||||
trackingError.value = 'Ange ett spårnings-ID eller en PostNord-länk.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const order = orders.value.find((o) => o.id === orderId)
|
|
||||||
if (!order) return
|
|
||||||
|
|
||||||
const previousStatus = order.status
|
|
||||||
const previousTrackingId = order.trackingId
|
|
||||||
const notifyCustomer = notifyCustomerValues[orderId] ?? true
|
|
||||||
trackingError.value = ''
|
|
||||||
registeringId.value = orderId
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updated = await registerShipment(
|
|
||||||
orderId,
|
|
||||||
trackingInput,
|
|
||||||
notifyCustomer,
|
|
||||||
)
|
|
||||||
order.status = updated.status
|
|
||||||
order.trackingId = updated.trackingId
|
|
||||||
order.shippedAt = updated.shippedAt
|
|
||||||
trackingInputValues[orderId] = updated.trackingId ?? trackingInput
|
|
||||||
} catch {
|
|
||||||
order.status = previousStatus
|
|
||||||
order.trackingId = previousTrackingId
|
|
||||||
trackingError.value =
|
|
||||||
'Kunde inte registrera utskick. Kontrollera spårnings-ID och försök igen.'
|
|
||||||
} finally {
|
|
||||||
registeringId.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleNotesSave(orderId: string) {
|
|
||||||
const order = orders.value.find((o) => o.id === orderId)
|
|
||||||
if (!order) return
|
|
||||||
|
|
||||||
const notes = adminNotesValues[orderId]?.trim() || null
|
|
||||||
const previousNotes = order.adminNotes
|
|
||||||
notesError.value = ''
|
|
||||||
savingNotesId.value = orderId
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updated = await updateAdminNotes(orderId, notes)
|
|
||||||
order.adminNotes = updated.adminNotes
|
|
||||||
adminNotesValues[orderId] = updated.adminNotes ?? ''
|
|
||||||
} catch {
|
|
||||||
order.adminNotes = previousNotes
|
|
||||||
adminNotesValues[orderId] = previousNotes ?? ''
|
|
||||||
notesError.value = 'Kunde inte spara anteckningar. Försök igen.'
|
|
||||||
} finally {
|
|
||||||
savingNotesId.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
window.addEventListener('keydown', handleModalKeydown)
|
window.addEventListener('keydown', handleModalKeydown)
|
||||||
try {
|
void loadOrders()
|
||||||
orders.value = await fetchAllOrders()
|
|
||||||
} catch {
|
|
||||||
error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|
@ -279,57 +72,14 @@ onUnmounted(() => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="admin__stats">
|
<AdminStatsBar
|
||||||
<button
|
v-model:active-filter="activeFilter"
|
||||||
type="button"
|
v-model:search-query="searchQuery"
|
||||||
class="admin__stat"
|
:total="stats.total"
|
||||||
:class="{ 'admin__stat--active': activeFilter === 'all' }"
|
:todo="stats.todo"
|
||||||
@click="activeFilter = 'all'"
|
:paid="stats.paid"
|
||||||
>
|
:pending="stats.pending"
|
||||||
<span class="admin__stat-value">{{ stats.total }}</span>
|
/>
|
||||||
<span class="admin__stat-label">Totalt</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="admin__stat"
|
|
||||||
:class="{ 'admin__stat--active': activeFilter === 'processing' }"
|
|
||||||
@click="activeFilter = 'processing'"
|
|
||||||
>
|
|
||||||
<span class="admin__stat-value">{{ stats.todo }}</span>
|
|
||||||
<span class="admin__stat-label">Att göra</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="admin__stat"
|
|
||||||
:class="{ 'admin__stat--active': activeFilter === 'paid_group' }"
|
|
||||||
@click="activeFilter = 'paid_group'"
|
|
||||||
>
|
|
||||||
<span class="admin__stat-value">{{ stats.paid }}</span>
|
|
||||||
<span class="admin__stat-label">Betalda</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="admin__stat"
|
|
||||||
:class="{ 'admin__stat--active': activeFilter === 'pending_payment' }"
|
|
||||||
@click="activeFilter = 'pending_payment'"
|
|
||||||
>
|
|
||||||
<span class="admin__stat-value">{{ stats.pending }}</span>
|
|
||||||
<span class="admin__stat-label">Väntar</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin__toolbar">
|
|
||||||
<label for="admin-order-search" class="admin__search-label"
|
|
||||||
>Sök beställnings-ID eller regnr</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="admin-order-search"
|
|
||||||
v-model="searchQuery"
|
|
||||||
class="admin__search-input"
|
|
||||||
type="search"
|
|
||||||
placeholder="t.ex. c1eebc99 eller ABC123"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="filteredOrders.length === 0"
|
v-if="filteredOrders.length === 0"
|
||||||
|
|
@ -338,305 +88,45 @@ onUnmounted(() => {
|
||||||
Inga beställningar matchar filtret.
|
Inga beställningar matchar filtret.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p
|
<AdminOrdersTable
|
||||||
v-if="statusError"
|
v-if="filteredOrders.length > 0"
|
||||||
class="message message--error admin__status-error"
|
:orders="filteredOrders"
|
||||||
role="alert"
|
:expanded-order-id="expandedOrderId"
|
||||||
>
|
:status-error="statusError"
|
||||||
{{ statusError }}
|
:tracking-error="trackingError"
|
||||||
</p>
|
:notes-error="notesError"
|
||||||
|
:tracking-input-values="trackingInputValues"
|
||||||
<div v-if="filteredOrders.length > 0" class="admin__table-wrap">
|
:admin-notes-values="adminNotesValues"
|
||||||
<table class="admin__table">
|
:notify-customer-values="notifyCustomerValues"
|
||||||
<thead>
|
:saving-notes-id="savingNotesId"
|
||||||
<tr>
|
:registering-id="registeringId"
|
||||||
<th class="admin__th-expand" scope="col">
|
@toggle-expand="toggleExpand"
|
||||||
<span class="visually-hidden">Visa detaljer</span>
|
@open-message="openMessageModal"
|
||||||
</th>
|
@status-change="handleStatusChange"
|
||||||
<th class="admin__th-date">Datum</th>
|
@register-shipment="handleRegisterShipment"
|
||||||
<th class="admin__th-id" title="Beställnings-ID">ID</th>
|
@save-notes="handleNotesSave"
|
||||||
<th class="admin__th-email">E-post</th>
|
@update:tracking-input="
|
||||||
<th class="admin__th-plate">Regnr</th>
|
(id, value) => {
|
||||||
<th class="admin__th-message" title="Meddelande">Brev</th>
|
trackingInputValues[id] = value
|
||||||
<th class="admin__th-status">Status</th>
|
}
|
||||||
</tr>
|
"
|
||||||
</thead>
|
@update:admin-notes="
|
||||||
<tbody>
|
(id, value) => {
|
||||||
<template v-for="order in filteredOrders" :key="order.id">
|
adminNotesValues[id] = value
|
||||||
<tr
|
}
|
||||||
class="admin__row"
|
"
|
||||||
:class="{
|
@update:notify-customer="
|
||||||
'admin__row--expanded': expandedOrderId === order.id,
|
(id, value) => {
|
||||||
'admin__row--todo': order.status === 'processing',
|
notifyCustomerValues[id] = value
|
||||||
}"
|
}
|
||||||
:aria-expanded="expandedOrderId === order.id"
|
"
|
||||||
:title="
|
/>
|
||||||
expandedOrderId === order.id
|
|
||||||
? 'Klicka för att dölja detaljer'
|
|
||||||
: 'Klicka för att visa utskick och detaljer'
|
|
||||||
"
|
|
||||||
@click="toggleExpand(order.id)"
|
|
||||||
>
|
|
||||||
<td class="admin__expand-cell">
|
|
||||||
<span
|
|
||||||
class="admin__expand-icon"
|
|
||||||
:class="{
|
|
||||||
'admin__expand-icon--open': expandedOrderId === order.id,
|
|
||||||
}"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<polyline
|
|
||||||
:points="
|
|
||||||
expandedOrderId === order.id
|
|
||||||
? '6 9 12 15 18 9'
|
|
||||||
: '9 6 15 12 9 18'
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="admin__td-date">
|
|
||||||
{{ formatDate(order.createdAt) }}
|
|
||||||
</td>
|
|
||||||
<td class="admin__order-id" :title="order.id">
|
|
||||||
{{ shortOrderId(order.id) }}
|
|
||||||
</td>
|
|
||||||
<td class="admin__email" :title="order.email">
|
|
||||||
{{ order.email }}
|
|
||||||
</td>
|
|
||||||
<td class="admin__plate">{{ order.plate }}</td>
|
|
||||||
<td class="admin__message-cell">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn--ghost btn--sm admin__message-btn"
|
|
||||||
@click.stop="openMessageModal(order)"
|
|
||||||
>
|
|
||||||
Visa meddelande
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td class="admin__status-cell">
|
|
||||||
<select
|
|
||||||
class="admin__status-select"
|
|
||||||
:class="statusBadge[order.status] || 'badge--muted'"
|
|
||||||
:value="order.status"
|
|
||||||
:disabled="isStatusDropdownDisabled(order)"
|
|
||||||
@change="
|
|
||||||
handleStatusChange(
|
|
||||||
order.id,
|
|
||||||
($event.target as HTMLSelectElement).value,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="s in allowedStatuses(order)"
|
|
||||||
:key="s"
|
|
||||||
:value="s"
|
|
||||||
>
|
|
||||||
{{ statusLabels[s] }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr
|
|
||||||
v-if="expandedOrderId === order.id"
|
|
||||||
class="admin__expanded-row"
|
|
||||||
>
|
|
||||||
<td :colspan="7">
|
|
||||||
<div class="admin__expanded-inner">
|
|
||||||
<ol
|
|
||||||
v-if="order.status === 'processing'"
|
|
||||||
class="admin__checklist"
|
|
||||||
>
|
|
||||||
<li>Hämta ägaradress via Transportstyrelsen</li>
|
|
||||||
<li>Skriv ut brevet och lägg i kuvert</li>
|
|
||||||
<li>Skicka med PostNord och få spårnings-ID</li>
|
|
||||||
<li>Registrera utskick nedan</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="canRegisterShipment(order)"
|
|
||||||
class="admin__section"
|
|
||||||
>
|
|
||||||
<div class="admin__section-header">
|
|
||||||
<span class="admin__section-label"
|
|
||||||
>Registrera utskick</span
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
v-if="order.trackingId"
|
|
||||||
class="admin__tracking-link"
|
|
||||||
:href="`https://www.postnord.se/verktyg/spara/?id=${order.trackingId}`"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
Spåra hos PostNord
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="admin__section-hint">
|
|
||||||
Klistra in spårnings-ID eller PostNord-länk. Vid
|
|
||||||
beställningar som hanteras markeras brevet som skickat
|
|
||||||
och kunden kan få e-post.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p
|
|
||||||
v-if="trackingError"
|
|
||||||
class="message message--error admin__tracking-error"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
{{ trackingError }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="admin__tracking-row">
|
|
||||||
<label
|
|
||||||
:for="`tracking-${order.id}`"
|
|
||||||
class="visually-hidden"
|
|
||||||
>Spårnings-ID</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
:id="`tracking-${order.id}`"
|
|
||||||
class="admin__tracking-input"
|
|
||||||
type="text"
|
|
||||||
:value="
|
|
||||||
trackingInputValues[order.id] ??
|
|
||||||
order.trackingId ??
|
|
||||||
''
|
|
||||||
"
|
|
||||||
placeholder="PN... eller PostNord-länk"
|
|
||||||
@input="
|
|
||||||
trackingInputValues[order.id] = (
|
|
||||||
$event.target as HTMLInputElement
|
|
||||||
).value
|
|
||||||
"
|
|
||||||
@click.stop
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="btn btn--primary btn--sm"
|
|
||||||
:disabled="registeringId === order.id"
|
|
||||||
@click.stop="handleRegisterShipment(order.id)"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
registeringId === order.id
|
|
||||||
? 'Registrerar...'
|
|
||||||
: 'Registrera utskick'
|
|
||||||
}}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label
|
|
||||||
v-if="order.status === 'processing'"
|
|
||||||
class="admin__notify"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="notifyCustomerValues[order.id]"
|
|
||||||
type="checkbox"
|
|
||||||
@click.stop
|
|
||||||
/>
|
|
||||||
Skicka e-post till kund
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin__section">
|
|
||||||
<span class="admin__section-label"
|
|
||||||
>Interna anteckningar</span
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
v-if="notesError"
|
|
||||||
class="message message--error"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
{{ notesError }}
|
|
||||||
</p>
|
|
||||||
<textarea
|
|
||||||
:id="`notes-${order.id}`"
|
|
||||||
class="admin__notes-input"
|
|
||||||
rows="3"
|
|
||||||
placeholder="T.ex. TS-begäran skickad..."
|
|
||||||
:value="adminNotesValues[order.id] ?? ''"
|
|
||||||
@input="
|
|
||||||
adminNotesValues[order.id] = (
|
|
||||||
$event.target as HTMLTextAreaElement
|
|
||||||
).value
|
|
||||||
"
|
|
||||||
@click.stop
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="btn btn--ghost btn--sm admin__notes-save"
|
|
||||||
:disabled="savingNotesId === order.id"
|
|
||||||
@click.stop="handleNotesSave(order.id)"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
savingNotesId === order.id
|
|
||||||
? 'Sparar...'
|
|
||||||
: 'Spara anteckningar'
|
|
||||||
}}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div
|
<AdminOrderMessageModal
|
||||||
v-if="messageModalOrder"
|
:order="messageModalOrder"
|
||||||
class="admin-modal-overlay"
|
@close="closeMessageModal"
|
||||||
@click.self="closeMessageModal"
|
/>
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="admin-modal"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="admin-message-modal-title"
|
|
||||||
>
|
|
||||||
<div class="admin-modal__header">
|
|
||||||
<h2 id="admin-message-modal-title" class="admin-modal__title">
|
|
||||||
Brevtext
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="admin-modal__close"
|
|
||||||
aria-label="Stäng"
|
|
||||||
@click="closeMessageModal"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="admin-modal__meta">
|
|
||||||
{{ messageModalOrder.plate }} ·
|
|
||||||
{{ shortOrderId(messageModalOrder.id) }}
|
|
||||||
</p>
|
|
||||||
<div class="admin-modal__body">
|
|
||||||
{{ messageModalOrder.letterText }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -945,13 +435,6 @@ onUnmounted(() => {
|
||||||
margin-bottom: var(--space-sm);
|
margin-bottom: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin__section-body {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--color-ink);
|
|
||||||
line-height: 1.6;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin__section-header {
|
.admin__section-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@ import { ref, computed, onMounted } from 'vue'
|
||||||
import { fetchOrders, cancelOrder, type Order } from '@/api/orders'
|
import { fetchOrders, cancelOrder, type Order } from '@/api/orders'
|
||||||
import { fetchSwishInfo } from '@/api/payment'
|
import { fetchSwishInfo } from '@/api/payment'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
|
import {
|
||||||
|
ORDER_STATUS_BADGE,
|
||||||
|
ORDER_STATUS_LABELS,
|
||||||
|
} from '@/constants/orderStatus'
|
||||||
|
|
||||||
const ORDER_AMOUNT_FALLBACK = 49
|
const ORDER_AMOUNT_FALLBACK = 49
|
||||||
|
|
||||||
|
|
@ -42,26 +46,6 @@ const completedOrders = computed(() =>
|
||||||
orders.value.filter((order) => order.status !== 'pending_payment'),
|
orders.value.filter((order) => order.status !== 'pending_payment'),
|
||||||
)
|
)
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
|
||||||
pending_payment: 'Väntar på betalning',
|
|
||||||
paid: 'Betalad',
|
|
||||||
processing: 'Hanteras',
|
|
||||||
sent: 'Skickat',
|
|
||||||
delivered: 'Levererat',
|
|
||||||
failed: 'Misslyckad',
|
|
||||||
cancelled: 'Avbruten',
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusBadge: Record<string, string> = {
|
|
||||||
pending_payment: 'badge--muted',
|
|
||||||
paid: 'badge--success',
|
|
||||||
processing: 'badge--primary',
|
|
||||||
sent: 'badge--success',
|
|
||||||
delivered: 'badge--success',
|
|
||||||
failed: 'badge--danger',
|
|
||||||
cancelled: 'badge--muted',
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
function formatDate(iso: string): string {
|
||||||
return new Date(iso).toLocaleDateString('sv-SE', {
|
return new Date(iso).toLocaleDateString('sv-SE', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
|
|
@ -166,7 +150,7 @@ onMounted(loadOrders)
|
||||||
<span class="orders__plate-value">{{ order.plate }}</span>
|
<span class="orders__plate-value">{{ order.plate }}</span>
|
||||||
</p>
|
</p>
|
||||||
<span class="badge badge--warning">
|
<span class="badge badge--warning">
|
||||||
{{ statusLabels[order.status] }}
|
{{ ORDER_STATUS_LABELS[order.status] }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -262,9 +246,9 @@ onMounted(loadOrders)
|
||||||
</p>
|
</p>
|
||||||
<span
|
<span
|
||||||
class="badge"
|
class="badge"
|
||||||
:class="statusBadge[order.status] || 'badge--muted'"
|
:class="ORDER_STATUS_BADGE[order.status] || 'badge--muted'"
|
||||||
>
|
>
|
||||||
{{ statusLabels[order.status] || order.status }}
|
{{ ORDER_STATUS_LABELS[order.status] || order.status }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
11
frontend/src/utils/orderDisplay.ts
Normal file
11
frontend/src/utils/orderDisplay.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
export function shortOrderId(id: string): string {
|
||||||
|
return id.slice(0, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatOrderDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString('sv-SE', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue