diff --git a/frontend/e2e/admin-dashboard.spec.ts b/frontend/e2e/admin-dashboard.spec.ts
new file mode 100644
index 0000000..fcc973a
--- /dev/null
+++ b/frontend/e2e/admin-dashboard.spec.ts
@@ -0,0 +1,84 @@
+import { test, expect } from '@playwright/test'
+
+test.describe('Admin dashboard', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/logga-in')
+ await page.getByLabel('E-postadress').fill('admin@bilhalsning.se')
+ await page.getByLabel('Lösenord').fill('test1234')
+ await page.getByRole('button', { name: 'Logga in' }).click()
+ await page.waitForURL('/')
+ })
+
+ test('admin can navigate to admin page', async ({ page }) => {
+ await page.goto('/admin')
+
+ await expect(
+ page.getByRole('heading', { name: 'Administration' }),
+ ).toBeVisible()
+ })
+
+ test('non-admin user is redirected away from admin', async ({ page }) => {
+ await page.goto('/logga-in')
+ await page.getByLabel('E-postadress').fill('test@bilhalsning.se')
+ await page.getByLabel('Lösenord').fill('test1234')
+ await page.getByRole('button', { name: 'Logga in' }).click()
+ await page.waitForURL('/')
+
+ await page.goto('/admin')
+
+ await expect(page).toHaveURL('/')
+ })
+
+ test('shows orders table with columns', async ({ page }) => {
+ await page.goto('/admin')
+
+ await expect(page.getByText('Datum')).toBeVisible()
+ await expect(page.getByText('E-post')).toBeVisible()
+ await expect(page.getByText('Regnr')).toBeVisible()
+ await expect(page.getByText('Status')).toBeVisible()
+ })
+
+ test('shows seeded order data', async ({ page }) => {
+ await page.goto('/admin')
+
+ await expect(page.getByText('ABC123')).toBeVisible()
+ await expect(page.getByText('DEF456')).toBeVisible()
+ await expect(page.getByText('GHI789')).toBeVisible()
+ })
+
+ test('click row expands letter content', async ({ page }) => {
+ await page.goto('/admin')
+
+ const rows = page.locator('.admin-dashboard__row')
+ await rows.first().click()
+
+ await expect(page.getByText('Brevtext')).toBeVisible()
+ })
+
+ test('click expanded row collapses it', async ({ page }) => {
+ await page.goto('/admin')
+
+ const rows = page.locator('.admin-dashboard__row')
+ await rows.first().click()
+ await expect(page.getByText('Brevtext')).toBeVisible()
+
+ await rows.first().click()
+ await expect(page.getByText('Brevtext')).not.toBeVisible()
+ })
+
+ test('status dropdown changes update order status', async ({ page }) => {
+ await page.goto('/admin')
+
+ const selects = page.locator('.admin-dashboard__status-select')
+ await selects.first().selectOption('delivered')
+
+ const updatedSelect = selects.first()
+ await expect(updatedSelect).toHaveValue('delivered')
+ })
+
+ test('admin cannot access admin page without auth', async ({ page }) => {
+ await page.goto('/admin')
+
+ await expect(page).toHaveURL(/\/logga-in\?redirect=\/admin/)
+ })
+})
diff --git a/frontend/src/__tests__/AdminDashboard.spec.ts b/frontend/src/__tests__/AdminDashboard.spec.ts
new file mode 100644
index 0000000..a6d2428
--- /dev/null
+++ b/frontend/src/__tests__/AdminDashboard.spec.ts
@@ -0,0 +1,224 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { createRouter, createMemoryHistory } from 'vue-router'
+import { createPinia } from 'pinia'
+import AdminPage from '@/pages/AdminPage.vue'
+
+function mockFetchResponse(status: number, body: unknown) {
+ return Promise.resolve({
+ ok: status >= 200 && status < 300,
+ status,
+ json: () => Promise.resolve(body),
+ })
+}
+
+function createTestRouter() {
+ return createRouter({
+ history: createMemoryHistory(),
+ routes: [
+ { path: '/admin', name: 'admin', component: AdminPage },
+ { path: '/', name: 'home', component: { template: '
Home
' } },
+ ],
+ })
+}
+
+function mountPage() {
+ const router = createTestRouter()
+ const pinia = createPinia()
+ router.push('/admin')
+ return {
+ router,
+ wrapper: mount(AdminPage, {
+ global: { plugins: [router, pinia] },
+ }),
+ }
+}
+
+const mockOrders = [
+ {
+ id: 'c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
+ email: 'test@bilhalsning.se',
+ plate: 'ABC123',
+ letterText: 'Hej fin bil!',
+ status: 'sent',
+ trackingId: 'PN123456789',
+ amountPaid: 49.0,
+ createdAt: '2026-05-11T12:00:00Z',
+ },
+ {
+ id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
+ email: 'user@example.com',
+ plate: 'XYZ789',
+ letterText: 'Vill köpa din bil.',
+ status: 'pending_payment',
+ trackingId: null,
+ amountPaid: null,
+ createdAt: '2026-05-14T13:00:00Z',
+ },
+]
+
+describe('AdminDashboard', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ globalThis.fetch = vi.fn()
+ vi.mocked(globalThis.fetch).mockResolvedValue(
+ mockFetchResponse(200, mockOrders),
+ )
+ })
+
+ it('renders heading and subtitle', async () => {
+ const { wrapper } = mountPage()
+ await new Promise((r) => setTimeout(r, 50))
+ expect(wrapper.text()).toContain('Administration')
+ expect(wrapper.text()).toContain(
+ 'Hantera beställningar, mallar och användare',
+ )
+ })
+
+ it('shows loading state initially', async () => {
+ globalThis.fetch = vi.fn().mockImplementation(() => new Promise(() => {}))
+ const { wrapper } = mountPage()
+ expect(wrapper.text()).toContain('Laddar beställningar...')
+ })
+
+ it('fetches orders from API on mount', async () => {
+ mountPage()
+ await new Promise((r) => setTimeout(r, 50))
+ expect(globalThis.fetch).toHaveBeenCalledWith(
+ '/api/admin/orders',
+ expect.objectContaining({ headers: expect.any(Object) }),
+ )
+ })
+
+ it('renders table with all columns', async () => {
+ const { wrapper } = mountPage()
+ await new Promise((r) => setTimeout(r, 50))
+ expect(wrapper.text()).toContain('Datum')
+ expect(wrapper.text()).toContain('E-post')
+ expect(wrapper.text()).toContain('Regnr')
+ expect(wrapper.text()).toContain('Status')
+ })
+
+ it('renders order data in rows', async () => {
+ const { wrapper } = mountPage()
+ await new Promise((r) => setTimeout(r, 50))
+ expect(wrapper.text()).toContain('test@bilhalsning.se')
+ expect(wrapper.text()).toContain('ABC123')
+ expect(wrapper.text()).toContain('user@example.com')
+ expect(wrapper.text()).toContain('XYZ789')
+ })
+
+ it('shows empty state when no orders', async () => {
+ vi.mocked(globalThis.fetch).mockResolvedValue(mockFetchResponse(200, []))
+ const { wrapper } = mountPage()
+ await new Promise((r) => setTimeout(r, 50))
+ expect(wrapper.text()).toContain('Inga beställningar ännu')
+ })
+
+ it('shows error state on API failure', async () => {
+ vi.mocked(globalThis.fetch).mockResolvedValue(
+ mockFetchResponse(500, { message: 'Internal server error' }),
+ )
+ const { wrapper } = mountPage()
+ await new Promise((r) => setTimeout(r, 50))
+ expect(wrapper.text()).toContain('Kunde inte hämta beställningar')
+ })
+
+ it('expands row on click to show letter content', async () => {
+ const { wrapper } = mountPage()
+ await new Promise((r) => setTimeout(r, 50))
+
+ const rows = wrapper.findAll('.admin-dashboard__row')
+ expect(rows.length).toBe(2)
+
+ await rows[0].trigger('click')
+ await new Promise((r) => setTimeout(r, 50))
+
+ expect(wrapper.text()).toContain('Hej fin bil!')
+ expect(wrapper.text()).toContain('Brevtext')
+ })
+
+ it('collapses row on second click', async () => {
+ const { wrapper } = mountPage()
+ await new Promise((r) => setTimeout(r, 50))
+
+ const rows = wrapper.findAll('.admin-dashboard__row')
+ await rows[0].trigger('click')
+ await new Promise((r) => setTimeout(r, 50))
+ expect(wrapper.text()).toContain('Hej fin bil!')
+
+ await rows[0].trigger('click')
+ await new Promise((r) => setTimeout(r, 50))
+ expect(wrapper.text()).not.toContain('Hej fin bil!')
+ })
+
+ it('only expands one row at a time', async () => {
+ const { wrapper } = mountPage()
+ await new Promise((r) => setTimeout(r, 50))
+
+ const rows = wrapper.findAll('.admin-dashboard__row')
+ await rows[0].trigger('click')
+ await new Promise((r) => setTimeout(r, 50))
+ expect(wrapper.text()).toContain('Hej fin bil!')
+
+ await rows[1].trigger('click')
+ await new Promise((r) => setTimeout(r, 50))
+ expect(wrapper.text()).not.toContain('Hej fin bil!')
+ expect(wrapper.text()).toContain('Vill köpa din bil.')
+ })
+
+ it('renders status dropdowns', async () => {
+ const { wrapper } = mountPage()
+ await new Promise((r) => setTimeout(r, 50))
+
+ const selects = wrapper.findAll('.admin-dashboard__status-select')
+ expect(selects.length).toBe(2)
+ })
+
+ it('fires status update API on dropdown change', async () => {
+ vi.mocked(globalThis.fetch)
+ .mockResolvedValueOnce(mockFetchResponse(200, mockOrders))
+ .mockResolvedValueOnce(
+ mockFetchResponse(200, { ...mockOrders[0], status: 'paid' }),
+ )
+
+ const { wrapper } = mountPage()
+ await new Promise((r) => setTimeout(r, 50))
+
+ const selects = wrapper.findAll('.admin-dashboard__status-select')
+ await selects[0].trigger('change')
+ await new Promise((r) => setTimeout(r, 50))
+
+ expect(globalThis.fetch).toHaveBeenCalledWith(
+ '/api/admin/orders/c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11/status',
+ expect.objectContaining({
+ method: 'PATCH',
+ body: '{"status":"sent"}',
+ }),
+ )
+ })
+
+ it('shows status error on failed update', async () => {
+ vi.mocked(globalThis.fetch)
+ .mockResolvedValueOnce(mockFetchResponse(200, mockOrders))
+ .mockResolvedValueOnce(
+ mockFetchResponse(500, { message: 'Server error' }),
+ )
+
+ const { wrapper } = mountPage()
+ await new Promise((r) => setTimeout(r, 50))
+
+ const selects = wrapper.findAll('.admin-dashboard__status-select')
+ await selects[0].trigger('change')
+ await new Promise((r) => setTimeout(r, 50))
+
+ expect(wrapper.text()).toContain('Kunde inte uppdatera status')
+ })
+
+ it('formats dates in Swedish locale', async () => {
+ const { wrapper } = mountPage()
+ await new Promise((r) => setTimeout(r, 50))
+
+ expect(wrapper.text()).toContain('2026')
+ })
+})