Add admin order fulfillment tracking.
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:
parent
17fe67ae3f
commit
1c9269699e
26 changed files with 1251 additions and 303 deletions
51
AGENTS.md
51
AGENTS.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
) {}
|
) {}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package se.bilhalsning.dto;
|
||||||
|
|
||||||
|
public record UpdateAdminNotesRequest(
|
||||||
|
String adminNotes
|
||||||
|
) {}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
package se.bilhalsning.dto;
|
|
||||||
|
|
||||||
public record UpdateTrackingRequest(
|
|
||||||
String trackingId
|
|
||||||
) {}
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 på 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
ALTER TABLE orders ADD COLUMN shipped_at TIMESTAMP WITH TIME ZONE;
|
||||||
|
|
||||||
|
ALTER TABLE orders ADD COLUMN admin_notes TEXT;
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
99
frontend/e2e/admin-fulfillment.spec.ts
Normal file
99
frontend/e2e/admin-fulfillment.spec.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 få 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 få 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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue