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:
parent
5df7c97977
commit
9b4f08469c
2 changed files with 331 additions and 9 deletions
26
frontend/src/api/admin.ts
Normal file
26
frontend/src/api/admin.ts
Normal 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 }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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>
|
<template>
|
||||||
<div class="admin">
|
<div class="admin-dashboard">
|
||||||
<h1 class="admin__title">Administration</h1>
|
<h1 class="admin-dashboard__title">Administration</h1>
|
||||||
<p class="admin__subtitle">Hantera beställningar, mallar och användare.</p>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.admin {
|
.admin-dashboard {
|
||||||
max-width: 48rem;
|
max-width: 64rem;
|
||||||
margin: 3rem auto 0;
|
margin: 3rem auto 0;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin__title {
|
.admin-dashboard__title {
|
||||||
margin: 0 0 0.25rem 0;
|
margin: 0 0 0.25rem 0;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
color: #1a202c;
|
color: #1a202c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin__subtitle {
|
.admin-dashboard__subtitle {
|
||||||
margin: 0;
|
margin: 0 0 1.5rem 0;
|
||||||
color: #718096;
|
color: #718096;
|
||||||
font-size: 0.875rem;
|
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>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue