feat: build admin dashboard with orders table and status dropdown

- api/admin.ts: AdminOrder interface (id, email, plate, letterText,
  status, trackingId, amountPaid, createdAt), fetchAllOrders() calls
  GET /api/admin/orders, updateOrderStatus(orderId, status) calls
  PATCH /api/admin/orders/{id}/status
- AdminPage.vue replaces placeholder with full dashboard:
  - Table columns: Datum, E-post, Regnr, Status, expand chevron
  - Click any row to toggle expanded letter preview below the row
  - Only one row expanded at a time; second click collapses
  - Status column has a <select> dropdown showing Swedish labels
  - Changing dropdown fires PATCH API immediately (no save button)
  - On API failure the dropdown reverts to previous value and a
    red inline error "Kunde inte uppdatera status" appears
  - Loading, empty, and API error states with Swedish messages
  - Responsive table wrapper for horizontal scroll on small screens
  - Expanded rows use a separate <tr> with colspan(5) for clean
    table semantics
This commit is contained in:
Joakim Mörling 2026-05-15 12:15:19 +02:00
parent 5df7c97977
commit 9b4f08469c
2 changed files with 331 additions and 9 deletions

26
frontend/src/api/admin.ts Normal file
View file

@ -0,0 +1,26 @@
import { request } from './client'
export interface AdminOrder {
id: string
email: string
plate: string
letterText: string
status: string
trackingId: string | null
amountPaid: number | null
createdAt: string
}
export function fetchAllOrders(): Promise<AdminOrder[]> {
return request<AdminOrder[]>('/admin/orders')
}
export function updateOrderStatus(
orderId: string,
status: string,
): Promise<AdminOrder> {
return request<AdminOrder>(`/admin/orders/${orderId}/status`, {
method: 'PATCH',
body: JSON.stringify({ status }),
})
}

View file

@ -1,28 +1,324 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { fetchAllOrders, updateOrderStatus, 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 statusLabels: Record<string, string> = {
pending_payment: 'Väntar på betalning',
paid: 'Betalad',
lookup_started: 'Hanteras',
sent: 'Skickat',
delivered: 'Levererat',
failed: 'Misslyckad',
}
const statusClasses: Record<string, string> = {
pending_payment: 'badge--gray',
paid: 'badge--blue',
lookup_started: 'badge--blue',
sent: 'badge--green',
delivered: 'badge--green',
failed: 'badge--red',
}
const allStatuses = [
'pending_payment',
'paid',
'lookup_started',
'sent',
'delivered',
'failed',
]
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('sv-SE', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
function toggleExpand(orderId: string) {
expandedOrderId.value = expandedOrderId.value === orderId ? null : orderId
}
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.'
}
}
onMounted(async () => {
try {
orders.value = await fetchAllOrders()
} catch {
error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
} finally {
loading.value = false
}
})
</script>
<template>
<div class="admin">
<h1 class="admin__title">Administration</h1>
<p class="admin__subtitle">Hantera beställningar, mallar och användare.</p>
<div class="admin-dashboard">
<h1 class="admin-dashboard__title">Administration</h1>
<p class="admin-dashboard__subtitle">
Hantera beställningar, mallar och användare.
</p>
<p v-if="loading" class="admin-dashboard__loading">
Laddar beställningar...
</p>
<p v-else-if="error" class="admin-dashboard__error">{{ error }}</p>
<p v-else-if="orders.length === 0" class="admin-dashboard__empty">
Inga beställningar ännu.
</p>
<div v-else class="admin-dashboard__table-wrapper">
<p v-if="statusError" class="admin-dashboard__status-error">
{{ statusError }}
</p>
<table class="admin-dashboard__table">
<thead>
<tr>
<th>Datum</th>
<th>E-post</th>
<th>Regnr</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<template v-for="order in orders" :key="order.id">
<tr
class="admin-dashboard__row"
:class="{
'admin-dashboard__row--expanded': expandedOrderId === order.id,
}"
@click="toggleExpand(order.id)"
>
<td>{{ formatDate(order.createdAt) }}</td>
<td>{{ order.email }}</td>
<td class="admin-dashboard__plate">{{ order.plate }}</td>
<td>
<select
class="admin-dashboard__status-select"
:class="statusClasses[order.status] || 'badge--gray'"
: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-dashboard__expand">
<span class="admin-dashboard__chevron">
{{ expandedOrderId === order.id ? '▼' : '▶' }}
</span>
</td>
</tr>
<tr
v-if="expandedOrderId === order.id"
class="admin-dashboard__expanded-row"
>
<td :colspan="5">
<div class="admin-dashboard__letter">
<div class="admin-dashboard__letter-label">Brevtext</div>
<div class="admin-dashboard__letter-text">
{{ order.letterText }}
</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
.admin {
max-width: 48rem;
.admin-dashboard {
max-width: 64rem;
margin: 3rem auto 0;
padding: 0 1rem;
}
.admin__title {
.admin-dashboard__title {
margin: 0 0 0.25rem 0;
font-size: 1.5rem;
color: #1a202c;
}
.admin__subtitle {
margin: 0;
.admin-dashboard__subtitle {
margin: 0 0 1.5rem 0;
color: #718096;
font-size: 0.875rem;
}
.admin-dashboard__loading,
.admin-dashboard__error,
.admin-dashboard__empty {
margin: 2rem 0;
padding: 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
text-align: center;
}
.admin-dashboard__loading {
color: #718096;
}
.admin-dashboard__error {
background: #fff5f5;
border: 1px solid #fed7d7;
color: #c53030;
}
.admin-dashboard__empty {
background: #f7fafc;
border: 1px solid #e2e8f0;
color: #718096;
}
.admin-dashboard__status-error {
margin: 0 0 0.75rem 0;
padding: 0.5rem 0.75rem;
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 0.375rem;
color: #c53030;
font-size: 0.8125rem;
}
.admin-dashboard__table-wrapper {
overflow-x: auto;
}
.admin-dashboard__table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.admin-dashboard__table thead {
background: #f7fafc;
}
.admin-dashboard__table th {
padding: 0.75rem 1rem;
text-align: left;
font-size: 0.75rem;
font-weight: 600;
color: #718096;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 2px solid #e2e8f0;
}
.admin-dashboard__row {
cursor: pointer;
border-bottom: 1px solid #e2e8f0;
transition: background 0.1s;
}
.admin-dashboard__row:hover {
background: #f7fafc;
}
.admin-dashboard__row--expanded {
background: #ebf8ff;
}
.admin-dashboard__row td {
padding: 0.75rem 1rem;
color: #4a5568;
white-space: nowrap;
}
.admin-dashboard__plate {
font-weight: 600;
letter-spacing: 0.05em;
color: #1a202c !important;
}
.admin-dashboard__status-select {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid #e2e8f0;
font-size: 0.75rem;
font-weight: 600;
color: #4a5568;
outline: none;
cursor: pointer;
background: #fff;
}
.admin-dashboard__status-select:focus {
border-color: #4299e1;
box-shadow: 0 0 0 2px rgba(66, 153, 225, 0.2);
}
.admin-dashboard__expand {
text-align: center;
width: 2rem;
}
.admin-dashboard__chevron {
font-size: 0.625rem;
color: #a0aec0;
}
.admin-dashboard__expanded-row td {
padding: 0;
background: #f7fafc;
}
.admin-dashboard__letter {
padding: 1rem 1.25rem;
border-top: 1px solid #e2e8f0;
}
.admin-dashboard__letter-label {
font-size: 0.75rem;
font-weight: 600;
color: #a0aec0;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.admin-dashboard__letter-text {
font-size: 0.875rem;
color: #4a5568;
line-height: 1.6;
white-space: pre-wrap;
}
</style>