frontend: Workload pages

This commit is contained in:
Frank Agerholm 2026-01-25 19:47:11 +01:00
commit 9eb9be7318
No known key found for this signature in database
8 changed files with 372 additions and 21 deletions

View file

@ -26,7 +26,7 @@ export function useApi() {
if (status === 401) { if (status === 401) {
console.warn("API returned 401 — logging out") console.warn("API returned 401 — logging out")
logout() logout()
throw new Error("Deine Sitzung ist abgelaufen. Bitte erneut einloggen.") return { unauthorized: true }
} }
// 403 → keine Berechtigung // 403 → keine Berechtigung

View file

@ -1,6 +1,8 @@
<script setup> <script setup>
const { token, loadToken, logout } = useAuth() const { token, loadToken, logout } = useAuth()
const router = useRouter() const router = useRouter()
const showWorkMenu = ref(false)
if (process.client) { if (process.client) {
loadToken() loadToken()
} }
@ -13,19 +15,31 @@ function doLogout() {
<template> <template>
<div> <div>
<header class="p-4 bg-white shadow flex justify-between"> <header class="p-4 bg-white shadow flex justify-between">
<nav class="flex gap-4"> <nav class="flex gap-4 items-center">
<NuxtLink to="/">Dashboard</NuxtLink> <NuxtLink to="/">Dashboard</NuxtLink>
<NuxtLink to="/owners">Owners</NuxtLink> <NuxtLink to="/owners">Owners</NuxtLink>
<NuxtLink to="/servers">Servers</NuxtLink> <NuxtLink to="/servers">Servers</NuxtLink>
<NuxtLink to="/workentries">Work Entries</NuxtLink>
<div class="relative">
<button @click="showWorkMenu = !showWorkMenu" class="cursor-pointer">
Work Entries
</button>
<div v-if="showWorkMenu" class="absolute left-0 mt-2 bg-white shadow rounded p-2 space-y-1 z-50">
<NuxtLink @click="showWorkMenu = false" to="/workentries/create" class="block px-3 py-1 hover:bg-gray-100 rounded">
Neu erstellen
</NuxtLink>
<NuxtLink @click="showWorkMenu = false" to="/workentries/search" class="block px-3 py-1 hover:bg-gray-100 rounded">
Suchen
</NuxtLink>
</div>
</div>
</nav> </nav>
<div> <div>
<button <button v-if="token" @click="doLogout" class="text-red-600 hover:underline">
v-if="token"
@click="doLogout"
class="text-red-600 hover:underline"
>
Logout Logout
</button> </button>
</div> </div>

View file

@ -36,7 +36,7 @@ async function login() {
<div class="max-w-sm mx-auto mt-20 p-6 bg-white shadow rounded"> <div class="max-w-sm mx-auto mt-20 p-6 bg-white shadow rounded">
<h1 class="text-2xl font-bold mb-4">Login</h1> <h1 class="text-2xl font-bold mb-4">Login</h1>
<div class="space-y-4"> <form @submit.prevent="login" class="space-y-4">
<input <input
v-model="username" v-model="username"
type="text" type="text"
@ -52,13 +52,13 @@ async function login() {
/> />
<button <button
@click="login" type="submit"
class="w-full bg-blue-600 text-white p-2 rounded hover:bg-blue-700" class="w-full bg-blue-600 text-white p-2 rounded hover:bg-blue-700"
> >
Login Login
</button> </button>
<p v-if="error" class="text-red-600">{{ error }}</p> <p v-if="error" class="text-red-600">{{ error }}</p>
</div> </form>
</div> </div>
</template> </template>

View file

@ -4,6 +4,7 @@ definePageMeta({
requiresAuth: true requiresAuth: true
}) })
import { isUnauthorized } from "~/utils/auth"
import { ref, onMounted } from "vue" import { ref, onMounted } from "vue"
const { loadToken } = useAuth() const { loadToken } = useAuth()
@ -17,7 +18,12 @@ const error = ref("")
async function loadOwners() { async function loadOwners() {
loading.value = true loading.value = true
try { try {
owners.value = await apiFetch("/owners") const result = await apiFetch("/owners")
if (isUnauthorized(result)) {
return result
}
owners.value = result
return result
} catch (e: any) { } catch (e: any) {
error.value = e.message error.value = e.message
} finally { } finally {
@ -29,23 +35,27 @@ async function createOwner() {
if (!newOwnerName.value.trim()) return if (!newOwnerName.value.trim()) return
try { try {
const owner = await apiFetch("/owners", { const result = await apiFetch("/owners", {
method: "POST", method: "POST",
body: { body: {
name: newOwnerName.value name: newOwnerName.value
} }
}) })
if (isUnauthorized(result)) {
return result
}
owners.value.push(owner) owners.value.push(result)
newOwnerName.value = "" newOwnerName.value = ""
return result
} catch (e: any) { } catch (e: any) {
error.value = e.message error.value = e.message
} }
} }
onMounted(() => { onMounted( async () => {
loadToken() loadToken()
loadOwners() if (isUnauthorized(await loadOwners())) return
}) })
</script> </script>

View file

@ -5,6 +5,7 @@ definePageMeta({
}) })
import { ref, onMounted } from "vue" import { ref, onMounted } from "vue"
import { isUnauthorized } from "~/utils/auth"
const { apiFetch } = useApi() const { apiFetch } = useApi()
@ -49,6 +50,10 @@ async function saveEdit(serverId: number) {
} }
}) })
if (isUnauthorized(updated)) {
return updated
}
// Liste aktualisieren // Liste aktualisieren
const idx = servers.value.findIndex(s => s.id === serverId) const idx = servers.value.findIndex(s => s.id === serverId)
if (idx !== -1) servers.value[idx] = updated if (idx !== -1) servers.value[idx] = updated
@ -64,7 +69,11 @@ const owners = ref([])
async function loadOwners() { async function loadOwners() {
try { try {
owners.value = await apiFetch("/owners") const result = await apiFetch("/owners")
if (isUnauthorized(result)) {
return result
}
owners.value = result
} catch (e: any) { } catch (e: any) {
error.value = e.message error.value = e.message
} }
@ -73,7 +82,11 @@ async function loadOwners() {
async function loadServers() { async function loadServers() {
loading.value = true loading.value = true
try { try {
servers.value = await apiFetch("/servers") const result = await apiFetch("/servers")
if (isUnauthorized(result)) {
return result
}
servers.value = result
} catch (e: any) { } catch (e: any) {
error.value = e.message error.value = e.message
} finally { } finally {
@ -93,6 +106,9 @@ async function createServer() {
owner_id: newServer.value.owner_id owner_id: newServer.value.owner_id
} }
}) })
if (isUnauthorized(server)) {
return server
}
servers.value.push(server) servers.value.push(server)
newServer.value = { hostname: "", description: "" } newServer.value = { hostname: "", description: "" }
@ -101,9 +117,9 @@ async function createServer() {
} }
} }
onMounted(() => { onMounted(async () => {
loadOwners() if (isUnauthorized(await loadOwners())) return
loadServers() if (isUnauthorized(await loadServers())) return
}) })
</script> </script>

View file

@ -0,0 +1,149 @@
<script setup lang="ts">
definePageMeta({
requiresAuth: true
})
import { ref, onMounted } from "vue"
import { isUnauthorized } from "~/utils/auth"
const { apiFetch } = useApi()
const servers = ref([])
const loading = ref(false)
const error = ref("")
const createdEntry = ref(null)
const form = ref({
title: "",
description: "",
duration_minutes: null,
server_ids: []
})
async function loadServers() {
try {
const result = await apiFetch("/servers")
if (isUnauthorized(result)) {
return result
}
servers.value = result
} catch (e: any) {
error.value = e.message
}
}
async function createEntry() {
try {
const entry = await apiFetch("/work-entries", {
method: "POST",
body: form.value
})
if (isUnauthorized(entry)) {
return entry
}
createdEntry.value = entry
// Formular zurücksetzen
form.value = {
title: "",
description: "",
duration_minutes: null,
server_ids: []
}
} catch (e: any) {
error.value = e.message
}
}
function resetForm() {
createdEntry.value = null
}
onMounted(async () => {
if (isUnauthorized(await loadServers())) return
})
</script>
<template>
<div class="space-y-6">
<h1 class="text-3xl font-bold">Neuen Worklog-Eintrag erstellen</h1>
<div v-if="error" class="text-red-600">{{ error }}</div>
<!-- Anzeige des erstellten Eintrags -->
<div v-if="createdEntry" class="p-4 bg-green-50 border border-green-300 rounded">
<h2 class="text-xl font-semibold mb-2">Eintrag erstellt</h2>
<p><strong>Titel:</strong> {{ createdEntry.title }}</p>
<p><strong>Beschreibung:</strong> {{ createdEntry.description || "—" }}</p>
<p><strong>Dauer:</strong> {{ createdEntry.duration_minutes || "—" }}</p>
<p class="mt-2">
<strong>Server:</strong>
<span
v-for="s in createdEntry.servers"
:key="s.id"
class="inline-block bg-gray-200 px-2 py-1 rounded mr-1"
>
{{ s.hostname }}
</span>
</p>
<button
@click="resetForm"
class="mt-4 bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Weiteren Eintrag erstellen
</button>
</div>
<!-- Formular -->
<div v-else class="p-4 bg-white shadow rounded space-y-4">
<input
v-model="form.title"
type="text"
placeholder="Titel"
class="border p-2 rounded w-full"
/>
<textarea
v-model="form.description"
placeholder="Beschreibung (optional)"
class="border p-2 rounded w-full"
/>
<input
v-model.number="form.duration_minutes"
type="number"
placeholder="Dauer in Minuten (optional)"
class="border p-2 rounded w-full"
/>
<div>
<label class="font-semibold">Server auswählen</label>
<div class="flex flex-col gap-1 mt-1">
<label
v-for="server in servers"
:key="server.id"
class="flex items-center gap-2"
>
<input
type="checkbox"
:value="server.id"
v-model="form.server_ids"
/>
{{ server.hostname }}
</label>
</div>
</div>
<button
@click="createEntry"
class="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700"
>
Speichern
</button>
</div>
</div>
</template>

View file

@ -0,0 +1,159 @@
<script setup lang="ts">
definePageMeta({
requiresAuth: true
})
import { ref, onMounted } from "vue"
import { isUnauthorized } from "~/utils/auth"
const { apiFetch } = useApi()
const servers = ref([])
const selectedServer = ref(null)
const query = ref("")
const results = ref([])
const loading = ref(false)
const error = ref("")
const expandedRow = ref<number | null>(null)
function toggleRow(id: number) {
expandedRow.value = expandedRow.value === id ? null : id
}
async function loadServers() {
try {
const result = await apiFetch("/servers")
if (isUnauthorized(result)) {
return result
}
servers.value = result
} catch (e: any) {
error.value = e.message
}
}
async function search() {
if (!selectedServer.value && !query.value) {
results.value = []
return
}
loading.value = true
error.value = ""
try {
const params: any = {}
if (selectedServer.value) {
params.server_id = selectedServer.value
}
if (query.value) {
params.q = query.value
}
const result = await apiFetch("/work-entries", {
query: params
})
if (isUnauthorized(result)) {
return result
}
results.value = result
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
onMounted(async () => {
if (isUnauthorized(await loadServers())) return
})
</script>
<template>
<div class="space-y-6">
<h1 class="text-3xl font-bold">Worklog-Suche</h1>
<form @submit.prevent="search" class="p-4 bg-white shadow rounded space-y-4">
<select
v-model="selectedServer"
class="border p-2 rounded w-full"
>
<option :value="null">Server auswählen</option>
<option
v-for="s in servers"
:key="s.id"
:value="s.id"
>
{{ s.hostname }}
</option>
</select>
<input
v-model="query"
type="text"
placeholder="Freitextsuche (optional)"
class="border p-2 rounded w-full"
/>
<button
type="submit"
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Suchen
</button>
</form>
<!-- Nur anzeigen, wenn ein Server gewählt wurde -->
<div v-if="selectedServer || query" class="p-4 bg-white shadow rounded">
<h2 class="text-xl font-semibold mb-4">Suchergebnisse</h2>
<div v-if="loading">Lade</div>
<div v-if="error" class="text-red-600">{{ error }}</div>
<table v-if="results.length" class="w-full border-collapse">
<thead>
<tr class="border-b">
<th class="text-left p-2">Titel</th>
<th class="text-left p-2">Dauer</th>
<th class="text-left p-2">Erstellt am</th>
<th class="text-left p-2">Server</th>
</tr>
</thead>
<tbody>
<template v-for="entry in results" :key="entry.id">
<tr
@click="toggleRow(entry.id)"
class="border-b hover:bg-gray-50 cursor-pointer"
>
<td class="p-2">{{ entry.title }}</td>
<td class="p-2">{{ entry.duration_minutes ?? "—" }}</td>
<td class="p-2">{{ new Date(entry.created_at).toLocaleString() }}</td>
<td class="p-2">
<span
v-for="s in entry.servers"
:key="s.id"
class="inline-block bg-gray-200 px-2 py-1 rounded mr-1 text-sm"
>
{{ s.hostname }}
</span>
</td>
</tr>
<!-- Detailzeile -->
<tr v-if="expandedRow === entry.id" class="bg-gray-50">
<td colspan="4" class="p-4">
<h3 class="font-semibold mb-2">Beschreibung</h3>
<p class="whitespace-pre-line">
{{ entry.description || "Keine Beschreibung vorhanden." }}
</p>
</td>
</tr>
</template>
</tbody>
</table>
<div v-else class="text-gray-500">
Keine Einträge gefunden.
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,3 @@
export function isUnauthorized(result: any) {
return result && result.unauthorized === true
}