- Rewrite homepage: practical headline, use-case cards, calm trust note - Switch from purple to blue brand tokens across all pages - Replace all CTA buttons with brand-primary, reserve green for success - Remove emoji from template picker and compose page - Replace unicode chevrons with SVG expand buttons in admin - Redesign template picker modal with accessibility semantics - Add aria-invalid, aria-describedby to form validation - Add role=status/alert to loading, error, and result messages - Remove inline styles, replace with scoped utility classes - Update compose submit text, payment button, order empty state copy - Remove icon field from letter templates
559 lines
14 KiB
Vue
559 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',
|
|
lookup_started: 'Hanteras',
|
|
sent: 'Skickat',
|
|
delivered: 'Levererat',
|
|
failed: 'Misslyckad',
|
|
}
|
|
|
|
const statusBadge: Record<string, string> = {
|
|
pending_payment: 'badge--muted',
|
|
paid: 'badge--primary',
|
|
lookup_started: 'badge--primary',
|
|
sent: 'badge--success',
|
|
delivered: 'badge--success',
|
|
failed: 'badge--danger',
|
|
}
|
|
|
|
const allStatuses = [
|
|
'pending_payment',
|
|
'paid',
|
|
'lookup_started',
|
|
'sent',
|
|
'delivered',
|
|
'failed',
|
|
]
|
|
|
|
const stats = computed(() => {
|
|
const total = orders.value.length
|
|
const paid = orders.value.filter((o) =>
|
|
['paid', 'lookup_started', 'sent', 'delivered'].includes(o.status),
|
|
).length
|
|
const pending = orders.value.filter(
|
|
(o) => o.status === 'pending_payment',
|
|
).length
|
|
const sent = orders.value.filter(
|
|
(o) => o.status === 'sent' || o.status === 'delivered',
|
|
).length
|
|
return { total, paid, pending, sent }
|
|
})
|
|
|
|
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">
|
|
<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 class="admin__stat">
|
|
<span class="admin__stat-value">{{ stats.sent }}</span>
|
|
<span class="admin__stat-label">Skickade</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,
|
|
}"
|
|
>
|
|
<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-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 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>
|