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>
|
||||
<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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue