bilhej/frontend/src/pages/AdminPage.vue
Joakim Mörling 98d5545be0 feat: replace Stripe mock with manual Swish payment flow
Replace the mock test-payment button with a real manual Swish flow
where the user sends a Swish payment with the order ID as message
and confirms via a button. Admin verifies Swish and processes manually.

Backend
- Rename OrderStatus LOOKUP_STARTED to PROCESSING (Swedish: Hanteras)
- Update V5 migration CHECK constraint from lookup_started to processing
- Rename OrderService.markAsPaid() to confirmPayment(), sets PROCESSING
  instead of PAID, stop hardcoding amountPaid
- Add GET /api/payment/swish-info endpoint returning swish number and
  letter price from app.payment config
- Permit /api/payment/swish-info without authentication
- Update UpdateStatusRequest regex to accept processing
- Update PaymentControllerTest for renamed method, new status, and
  public swish-info endpoint test

Frontend
- Rewrite PaymentRedirect.vue: Swish number, order ID as message,
  Jag har betalat button with confirmation dialog
- Add fetchSwishInfo() to api/payment.ts
- AdminPage: rename Skickade stat to Att göra (processing orders),
  highlight processing rows with admin__row--todo
- OrdersPage: update status labels/badge classes for new flow
- Refactor ApiError in client.ts to property declaration syntax
- Exclude __tests__ from tsconfig.app.json and Docker builds

Tests
- Rewrite PaymentRedirect.spec.ts for Swish info, confirmation dialog,
  cancel flow, and processing status
- Update OrdersPage.spec.ts with processing status test
- Update AdminDashboard.spec.ts with Att göra stat and row highlight
- Add amountPaid to ComposePage.spec.ts mock

Config
- Add SWISH_NUMBER to .env.example and docker-compose.yml
2026-05-19 19:23:37 +02:00

567 lines
14 KiB
Vue

<script setup lang="ts">
import { ref, onMounted, 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 trackingInputValues = reactive<Record<string, string>>({})
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 }
})
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('sv-SE', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
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 () => {
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
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">
<div class="admin__stat">
<span class="admin__stat-value">{{ stats.total }}</span>
<span class="admin__stat-label">Totalt</span>
</div>
<div class="admin__stat admin__stat--todo">
<span class="admin__stat-value">{{ stats.todo }}</span>
<span class="admin__stat-label">Att göra</span>
</div>
<div class="admin__stat">
<span class="admin__stat-value">{{ stats.paid }}</span>
<span class="admin__stat-label">Betalda</span>
</div>
<div class="admin__stat">
<span class="admin__stat-value">{{ stats.pending }}</span>
<span class="admin__stat-label">Väntar</span>
</div>
</div>
<p
v-if="statusError"
class="message message--error admin__status-error"
role="alert"
>
{{ statusError }}
</p>
<div class="admin__table-wrap">
<table class="admin__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__row"
:class="{
'admin__row--expanded': expandedOrderId === order.id,
'admin__row--todo': order.status === 'processing',
}"
>
<td>{{ formatDate(order.createdAt) }}</td>
<td>{{ order.email }}</td>
<td class="admin__plate">{{ order.plate }}</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="5">
<div class="admin__expanded-inner">
<div class="admin__section">
<div class="admin__section-label">Brevtext</div>
<div class="admin__section-body">
{{ order.letterText }}
</div>
</div>
<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>
</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);
}
.admin__stat--todo {
background: var(--color-primary-soft);
border-color: var(--color-primary);
}
.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;
}
@media (max-width: 768px) {
.admin__stats {
grid-template-columns: repeat(2, 1fr);
}
}
</style>