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>
620 lines
15 KiB
Vue
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>
|