Add admin order fulfillment tracking.
Some checks failed
CI / Lint, type check, unit tests, coverage (pull_request) Failing after 1m50s
CI / E2E browser tests (pull_request) Failing after 1m38s

Register PostNord shipments, admin notes, and guarded status transitions
with customer emails. Expandable admin UI, V11 migration, serial E2E suite,
and AGENTS.md Docker-only E2E guidance.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Joakim Mörling 2026-05-27 12:21:17 +02:00
parent 17fe67ae3f
commit 1c9269699e
26 changed files with 1251 additions and 303 deletions

View file

@ -74,6 +74,14 @@ stripe listen --forward-to localhost:8080/api/webhooks/stripe
Flyway migrations run automatically on Spring Boot startup. Migration files Flyway migrations run automatically on Spring Boot startup. Migration files
live in `backend/src/main/resources/db/migration/`. Naming: `V<number>__descriptive_name.sql`. live in `backend/src/main/resources/db/migration/`. Naming: `V<number>__descriptive_name.sql`.
**Before adding a migration:** run `./scripts/next-flyway-version.sh` and use that
version. Never reuse a version number already on `master`. Never edit a migration
after it has merged — add a new higher version instead. CI runs
`scripts/check-flyway-migrations.sh` against `origin/master`.
If local dev Postgres fails with Flyway checksum / “migration not resolved locally”
after switching branches, run `./gradlew reset` (wipes the Docker DB volume).
To reset: `docker compose down -v && docker compose up -d`. To reset: `docker compose down -v && docker compose up -d`.
Flyway schema migrations live in `db/migration/`; dev-only seeds (test users, Flyway schema migrations live in `db/migration/`; dev-only seeds (test users,
@ -187,6 +195,9 @@ After the address is used to mail the letter, it must be deleted. The Order
entity must NOT have an address field. The address lookup and mailing are entity must NOT have an address field. The address lookup and mailing are
external/human processes in Phase 0. external/human processes in Phase 0.
### E2E must use Docker (not host Playwright)
See **Testing Approach → E2E (Playwright) — Docker only** above. Do not run `npx playwright install` or `npm run test:e2e` on the host when verifying E2E.
### Local email (Mailpit) ### Local email (Mailpit)
`docker compose up` includes Mailpit (`ghcr.io/axllent/mailpit:v1.28`); password-reset mail appears at http://localhost:8025. E2E verifies SMTP via Mailpit API (`frontend/e2e/helpers/mailpit.ts`). Production uses Resend SMTP—see docs/production-email-checklist.md. `docker compose up` includes Mailpit (`ghcr.io/axllent/mailpit:v1.28`); password-reset mail appears at http://localhost:8025. E2E verifies SMTP via Mailpit API (`frontend/e2e/helpers/mailpit.ts`). Production uses Resend SMTP—see docs/production-email-checklist.md.
@ -228,12 +239,40 @@ the same PR — never merge code without corresponding tests.
- Component tests with Vue Test Utils where needed. - Component tests with Vue Test Utils where needed.
- E2E tests with Playwright in `frontend/e2e/`. - E2E tests with Playwright in `frontend/e2e/`.
### E2E (Playwright) ### E2E (Playwright) — **Docker only**
- `npm run test:e2e` — runs all Playwright tests (headless Chromium).
- Requires `docker compose up` (backend + frontend running). **Agents and humans: never run Playwright on the host.**
- Config: `frontend/playwright.config.ts`.
- Tests: `frontend/e2e/*.spec.ts`. | Do **not** run | Why |
- Docker CI: `npm run test:e2e:ci` — runs tests inside official Playwright Docker container. Starts postgres, backend, frontend, and Playwright via `docker-compose.ci.yml`. Use for consistent environment or CI pipelines. |----------------|-----|
| `npx playwright test` | Wrong environment; needs Docker stack |
| `npm run test:e2e` | Same — host Playwright, not supported for agents |
| `npx playwright install` | Do not install browsers on the host; the Playwright image already includes them |
**Always use Docker** (`docker-compose.e2e.yml`): isolated postgres (tmpfs), backend, frontend, Mailpit, and the official Playwright container on the `e2e` network (`PLAYWRIGHT_BASE_URL=http://frontend`).
**Full E2E suite** (same as Forgejo CI / `./gradlew check`):
```bash
# from repo root — set env (or use .env; see .env.example)
cd frontend && npm run test:e2e:ci
# equivalent:
./gradlew frontendE2E
```
**Single spec or project** (stack must be reachable on the `e2e` network):
```bash
# from repo root, after exporting the same vars as frontendE2E / .env
docker compose -f docker-compose.e2e.yml up -d --build postgres mailpit backend frontend
docker compose -f docker-compose.e2e.yml run --rm --build playwright \
sh -c 'npx playwright test admin-fulfillment.spec.ts --project=chromium-serial --reporter=list'
docker compose -f docker-compose.e2e.yml down
```
- Config: `frontend/playwright.config.ts`
- Tests: `frontend/e2e/*.spec.ts`
- Serial specs (`deferred-payment-admin`, `admin-fulfillment`): Playwright project `chromium-serial`, `workers: 1`
### CI (future) ### CI (future)
- `./gradlew check` and `npm run test && npm run lint` must pass before merge. - `./gradlew check` and `npm run test && npm run lint` must pass before merge.

View file

@ -10,8 +10,9 @@ 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.AdminOrderResponse; import se.bilhalsning.dto.AdminOrderResponse;
import se.bilhalsning.dto.RegisterShipmentRequest;
import se.bilhalsning.dto.UpdateAdminNotesRequest;
import se.bilhalsning.dto.UpdateStatusRequest; import se.bilhalsning.dto.UpdateStatusRequest;
import se.bilhalsning.dto.UpdateTrackingRequest;
import se.bilhalsning.entity.Order; import se.bilhalsning.entity.Order;
import se.bilhalsning.service.OrderService; import se.bilhalsning.service.OrderService;
@ -41,11 +42,22 @@ public class AdminController {
return ResponseEntity.ok(toAdminResponse(order)); return ResponseEntity.ok(toAdminResponse(order));
} }
@PatchMapping("/orders/{id}") @PatchMapping("/orders/{id}/register-shipment")
public ResponseEntity<AdminOrderResponse> updateTracking( public ResponseEntity<AdminOrderResponse> registerShipment(
@PathVariable UUID id, @PathVariable UUID id,
@Valid @RequestBody UpdateTrackingRequest request) { @Valid @RequestBody RegisterShipmentRequest request) {
Order order = orderService.updateTracking(id, request.trackingId()); Order order = orderService.registerShipment(
id,
request.trackingInput(),
request.notifyCustomerOrDefault());
return ResponseEntity.ok(toAdminResponse(order));
}
@PatchMapping("/orders/{id}/notes")
public ResponseEntity<AdminOrderResponse> updateNotes(
@PathVariable UUID id,
@RequestBody UpdateAdminNotesRequest request) {
Order order = orderService.updateAdminNotes(id, request.adminNotes());
return ResponseEntity.ok(toAdminResponse(order)); return ResponseEntity.ok(toAdminResponse(order));
} }
@ -59,6 +71,8 @@ public class AdminController {
order.getStatus().getValue(), order.getStatus().getValue(),
order.getTrackingId(), order.getTrackingId(),
order.getAmountPaid(), order.getAmountPaid(),
order.getShippedAt(),
order.getAdminNotes(),
order.getCreatedAt() order.getCreatedAt()
); );
} }

View file

@ -12,5 +12,7 @@ public record AdminOrderResponse(
String status, String status,
String trackingId, String trackingId,
BigDecimal amountPaid, BigDecimal amountPaid,
Instant shippedAt,
String adminNotes,
Instant createdAt Instant createdAt
) {} ) {}

View file

@ -0,0 +1,13 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.NotBlank;
public record RegisterShipmentRequest(
@NotBlank(message = "Spårnings-ID krävs")
String trackingInput,
Boolean notifyCustomer
) {
public boolean notifyCustomerOrDefault() {
return notifyCustomer == null || notifyCustomer;
}
}

View file

@ -0,0 +1,5 @@
package se.bilhalsning.dto;
public record UpdateAdminNotesRequest(
String adminNotes
) {}

View file

@ -6,7 +6,7 @@ import jakarta.validation.constraints.Pattern;
public record UpdateStatusRequest( public record UpdateStatusRequest(
@NotBlank(message = "Status krävs") @NotBlank(message = "Status krävs")
@Pattern( @Pattern(
regexp = "pending_payment|paid|processing|sent|delivered|failed", regexp = "pending_payment|paid|processing|sent|delivered|failed|cancelled",
message = "Ogiltig status" message = "Ogiltig status"
) )
String status String status

View file

@ -1,5 +0,0 @@
package se.bilhalsning.dto;
public record UpdateTrackingRequest(
String trackingId
) {}

View file

@ -43,6 +43,12 @@ public class Order {
@Column(name = "tracking_id", length = 100) @Column(name = "tracking_id", length = 100)
private String trackingId; private String trackingId;
@Column(name = "shipped_at")
private Instant shippedAt;
@Column(name = "admin_notes", columnDefinition = "text")
private String adminNotes;
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
private Instant createdAt; private Instant createdAt;
@ -130,6 +136,22 @@ public class Order {
this.trackingId = trackingId; this.trackingId = trackingId;
} }
public Instant getShippedAt() {
return shippedAt;
}
public void setShippedAt(Instant shippedAt) {
this.shippedAt = shippedAt;
}
public String getAdminNotes() {
return adminNotes;
}
public void setAdminNotes(String adminNotes) {
this.adminNotes = adminNotes;
}
public Instant getCreatedAt() { public Instant getCreatedAt() {
return createdAt; return createdAt;
} }

View file

@ -7,6 +7,7 @@ import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus; import se.bilhalsning.entity.OrderStatus;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@Repository @Repository
@ -17,4 +18,7 @@ public interface OrderRepository extends JpaRepository<Order, UUID> {
@EntityGraph(attributePaths = {"user"}) @EntityGraph(attributePaths = {"user"})
List<Order> findAllByOrderByCreatedAtDesc(); List<Order> findAllByOrderByCreatedAtDesc();
@EntityGraph(attributePaths = {"user"})
Optional<Order> findWithUserById(UUID id);
} }

View file

@ -93,4 +93,71 @@ public class EmailService {
throw new IllegalStateException("Kunde inte skicka e-post just nu"); throw new IllegalStateException("Kunde inte skicka e-post just nu");
} }
} }
public void sendOrderProcessingEmail(String toEmail, String plate, String ordersUrl) {
String subject = "Din beställning hanteras BilHej";
String body = """
Hej,
Tack för din betalning! Vi har tagit emot din beställning för fordonet %s och börjar hantera brevet.
Du kan följa status dina beställningar här:
%s
Vänliga hälsningar,
BilHej
""".formatted(plate, ordersUrl);
sendPlainText(toEmail, subject, body);
}
public void sendOrderSentEmail(String toEmail, String plate, String trackingId, String trackingUrl) {
String subject = "Ditt brev är skickat BilHej";
String body = """
Hej,
Ditt brev till fordonet %s har skickats med PostNord.
Spårnings-ID: %s
Spåra brevet: %s
Vänliga hälsningar,
BilHej
""".formatted(plate, trackingId, trackingUrl);
sendPlainText(toEmail, subject, body);
}
public void sendOrderFailedEmail(String toEmail, String plate, String ordersUrl) {
String subject = "Din beställning kunde inte slutföras BilHej";
String body = """
Hej,
Tyvärr kunde vi inte slutföra din beställning för fordonet %s. Vi återkommer om återbetalning behövs.
Se dina beställningar här:
%s
Vänliga hälsningar,
BilHej
""".formatted(plate, ordersUrl);
sendPlainText(toEmail, subject, body);
}
private void sendPlainText(String toEmail, String subject, String body) {
if (mailHost == null || mailHost.isBlank() || mailSender == null) {
log.info("SMTP not configured. Email to {} — subject: {}", toEmail, subject);
return;
}
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(mailFrom);
message.setTo(toEmail);
message.setSubject(subject);
message.setText(body);
try {
mailSender.send(message);
} catch (MailException ex) {
log.error("Failed to send email to {}", toEmail, ex);
throw new IllegalStateException("Kunde inte skicka e-post just nu");
}
}
} }

View file

@ -0,0 +1,69 @@
package se.bilhalsning.service;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.User;
import se.bilhalsning.repository.UserRepository;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class OrderNotificationService {
private final UserRepository userRepository;
private final EmailService emailService;
@Value("${app.public-base-url:http://localhost:3000}")
private String publicBaseUrl;
public void notifyOrderProcessing(Order order) {
String email = resolveCustomerEmail(order);
if (email.isBlank()) {
return;
}
emailService.sendOrderProcessingEmail(
email,
order.getPlate(),
ordersPageUrl());
}
public void notifyOrderSent(Order order, String trackingId) {
String email = resolveCustomerEmail(order);
if (email.isBlank()) {
return;
}
String trackingUrl = "https://www.postnord.se/verktyg/spara/?id=" + trackingId;
emailService.sendOrderSentEmail(email, order.getPlate(), trackingId, trackingUrl);
}
public void notifyOrderFailed(Order order) {
String email = resolveCustomerEmail(order);
if (email.isBlank()) {
return;
}
emailService.sendOrderFailedEmail(email, order.getPlate(), ordersPageUrl());
}
private String resolveCustomerEmail(Order order) {
if (order.getUser() != null && order.getUser().getEmail() != null) {
return order.getUser().getEmail();
}
UUID userId = order.getUserId();
if (userId == null) {
return "";
}
return userRepository.findById(userId)
.map(User::getEmail)
.orElse("");
}
private String ordersPageUrl() {
String base = publicBaseUrl.endsWith("/")
? publicBaseUrl.substring(0, publicBaseUrl.length() - 1)
: publicBaseUrl;
return base + "/mina-bestallningar";
}
}

View file

@ -7,8 +7,11 @@ 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
@ -16,6 +19,7 @@ import java.util.UUID;
public class OrderService { public class OrderService {
private final OrderRepository orderRepository; private final OrderRepository orderRepository;
private final OrderNotificationService orderNotificationService;
public Order createOrder(UUID userId, String plate, String letterText) { public Order createOrder(UUID userId, String plate, String letterText) {
Order order = new Order(); Order order = new Order();
@ -40,26 +44,68 @@ public class OrderService {
} }
public Order updateOrderStatus(UUID orderId, String statusString) { public Order updateOrderStatus(UUID orderId, String statusString) {
Order order = orderRepository.findById(orderId) Order order = requireOrder(orderId);
.orElseThrow(() -> new OrderNotFoundException(orderId)); OrderStatus newStatus = parseStatus(statusString);
OrderStatus previousStatus = order.getStatus();
OrderStatus newStatus = OrderStatus.valueOf(statusString.toUpperCase()); validateAdminStatusTransition(order, newStatus);
order.setStatus(newStatus); order.setStatus(newStatus);
return orderRepository.save(order); 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 updateTracking(UUID orderId, String trackingId) { public Order registerShipment(UUID orderId, String rawTrackingInput, boolean notifyCustomer) {
Order order = orderRepository.findById(orderId) String trackingId = PostNordTrackingNormalizer.normalize(rawTrackingInput);
.orElseThrow(() -> new OrderNotFoundException(orderId)); 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); 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); 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);
return orderRepository.save(order); Order saved = orderRepository.save(order);
orderNotificationService.notifyOrderProcessing(saved);
return saved;
} }
public Order cancelOrder(UUID orderId, UUID userId) { public Order cancelOrder(UUID orderId, UUID userId) {
@ -74,6 +120,11 @@ 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));
@ -89,4 +140,53 @@ 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();
}
} }

View file

@ -0,0 +1,45 @@
package se.bilhalsning.util;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
public final class PostNordTrackingNormalizer {
private PostNordTrackingNormalizer() {
}
public static String normalize(String raw) {
if (raw == null || raw.isBlank()) {
throw new IllegalArgumentException("Spårnings-ID krävs");
}
String trimmed = raw.trim();
if (trimmed.toLowerCase().contains("postnord")) {
String fromUrl = extractIdFromPostNordUrl(trimmed);
if (fromUrl != null && !fromUrl.isBlank()) {
trimmed = fromUrl;
}
}
return trimmed.replaceAll("\\s+", "");
}
private static String extractIdFromPostNordUrl(String url) {
try {
URI uri = URI.create(url);
String query = uri.getQuery();
if (query == null) {
return null;
}
for (String param : query.split("&")) {
if (param.startsWith("id=")) {
return URLDecoder.decode(param.substring(3), StandardCharsets.UTF_8).trim();
}
}
} catch (IllegalArgumentException ignored) {
return null;
}
return null;
}
}

View file

@ -0,0 +1,14 @@
-- Dev/CI: order in "processing" for admin fulfillment testing
INSERT INTO orders (id, user_id, plate, letter_text, status, amount_paid, tracking_id, created_at, updated_at)
VALUES (
'c4eebc99-9c0b-4ef8-bb6d-6bb9bd380a14',
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
'JKL012',
'Hej! Bara en påminnelse om serviceboken.',
'processing',
49.00,
NULL,
TIMESTAMP '2026-05-16 09:00:00',
TIMESTAMP '2026-05-16 09:00:00'
)
ON CONFLICT (id) DO NOTHING;

View file

@ -0,0 +1,3 @@
ALTER TABLE orders ADD COLUMN shipped_at TIMESTAMP WITH TIME ZONE;
ALTER TABLE orders ADD COLUMN admin_notes TEXT;

View file

@ -1,6 +1,6 @@
package se.bilhalsning.controller; package se.bilhalsning.controller;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when; 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.get;
@ -23,6 +23,7 @@ import org.springframework.test.web.servlet.MockMvc;
import se.bilhalsning.entity.Order; import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus; import se.bilhalsning.entity.OrderStatus;
import se.bilhalsning.entity.User; import se.bilhalsning.entity.User;
import se.bilhalsning.exception.InvalidOrderStateException;
import se.bilhalsning.exception.OrderNotFoundException; import se.bilhalsning.exception.OrderNotFoundException;
import se.bilhalsning.service.OrderService; import se.bilhalsning.service.OrderService;
@ -61,151 +62,93 @@ 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].letterText").value("Test letter"))
.andExpect(jsonPath("$[0].status").value("sent")); .andExpect(jsonPath("$[0].status").value("sent"));
} }
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldReturnEmptyArrayWhenNoOrders() throws Exception {
when(orderService.getAllOrders()).thenReturn(List.of());
mockMvc.perform(get("/api/admin/orders"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$").isEmpty());
}
@Test
void shouldReturn403WhenPatchingStatusWithoutAuth() throws Exception {
mockMvc.perform(patch("/api/admin/orders/{id}/status",
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"paid\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "test@bilhej.se", roles = "USER")
void shouldReturn403WhenPatchingStatusAsNonAdmin() throws Exception {
mockMvc.perform(patch("/api/admin/orders/{id}/status",
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"paid\"}"))
.andExpect(status().isForbidden());
}
@Test @Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN") @WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldUpdateOrderStatusSuccessfully() throws Exception { void shouldUpdateOrderStatusSuccessfully() throws Exception {
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.PAID); Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.FAILED);
when(orderService.updateOrderStatus(eq(orderId), eq("paid"))).thenReturn(order); when(orderService.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)
.content("{\"status\":\"paid\"}")) .content("{\"status\":\"failed\"}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(orderId.toString())) .andExpect(jsonPath("$.status").value("failed"));
.andExpect(jsonPath("$.status").value("paid"));
} }
@Test @Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN") @WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldReturn400WhenStatusIsInvalid() throws Exception { void shouldReturn409WhenStatusTransitionInvalid() throws Exception {
mockMvc.perform(patch("/api/admin/orders/{id}/status",
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"invalid_status\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldReturn400WhenStatusIsBlank() throws Exception {
mockMvc.perform(patch("/api/admin/orders/{id}/status",
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldReturn404WhenOrderNotFound() 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("paid"))) when(orderService.updateOrderStatus(eq(orderId), eq("delivered")))
.thenThrow(new OrderNotFoundException(orderId)); .thenThrow(new InvalidOrderStateException("Ogiltig övergång"));
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)
.content("{\"status\":\"paid\"}")) .content("{\"status\":\"delivered\"}"))
.andExpect(status().isNotFound()); .andExpect(status().isConflict());
}
@Test
void shouldReturn403WhenPatchingTrackingWithoutAuth() throws Exception {
mockMvc.perform(patch("/api/admin/orders/{id}",
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"trackingId\":\"PN123456789\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "test@bilhej.se", roles = "USER")
void shouldReturn403WhenPatchingTrackingAsNonAdmin() throws Exception {
mockMvc.perform(patch("/api/admin/orders/{id}",
"c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"trackingId\":\"PN123456789\"}"))
.andExpect(status().isForbidden());
} }
@Test @Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN") @WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldUpdateTrackingSuccessfully() throws Exception { void shouldRegisterShipmentSuccessfully() throws Exception {
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.SENT); Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.SENT);
order.setTrackingId("PN123456789"); order.setTrackingId("PN123456789");
order.setShippedAt(Instant.parse("2026-05-13T12:00:00Z"));
when(orderService.updateTracking(eq(orderId), eq("PN123456789"))).thenReturn(order); when(orderService.registerShipment(eq(orderId), eq("PN123456789"), eq(true)))
.thenReturn(order);
mockMvc.perform(patch("/api/admin/orders/{id}", orderId) mockMvc.perform(patch("/api/admin/orders/{id}/register-shipment", orderId)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"trackingId\":\"PN123456789\"}")) .content("{\"trackingInput\":\"PN123456789\",\"notifyCustomer\":true}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(orderId.toString())) .andExpect(jsonPath("$.trackingId").value("PN123456789"))
.andExpect(jsonPath("$.trackingId").value("PN123456789")); .andExpect(jsonPath("$.status").value("sent"));
} }
@Test @Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN") @WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldClearTrackingWhenNull() throws Exception { void shouldReturn400WhenTrackingInputBlank() throws Exception {
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.SENT);
order.setTrackingId(null);
when(orderService.updateTracking(eq(orderId), eq(null))).thenReturn(order); mockMvc.perform(patch("/api/admin/orders/{id}/register-shipment", orderId)
mockMvc.perform(patch("/api/admin/orders/{id}", orderId)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"trackingId\":null}")) .content("{\"trackingInput\":\"\"}"))
.andExpect(status().isOk()) .andExpect(status().isBadRequest());
.andExpect(jsonPath("$.trackingId").doesNotExist());
} }
@Test @Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN") @WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldReturn404WhenOrderNotFoundForTracking() throws Exception { void shouldUpdateAdminNotes() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
when(orderService.updateTracking(eq(orderId), eq("PN123456789"))) Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.PROCESSING);
order.setAdminNotes("Kontaktat TS");
when(orderService.updateAdminNotes(orderId, "Kontaktat TS")).thenReturn(order);
mockMvc.perform(patch("/api/admin/orders/{id}/notes", orderId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"adminNotes\":\"Kontaktat TS\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.adminNotes").value("Kontaktat TS"));
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldReturn404WhenOrderNotFoundForRegisterShipment() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
when(orderService.registerShipment(eq(orderId), eq("PN123"), anyBoolean()))
.thenThrow(new OrderNotFoundException(orderId)); .thenThrow(new OrderNotFoundException(orderId));
mockMvc.perform(patch("/api/admin/orders/{id}", orderId) mockMvc.perform(patch("/api/admin/orders/{id}/register-shipment", orderId)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"trackingId\":\"PN123456789\"}")) .content("{\"trackingInput\":\"PN123\"}"))
.andExpect(status().isNotFound()); .andExpect(status().isNotFound());
} }
@ -219,7 +162,6 @@ class AdminControllerTest {
order.setPlate(plate); order.setPlate(plate);
order.setLetterText("Test letter"); order.setLetterText("Test letter");
order.setStatus(status); order.setStatus(status);
order.setTrackingId(null);
order.setAmountPaid(new BigDecimal("49.00")); order.setAmountPaid(new BigDecimal("49.00"));
return order; return order;

View file

@ -27,6 +27,9 @@ class OrderServiceTest {
@Mock @Mock
private OrderRepository orderRepository; private OrderRepository orderRepository;
@Mock
private OrderNotificationService orderNotificationService;
@InjectMocks @InjectMocks
private OrderService orderService; private OrderService orderService;
@ -249,4 +252,151 @@ class OrderServiceTest {
assertThrows(OrderNotFoundException.class, assertThrows(OrderNotFoundException.class,
() -> 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));
}
} }

View file

@ -0,0 +1,26 @@
package se.bilhalsning.util;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class PostNordTrackingNormalizerTest {
@Test
void shouldTrimAndRemoveWhitespaceFromPlainId() {
assertEquals("PN123456789", PostNordTrackingNormalizer.normalize(" PN 123 456 789 "));
}
@Test
void shouldExtractIdFromPostNordUrl() {
String url = "https://www.postnord.se/verktyg/spara/?id=PN987654321&utm=foo";
assertEquals("PN987654321", PostNordTrackingNormalizer.normalize(url));
}
@Test
void shouldThrowWhenInputIsBlank() {
assertThrows(IllegalArgumentException.class,
() -> PostNordTrackingNormalizer.normalize(" "));
}
}

View file

@ -2,6 +2,13 @@ import { test, expect } from '@playwright/test'
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)
const PROCESSING_PLATE = 'JKL012'
function rowByPlate(page: import('@playwright/test').Page, plate: string) {
return page.locator('.admin__row').filter({
has: page.locator('.admin__plate', { hasText: plate }),
})
}
test.describe('Admin dashboard', () => { test.describe('Admin dashboard', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
@ -37,9 +44,7 @@ test.describe('Admin dashboard', () => {
await page.goto('/admin') await page.goto('/admin')
await expect(page.getByRole('columnheader', { name: 'Datum' })).toBeVisible() await expect(page.getByRole('columnheader', { name: 'Datum' })).toBeVisible()
await expect( await expect(page.getByRole('columnheader', { name: 'ID' })).toBeVisible()
page.getByRole('columnheader', { name: 'Beställnings-ID' }),
).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'E-post' })).toBeVisible() await expect(page.getByRole('columnheader', { name: 'E-post' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Regnr' })).toBeVisible() await expect(page.getByRole('columnheader', { name: 'Regnr' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Status' })).toBeVisible() await expect(page.getByRole('columnheader', { name: 'Status' })).toBeVisible()
@ -69,34 +74,38 @@ test.describe('Admin dashboard', () => {
await expect(dialog).not.toBeVisible() await expect(dialog).not.toBeVisible()
}) })
test('click expand button shows tracking section', async ({ page }) => { test('click row shows tracking section', async ({ page }) => {
await page.goto('/admin') await page.goto('/admin')
await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 })
const expandBtns = page.locator('.admin__expand-btn') await rowByPlate(page, PROCESSING_PLATE).click()
await expandBtns.first().click()
await expect(page.getByText('Spårnings-ID').first()).toBeVisible() await expect(page.getByText('Registrera utskick').first()).toBeVisible()
}) })
test('click expand button again collapses it', async ({ page }) => { test('click row again collapses it', async ({ page }) => {
await page.goto('/admin') await page.goto('/admin')
await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 })
const expandBtns = page.locator('.admin__expand-btn') const row = rowByPlate(page, PROCESSING_PLATE)
await expandBtns.first().click() await row.click()
await expect(page.locator('.admin__tracking-input').first()).toBeVisible() await expect(page.locator('.admin__tracking-input').first()).toBeVisible()
await expandBtns.first().click() await row.click()
await expect(page.locator('.admin__tracking-input').first()).not.toBeVisible() await expect(page.locator('.admin__tracking-input').first()).not.toBeVisible()
}) })
test('status dropdown changes update order status', async ({ page }) => { test('status dropdown changes update order status for sent orders', async ({
page,
}) => {
await page.goto('/admin') await page.goto('/admin')
await page.locator('#admin-order-search').fill(SEEDED_ORDER_SHORT_ID)
const selects = page.locator('.admin__status-select') const row = page.locator('.admin__row', { hasText: SEEDED_ORDER_SHORT_ID })
await selects.first().selectOption('delivered') const select = row.locator('.admin__status-select')
await select.selectOption('delivered')
const updatedSelect = selects.first() await expect(select).toHaveValue('delivered')
await expect(updatedSelect).toHaveValue('delivered')
}) })
test('admin cannot access admin page without auth', async ({ page }) => { test('admin cannot access admin page without auth', async ({ page }) => {
@ -108,20 +117,21 @@ test.describe('Admin dashboard', () => {
test('expanded row shows tracking input and save button', async ({ page }) => { test('expanded row shows tracking input and save button', async ({ page }) => {
await page.goto('/admin') await page.goto('/admin')
await expect(page.locator('.admin__loading')).toBeHidden({ timeout: 30_000 })
const expandBtns = page.locator('.admin__expand-btn') await rowByPlate(page, PROCESSING_PLATE).click()
await expandBtns.first().click()
await expect(page.getByText('Spårnings-ID').first()).toBeVisible() await expect(page.getByText('Registrera utskick').first()).toBeVisible()
await expect(page.locator('.admin__tracking-input')).toBeVisible() await expect(page.locator('.admin__tracking-input')).toBeVisible()
await expect(page.getByRole('button', { name: 'Spara' })).toBeVisible() await expect(
page.getByRole('button', { name: 'Registrera utskick' }),
).toBeVisible()
}) })
test('shows PostNord link when trackingId exists', async ({ page }) => { test('shows PostNord link when trackingId exists', async ({ page }) => {
await page.goto('/admin') await page.goto('/admin')
const expandBtns = page.locator('.admin__expand-btn') await page.locator('.admin__row').last().click()
await expandBtns.last().click()
const trackingLink = page.locator('.admin__tracking-link') const trackingLink = page.locator('.admin__tracking-link')
await expect(trackingLink).toBeVisible() await expect(trackingLink).toBeVisible()
@ -132,8 +142,7 @@ test.describe('Admin dashboard', () => {
await page.goto('/admin') await page.goto('/admin')
const defRow = page.locator('.admin__row', { hasText: 'DEF456' }).first() const defRow = page.locator('.admin__row', { hasText: 'DEF456' }).first()
const expandBtn = defRow.locator('.admin__expand-btn') await defRow.click()
await expandBtn.click()
const trackingLink = page.locator('.admin__tracking-link') const trackingLink = page.locator('.admin__tracking-link')
await expect(trackingLink).not.toBeVisible() await expect(trackingLink).not.toBeVisible()

View file

@ -0,0 +1,99 @@
import { test, expect } from '@playwright/test'
/**
* Admin order status and shipment flows (serial mutates seeded orders).
* Requires docker e2e stack with dev seeds (DEF456, JKL012, ABC123).
*/
test.describe.configure({ mode: 'serial' })
const PENDING_ORDER_SHORT_ID = 'c2eebc99'
const PROCESSING_PLATE = 'JKL012'
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) {
return page.locator('.admin__row').filter({
has: page.locator('.admin__plate', { hasText: plate }),
})
}
function orderRowByShortId(
page: import('@playwright/test').Page,
shortId: string,
) {
return page.locator('.admin__row', { hasText: shortId })
}
test.describe('Admin fulfillment flows', () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page)
await openAdmin(page)
})
test('can mark unpaid order as failed', async ({ page }) => {
await page.locator('#admin-order-search').fill(PENDING_ORDER_SHORT_ID)
const row = orderRowByShortId(page, PENDING_ORDER_SHORT_ID)
const select = row.locator('.admin__status-select')
await select.selectOption('failed')
await expect(select).toHaveValue('failed')
await expect(page.getByRole('alert')).not.toBeVisible()
})
test('can revert unpaid failed order to pending payment', async ({
page,
}) => {
await page.locator('#admin-order-search').fill(PENDING_ORDER_SHORT_ID)
const row = orderRowByShortId(page, PENDING_ORDER_SHORT_ID)
const select = row.locator('.admin__status-select')
await expect(select).toHaveValue('failed')
await select.selectOption('pending_payment')
await expect(select).toHaveValue('pending_payment')
})
test('can register shipment for processing order', async ({ page }) => {
const row = orderRowByPlate(page, PROCESSING_PLATE)
await row.click()
await page
.locator('.admin__tracking-input')
.fill('PN-E2E-FULFILLMENT-001')
await page.getByRole('button', { name: 'Registrera utskick' }).click()
await expect(row.locator('.admin__status-select')).toHaveValue('sent')
await expect(page.locator('.admin__tracking-link')).toBeVisible()
})
test('can mark sent order as delivered', async ({ page }) => {
await page.locator('#admin-order-search').fill(SENT_ORDER_SHORT_ID)
const row = orderRowByShortId(page, SENT_ORDER_SHORT_ID)
const select = row.locator('.admin__status-select')
if ((await select.inputValue()) !== 'delivered') {
await select.selectOption('delivered')
}
await expect(select).toHaveValue('delivered')
})
test('can mark delivered order as failed then back to sent when tracking exists', async ({
page,
}) => {
await page.locator('#admin-order-search').fill(SENT_ORDER_SHORT_ID)
const row = orderRowByShortId(page, SENT_ORDER_SHORT_ID)
const select = row.locator('.admin__status-select')
await select.selectOption('failed')
await expect(select).toHaveValue('failed')
await select.selectOption('sent')
await expect(select).toHaveValue('sent')
})
})

View file

@ -49,7 +49,7 @@ test.describe('Order history', () => {
await page.goto('/orders') await page.goto('/orders')
await expect(page.getByText('Skickat')).toBeVisible() await expect(page.getByText('Skickat').first()).toBeVisible()
await expect(page.getByText('Väntar på betalning').first()).toBeVisible() await expect(page.getByText('Väntar på betalning').first()).toBeVisible()
await expect(page.getByText('Levererat').first()).toBeVisible() await expect(page.getByText('Levererat').first()).toBeVisible()
}) })

View file

@ -23,12 +23,18 @@ export default defineConfig({
projects: [ projects: [
{ {
name: 'chromium', name: 'chromium',
testIgnore: '**/deferred-payment-admin.spec.ts', testIgnore: [
'**/deferred-payment-admin.spec.ts',
'**/admin-fulfillment.spec.ts',
],
use: { browserName: 'chromium' }, use: { browserName: 'chromium' },
}, },
{ {
name: 'chromium-serial', name: 'chromium-serial',
testMatch: '**/deferred-payment-admin.spec.ts', testMatch: [
'**/deferred-payment-admin.spec.ts',
'**/admin-fulfillment.spec.ts',
],
fullyParallel: false, fullyParallel: false,
workers: 1, workers: 1,
use: { browserName: 'chromium' }, use: { browserName: 'chromium' },

View file

@ -43,6 +43,8 @@ const mockOrders = [
status: 'sent', status: 'sent',
trackingId: 'PN123456789', trackingId: 'PN123456789',
amountPaid: 49.0, amountPaid: 49.0,
shippedAt: '2026-05-13T12:00:00Z',
adminNotes: null,
createdAt: '2026-05-11T12:00:00Z', createdAt: '2026-05-11T12:00:00Z',
}, },
{ {
@ -53,6 +55,8 @@ const mockOrders = [
status: 'processing', status: 'processing',
trackingId: null, trackingId: null,
amountPaid: null, amountPaid: null,
shippedAt: null,
adminNotes: null,
createdAt: '2026-05-14T13:00:00Z', createdAt: '2026-05-14T13:00:00Z',
}, },
{ {
@ -63,16 +67,22 @@ const mockOrders = [
status: 'pending_payment', status: 'pending_payment',
trackingId: null, trackingId: null,
amountPaid: null, amountPaid: null,
shippedAt: null,
adminNotes: null,
createdAt: '2026-05-15T14:00:00Z', createdAt: '2026-05-15T14:00:00Z',
}, },
] ]
function freshMockOrders() {
return mockOrders.map((order) => ({ ...order }))
}
describe('AdminDashboard', () => { describe('AdminDashboard', () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear() localStorage.clear()
globalThis.fetch = vi.fn() globalThis.fetch = vi.fn()
vi.mocked(globalThis.fetch).mockResolvedValue( vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, mockOrders), mockFetchResponse(200, freshMockOrders()),
) )
}) })
@ -101,10 +111,10 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Datum') expect(wrapper.text()).toContain('Datum')
expect(wrapper.text()).toContain('Beställnings-ID') expect(wrapper.text()).toContain('ID')
expect(wrapper.text()).toContain('E-post') expect(wrapper.text()).toContain('E-post')
expect(wrapper.text()).toContain('Regnr') expect(wrapper.text()).toContain('Regnr')
expect(wrapper.text()).toContain('Meddelande') expect(wrapper.text()).toContain('Brev')
expect(wrapper.text()).toContain('Status') expect(wrapper.text()).toContain('Status')
}) })
@ -163,7 +173,7 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const expandBtns = wrapper.findAll('.admin__expand-btn') const expandBtns = wrapper.findAll('.admin__row')
await expandBtns[0].trigger('click') await expandBtns[0].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(wrapper.find('.admin__expanded-row').exists()).toBe(true) expect(wrapper.find('.admin__expanded-row').exists()).toBe(true)
@ -177,7 +187,7 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const expandBtns = wrapper.findAll('.admin__expand-btn') const expandBtns = wrapper.findAll('.admin__row')
await expandBtns[0].trigger('click') await expandBtns[0].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(wrapper.findAll('.admin__expanded-row')).toHaveLength(1) expect(wrapper.findAll('.admin__expanded-row')).toHaveLength(1)
@ -198,15 +208,16 @@ describe('AdminDashboard', () => {
it('fires status update API on dropdown change', async () => { it('fires status update API on dropdown change', async () => {
vi.mocked(globalThis.fetch) vi.mocked(globalThis.fetch)
.mockResolvedValueOnce(mockFetchResponse(200, mockOrders)) .mockResolvedValueOnce(mockFetchResponse(200, freshMockOrders()))
.mockResolvedValueOnce( .mockResolvedValueOnce(
mockFetchResponse(200, { ...mockOrders[0], status: 'paid' }), mockFetchResponse(200, { ...mockOrders[0], status: 'delivered' }),
) )
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const selects = wrapper.findAll('.admin__status-select') const selects = wrapper.findAll('.admin__status-select')
await selects[0].setValue('delivered')
await selects[0].trigger('change') await selects[0].trigger('change')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
@ -214,14 +225,14 @@ describe('AdminDashboard', () => {
'/api/admin/orders/c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11/status', '/api/admin/orders/c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11/status',
expect.objectContaining({ expect.objectContaining({
method: 'PATCH', method: 'PATCH',
body: '{"status":"sent"}', body: '{"status":"delivered"}',
}), }),
) )
}) })
it('shows status error on failed update', async () => { it('shows status error on failed update', async () => {
vi.mocked(globalThis.fetch) vi.mocked(globalThis.fetch)
.mockResolvedValueOnce(mockFetchResponse(200, mockOrders)) .mockResolvedValueOnce(mockFetchResponse(200, freshMockOrders()))
.mockResolvedValueOnce( .mockResolvedValueOnce(
mockFetchResponse(500, { message: 'Server error' }), mockFetchResponse(500, { message: 'Server error' }),
) )
@ -247,7 +258,7 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const expandBtns = wrapper.findAll('.admin__expand-btn') const expandBtns = wrapper.findAll('.admin__row')
await expandBtns[0].trigger('click') await expandBtns[0].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
@ -260,7 +271,7 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const expandBtns = wrapper.findAll('.admin__expand-btn') const expandBtns = wrapper.findAll('.admin__row')
await expandBtns[0].trigger('click') await expandBtns[0].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
@ -274,7 +285,7 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const expandBtns = wrapper.findAll('.admin__expand-btn') const expandBtns = wrapper.findAll('.admin__row')
await expandBtns[1].trigger('click') await expandBtns[1].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
@ -282,32 +293,47 @@ describe('AdminDashboard', () => {
expect(link.exists()).toBe(false) expect(link.exists()).toBe(false)
}) })
it('fires PATCH on tracking save button click', async () => { it('fires register-shipment API on register button click', async () => {
vi.mocked(globalThis.fetch).mockResolvedValueOnce( vi.mocked(globalThis.fetch)
mockFetchResponse(200, mockOrders), .mockResolvedValueOnce(mockFetchResponse(200, freshMockOrders()))
) .mockResolvedValueOnce(
mockFetchResponse(200, {
...mockOrders[1],
status: 'sent',
trackingId: 'PN999',
}),
)
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const expandBtns = wrapper.findAll('.admin__expand-btn') const expandBtns = wrapper.findAll('.admin__row')
await expandBtns[1].trigger('click') await expandBtns[1].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
await wrapper.find('.btn--primary').trigger('click') await wrapper.find('.admin__tracking-input').setValue('PN999')
const registerBtn = wrapper
.findAll('button')
.find((btn) => btn.text() === 'Registrera utskick')
expect(registerBtn).toBeDefined()
await registerBtn!.trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(globalThis.fetch).toHaveBeenCalledWith( expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/admin/orders/c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', '/api/admin/orders/c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12/register-shipment',
expect.objectContaining({ expect.objectContaining({
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({
trackingInput: 'PN999',
notifyCustomer: true,
}),
}), }),
) )
}) })
it('shows tracking error on failed save', async () => { it('shows tracking error on failed save', async () => {
vi.mocked(globalThis.fetch) vi.mocked(globalThis.fetch)
.mockResolvedValueOnce(mockFetchResponse(200, mockOrders)) .mockResolvedValueOnce(mockFetchResponse(200, freshMockOrders()))
.mockResolvedValueOnce( .mockResolvedValueOnce(
mockFetchResponse(500, { message: 'Server error' }), mockFetchResponse(500, { message: 'Server error' }),
) )
@ -315,14 +341,18 @@ describe('AdminDashboard', () => {
const { wrapper } = mountPage() const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const expandBtns = wrapper.findAll('.admin__expand-btn') const expandBtns = wrapper.findAll('.admin__row')
await expandBtns[1].trigger('click') await expandBtns[1].trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
await wrapper.find('.btn--primary').trigger('click') await wrapper.find('.admin__tracking-input').setValue('PN999')
const registerBtn = wrapper
.findAll('button')
.find((btn) => btn.text() === 'Registrera utskick')
await registerBtn!.trigger('click')
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Kunde inte spara spårnings-ID') expect(wrapper.text()).toContain('Kunde inte registrera utskick')
}) })
it('shows Att göra stat for processing orders', async () => { it('shows Att göra stat for processing orders', async () => {
@ -392,7 +422,10 @@ describe('AdminDashboard', () => {
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin__row') const rows = wrapper.findAll('.admin__row')
const processingRow = rows.find((row) => row.text().includes('XYZ789')) const processingRow = rows.find(
(row) =>
row.text().includes('XYZ789') && row.classes().includes('admin__row'),
)
expect(processingRow?.classes()).toContain('admin__row--todo') expect(processingRow?.classes()).toContain('admin__row--todo')
}) })
}) })

View file

@ -8,6 +8,8 @@ export interface AdminOrder {
status: string status: string
trackingId: string | null trackingId: string | null
amountPaid: number | null amountPaid: number | null
shippedAt: string | null
adminNotes: string | null
createdAt: string createdAt: string
} }
@ -25,12 +27,23 @@ export function updateOrderStatus(
}) })
} }
export function updateTracking( export function registerShipment(
orderId: string, orderId: string,
trackingId: string | null, trackingInput: string,
notifyCustomer = true,
): Promise<AdminOrder> { ): Promise<AdminOrder> {
return request<AdminOrder>(`/admin/orders/${orderId}`, { return request<AdminOrder>(`/admin/orders/${orderId}/register-shipment`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ trackingId }), body: JSON.stringify({ trackingInput, notifyCustomer }),
})
}
export function updateAdminNotes(
orderId: string,
adminNotes: string | null,
): Promise<AdminOrder> {
return request<AdminOrder>(`/admin/orders/${orderId}/notes`, {
method: 'PATCH',
body: JSON.stringify({ adminNotes }),
}) })
} }

View file

@ -171,7 +171,7 @@ onUnmounted(() => {
<RouterLink <RouterLink
v-if="auth.isAdmin" v-if="auth.isAdmin"
to="/admin" to="/admin"
class="app-header__link app-header__link--admin" class="app-header__link"
@click="handleNavClick" @click="handleNavClick"
>Admin</RouterLink >Admin</RouterLink
> >
@ -348,17 +348,6 @@ onUnmounted(() => {
background: var(--color-primary-soft); background: var(--color-primary-soft);
} }
.app-header__link--admin {
background: var(--color-primary-soft);
color: var(--color-primary);
font-weight: 600;
}
.app-header__link--admin:hover {
background: #e9d5ff;
color: var(--color-primary-dark);
}
.app-header__link--settings-mobile { .app-header__link--settings-mobile {
display: none; display: none;
} }

View file

@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, reactive, computed } from 'vue' import { ref, onMounted, onUnmounted, reactive, computed } from 'vue'
import { ApiError } from '@/api/client'
import { import {
fetchAllOrders, fetchAllOrders,
updateOrderStatus, updateOrderStatus,
updateTracking, registerShipment,
updateAdminNotes,
type AdminOrder, type AdminOrder,
} from '@/api/admin' } from '@/api/admin'
@ -18,7 +20,12 @@ const activeFilter = ref<
>('all') >('all')
const searchQuery = ref('') const searchQuery = ref('')
const trackingInputValues = reactive<Record<string, string>>({}) const trackingInputValues = reactive<Record<string, string>>({})
const adminNotesValues = reactive<Record<string, string>>({})
const notifyCustomerValues = reactive<Record<string, boolean>>({})
const messageModalOrder = ref<AdminOrder | null>(null) 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 statusLabels: Record<string, string> = {
pending_payment: 'Väntar på betalning', pending_payment: 'Väntar på betalning',
@ -40,21 +47,11 @@ const statusBadge: Record<string, string> = {
cancelled: 'badge--muted', cancelled: 'badge--muted',
} }
const allStatuses = [
'pending_payment',
'paid',
'processing',
'sent',
'delivered',
'failed',
'cancelled',
]
const stats = computed(() => { const stats = computed(() => {
const total = orders.value.length const total = orders.value.length
const todo = orders.value.filter((o) => o.status === 'processing').length const todo = orders.value.filter((o) => o.status === 'processing').length
const paid = orders.value.filter((o) => const paid = orders.value.filter((o) =>
['paid', 'sent', 'delivered'].includes(o.status), ['processing', 'paid', 'sent', 'delivered'].includes(o.status),
).length ).length
const pending = orders.value.filter( const pending = orders.value.filter(
(o) => o.status === 'pending_payment', (o) => o.status === 'pending_payment',
@ -69,7 +66,7 @@ const filteredOrders = computed(() => {
result = result.filter((o) => o.status === 'processing') result = result.filter((o) => o.status === 'processing')
} else if (activeFilter.value === 'paid_group') { } else if (activeFilter.value === 'paid_group') {
result = result.filter((o) => result = result.filter((o) =>
['paid', 'sent', 'delivered'].includes(o.status), ['processing', 'paid', 'sent', 'delivered'].includes(o.status),
) )
} else if (activeFilter.value === 'pending_payment') { } else if (activeFilter.value === 'pending_payment') {
result = result.filter((o) => o.status === 'pending_payment') result = result.filter((o) => o.status === 'pending_payment')
@ -113,14 +110,59 @@ function handleModalKeydown(event: KeyboardEvent) {
} }
} }
function allowedStatuses(order: AdminOrder): string[] {
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) { function toggleExpand(orderId: string) {
if (expandedOrderId.value === orderId) { if (expandedOrderId.value === orderId) {
expandedOrderId.value = null expandedOrderId.value = null
} else { } else {
expandedOrderId.value = orderId expandedOrderId.value = orderId
const order = orders.value.find((o) => o.id === orderId) const order = orders.value.find((o) => o.id === orderId)
if (order && !(orderId in trackingInputValues)) { if (order) {
trackingInputValues[orderId] = order.trackingId ?? '' if (!(orderId in trackingInputValues)) {
trackingInputValues[orderId] = order.trackingId ?? ''
}
if (!(orderId in adminNotesValues)) {
adminNotesValues[orderId] = order.adminNotes ?? ''
}
if (!(orderId in notifyCustomerValues)) {
notifyCustomerValues[orderId] = true
}
} }
} }
} }
@ -135,26 +177,66 @@ async function handleStatusChange(orderId: string, newStatus: string) {
try { try {
await updateOrderStatus(orderId, newStatus) await updateOrderStatus(orderId, newStatus)
} catch { } catch (err) {
order.status = previousStatus order.status = previousStatus
statusError.value = 'Kunde inte uppdatera status. Försök igen.' statusError.value =
err instanceof ApiError && err.message
? err.message
: 'Kunde inte uppdatera status. Försök igen.'
} }
} }
async function handleTrackingSave(orderId: string) { async function handleRegisterShipment(orderId: string) {
const newTrackingId = trackingInputValues[orderId]?.trim() || null 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) const order = orders.value.find((o) => o.id === orderId)
if (!order) return if (!order) return
const previousStatus = order.status
const previousTrackingId = order.trackingId const previousTrackingId = order.trackingId
order.trackingId = newTrackingId const notifyCustomer = notifyCustomerValues[orderId] ?? true
trackingError.value = '' trackingError.value = ''
registeringId.value = orderId
try { try {
await updateTracking(orderId, newTrackingId) 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 { } catch {
order.status = previousStatus
order.trackingId = previousTrackingId order.trackingId = previousTrackingId
trackingError.value = 'Kunde inte spara spårnings-ID. Försök igen.' 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
} }
} }
@ -264,13 +346,15 @@ onUnmounted(() => {
<table class="admin__table"> <table class="admin__table">
<thead> <thead>
<tr> <tr>
<th>Datum</th> <th class="admin__th-expand" scope="col">
<th>Beställnings-ID</th> <span class="visually-hidden">Visa detaljer</span>
<th>E-post</th> </th>
<th>Regnr</th> <th class="admin__th-date">Datum</th>
<th>Meddelande</th> <th class="admin__th-id" title="Beställnings-ID">ID</th>
<th>Status</th> <th class="admin__th-email">E-post</th>
<th></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> </tr>
</thead> </thead>
<tbody> <tbody>
@ -281,50 +365,22 @@ onUnmounted(() => {
'admin__row--expanded': expandedOrderId === order.id, 'admin__row--expanded': expandedOrderId === order.id,
'admin__row--todo': order.status === 'processing', '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="toggleExpand(order.id)"
> >
<td>{{ formatDate(order.createdAt) }}</td> <td class="admin__expand-cell">
<td class="admin__order-id" :title="order.id"> <span
{{ shortOrderId(order.id) }} class="admin__expand-icon"
</td> :class="{
<td>{{ order.email }}</td> 'admin__expand-icon--open':
<td class="admin__plate">{{ order.plate }}</td> expandedOrderId === order.id,
<td> }"
<button aria-hidden="true"
type="button"
class="btn btn--ghost btn--sm admin__message-btn"
@click.stop="openMessageModal(order)"
>
Visa meddelande
</button>
</td>
<td>
<select
class="admin__status-select"
:class="statusBadge[order.status] || 'badge--muted'"
:value="order.status"
@change="
handleStatusChange(
order.id,
($event.target as HTMLSelectElement).value,
)
"
@click.stop
>
<option v-for="s in allStatuses" :key="s" :value="s">
{{ statusLabels[s] }}
</option>
</select>
</td>
<td class="admin__chevron-cell">
<button
class="admin__expand-btn"
:aria-expanded="expandedOrderId === order.id"
:aria-label="
expandedOrderId === order.id
? 'Dölj detaljer'
: 'Visa detaljer'
"
@click.stop="toggleExpand(order.id)"
> >
<svg <svg
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -335,7 +391,6 @@ onUnmounted(() => {
stroke-width="2.5" stroke-width="2.5"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
aria-hidden="true"
> >
<polyline <polyline
:points=" :points="
@ -345,8 +400,48 @@ onUnmounted(() => {
" "
/> />
</svg> </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> </button>
</td> </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>
<tr <tr
v-if="expandedOrderId === order.id" v-if="expandedOrderId === order.id"
@ -354,9 +449,24 @@ onUnmounted(() => {
> >
<td :colspan="7"> <td :colspan="7">
<div class="admin__expanded-inner"> <div class="admin__expanded-inner">
<div class="admin__section"> <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 spårnings-ID</li>
<li>Registrera utskick nedan</li>
</ol>
<div
v-if="canRegisterShipment(order)"
class="admin__section"
>
<div class="admin__section-header"> <div class="admin__section-header">
<span class="admin__section-label">Spårnings-ID</span> <span class="admin__section-label"
>Registrera utskick</span
>
<a <a
v-if="order.trackingId" v-if="order.trackingId"
class="admin__tracking-link" class="admin__tracking-link"
@ -369,6 +479,12 @@ onUnmounted(() => {
</a> </a>
</div> </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 e-post.
</p>
<p <p
v-if="trackingError" v-if="trackingError"
class="message message--error admin__tracking-error" class="message message--error admin__tracking-error"
@ -392,7 +508,7 @@ onUnmounted(() => {
order.trackingId ?? order.trackingId ??
'' ''
" "
placeholder="PN..." placeholder="PN... eller PostNord-länk"
@input=" @input="
trackingInputValues[order.id] = ( trackingInputValues[order.id] = (
$event.target as HTMLInputElement $event.target as HTMLInputElement
@ -402,11 +518,65 @@ onUnmounted(() => {
/> />
<button <button
class="btn btn--primary btn--sm" class="btn btn--primary btn--sm"
@click.stop="handleTrackingSave(order.id)" :disabled="registeringId === order.id"
@click.stop="handleRegisterShipment(order.id)"
> >
Spara {{
registeringId === order.id
? 'Registrerar...'
: 'Registrera utskick'
}}
</button> </button>
</div> </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>
</div> </div>
</td> </td>
@ -573,14 +743,16 @@ onUnmounted(() => {
background: var(--color-surface); background: var(--color-surface);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch;
} }
.admin__table { .admin__table {
width: 100%; width: 100%;
border-collapse: collapse; min-width: 60rem;
border-collapse: separate;
border-spacing: 0;
font-size: 0.875rem; font-size: 0.875rem;
} }
@ -589,7 +761,7 @@ onUnmounted(() => {
} }
.admin__table th { .admin__table th {
padding: 0.75rem var(--space-md); padding: 0.75rem 1rem;
text-align: left; text-align: left;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
@ -597,15 +769,88 @@ onUnmounted(() => {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
white-space: nowrap;
}
.admin__th-expand,
.admin__expand-cell {
width: 2.75rem;
padding-left: 0.75rem;
padding-right: 0.25rem;
}
.admin__expand-cell {
text-align: center;
vertical-align: middle;
}
.admin__expand-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.625rem;
height: 1.625rem;
border-radius: var(--radius-full);
background: var(--color-border-light);
color: var(--color-muted);
transition:
background var(--transition-fast),
color var(--transition-fast);
}
.admin__row:hover .admin__expand-icon {
background: var(--color-primary-soft);
color: var(--color-primary);
}
.admin__row--expanded .admin__expand-icon,
.admin__expand-icon--open {
background: var(--color-primary);
color: #fff;
}
.admin__row--todo .admin__expand-icon:not(.admin__expand-icon--open) {
background: var(--color-primary-soft);
color: var(--color-primary);
}
.admin__th-date,
.admin__td-date {
min-width: 6.5rem;
}
.admin__th-id,
.admin__order-id {
min-width: 5.5rem;
}
.admin__th-email,
.admin__email {
min-width: 11rem;
max-width: 16rem;
}
.admin__th-plate,
.admin__plate {
min-width: 5rem;
}
.admin__th-message,
.admin__message-cell {
min-width: 9.5rem;
}
.admin__th-status,
.admin__status-cell {
min-width: 11rem;
} }
.admin__row { .admin__row {
cursor: pointer; cursor: pointer;
border-bottom: 1px solid var(--color-border-light);
transition: background var(--transition-fast); transition: background var(--transition-fast);
} }
.admin__row:last-child { .admin__row:last-child td {
border-bottom: none; border-bottom: none;
} }
@ -622,14 +867,32 @@ onUnmounted(() => {
} }
.admin__row td { .admin__row td {
padding: 0.75rem var(--space-md); padding: 0.75rem 1rem;
color: var(--color-ink); color: var(--color-ink);
vertical-align: middle;
border-bottom: 1px solid var(--color-border-light);
}
.admin__email {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-size: 0.8125rem;
color: var(--color-muted);
} }
.admin__plate { .admin__plate {
font-weight: 600; font-weight: 600;
letter-spacing: 0.05em; letter-spacing: 0.05em;
white-space: nowrap;
}
.admin__message-cell {
text-align: center;
}
.admin__status-cell {
white-space: nowrap;
} }
.admin__status-select { .admin__status-select {
@ -649,31 +912,6 @@ onUnmounted(() => {
box-shadow: 0 0 0 2px var(--color-primary-ring); box-shadow: 0 0 0 2px var(--color-primary-ring);
} }
.admin__chevron-cell {
text-align: center;
width: 2rem;
}
.admin__expand-btn {
background: none;
border: none;
color: var(--color-soft);
cursor: pointer;
padding: 0.25rem;
border-radius: var(--radius-sm);
display: inline-flex;
align-items: center;
justify-content: center;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.admin__expand-btn:hover {
color: var(--color-ink);
background: var(--color-border-light);
}
.admin__expanded-row td { .admin__expanded-row td {
padding: 0; padding: 0;
background: var(--color-surface); background: var(--color-surface);
@ -756,6 +994,48 @@ onUnmounted(() => {
margin-bottom: var(--space-md); margin-bottom: var(--space-md);
} }
.admin__checklist {
margin: 0 0 var(--space-md);
padding-left: 1.25rem;
font-size: 0.875rem;
color: var(--color-ink-muted);
}
.admin__checklist li + li {
margin-top: var(--space-xs);
}
.admin__section-hint {
margin: var(--space-xs) 0 0;
font-size: 0.8125rem;
color: var(--color-ink-muted);
}
.admin__notify {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-top: var(--space-sm);
font-size: 0.8125rem;
color: var(--color-ink-muted);
cursor: pointer;
}
.admin__notes-input {
width: 100%;
margin-top: var(--space-sm);
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.8125rem;
font-family: inherit;
resize: vertical;
}
.admin__notes-save {
margin-top: var(--space-sm);
}
.admin__tracking-error { .admin__tracking-error {
margin-bottom: var(--space-sm); margin-bottom: var(--space-sm);
padding: var(--space-sm) var(--space-md); padding: var(--space-sm) var(--space-md);
@ -842,5 +1122,14 @@ onUnmounted(() => {
.admin__stats { .admin__stats {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
.admin__table {
min-width: 62rem;
}
.admin__message-btn {
font-size: 0.75rem;
padding-inline: 0.5rem;
}
} }
</style> </style>