bilhej/frontend/src/pages/OrdersPage.vue
Joakim Mörling c7eeaf6a6b
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m9s
CI / E2E browser tests (pull_request) Successful in 4m1s
Refactor admin fulfillment into focused modules.
Extract AdminOrderWorkflowService and status rules API; split AdminPage
into composables and components; share order status constants; update tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 14:34:03 +02:00

620 lines
15 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { fetchOrders, cancelOrder, type Order } from '@/api/orders'
import { fetchSwishInfo } from '@/api/payment'
import { RouterLink } from 'vue-router'
import {
ORDER_STATUS_BADGE,
ORDER_STATUS_LABELS,
} from '@/constants/orderStatus'
const ORDER_AMOUNT_FALLBACK = 49
const orders = ref<Order[]>([])
const orderAmount = ref(ORDER_AMOUNT_FALLBACK)
const loading = ref(true)
const error = ref('')
const actionError = ref('')
const cancellingId = ref<string | null>(null)
const expandedPreviewIds = ref<Set<string>>(new Set())
const PREVIEW_CHAR_LIMIT = 120
function isLongMessage(text: string): boolean {
return text.length > PREVIEW_CHAR_LIMIT
}
function isPreviewExpanded(orderId: string): boolean {
return expandedPreviewIds.value.has(orderId)
}
function togglePreview(orderId: string) {
const next = new Set(expandedPreviewIds.value)
if (next.has(orderId)) {
next.delete(orderId)
} else {
next.add(orderId)
}
expandedPreviewIds.value = next
}
const pendingOrders = computed(() =>
orders.value.filter((order) => order.status === 'pending_payment'),
)
const completedOrders = computed(() =>
orders.value.filter((order) => order.status !== 'pending_payment'),
)
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('sv-SE', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
async function loadOrders() {
try {
const [fetchedOrders, swishInfo] = await Promise.all([
fetchOrders(),
fetchSwishInfo().catch(() => ({
number: '',
amount: ORDER_AMOUNT_FALLBACK,
})),
])
orders.value = fetchedOrders
orderAmount.value = swishInfo.amount
} catch {
error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
} finally {
loading.value = false
}
}
async function handleCancel(order: Order) {
if (
!window.confirm(
'Vill du avbryta beställningen? Den kan inte återställas efteråt.',
)
) {
return
}
actionError.value = ''
cancellingId.value = order.id
try {
const updated = await cancelOrder(order.id)
orders.value = orders.value.map((o) => (o.id === updated.id ? updated : o))
} catch {
actionError.value = 'Kunde inte avbryta beställningen. Försök igen senare.'
} finally {
cancellingId.value = null
}
}
onMounted(loadOrders)
</script>
<template>
<div class="page">
<h1 class="page__title">Mina beställningar</h1>
<p class="page__subtitle">Här kan du se dina tidigare beställningar.</p>
<p
v-if="loading"
class="text-muted text-center orders__loading"
role="status"
>
Laddar beställningar...
</p>
<div v-else-if="error" class="message message--error" role="alert">
{{ error }}
</div>
<div v-else-if="orders.length === 0" class="orders__empty">
<div class="orders__empty-card">
<p class="orders__empty-title">Inga beställningar ännu</p>
<p class="orders__empty-text">
Följ dina brev och se tidigare skickade hälsningar.
</p>
<RouterLink to="/" class="btn btn--primary orders__empty-cta">
Skicka första brevet
</RouterLink>
</div>
</div>
<template v-else>
<div
v-if="actionError"
class="message message--error orders__action-error"
role="alert"
>
{{ actionError }}
</div>
<section v-if="pendingOrders.length" class="orders__section">
<h2 class="orders__section-title">Obetalda beställningar</h2>
<div class="orders__list">
<div
v-for="order in pendingOrders"
:key="order.id"
class="orders__card orders__card--pending"
>
<div class="orders__card-content">
<div class="orders__card-head">
<p class="orders__plate-badge">
<span class="orders__plate-label">Regnr</span>
<span class="orders__plate-value">{{ order.plate }}</span>
</p>
<span class="badge badge--warning">
{{ ORDER_STATUS_LABELS[order.status] }}
</span>
</div>
<div class="orders__preview-box">
<p
class="orders__preview"
:class="{
'orders__preview--expanded': isPreviewExpanded(order.id),
}"
>
{{ order.letterText }}
</p>
<button
v-if="isLongMessage(order.letterText)"
type="button"
class="orders__preview-toggle"
@click="togglePreview(order.id)"
>
{{ isPreviewExpanded(order.id) ? 'Visa mindre' : 'Visa mer' }}
</button>
</div>
<p class="orders__order-date">
Skapad {{ formatDate(order.createdAt) }}
</p>
<div class="orders__order-ref orders__order-ref--highlight">
<p class="orders__order-ref-label">Beställnings-ID</p>
<p class="orders__order-id">{{ order.id }}</p>
</div>
<div class="orders__price-row">
<span class="orders__price-label">Att betala</span>
<span class="orders__price-amount">{{ orderAmount }} kr</span>
</div>
<RouterLink
:to="{
name: 'payment',
params: { orderId: order.id },
query: { plate: order.plate },
}"
class="btn btn--primary orders__pay-btn"
>
Betala {{ orderAmount }} kr
</RouterLink>
<div class="orders__links">
<RouterLink
:to="{
name: 'edit-order',
params: { orderId: order.id },
}"
class="orders__text-link orders__edit-btn"
>
Redigera brev
</RouterLink>
<span class="orders__link-sep" aria-hidden="true">·</span>
<button
type="button"
class="orders__text-link orders__text-link--danger orders__cancel-btn"
spellcheck="false"
:disabled="cancellingId === order.id"
@click="handleCancel(order)"
>
{{
cancellingId === order.id
? 'Avbryter...'
: 'Avbryt beställning'
}}
</button>
</div>
</div>
</div>
</div>
</section>
<section v-if="completedOrders.length" class="orders__section">
<h2 v-if="pendingOrders.length" class="orders__section-title">
Tidigare beställningar
</h2>
<div class="orders__list">
<div
v-for="order in completedOrders"
:key="order.id"
class="orders__card"
>
<div class="orders__card-content">
<div class="orders__card-head">
<p class="orders__plate-badge">
<span class="orders__plate-label">Regnr</span>
<span class="orders__plate-value">{{ order.plate }}</span>
</p>
<span
class="badge"
:class="ORDER_STATUS_BADGE[order.status] || 'badge--muted'"
>
{{ ORDER_STATUS_LABELS[order.status] || order.status }}
</span>
</div>
<div class="orders__preview-box">
<p
class="orders__preview"
:class="{
'orders__preview--expanded': isPreviewExpanded(order.id),
}"
>
{{ order.letterText }}
</p>
<button
v-if="isLongMessage(order.letterText)"
type="button"
class="orders__preview-toggle"
@click="togglePreview(order.id)"
>
{{ isPreviewExpanded(order.id) ? 'Visa mindre' : 'Visa mer' }}
</button>
</div>
<p class="orders__order-date">
Skapad {{ formatDate(order.createdAt) }}
</p>
<a
v-if="order.trackingId"
class="btn btn--ghost orders__tracking-btn"
:href="`https://www.postnord.se/verktyg/spara/?id=${order.trackingId}`"
target="_blank"
rel="noopener noreferrer"
>
Spåra brev · {{ order.trackingId }}
</a>
<div class="orders__order-ref">
<p class="orders__order-ref-label">Beställnings-ID</p>
<p class="orders__order-id">{{ order.id }}</p>
</div>
</div>
</div>
</div>
</section>
</template>
</div>
</template>
<style scoped>
.page {
max-width: 48rem;
margin: clamp(var(--space-xl), 6vw, var(--space-3xl)) auto 0;
padding: 0 var(--page-gutter);
}
.page__title {
margin: 0 0 var(--space-sm) 0;
font-size: 1.5rem;
color: var(--color-ink);
}
.page__subtitle {
margin: 0 0 var(--space-xl) 0;
font-size: 0.875rem;
color: var(--color-muted);
}
.orders__action-error {
margin-bottom: var(--space-md);
}
.orders__section + .orders__section {
margin-top: var(--space-xl);
}
.orders__section-title {
margin: 0 0 var(--space-md) 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.orders__list {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.orders__card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-md);
}
.orders__card-content {
padding: var(--space-lg);
}
.orders__card--pending .orders__order-date {
margin-bottom: var(--space-md);
}
.orders__card-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-md);
margin-bottom: var(--space-md);
}
.orders__plate-badge {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
background: var(--color-primary-soft);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-full);
margin: 0;
}
.orders__plate-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--color-muted);
}
.orders__plate-value {
font-size: 0.9375rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--color-primary-dark);
}
.orders__preview-box {
background: var(--color-border-light);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-md);
margin-bottom: var(--space-sm);
}
.orders__preview {
margin: 0;
font-family: var(--font-serif);
font-size: 0.9375rem;
line-height: 1.6;
color: var(--color-ink);
white-space: pre-wrap;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
line-clamp: 3;
overflow: hidden;
}
.orders__preview--expanded {
display: block;
-webkit-line-clamp: unset;
line-clamp: unset;
overflow: visible;
}
.orders__preview-toggle {
margin-top: var(--space-sm);
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-primary);
background: none;
border: none;
padding: 0;
cursor: pointer;
}
.orders__preview-toggle:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
.orders__order-date {
margin: 0 0 var(--space-sm) 0;
font-size: 0.8125rem;
color: var(--color-muted);
}
.orders__tracking-btn {
width: 100%;
justify-content: center;
margin: var(--space-md) 0 var(--space-sm);
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-primary-dark);
border-color: #ddd6fe;
background: var(--color-primary-soft);
}
.orders__tracking-btn:hover {
background: #dbeafe;
border-color: #93c5fd;
}
.orders__order-ref {
margin: var(--space-md) 0 0;
padding-top: var(--space-md);
border-top: 1px solid var(--color-border);
}
.orders__order-ref--highlight {
margin: 0 0 var(--space-md);
padding: var(--space-md);
background: var(--color-border-light);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.orders__order-ref-label {
margin: 0 0 var(--space-xs) 0;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.orders__order-id {
margin: 0;
font-family: ui-monospace, monospace;
font-size: 0.8125rem;
color: var(--color-ink);
word-break: break-all;
line-height: 1.5;
}
.orders__price-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-md);
padding-bottom: var(--space-md);
border-bottom: 1px solid var(--color-border);
}
.orders__price-label {
font-size: 0.875rem;
color: var(--color-muted);
}
.orders__price-amount {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-ink);
}
.orders__pay-btn {
width: 100%;
justify-content: center;
margin-bottom: var(--space-md);
}
.orders__links {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
flex-wrap: wrap;
}
.orders__text-link {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-primary);
text-decoration: none;
background: none;
border: none;
padding: 0;
cursor: pointer;
}
.orders__text-link:hover:not(:disabled) {
text-decoration: underline;
text-underline-offset: 2px;
}
.orders__text-link--danger {
color: var(--color-danger);
}
.orders__text-link--danger:hover:not(:disabled) {
color: #991b1b;
}
.orders__text-link:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.orders__link-sep {
color: var(--color-soft);
user-select: none;
}
.orders__empty {
padding: var(--space-2xl) 0;
text-align: center;
}
.orders__empty-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-2xl);
box-shadow: var(--shadow-card);
}
.orders__empty-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--color-ink);
margin: 0 0 var(--space-sm) 0;
}
.orders__empty-text {
font-size: 0.875rem;
color: var(--color-muted);
margin: 0;
}
.orders__empty-cta {
margin-top: var(--space-md);
display: inline-flex;
}
.orders__loading {
padding: var(--space-2xl) 0;
}
@media (max-width: 639px) {
.orders__card-head {
flex-direction: column;
align-items: flex-start;
}
.orders__plate-badge {
max-width: 100%;
}
.orders__links {
flex-direction: column;
align-items: stretch;
gap: var(--space-sm);
}
.orders__link-sep {
display: none;
}
.orders__text-link {
padding: 0.5rem 0;
min-height: 2.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
}
}
</style>