Merge pull request 'feature/cancel-edit-pending-orders' (#1) from feature/cancel-edit-pending-orders into master
Reviewed-on: https://srvr.nu/git/git/jocke/bilhej/pulls/1
This commit is contained in:
commit
41cfea09a6
19 changed files with 1400 additions and 141 deletions
|
|
@ -7,12 +7,15 @@ import org.springframework.http.ResponseEntity;
|
|||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import se.bilhalsning.dto.CreateOrderRequest;
|
||||
import se.bilhalsning.dto.OrderResponse;
|
||||
import se.bilhalsning.dto.UpdateOrderRequest;
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.entity.User;
|
||||
import se.bilhalsning.exception.InvalidCredentialsException;
|
||||
|
|
@ -20,6 +23,7 @@ import se.bilhalsning.service.OrderService;
|
|||
import se.bilhalsning.service.UserService;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/orders")
|
||||
|
|
@ -41,6 +45,21 @@ public class OrderController {
|
|||
return ResponseEntity.ok(orders);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<OrderResponse> get(@PathVariable UUID id,
|
||||
@AuthenticationPrincipal UserDetails userDetails) {
|
||||
User user = userService.findByEmail(userDetails.getUsername())
|
||||
.orElseThrow(InvalidCredentialsException::new);
|
||||
|
||||
Order order = orderService.getOrderById(id);
|
||||
|
||||
if (!order.getUserId().equals(user.getId())) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(toResponse(order));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<OrderResponse> create(
|
||||
@Valid @RequestBody CreateOrderRequest request,
|
||||
|
|
@ -57,6 +76,31 @@ public class OrderController {
|
|||
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(order));
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}")
|
||||
public ResponseEntity<OrderResponse> update(
|
||||
@PathVariable UUID id,
|
||||
@Valid @RequestBody UpdateOrderRequest request,
|
||||
@AuthenticationPrincipal UserDetails userDetails) {
|
||||
User user = userService.findByEmail(userDetails.getUsername())
|
||||
.orElseThrow(InvalidCredentialsException::new);
|
||||
|
||||
Order order = orderService.updatePendingOrder(id, user.getId(), request.letterText());
|
||||
|
||||
return ResponseEntity.ok(toResponse(order));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/cancel")
|
||||
public ResponseEntity<OrderResponse> cancel(
|
||||
@PathVariable UUID id,
|
||||
@AuthenticationPrincipal UserDetails userDetails) {
|
||||
User user = userService.findByEmail(userDetails.getUsername())
|
||||
.orElseThrow(InvalidCredentialsException::new);
|
||||
|
||||
Order order = orderService.cancelOrder(id, user.getId());
|
||||
|
||||
return ResponseEntity.ok(toResponse(order));
|
||||
}
|
||||
|
||||
private OrderResponse toResponse(Order order) {
|
||||
return new OrderResponse(
|
||||
order.getId(),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import java.util.UUID;
|
|||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
|
|
@ -12,28 +14,38 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||
import org.springframework.web.bind.annotation.RestController;
|
||||
import se.bilhalsning.dto.OrderResponse;
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.entity.User;
|
||||
import se.bilhalsning.exception.InvalidCredentialsException;
|
||||
import se.bilhalsning.service.OrderService;
|
||||
import se.bilhalsning.service.UserService;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/payment")
|
||||
public class PaymentController {
|
||||
|
||||
private final OrderService orderService;
|
||||
private final UserService userService;
|
||||
private final String swishNumber;
|
||||
private final int letterPrice;
|
||||
|
||||
public PaymentController(
|
||||
OrderService orderService,
|
||||
UserService userService,
|
||||
@Value("${app.payment.swish-number}") String swishNumber,
|
||||
@Value("${app.payment.letter-price}") int letterPrice) {
|
||||
this.orderService = orderService;
|
||||
this.userService = userService;
|
||||
this.swishNumber = swishNumber;
|
||||
this.letterPrice = letterPrice;
|
||||
}
|
||||
|
||||
@PostMapping("/{orderId}/pay")
|
||||
public ResponseEntity<OrderResponse> pay(@PathVariable UUID orderId) {
|
||||
Order order = orderService.confirmPayment(orderId);
|
||||
public ResponseEntity<OrderResponse> pay(@PathVariable UUID orderId,
|
||||
@AuthenticationPrincipal UserDetails userDetails) {
|
||||
User user = userService.findByEmail(userDetails.getUsername())
|
||||
.orElseThrow(InvalidCredentialsException::new);
|
||||
|
||||
Order order = orderService.confirmPayment(orderId, user.getId());
|
||||
return ResponseEntity.ok(toResponse(order));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record UpdateOrderRequest(
|
||||
@NotBlank(message = "Brevtext krävs")
|
||||
@Size(min = 1, max = 1000, message = "Brevtexten måste vara mellan 1 och 1000 tecken")
|
||||
String letterText
|
||||
) {}
|
||||
|
|
@ -6,7 +6,8 @@ public enum OrderStatus {
|
|||
PROCESSING("processing"),
|
||||
SENT("sent"),
|
||||
DELIVERED("delivered"),
|
||||
FAILED("failed");
|
||||
FAILED("failed"),
|
||||
CANCELLED("cancelled");
|
||||
|
||||
private final String value;
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,13 @@ public class GlobalExceptionHandler {
|
|||
.body(new ErrorResponse("E-postadressen är redan registrerad"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(InvalidOrderStateException.class)
|
||||
public ResponseEntity<ErrorResponse> handleInvalidOrderState(InvalidOrderStateException ex) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.CONFLICT)
|
||||
.body(new ErrorResponse(ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(OrderNotFoundException.class)
|
||||
public ResponseEntity<ErrorResponse> handleOrderNotFound(OrderNotFoundException ex) {
|
||||
return ResponseEntity
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package se.bilhalsning.exception;
|
||||
|
||||
public class InvalidOrderStateException extends RuntimeException {
|
||||
public InvalidOrderStateException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ 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;
|
||||
|
||||
|
|
@ -55,11 +56,37 @@ public class OrderService {
|
|||
return orderRepository.save(order);
|
||||
}
|
||||
|
||||
public Order confirmPayment(UUID orderId) {
|
||||
Order order = orderRepository.findById(orderId)
|
||||
.orElseThrow(() -> new OrderNotFoundException(orderId));
|
||||
|
||||
public Order confirmPayment(UUID orderId, UUID userId) {
|
||||
Order order = requirePendingOwnedBy(orderId, userId);
|
||||
order.setStatus(OrderStatus.PROCESSING);
|
||||
return orderRepository.save(order);
|
||||
}
|
||||
|
||||
public Order cancelOrder(UUID orderId, UUID userId) {
|
||||
Order order = requirePendingOwnedBy(orderId, userId);
|
||||
order.setStatus(OrderStatus.CANCELLED);
|
||||
return orderRepository.save(order);
|
||||
}
|
||||
|
||||
public Order updatePendingOrder(UUID orderId, UUID userId, String letterText) {
|
||||
Order order = requirePendingOwnedBy(orderId, userId);
|
||||
order.setLetterText(letterText);
|
||||
return orderRepository.save(order);
|
||||
}
|
||||
|
||||
private Order requirePendingOwnedBy(UUID orderId, UUID userId) {
|
||||
Order order = orderRepository.findById(orderId)
|
||||
.orElseThrow(() -> new OrderNotFoundException(orderId));
|
||||
|
||||
if (!order.getUserId().equals(userId)) {
|
||||
throw new OrderNotFoundException(orderId);
|
||||
}
|
||||
|
||||
if (order.getStatus() != OrderStatus.PENDING_PAYMENT) {
|
||||
throw new InvalidOrderStateException(
|
||||
"Beställningen kan inte ändras i detta tillstånd");
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package se.bilhalsning.controller;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
|
@ -163,4 +165,115 @@ class OrderControllerTest {
|
|||
.content("{\"plate\":\"ABC123\",\"letterText\":\"" + longText + "\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "test@bilhej.se")
|
||||
void shouldGetSingleOrderForOwner() throws Exception {
|
||||
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
User user = new User();
|
||||
user.setId(userId);
|
||||
user.setEmail("test@bilhej.se");
|
||||
|
||||
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
|
||||
|
||||
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
|
||||
order.setId(orderId);
|
||||
order.setUserId(userId);
|
||||
order.setPlate("ABC123");
|
||||
order.setLetterText("Test letter");
|
||||
order.setStatus(se.bilhalsning.entity.OrderStatus.PENDING_PAYMENT);
|
||||
|
||||
when(orderService.getOrderById(orderId)).thenReturn(order);
|
||||
|
||||
mockMvc.perform(get("/api/orders/" + orderId))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").value(orderId.toString()))
|
||||
.andExpect(jsonPath("$.plate").value("ABC123"))
|
||||
.andExpect(jsonPath("$.status").value("pending_payment"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "test@bilhej.se")
|
||||
void shouldReturn404WhenGettingOtherUsersOrder() throws Exception {
|
||||
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
User user = new User();
|
||||
user.setId(userId);
|
||||
user.setEmail("test@bilhej.se");
|
||||
|
||||
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
|
||||
|
||||
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
|
||||
order.setId(orderId);
|
||||
order.setUserId(UUID.randomUUID());
|
||||
|
||||
when(orderService.getOrderById(orderId)).thenReturn(order);
|
||||
|
||||
mockMvc.perform(get("/api/orders/" + orderId))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "test@bilhej.se")
|
||||
void shouldPatchOrderSuccessfully() throws Exception {
|
||||
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
User user = new User();
|
||||
user.setId(userId);
|
||||
user.setEmail("test@bilhej.se");
|
||||
|
||||
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
|
||||
|
||||
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
|
||||
order.setId(orderId);
|
||||
order.setUserId(userId);
|
||||
order.setPlate("ABC123");
|
||||
order.setLetterText("Updated text");
|
||||
order.setStatus(se.bilhalsning.entity.OrderStatus.PENDING_PAYMENT);
|
||||
|
||||
when(orderService.updatePendingOrder(any(), any(), any())).thenReturn(order);
|
||||
|
||||
mockMvc.perform(patch("/api/orders/" + orderId)
|
||||
.contentType("application/json")
|
||||
.content("{\"letterText\":\"Updated text\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.letterText").value("Updated text"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "test@bilhej.se")
|
||||
void shouldRejectPatchWithEmptyLetterText() throws Exception {
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
|
||||
mockMvc.perform(patch("/api/orders/" + orderId)
|
||||
.contentType("application/json")
|
||||
.content("{\"letterText\":\"\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "test@bilhej.se")
|
||||
void shouldCancelOrderSuccessfully() throws Exception {
|
||||
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
User user = new User();
|
||||
user.setId(userId);
|
||||
user.setEmail("test@bilhej.se");
|
||||
|
||||
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
|
||||
|
||||
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
|
||||
order.setId(orderId);
|
||||
order.setUserId(userId);
|
||||
order.setPlate("ABC123");
|
||||
order.setLetterText("Test letter");
|
||||
order.setStatus(se.bilhalsning.entity.OrderStatus.CANCELLED);
|
||||
|
||||
when(orderService.cancelOrder(orderId, userId)).thenReturn(order);
|
||||
|
||||
mockMvc.perform(post("/api/orders/" + orderId + "/cancel"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("cancelled"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package se.bilhalsning.controller;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
|
|
@ -7,6 +8,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
|
@ -18,8 +20,10 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
|||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.entity.OrderStatus;
|
||||
import se.bilhalsning.entity.User;
|
||||
import se.bilhalsning.exception.OrderNotFoundException;
|
||||
import se.bilhalsning.service.OrderService;
|
||||
import se.bilhalsning.service.UserService;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
|
|
@ -31,6 +35,9 @@ class PaymentControllerTest {
|
|||
@MockitoBean
|
||||
private OrderService orderService;
|
||||
|
||||
@MockitoBean
|
||||
private UserService userService;
|
||||
|
||||
@Test
|
||||
void shouldReturn403WhenNotAuthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/payment/{orderId}/pay",
|
||||
|
|
@ -42,12 +49,19 @@ class PaymentControllerTest {
|
|||
@WithMockUser(username = "test@bilhej.se")
|
||||
void shouldConfirmPaymentSuccessfully() throws Exception {
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
User user = new User();
|
||||
user.setId(userId);
|
||||
user.setEmail("test@bilhej.se");
|
||||
|
||||
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
|
||||
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setPlate("ABC123");
|
||||
order.setStatus(OrderStatus.PROCESSING);
|
||||
|
||||
when(orderService.confirmPayment(eq(orderId))).thenReturn(order);
|
||||
when(orderService.confirmPayment(eq(orderId), eq(userId))).thenReturn(order);
|
||||
|
||||
mockMvc.perform(post("/api/payment/{orderId}/pay", orderId)
|
||||
.contentType(MediaType.APPLICATION_JSON))
|
||||
|
|
@ -60,7 +74,14 @@ class PaymentControllerTest {
|
|||
@WithMockUser(username = "test@bilhej.se")
|
||||
void shouldReturn404WhenOrderNotFound() throws Exception {
|
||||
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
when(orderService.confirmPayment(eq(orderId)))
|
||||
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
|
||||
User user = new User();
|
||||
user.setId(userId);
|
||||
user.setEmail("test@bilhej.se");
|
||||
|
||||
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
|
||||
|
||||
when(orderService.confirmPayment(eq(orderId), eq(userId)))
|
||||
.thenThrow(new OrderNotFoundException(orderId));
|
||||
|
||||
mockMvc.perform(post("/api/payment/{orderId}/pay", orderId)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ 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.exception.OrderNotFoundException;
|
||||
import se.bilhalsning.repository.OrderRepository;
|
||||
|
||||
|
|
@ -127,4 +128,125 @@ class OrderServiceTest {
|
|||
assertThrows(OrderNotFoundException.class,
|
||||
() -> orderService.getOrderById(orderId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCancelOrderWhenPendingPayment() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setUserId(userId);
|
||||
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
||||
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = orderService.cancelOrder(orderId, userId);
|
||||
|
||||
assertEquals(OrderStatus.CANCELLED, result.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenCancellingNonPendingOrder() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setUserId(userId);
|
||||
order.setStatus(OrderStatus.PROCESSING);
|
||||
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
|
||||
|
||||
assertThrows(InvalidOrderStateException.class,
|
||||
() -> orderService.cancelOrder(orderId, userId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenCancellingOtherUsersOrder() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID otherUserId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setUserId(ownerId);
|
||||
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
||||
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
|
||||
|
||||
assertThrows(OrderNotFoundException.class,
|
||||
() -> orderService.cancelOrder(orderId, otherUserId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUpdatePendingOrderLetterText() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setUserId(userId);
|
||||
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
||||
order.setLetterText("Old text");
|
||||
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = orderService.updatePendingOrder(orderId, userId, "New text");
|
||||
|
||||
assertEquals("New text", result.getLetterText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenUpdatingNonPendingOrder() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setUserId(userId);
|
||||
order.setStatus(OrderStatus.PROCESSING);
|
||||
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
|
||||
|
||||
assertThrows(InvalidOrderStateException.class,
|
||||
() -> orderService.updatePendingOrder(orderId, userId, "New text"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldConfirmPaymentForPendingOrder() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setUserId(userId);
|
||||
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
||||
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
|
||||
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Order result = orderService.confirmPayment(orderId, userId);
|
||||
|
||||
assertEquals(OrderStatus.PROCESSING, result.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenConfirmingPaymentForNonPendingOrder() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setUserId(userId);
|
||||
order.setStatus(OrderStatus.CANCELLED);
|
||||
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
|
||||
|
||||
assertThrows(InvalidOrderStateException.class,
|
||||
() -> orderService.confirmPayment(orderId, userId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenConfirmingPaymentForOtherUsersOrder() {
|
||||
UUID orderId = UUID.randomUUID();
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID otherUserId = UUID.randomUUID();
|
||||
Order order = new Order();
|
||||
order.setId(orderId);
|
||||
order.setUserId(ownerId);
|
||||
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
||||
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
|
||||
|
||||
assertThrows(OrderNotFoundException.class,
|
||||
() -> orderService.confirmPayment(orderId, otherUserId));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,15 +59,15 @@ test.describe('Deferred payment and admin lookup', () => {
|
|||
const orderCard = page.locator('.orders__card', { hasText: orderId })
|
||||
await expect(orderCard.getByText(plate)).toBeVisible()
|
||||
await expect(orderCard.locator('.badge')).toHaveText('Väntar på betalning')
|
||||
await expect(orderCard.getByRole('link', { name: 'Betala nu' })).toBeVisible()
|
||||
await expect(orderCard.getByRole('link', { name: 'Betala 49 kr' })).toBeVisible()
|
||||
|
||||
await orderCard.getByRole('link', { name: 'Betala nu' }).click()
|
||||
await orderCard.getByRole('link', { name: 'Betala 49 kr' }).click()
|
||||
await expect(page).toHaveURL(new RegExp(`/betalning/${orderId}`))
|
||||
await completeSwishPayment(page)
|
||||
|
||||
await expect(page).toHaveURL('/orders')
|
||||
await expect(orderCard.locator('.badge')).toHaveText('Hanteras')
|
||||
await expect(orderCard.getByRole('link', { name: 'Betala nu' })).not.toBeVisible()
|
||||
await expect(orderCard.getByRole('link', { name: 'Betala 49 kr' })).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('admin finds paid order under Att göra when searching partial order id', async ({
|
||||
|
|
|
|||
|
|
@ -66,8 +66,8 @@ test.describe('Order history', () => {
|
|||
await page.goto('/orders')
|
||||
|
||||
const unpaidCard = page.locator('.orders__card', { hasText: 'DEF456' })
|
||||
await expect(unpaidCard.getByRole('link', { name: 'Betala nu' })).toBeVisible()
|
||||
await unpaidCard.getByRole('link', { name: 'Betala nu' }).click()
|
||||
await expect(unpaidCard.getByRole('link', { name: 'Betala 49 kr' })).toBeVisible()
|
||||
await unpaidCard.getByRole('link', { name: 'Betala 49 kr' }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/betalning\/c2eebc99/)
|
||||
await expect(page.getByRole('heading', { name: 'Betalning' })).toBeVisible()
|
||||
|
|
|
|||
149
frontend/src/__tests__/EditOrderPage.spec.ts
Normal file
149
frontend/src/__tests__/EditOrderPage.spec.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import EditOrderPage from '@/pages/EditOrderPage.vue'
|
||||
import PaymentRedirect from '@/pages/PaymentRedirect.vue'
|
||||
|
||||
vi.mock('@/api/orders', () => ({
|
||||
fetchOrder: vi.fn(),
|
||||
updateOrder: vi.fn(),
|
||||
}))
|
||||
|
||||
import { fetchOrder, updateOrder } from '@/api/orders'
|
||||
|
||||
const mockFetchOrder = vi.mocked(fetchOrder)
|
||||
const mockUpdateOrder = vi.mocked(updateOrder)
|
||||
|
||||
const pendingOrder = {
|
||||
id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
|
||||
plate: 'DEF456',
|
||||
letterText: 'Vill köpa din bil.',
|
||||
status: 'pending_payment',
|
||||
trackingId: null,
|
||||
amountPaid: null,
|
||||
createdAt: '2026-05-14T13:00:00Z',
|
||||
}
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/orders',
|
||||
name: 'orders',
|
||||
component: { template: '<div>Orders</div>' },
|
||||
},
|
||||
{
|
||||
path: '/bestallning/:orderId/redigera',
|
||||
name: 'edit-order',
|
||||
component: EditOrderPage,
|
||||
},
|
||||
{
|
||||
path: '/betalning/:orderId',
|
||||
name: 'payment',
|
||||
component: PaymentRedirect,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
async function mountPage(orderId = pendingOrder.id) {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
|
||||
const router = createTestRouter()
|
||||
await router.push({ name: 'edit-order', params: { orderId } })
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(EditOrderPage, {
|
||||
global: {
|
||||
plugins: [router, pinia],
|
||||
},
|
||||
})
|
||||
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
describe('EditOrderPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchOrder.mockResolvedValue(pendingOrder)
|
||||
mockUpdateOrder.mockResolvedValue(pendingOrder)
|
||||
})
|
||||
|
||||
it('shows loading state while fetching', async () => {
|
||||
mockFetchOrder.mockImplementation(() => new Promise(() => {}))
|
||||
const { wrapper } = await mountPage()
|
||||
expect(wrapper.text()).toContain('Laddar beställning...')
|
||||
})
|
||||
|
||||
it('loads order and pre-fills textarea', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFetchOrder).toHaveBeenCalledWith(pendingOrder.id)
|
||||
})
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
expect(textarea.element.value).toBe('Vill köpa din bil.')
|
||||
expect(wrapper.text()).toContain('DEF456')
|
||||
expect(wrapper.text()).toContain('Redigera brev')
|
||||
})
|
||||
|
||||
it('shows error when order is not pending_payment', async () => {
|
||||
mockFetchOrder.mockResolvedValue({
|
||||
...pendingOrder,
|
||||
status: 'sent',
|
||||
})
|
||||
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain(
|
||||
'Den här beställningen kan inte redigeras',
|
||||
)
|
||||
})
|
||||
|
||||
expect(wrapper.find('textarea').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('Tillbaka till beställningar')
|
||||
})
|
||||
|
||||
it('submit calls updateOrder and navigates to payment', async () => {
|
||||
const { wrapper, router } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('textarea').exists()).toBe(true)
|
||||
})
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Uppdaterat meddelande')
|
||||
const button = wrapper.find('button[type="submit"]')
|
||||
await button.trigger('submit')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockUpdateOrder).toHaveBeenCalledWith(
|
||||
pendingOrder.id,
|
||||
'Uppdaterat meddelande',
|
||||
)
|
||||
expect(router.currentRoute.value.name).toBe('payment')
|
||||
expect(router.currentRoute.value.params.orderId).toBe(pendingOrder.id)
|
||||
expect(router.currentRoute.value.query.plate).toBe('DEF456')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error message on update failure', async () => {
|
||||
mockUpdateOrder.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const { wrapper } = await mountPage()
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find('textarea').exists()).toBe(true)
|
||||
})
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Uppdaterat meddelande')
|
||||
const button = wrapper.find('button[type="submit"]')
|
||||
await button.trigger('submit')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Kunde inte spara ändringarna')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -22,6 +22,11 @@ function createTestRouter() {
|
|||
name: 'payment',
|
||||
component: { template: '<div>Payment</div>' },
|
||||
},
|
||||
{
|
||||
path: '/bestallning/:orderId/redigera',
|
||||
name: 'edit-order',
|
||||
component: { template: '<div>Edit</div>' },
|
||||
},
|
||||
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
|
||||
],
|
||||
})
|
||||
|
|
@ -58,13 +63,39 @@ const mockOrders = [
|
|||
},
|
||||
]
|
||||
|
||||
function mockOrdersFetch(orders: unknown) {
|
||||
vi.mocked(globalThis.fetch).mockImplementation((url, init) => {
|
||||
const urlStr = String(url)
|
||||
const method = init?.method ?? 'GET'
|
||||
|
||||
if (urlStr.includes('/payment/swish-info')) {
|
||||
return mockFetchResponse(200, { number: '1234567890', amount: 49 })
|
||||
}
|
||||
|
||||
if (urlStr.includes('/cancel') && method === 'POST') {
|
||||
return mockFetchResponse(200, {
|
||||
id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
|
||||
plate: 'DEF456',
|
||||
letterText: 'Vill köpa din bil.',
|
||||
status: 'cancelled',
|
||||
trackingId: null,
|
||||
createdAt: '2026-05-14T13:00:00Z',
|
||||
})
|
||||
}
|
||||
|
||||
if (urlStr.includes('/orders')) {
|
||||
return mockFetchResponse(200, orders)
|
||||
}
|
||||
|
||||
return mockFetchResponse(404, { message: 'Not found' })
|
||||
})
|
||||
}
|
||||
|
||||
describe('OrdersPage', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
globalThis.fetch = vi.fn()
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, mockOrders),
|
||||
)
|
||||
mockOrdersFetch(mockOrders)
|
||||
})
|
||||
|
||||
it('renders heading and subtitle', async () => {
|
||||
|
|
@ -82,6 +113,13 @@ describe('OrdersPage', () => {
|
|||
expect(wrapper.text()).toContain('Laddar beställningar...')
|
||||
})
|
||||
|
||||
it('shows section headings when pending and completed orders exist', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.text()).toContain('Obetalda beställningar')
|
||||
expect(wrapper.text()).toContain('Tidigare beställningar')
|
||||
})
|
||||
|
||||
it('fetches orders from API on mount', async () => {
|
||||
mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
|
@ -110,7 +148,9 @@ describe('OrdersPage', () => {
|
|||
await new Promise((r) => setTimeout(r, 50))
|
||||
const link = wrapper.find('a[href*="postnord"]')
|
||||
expect(link.exists()).toBe(true)
|
||||
expect(link.classes()).toContain('orders__tracking-btn')
|
||||
expect(link.text()).toContain('PN123456789')
|
||||
expect(link.text()).toContain('Spåra brev')
|
||||
expect(link.attributes('target')).toBe('_blank')
|
||||
})
|
||||
|
||||
|
|
@ -125,9 +165,7 @@ describe('OrdersPage', () => {
|
|||
createdAt: '2026-05-14T13:00:00Z',
|
||||
},
|
||||
]
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, ordersWithoutTracking),
|
||||
)
|
||||
mockOrdersFetch(ordersWithoutTracking)
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
const link = wrapper.find('a[href*="postnord"]')
|
||||
|
|
@ -137,9 +175,8 @@ describe('OrdersPage', () => {
|
|||
it('renders order id and message', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.text()).toContain('Beställnings-ID')
|
||||
expect(wrapper.text()).toContain('c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11')
|
||||
expect(wrapper.text()).toContain('Meddelande')
|
||||
expect(wrapper.text()).toContain('Beställnings-ID')
|
||||
expect(wrapper.text()).toContain('Hej fin bil!')
|
||||
expect(wrapper.text()).toContain('Vill köpa din bil.')
|
||||
})
|
||||
|
|
@ -148,19 +185,24 @@ describe('OrdersPage', () => {
|
|||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.text()).toContain('2026')
|
||||
expect(wrapper.text()).toContain('Skapad')
|
||||
})
|
||||
|
||||
it('shows empty state when no orders', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(mockFetchResponse(200, []))
|
||||
mockOrdersFetch([])
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.text()).toContain('Inga beställningar ännu')
|
||||
})
|
||||
|
||||
it('shows error state on API failure', async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(500, { message: 'Internal server error' }),
|
||||
)
|
||||
vi.mocked(globalThis.fetch).mockImplementation((url) => {
|
||||
const urlStr = String(url)
|
||||
if (urlStr.includes('/payment/swish-info')) {
|
||||
return mockFetchResponse(200, { number: '1234567890', amount: 49 })
|
||||
}
|
||||
return mockFetchResponse(500, { message: 'Internal server error' })
|
||||
})
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.text()).toContain('Kunde inte hämta beställningar')
|
||||
|
|
@ -170,19 +212,35 @@ describe('OrdersPage', () => {
|
|||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
const badges = wrapper.findAll('.badge')
|
||||
expect(badges[0].classes()).toContain('badge--success')
|
||||
expect(badges[1].classes()).toContain('badge--muted')
|
||||
expect(badges[0].classes()).toContain('badge--warning')
|
||||
expect(badges[1].classes()).toContain('badge--success')
|
||||
})
|
||||
|
||||
it('shows order id on pending payment orders', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const pendingCard = wrapper
|
||||
.findAll('.orders__card')
|
||||
.find((card) => card.text().includes('DEF456'))
|
||||
expect(pendingCard?.text()).toContain('Beställnings-ID')
|
||||
expect(pendingCard?.text()).toContain(
|
||||
'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows pay button only for pending payment orders', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const payLinks = wrapper.findAll('.orders__pay-btn')
|
||||
expect(payLinks).toHaveLength(1)
|
||||
expect(payLinks[0].text()).toBe('Betala nu')
|
||||
const pendingCard = wrapper
|
||||
.findAll('.orders__card')
|
||||
.find((card) => card.text().includes('DEF456'))
|
||||
const payLink = pendingCard?.find('a.orders__pay-btn')
|
||||
expect(payLink?.exists()).toBe(true)
|
||||
expect(payLink?.text()).toBe('Betala 49 kr')
|
||||
|
||||
const href = payLinks[0].attributes('href')
|
||||
const href = payLink?.attributes('href')
|
||||
expect(href).toContain('c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12')
|
||||
expect(href).toContain('plate=DEF456')
|
||||
})
|
||||
|
|
@ -194,7 +252,71 @@ describe('OrdersPage', () => {
|
|||
const sentCard = wrapper
|
||||
.findAll('.orders__card')
|
||||
.find((card) => card.text().includes('ABC123'))
|
||||
expect(sentCard?.find('.orders__pay-btn').exists()).toBe(false)
|
||||
expect(sentCard?.find('a.orders__pay-btn').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows edit link for pending payment orders', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const pendingCard = wrapper
|
||||
.findAll('.orders__card')
|
||||
.find((card) => card.text().includes('DEF456'))
|
||||
const editLink = pendingCard?.find('a.orders__edit-btn')
|
||||
expect(editLink?.exists()).toBe(true)
|
||||
expect(editLink?.text()).toBe('Redigera brev')
|
||||
|
||||
const href = editLink?.attributes('href')
|
||||
expect(href).toContain('c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12')
|
||||
expect(href).toContain('redigera')
|
||||
})
|
||||
|
||||
it('shows cancel button for pending payment orders', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const pendingCard = wrapper
|
||||
.findAll('.orders__card')
|
||||
.find((card) => card.text().includes('DEF456'))
|
||||
const cancelBtn = pendingCard?.find('.orders__cancel-btn')
|
||||
expect(cancelBtn?.exists()).toBe(true)
|
||||
expect(cancelBtn?.text()).toBe('Avbryt beställning')
|
||||
})
|
||||
|
||||
it('calls cancel API and updates status to Avbruten', async () => {
|
||||
vi.stubGlobal(
|
||||
'confirm',
|
||||
vi.fn(() => true),
|
||||
)
|
||||
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const pendingCard = wrapper
|
||||
.findAll('.orders__card')
|
||||
.find((card) => card.text().includes('DEF456'))
|
||||
await pendingCard?.find('.orders__cancel-btn').trigger('click')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'/api/orders/c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12/cancel',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
)
|
||||
expect(wrapper.text()).toContain('Avbruten')
|
||||
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('does not show edit or cancel actions for non-pending orders', async () => {
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const sentCard = wrapper
|
||||
.findAll('.orders__card')
|
||||
.find((card) => card.text().includes('ABC123'))
|
||||
expect(sentCard?.find('.orders__cancel-btn').exists()).toBe(false)
|
||||
expect(sentCard?.text()).not.toContain('Redigera brev')
|
||||
expect(sentCard?.text()).not.toContain('Avbryt beställning')
|
||||
})
|
||||
|
||||
it('renders processing status correctly', async () => {
|
||||
|
|
@ -208,9 +330,7 @@ describe('OrdersPage', () => {
|
|||
createdAt: '2026-05-15T10:00:00Z',
|
||||
},
|
||||
]
|
||||
vi.mocked(globalThis.fetch).mockResolvedValue(
|
||||
mockFetchResponse(200, ordersWithProcessing),
|
||||
)
|
||||
mockOrdersFetch(ordersWithProcessing)
|
||||
const { wrapper } = mountPage()
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(wrapper.text()).toContain('Hanteras')
|
||||
|
|
|
|||
|
|
@ -14,9 +14,26 @@ export function fetchOrders(): Promise<Order[]> {
|
|||
return request<Order[]>('/orders')
|
||||
}
|
||||
|
||||
export function fetchOrder(id: string): Promise<Order> {
|
||||
return request<Order>(`/orders/${id}`)
|
||||
}
|
||||
|
||||
export function createOrder(plate: string, letterText: string): Promise<Order> {
|
||||
return request<Order>('/orders', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ plate, letterText }),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateOrder(id: string, letterText: string): Promise<Order> {
|
||||
return request<Order>(`/orders/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ letterText }),
|
||||
})
|
||||
}
|
||||
|
||||
export function cancelOrder(id: string): Promise<Order> {
|
||||
return request<Order>(`/orders/${id}/cancel`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ const statusLabels: Record<string, string> = {
|
|||
sent: 'Skickat',
|
||||
delivered: 'Levererat',
|
||||
failed: 'Misslyckad',
|
||||
cancelled: 'Avbruten',
|
||||
}
|
||||
|
||||
const statusBadge: Record<string, string> = {
|
||||
|
|
@ -36,6 +37,7 @@ const statusBadge: Record<string, string> = {
|
|||
sent: 'badge--success',
|
||||
delivered: 'badge--success',
|
||||
failed: 'badge--danger',
|
||||
cancelled: 'badge--muted',
|
||||
}
|
||||
|
||||
const allStatuses = [
|
||||
|
|
@ -45,6 +47,7 @@ const allStatuses = [
|
|||
'sent',
|
||||
'delivered',
|
||||
'failed',
|
||||
'cancelled',
|
||||
]
|
||||
|
||||
const stats = computed(() => {
|
||||
|
|
|
|||
339
frontend/src/pages/EditOrderPage.vue
Normal file
339
frontend/src/pages/EditOrderPage.vue
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||
import { fetchOrder, updateOrder, type Order } from '@/api/orders'
|
||||
import { type LetterTemplate } from '@/data/templates'
|
||||
import TemplatePicker from '@/components/TemplatePicker.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const order = ref<Order | null>(null)
|
||||
const letterText = ref('')
|
||||
const loading = ref(true)
|
||||
const loadError = ref('')
|
||||
const submitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const showPicker = ref(false)
|
||||
|
||||
const orderId = computed(() => route.params.orderId as string)
|
||||
const plate = computed(() => order.value?.plate ?? '')
|
||||
const canEdit = computed(() => order.value?.status === 'pending_payment')
|
||||
|
||||
const charCount = computed(() => letterText.value.length)
|
||||
const maxChars = 1000
|
||||
const canSubmit = computed(
|
||||
() =>
|
||||
canEdit.value && letterText.value.trim().length > 0 && !submitting.value,
|
||||
)
|
||||
|
||||
const GDPR_FOOTER =
|
||||
'Detta brev skickades via Bilhej. Din adress hämtades från Transportstyrelsens fordonsregister och har raderats efter utskick. För frågor: hej@bilhalsning.se'
|
||||
|
||||
function handleTemplateSelect(template: LetterTemplate) {
|
||||
letterText.value = template.body
|
||||
}
|
||||
|
||||
async function loadOrder() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
|
||||
try {
|
||||
const fetched = await fetchOrder(orderId.value)
|
||||
order.value = fetched
|
||||
if (fetched.status === 'pending_payment') {
|
||||
letterText.value = fetched.letterText
|
||||
}
|
||||
} catch {
|
||||
loadError.value = 'Kunde inte hämta beställningen. Försök igen senare.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!canSubmit.value || !order.value) return
|
||||
|
||||
submitting.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await updateOrder(order.value.id, letterText.value)
|
||||
await router.push({
|
||||
name: 'payment',
|
||||
params: { orderId: order.value.id },
|
||||
query: { plate: order.value.plate },
|
||||
})
|
||||
} catch {
|
||||
errorMessage.value = 'Kunde inte spara ändringarna. Försök igen senare.'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadOrder)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="compose">
|
||||
<p
|
||||
v-if="loading"
|
||||
class="text-muted text-center compose__loading"
|
||||
role="status"
|
||||
>
|
||||
Laddar beställning...
|
||||
</p>
|
||||
|
||||
<div v-else-if="loadError" class="message message--error compose__error">
|
||||
{{ loadError }}
|
||||
<RouterLink to="/orders">Tillbaka till beställningar</RouterLink>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="order && !canEdit"
|
||||
class="message message--error compose__error"
|
||||
>
|
||||
Den här beställningen kan inte redigeras.
|
||||
<RouterLink to="/orders">Tillbaka till beställningar</RouterLink>
|
||||
</div>
|
||||
|
||||
<div v-else-if="order && canEdit" class="compose__layout">
|
||||
<div class="compose__editor">
|
||||
<h1 class="compose__title">Redigera brev</h1>
|
||||
<p class="compose__plate-badge">
|
||||
<span class="compose__plate-label">Regnr</span>
|
||||
<span class="compose__plate-value">{{ plate }}</span>
|
||||
</p>
|
||||
|
||||
<form class="compose__form" @submit.prevent="handleSubmit">
|
||||
<div class="field">
|
||||
<div class="compose__label-row">
|
||||
<label for="letter" class="field__label">Ditt meddelande</label>
|
||||
<button
|
||||
type="button"
|
||||
class="compose__templates-btn"
|
||||
@click="showPicker = true"
|
||||
>
|
||||
Visa mallar
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
id="letter"
|
||||
v-model="letterText"
|
||||
class="field__input compose__textarea"
|
||||
:maxlength="maxChars"
|
||||
rows="12"
|
||||
placeholder="Skriv ditt meddelande här..."
|
||||
></textarea>
|
||||
<p
|
||||
class="field__hint compose__counter"
|
||||
:class="{ 'compose__counter--warn': charCount > 900 }"
|
||||
>
|
||||
{{ charCount }} / {{ maxChars }} tecken
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="message message--error">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn--primary btn--lg compose__submit"
|
||||
:disabled="!canSubmit"
|
||||
>
|
||||
{{ submitting ? 'Sparar...' : 'Spara och fortsätt till betalning' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="compose__preview">
|
||||
<h2 class="compose__preview-title">Förhandsvisning</h2>
|
||||
<div class="compose__preview-page">
|
||||
<p class="compose__preview-plate-label">
|
||||
Registreringsnummer: {{ plate }}
|
||||
</p>
|
||||
<p class="compose__preview-body">
|
||||
{{ letterText }}
|
||||
</p>
|
||||
<hr class="compose__preview-divider" />
|
||||
<p class="compose__preview-footer-text">{{ GDPR_FOOTER }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TemplatePicker
|
||||
v-if="showPicker"
|
||||
@select="handleTemplateSelect"
|
||||
@close="showPicker = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.compose__loading {
|
||||
margin: var(--space-2xl) auto;
|
||||
}
|
||||
|
||||
.compose__layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-xl);
|
||||
align-items: start;
|
||||
max-width: 56rem;
|
||||
margin: var(--space-2xl) auto 0;
|
||||
padding: 0 var(--space-lg);
|
||||
}
|
||||
|
||||
.compose__title {
|
||||
margin: 0 0 var(--space-lg) 0;
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.compose__plate-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
background: var(--color-primary-soft);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-full);
|
||||
margin: 0 0 var(--space-lg) 0;
|
||||
}
|
||||
|
||||
.compose__plate-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.compose__plate-value {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.compose__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.compose__label-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.compose__templates-btn {
|
||||
background: var(--color-primary-soft);
|
||||
border: 1px solid #ddd6fe;
|
||||
color: var(--color-primary-dark);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0.3rem 0.75rem;
|
||||
border-radius: var(--radius-full);
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.compose__templates-btn:hover {
|
||||
background: #e9d5ff;
|
||||
border-color: #c4b5fd;
|
||||
}
|
||||
|
||||
.compose__textarea {
|
||||
resize: vertical;
|
||||
min-height: 10rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.compose__counter {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.compose__counter--warn {
|
||||
color: var(--color-danger) !important;
|
||||
}
|
||||
|
||||
.compose__submit {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.compose__error {
|
||||
max-width: 28rem;
|
||||
margin: var(--space-2xl) auto;
|
||||
}
|
||||
|
||||
.compose__preview-body {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.compose__preview {
|
||||
position: sticky;
|
||||
top: 5rem;
|
||||
}
|
||||
|
||||
.compose__preview-title {
|
||||
margin: 0 0 var(--space-md) 0;
|
||||
font-size: 1rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.compose__preview-page {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-xl);
|
||||
box-shadow: var(--shadow-lg);
|
||||
font-family: var(--font-serif);
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.7;
|
||||
color: var(--color-ink);
|
||||
min-height: 20rem;
|
||||
}
|
||||
|
||||
.compose__preview-plate-label {
|
||||
margin: 0 0 var(--space-lg) 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.compose__preview-body {
|
||||
margin: 0 0 var(--space-lg) 0;
|
||||
}
|
||||
|
||||
.compose__preview-divider {
|
||||
margin: var(--space-lg) 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.compose__preview-footer-text {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-soft);
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.compose__layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.compose__preview {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,11 +1,25 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { fetchOrders, type Order } from '@/api/orders'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { fetchOrders, cancelOrder, type Order } from '@/api/orders'
|
||||
import { fetchSwishInfo } from '@/api/payment'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
const ORDER_AMOUNT_FALLBACK = 49
|
||||
|
||||
const orders = ref<Order[]>([])
|
||||
const orderAmount = ref(ORDER_AMOUNT_FALLBACK)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const actionError = ref('')
|
||||
const cancellingId = ref<string | null>(null)
|
||||
|
||||
const pendingOrders = computed(() =>
|
||||
orders.value.filter((order) => order.status === 'pending_payment'),
|
||||
)
|
||||
|
||||
const completedOrders = computed(() =>
|
||||
orders.value.filter((order) => order.status !== 'pending_payment'),
|
||||
)
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending_payment: 'Väntar på betalning',
|
||||
|
|
@ -14,6 +28,7 @@ const statusLabels: Record<string, string> = {
|
|||
sent: 'Skickat',
|
||||
delivered: 'Levererat',
|
||||
failed: 'Misslyckad',
|
||||
cancelled: 'Avbruten',
|
||||
}
|
||||
|
||||
const statusBadge: Record<string, string> = {
|
||||
|
|
@ -23,6 +38,7 @@ const statusBadge: Record<string, string> = {
|
|||
sent: 'badge--success',
|
||||
delivered: 'badge--success',
|
||||
failed: 'badge--danger',
|
||||
cancelled: 'badge--muted',
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
|
|
@ -33,15 +49,47 @@ function formatDate(iso: string): string {
|
|||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
async function loadOrders() {
|
||||
try {
|
||||
orders.value = await fetchOrders()
|
||||
const [fetchedOrders, swishInfo] = await Promise.all([
|
||||
fetchOrders(),
|
||||
fetchSwishInfo().catch(() => ({
|
||||
number: '',
|
||||
amount: ORDER_AMOUNT_FALLBACK,
|
||||
})),
|
||||
])
|
||||
orders.value = fetchedOrders
|
||||
orderAmount.value = swishInfo.amount
|
||||
} catch {
|
||||
error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleCancel(order: Order) {
|
||||
if (
|
||||
!window.confirm(
|
||||
'Vill du avbryta beställningen? Den kan inte återställas efteråt.',
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
actionError.value = ''
|
||||
cancellingId.value = order.id
|
||||
|
||||
try {
|
||||
const updated = await cancelOrder(order.id)
|
||||
orders.value = orders.value.map((o) => (o.id === updated.id ? updated : o))
|
||||
} catch {
|
||||
actionError.value = 'Kunde inte avbryta beställningen. Försök igen senare.'
|
||||
} finally {
|
||||
cancellingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadOrders)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -73,51 +121,52 @@ onMounted(async () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="orders__list">
|
||||
<div v-for="order in orders" :key="order.id" class="orders__card">
|
||||
<div class="orders__card-top">
|
||||
<span class="orders__plate">{{ order.plate }}</span>
|
||||
<span
|
||||
class="badge"
|
||||
:class="statusBadge[order.status] || 'badge--muted'"
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="actionError"
|
||||
class="message message--error orders__action-error"
|
||||
role="alert"
|
||||
>
|
||||
{{ statusLabels[order.status] || order.status }}
|
||||
{{ actionError }}
|
||||
</div>
|
||||
|
||||
<section v-if="pendingOrders.length" class="orders__section">
|
||||
<h2 class="orders__section-title">Obetalda beställningar</h2>
|
||||
<div class="orders__list">
|
||||
<div
|
||||
v-for="order in pendingOrders"
|
||||
:key="order.id"
|
||||
class="orders__card orders__card--pending"
|
||||
>
|
||||
<div class="orders__card-content">
|
||||
<div class="orders__card-head">
|
||||
<p class="orders__plate-badge">
|
||||
<span class="orders__plate-label">Regnr</span>
|
||||
<span class="orders__plate-value">{{ order.plate }}</span>
|
||||
</p>
|
||||
<span class="badge badge--warning">
|
||||
{{ statusLabels[order.status] }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="orders__card-meta">
|
||||
<span class="orders__meta-label">Beställnings-ID</span>
|
||||
<span class="orders__meta-value orders__order-id">{{
|
||||
order.id
|
||||
}}</span>
|
||||
|
||||
<span class="orders__meta-label">Meddelande</span>
|
||||
<span class="orders__meta-value orders__message">{{
|
||||
order.letterText
|
||||
}}</span>
|
||||
|
||||
<span class="orders__meta-label">Datum</span>
|
||||
<span class="orders__meta-value">{{
|
||||
formatDate(order.createdAt)
|
||||
}}</span>
|
||||
|
||||
<template v-if="order.trackingId">
|
||||
<span class="orders__meta-label">Spårning</span>
|
||||
<a
|
||||
class="orders__tracking"
|
||||
:href="`https://www.postnord.se/verktyg/spara/?id=${order.trackingId}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ order.trackingId }}
|
||||
</a>
|
||||
</template>
|
||||
<div class="orders__preview-box">
|
||||
<p class="orders__preview">{{ order.letterText }}</p>
|
||||
</div>
|
||||
|
||||
<p class="orders__order-date">
|
||||
Skapad {{ formatDate(order.createdAt) }}
|
||||
</p>
|
||||
|
||||
<div class="orders__order-ref orders__order-ref--highlight">
|
||||
<p class="orders__order-ref-label">Beställnings-ID</p>
|
||||
<p class="orders__order-id">{{ order.id }}</p>
|
||||
</div>
|
||||
|
||||
<div class="orders__price-row">
|
||||
<span class="orders__price-label">Att betala</span>
|
||||
<span class="orders__price-amount">{{ orderAmount }} kr</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="order.status === 'pending_payment'"
|
||||
class="orders__card-actions"
|
||||
>
|
||||
<RouterLink
|
||||
:to="{
|
||||
name: 'payment',
|
||||
|
|
@ -126,12 +175,91 @@ onMounted(async () => {
|
|||
}"
|
||||
class="btn btn--primary orders__pay-btn"
|
||||
>
|
||||
Betala nu
|
||||
Betala {{ orderAmount }} kr
|
||||
</RouterLink>
|
||||
|
||||
<div class="orders__links">
|
||||
<RouterLink
|
||||
:to="{
|
||||
name: 'edit-order',
|
||||
params: { orderId: order.id },
|
||||
}"
|
||||
class="orders__text-link orders__edit-btn"
|
||||
>
|
||||
Redigera brev
|
||||
</RouterLink>
|
||||
<span class="orders__link-sep" aria-hidden="true">·</span>
|
||||
<button
|
||||
type="button"
|
||||
class="orders__text-link orders__text-link--danger orders__cancel-btn"
|
||||
spellcheck="false"
|
||||
:disabled="cancellingId === order.id"
|
||||
@click="handleCancel(order)"
|
||||
>
|
||||
{{
|
||||
cancellingId === order.id
|
||||
? 'Avbryter...'
|
||||
: 'Avbryt beställning'
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="completedOrders.length" class="orders__section">
|
||||
<h2 v-if="pendingOrders.length" class="orders__section-title">
|
||||
Tidigare beställningar
|
||||
</h2>
|
||||
<div class="orders__list">
|
||||
<div
|
||||
v-for="order in completedOrders"
|
||||
:key="order.id"
|
||||
class="orders__card"
|
||||
>
|
||||
<div class="orders__card-content">
|
||||
<div class="orders__card-head">
|
||||
<p class="orders__plate-badge">
|
||||
<span class="orders__plate-label">Regnr</span>
|
||||
<span class="orders__plate-value">{{ order.plate }}</span>
|
||||
</p>
|
||||
<span
|
||||
class="badge"
|
||||
:class="statusBadge[order.status] || 'badge--muted'"
|
||||
>
|
||||
{{ statusLabels[order.status] || order.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="orders__preview-box">
|
||||
<p class="orders__preview">{{ order.letterText }}</p>
|
||||
</div>
|
||||
|
||||
<p class="orders__order-date">
|
||||
Skapad {{ formatDate(order.createdAt) }}
|
||||
</p>
|
||||
|
||||
<a
|
||||
v-if="order.trackingId"
|
||||
class="btn btn--ghost orders__tracking-btn"
|
||||
:href="`https://www.postnord.se/verktyg/spara/?id=${order.trackingId}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Spåra brev · {{ order.trackingId }}
|
||||
</a>
|
||||
|
||||
<div class="orders__order-ref">
|
||||
<p class="orders__order-ref-label">Beställnings-ID</p>
|
||||
<p class="orders__order-id">{{ order.id }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -153,6 +281,23 @@ onMounted(async () => {
|
|||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.orders__action-error {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.orders__section + .orders__section {
|
||||
margin-top: var(--space-xl);
|
||||
}
|
||||
|
||||
.orders__section-title {
|
||||
margin: 0 0 var(--space-md) 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.orders__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -164,76 +309,191 @@ onMounted(async () => {
|
|||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-card);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.orders__card-top {
|
||||
.orders__card-content {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.orders__card--pending .orders__order-date {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.orders__card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
background: var(--color-border-light);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.orders__plate {
|
||||
font-size: 1.125rem;
|
||||
.orders__plate-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
background: var(--color-primary-soft);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-full);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.orders__plate-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.orders__plate-value {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.orders__preview-box {
|
||||
background: var(--color-border-light);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-md);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.orders__preview {
|
||||
margin: 0;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-ink);
|
||||
white-space: pre-wrap;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.orders__order-date {
|
||||
margin: 0 0 var(--space-sm) 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.orders__tracking-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
margin: var(--space-md) 0 var(--space-sm);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary-dark);
|
||||
border-color: #ddd6fe;
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.orders__tracking-btn:hover {
|
||||
background: #dbeafe;
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
|
||||
.orders__order-ref {
|
||||
margin: var(--space-md) 0 0;
|
||||
padding-top: var(--space-md);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.orders__order-ref--highlight {
|
||||
margin: 0 0 var(--space-md);
|
||||
padding: var(--space-md);
|
||||
background: var(--color-border-light);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.orders__order-ref-label {
|
||||
margin: 0 0 var(--space-xs) 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.orders__card-meta {
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--space-sm) var(--space-lg);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.orders__meta-label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-soft);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.orders__meta-value {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.orders__order-id {
|
||||
margin: 0;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-ink);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.orders__message {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.orders__tracking {
|
||||
.orders__price-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-md);
|
||||
padding-bottom: var(--space-md);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.orders__price-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.orders__tracking:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.orders__card-actions {
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-border-light);
|
||||
.orders__price-amount {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.orders__pay-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.orders__links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.orders__text-link {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.orders__text-link:hover:not(:disabled) {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.orders__text-link--danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.orders__text-link--danger:hover:not(:disabled) {
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.orders__text-link:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.orders__link-sep {
|
||||
color: var(--color-soft);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.orders__empty {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import ForgotPasswordPage from '@/pages/ForgotPasswordPage.vue'
|
|||
import ResetPasswordPage from '@/pages/ResetPasswordPage.vue'
|
||||
import ChangePasswordPage from '@/pages/ChangePasswordPage.vue'
|
||||
import OrdersPage from '@/pages/OrdersPage.vue'
|
||||
import EditOrderPage from '@/pages/EditOrderPage.vue'
|
||||
import AdminPage from '@/pages/AdminPage.vue'
|
||||
import PaymentRedirect from '@/pages/PaymentRedirect.vue'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
|
@ -34,6 +35,12 @@ const router = createRouter({
|
|||
component: OrdersPage,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/bestallning/:orderId/redigera',
|
||||
name: 'edit-order',
|
||||
component: EditOrderPage,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/andra-losenord',
|
||||
name: 'change-password',
|
||||
|
|
|
|||
Loading…
Reference in a new issue