bilhej/frontend/src/pages/AdminPage.vue
Joakim Mörling 01db53860b Rework admin dashboard filtering, search, and message viewing.
Admins need to find orders quickly and read full letter text without a
cramped table column.

- Make stat cards clickable filters (Totalt, Att göra, Betalda, Väntar)
- Add search by partial order ID or registration number
- Show shortened order ID in table with full ID on hover
- Replace message column with "Visa meddelande" opening a modal
- Keep expanded row for tracking only; remove duplicate brevtext block
- Update AdminDashboard unit tests and admin-dashboard e2e specs
2026-05-21 14:49:50 +02:00

843 lines
21 KiB
Vue

<script setup lang="ts">
import { ref, onMounted, onUnmounted, reactive, computed } from 'vue'
import {
fetchAllOrders,
updateOrderStatus,
updateTracking,
type AdminOrder,
} from '@/api/admin'
const orders = ref<AdminOrder[]>([])
const expandedOrderId = ref<string | null>(null)
const loading = ref(true)
const error = ref('')
const statusError = ref('')
const trackingError = ref('')
const activeFilter = ref<'all' | 'processing' | 'paid_group' | 'pending_payment'>(
'all',
)
const searchQuery = ref('')
const trackingInputValues = reactive<Record<string, string>>({})
const messageModalOrder = ref<AdminOrder | null>(null)
const statusLabels: Record<string, string> = {
pending_payment: 'Väntar på betalning',
paid: 'Betalad',
processing: 'Hanteras',
sent: 'Skickat',
delivered: 'Levererat',
failed: 'Misslyckad',
}
const statusBadge: Record<string, string> = {
pending_payment: 'badge--muted',
paid: 'badge--success',
processing: 'badge--primary',
sent: 'badge--success',
delivered: 'badge--success',
failed: 'badge--danger',
}
const allStatuses = [
'pending_payment',
'paid',
'processing',
'sent',
'delivered',
'failed',
]
const stats = computed(() => {
const total = orders.value.length
const todo = orders.value.filter((o) => o.status === 'processing').length
const paid = orders.value.filter((o) =>
['paid', 'sent', 'delivered'].includes(o.status),
).length
const pending = orders.value.filter(
(o) => o.status === 'pending_payment',
).length
return { total, todo, paid, pending }
})
const filteredOrders = computed(() => {
let result = orders.value
if (activeFilter.value === 'processing') {
result = result.filter((o) => o.status === 'processing')
} else if (activeFilter.value === 'paid_group') {
result = result.filter((o) =>
['paid', 'sent', 'delivered'].includes(o.status),
)
} else if (activeFilter.value === 'pending_payment') {
result = result.filter((o) => o.status === 'pending_payment')
}
const query = searchQuery.value.trim().toLowerCase()
if (query) {
result = result.filter(
(o) =>
o.id.toLowerCase().includes(query) ||
o.plate.toLowerCase().includes(query),
)
}
return result
})
function shortOrderId(id: string): string {
return id.slice(0, 8)
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('sv-SE', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
function openMessageModal(order: AdminOrder) {
messageModalOrder.value = order
}
function closeMessageModal() {
messageModalOrder.value = null
}
function handleModalKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && messageModalOrder.value) {
closeMessageModal()
}
}
function toggleExpand(orderId: string) {
if (expandedOrderId.value === orderId) {
expandedOrderId.value = null
} else {
expandedOrderId.value = orderId
const order = orders.value.find((o) => o.id === orderId)
if (order && !(orderId in trackingInputValues)) {
trackingInputValues[orderId] = order.trackingId ?? ''
}
}
}
async function handleStatusChange(orderId: string, newStatus: string) {
const order = orders.value.find((o) => o.id === orderId)
if (!order) return
const previousStatus = order.status
order.status = newStatus
statusError.value = ''
try {
await updateOrderStatus(orderId, newStatus)
} catch {
order.status = previousStatus
statusError.value = 'Kunde inte uppdatera status. Försök igen.'
}
}
async function handleTrackingSave(orderId: string) {
const newTrackingId = trackingInputValues[orderId]?.trim() || null
const order = orders.value.find((o) => o.id === orderId)
if (!order) return
const previousTrackingId = order.trackingId
order.trackingId = newTrackingId
trackingError.value = ''
try {
await updateTracking(orderId, newTrackingId)
} catch {
order.trackingId = previousTrackingId
trackingError.value = 'Kunde inte spara spårnings-ID. Försök igen.'
}
}
onMounted(async () => {
window.addEventListener('keydown', handleModalKeydown)
try {
orders.value = await fetchAllOrders()
} catch {
error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
} finally {
loading.value = false
}
})
onUnmounted(() => {
window.removeEventListener('keydown', handleModalKeydown)
})
</script>
<template>
<div class="admin">
<h1 class="admin__title">Administration</h1>
<p
v-if="loading"
class="text-muted text-center admin__loading"
role="status"
>
Laddar beställningar...
</p>
<div v-else-if="error" class="message message--error">{{ error }}</div>
<div v-else-if="orders.length === 0" class="message message--info">
Inga beställningar ännu.
</div>
<template v-else>
<div class="admin__stats">
<button
type="button"
class="admin__stat"
:class="{ 'admin__stat--active': activeFilter === 'all' }"
@click="activeFilter = 'all'"
>
<span class="admin__stat-value">{{ stats.total }}</span>
<span class="admin__stat-label">Totalt</span>
</button>
<button
type="button"
class="admin__stat"
:class="{ 'admin__stat--active': activeFilter === 'processing' }"
@click="activeFilter = 'processing'"
>
<span class="admin__stat-value">{{ stats.todo }}</span>
<span class="admin__stat-label">Att göra</span>
</button>
<button
type="button"
class="admin__stat"
:class="{ 'admin__stat--active': activeFilter === 'paid_group' }"
@click="activeFilter = 'paid_group'"
>
<span class="admin__stat-value">{{ stats.paid }}</span>
<span class="admin__stat-label">Betalda</span>
</button>
<button
type="button"
class="admin__stat"
:class="{ 'admin__stat--active': activeFilter === 'pending_payment' }"
@click="activeFilter = 'pending_payment'"
>
<span class="admin__stat-value">{{ stats.pending }}</span>
<span class="admin__stat-label">Väntar</span>
</button>
</div>
<div class="admin__toolbar">
<label for="admin-order-search" class="admin__search-label"
>Sök beställnings-ID eller regnr</label
>
<input
id="admin-order-search"
v-model="searchQuery"
class="admin__search-input"
type="search"
placeholder="t.ex. c1eebc99 eller ABC123"
/>
</div>
<p
v-if="filteredOrders.length === 0"
class="message message--info admin__filter-empty"
>
Inga beställningar matchar filtret.
</p>
<p
v-if="statusError"
class="message message--error admin__status-error"
role="alert"
>
{{ statusError }}
</p>
<div v-if="filteredOrders.length > 0" class="admin__table-wrap">
<table class="admin__table">
<thead>
<tr>
<th>Datum</th>
<th>Beställnings-ID</th>
<th>E-post</th>
<th>Regnr</th>
<th>Meddelande</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<template v-for="order in filteredOrders" :key="order.id">
<tr
class="admin__row"
:class="{
'admin__row--expanded': expandedOrderId === order.id,
'admin__row--todo': order.status === 'processing',
}"
>
<td>{{ formatDate(order.createdAt) }}</td>
<td class="admin__order-id" :title="order.id">
{{ shortOrderId(order.id) }}
</td>
<td>{{ order.email }}</td>
<td class="admin__plate">{{ order.plate }}</td>
<td>
<button
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
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline
:points="
expandedOrderId === order.id
? '6 9 12 15 18 9'
: '9 6 15 12 9 18'
"
/>
</svg>
</button>
</td>
</tr>
<tr
v-if="expandedOrderId === order.id"
class="admin__expanded-row"
>
<td :colspan="7">
<div class="admin__expanded-inner">
<div class="admin__section">
<div class="admin__section-header">
<span class="admin__section-label">Spårnings-ID</span>
<a
v-if="order.trackingId"
class="admin__tracking-link"
:href="`https://www.postnord.se/verktyg/spara/?id=${order.trackingId}`"
target="_blank"
rel="noopener noreferrer"
@click.stop
>
Spåra hos PostNord
</a>
</div>
<p
v-if="trackingError"
class="message message--error admin__tracking-error"
role="alert"
>
{{ trackingError }}
</p>
<div class="admin__tracking-row">
<label
:for="`tracking-${order.id}`"
class="visually-hidden"
>Spårnings-ID</label
>
<input
:id="`tracking-${order.id}`"
class="admin__tracking-input"
type="text"
:value="
trackingInputValues[order.id] ??
order.trackingId ??
''
"
placeholder="PN..."
@input="
trackingInputValues[order.id] = (
$event.target as HTMLInputElement
).value
"
@click.stop
/>
<button
class="btn btn--primary btn--sm"
@click.stop="handleTrackingSave(order.id)"
>
Spara
</button>
</div>
</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
<div
v-if="messageModalOrder"
class="admin-modal-overlay"
@click.self="closeMessageModal"
>
<div
class="admin-modal"
role="dialog"
aria-modal="true"
aria-labelledby="admin-message-modal-title"
>
<div class="admin-modal__header">
<h2 id="admin-message-modal-title" class="admin-modal__title">
Brevtext
</h2>
<button
type="button"
class="admin-modal__close"
aria-label="Stäng"
@click="closeMessageModal"
>
<svg
viewBox="0 0 24 24"
width="20"
height="20"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
aria-hidden="true"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<p class="admin-modal__meta">
{{ messageModalOrder.plate }} ·
{{ shortOrderId(messageModalOrder.id) }}
</p>
<div class="admin-modal__body">
{{ messageModalOrder.letterText }}
</div>
</div>
</div>
</div>
</template>
<style scoped>
.admin {
max-width: 72rem;
margin: var(--space-2xl) auto 0;
padding: 0 var(--space-lg);
}
.admin__title {
margin: 0 0 var(--space-xl) 0;
font-size: 1.5rem;
color: var(--color-ink);
}
.admin__stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-md);
margin-bottom: var(--space-xl);
}
.admin__stat {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-md) var(--space-lg);
text-align: center;
box-shadow: var(--shadow-sm);
cursor: pointer;
font: inherit;
width: 100%;
transition:
border-color var(--transition-fast),
background var(--transition-fast);
}
.admin__stat:hover {
border-color: var(--color-primary);
}
.admin__stat--active {
background: var(--color-primary-soft);
border-color: var(--color-primary);
}
.admin__toolbar {
margin-bottom: var(--space-lg);
}
.admin__search-label {
display: block;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-xs);
}
.admin__search-input {
width: 100%;
max-width: 24rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.875rem;
color: var(--color-ink);
outline: none;
transition: border-color var(--transition-fast);
}
.admin__search-input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-ring);
}
.admin__filter-empty {
margin-bottom: var(--space-md);
}
.admin__order-id {
font-family: ui-monospace, monospace;
font-size: 0.8125rem;
}
.admin__message-btn {
white-space: nowrap;
}
.admin__stat-value {
display: block;
font-size: 1.5rem;
font-weight: 700;
color: var(--color-ink);
}
.admin__stat-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-muted);
margin-top: var(--space-xs);
}
.admin__table-wrap {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-card);
overflow-x: auto;
}
.admin__table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.admin__table thead {
background: var(--color-border-light);
}
.admin__table th {
padding: 0.75rem var(--space-md);
text-align: left;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--color-border);
}
.admin__row {
cursor: pointer;
border-bottom: 1px solid var(--color-border-light);
transition: background var(--transition-fast);
}
.admin__row:last-child {
border-bottom: none;
}
.admin__row:hover {
background: var(--color-border-light);
}
.admin__row--expanded {
background: var(--color-primary-soft) !important;
}
.admin__row--todo {
border-left: 3px solid var(--color-primary);
}
.admin__row td {
padding: 0.75rem var(--space-md);
color: var(--color-ink);
white-space: nowrap;
}
.admin__plate {
font-weight: 600;
letter-spacing: 0.05em;
}
.admin__status-select {
padding: 0.2rem 0.5rem;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
font-size: 0.75rem;
font-weight: 600;
color: var(--color-ink);
outline: none;
cursor: pointer;
background: var(--color-surface);
}
.admin__status-select:focus {
border-color: var(--color-primary);
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 {
padding: 0;
background: var(--color-surface);
}
.admin__expanded-inner {
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-lg);
border-top: 1px solid var(--color-border-light);
}
.admin__section {
padding: var(--space-md);
background: var(--color-border-light);
border-radius: var(--radius-md);
}
.admin__section-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-sm);
}
.admin__section-body {
font-size: 0.875rem;
color: var(--color-ink);
line-height: 1.6;
white-space: pre-wrap;
}
.admin__section-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.admin__tracking-link {
font-size: 0.8125rem;
color: var(--color-primary);
text-decoration: none;
font-weight: 500;
}
.admin__tracking-link:hover {
text-decoration: underline;
}
.admin__tracking-row {
display: flex;
gap: var(--space-sm);
margin-top: var(--space-sm);
}
.admin__tracking-input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.8125rem;
color: var(--color-ink);
outline: none;
transition: border-color var(--transition-fast);
}
.admin__tracking-input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-ring);
}
.admin__loading {
padding: var(--space-2xl) 0;
}
.admin__status-error {
margin-bottom: var(--space-md);
}
.admin__tracking-error {
margin-bottom: var(--space-sm);
padding: var(--space-sm) var(--space-md);
font-size: 0.8125rem;
}
.admin-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: var(--space-md);
}
.admin-modal {
background: var(--color-surface);
border-radius: var(--radius-xl);
width: 100%;
max-width: 32rem;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-card);
border: 1px solid var(--color-border);
}
.admin-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-lg) var(--space-lg) 0;
}
.admin-modal__title {
margin: 0;
font-size: 1.125rem;
color: var(--color-ink);
}
.admin-modal__close {
background: none;
border: none;
color: var(--color-muted);
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-modal__close:hover {
color: var(--color-ink);
background: var(--color-border-light);
}
.admin-modal__meta {
margin: var(--space-sm) var(--space-lg) 0;
font-size: 0.8125rem;
color: var(--color-muted);
font-family: ui-monospace, monospace;
}
.admin-modal__body {
margin: var(--space-md) var(--space-lg) var(--space-lg);
padding: var(--space-md);
background: var(--color-border-light);
border-radius: var(--radius-md);
font-size: 0.875rem;
color: var(--color-ink);
line-height: 1.6;
white-space: pre-wrap;
overflow-y: auto;
max-height: 60vh;
}
@media (max-width: 768px) {
.admin__stats {
grid-template-columns: repeat(2, 1fr);
}
}
</style>