frontend: Workload pages
This commit is contained in:
parent
c24a5ce480
commit
9eb9be7318
8 changed files with 372 additions and 21 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
149
frontend/app/pages/workentries/create.vue
Normal file
149
frontend/app/pages/workentries/create.vue
Normal 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>
|
||||||
159
frontend/app/pages/workentries/search.vue
Normal file
159
frontend/app/pages/workentries/search.vue
Normal 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>
|
||||||
3
frontend/app/utils/auth.ts
Normal file
3
frontend/app/utils/auth.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export function isUnauthorized(result: any) {
|
||||||
|
return result && result.unauthorized === true
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue