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) {
|
||||
console.warn("API returned 401 — logging out")
|
||||
logout()
|
||||
throw new Error("Deine Sitzung ist abgelaufen. Bitte erneut einloggen.")
|
||||
return { unauthorized: true }
|
||||
}
|
||||
|
||||
// 403 → keine Berechtigung
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<script setup>
|
||||
const { token, loadToken, logout } = useAuth()
|
||||
const router = useRouter()
|
||||
const showWorkMenu = ref(false)
|
||||
|
||||
if (process.client) {
|
||||
loadToken()
|
||||
}
|
||||
|
|
@ -13,19 +15,31 @@ function doLogout() {
|
|||
<template>
|
||||
<div>
|
||||
<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="/owners">Owners</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>
|
||||
|
||||
|
||||
<div>
|
||||
<button
|
||||
v-if="token"
|
||||
@click="doLogout"
|
||||
class="text-red-600 hover:underline"
|
||||
>
|
||||
<button v-if="token" @click="doLogout" class="text-red-600 hover:underline">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ async function login() {
|
|||
<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>
|
||||
|
||||
<div class="space-y-4">
|
||||
<form @submit.prevent="login" class="space-y-4">
|
||||
<input
|
||||
v-model="username"
|
||||
type="text"
|
||||
|
|
@ -52,13 +52,13 @@ async function login() {
|
|||
/>
|
||||
|
||||
<button
|
||||
@click="login"
|
||||
type="submit"
|
||||
class="w-full bg-blue-600 text-white p-2 rounded hover:bg-blue-700"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
|
||||
<p v-if="error" class="text-red-600">{{ error }}</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ definePageMeta({
|
|||
requiresAuth: true
|
||||
})
|
||||
|
||||
import { isUnauthorized } from "~/utils/auth"
|
||||
import { ref, onMounted } from "vue"
|
||||
|
||||
const { loadToken } = useAuth()
|
||||
|
|
@ -17,7 +18,12 @@ const error = ref("")
|
|||
async function loadOwners() {
|
||||
loading.value = true
|
||||
try {
|
||||
owners.value = await apiFetch("/owners")
|
||||
const result = await apiFetch("/owners")
|
||||
if (isUnauthorized(result)) {
|
||||
return result
|
||||
}
|
||||
owners.value = result
|
||||
return result
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
|
|
@ -29,23 +35,27 @@ async function createOwner() {
|
|||
if (!newOwnerName.value.trim()) return
|
||||
|
||||
try {
|
||||
const owner = await apiFetch("/owners", {
|
||||
const result = await apiFetch("/owners", {
|
||||
method: "POST",
|
||||
body: {
|
||||
name: newOwnerName.value
|
||||
}
|
||||
})
|
||||
if (isUnauthorized(result)) {
|
||||
return result
|
||||
}
|
||||
|
||||
owners.value.push(owner)
|
||||
owners.value.push(result)
|
||||
newOwnerName.value = ""
|
||||
return result
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted( async () => {
|
||||
loadToken()
|
||||
loadOwners()
|
||||
if (isUnauthorized(await loadOwners())) return
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ definePageMeta({
|
|||
})
|
||||
|
||||
import { ref, onMounted } from "vue"
|
||||
import { isUnauthorized } from "~/utils/auth"
|
||||
|
||||
const { apiFetch } = useApi()
|
||||
|
||||
|
|
@ -49,6 +50,10 @@ async function saveEdit(serverId: number) {
|
|||
}
|
||||
})
|
||||
|
||||
if (isUnauthorized(updated)) {
|
||||
return updated
|
||||
}
|
||||
|
||||
// Liste aktualisieren
|
||||
const idx = servers.value.findIndex(s => s.id === serverId)
|
||||
if (idx !== -1) servers.value[idx] = updated
|
||||
|
|
@ -64,7 +69,11 @@ const owners = ref([])
|
|||
|
||||
async function loadOwners() {
|
||||
try {
|
||||
owners.value = await apiFetch("/owners")
|
||||
const result = await apiFetch("/owners")
|
||||
if (isUnauthorized(result)) {
|
||||
return result
|
||||
}
|
||||
owners.value = result
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
}
|
||||
|
|
@ -73,7 +82,11 @@ async function loadOwners() {
|
|||
async function loadServers() {
|
||||
loading.value = true
|
||||
try {
|
||||
servers.value = await apiFetch("/servers")
|
||||
const result = await apiFetch("/servers")
|
||||
if (isUnauthorized(result)) {
|
||||
return result
|
||||
}
|
||||
servers.value = result
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
|
|
@ -93,6 +106,9 @@ async function createServer() {
|
|||
owner_id: newServer.value.owner_id
|
||||
}
|
||||
})
|
||||
if (isUnauthorized(server)) {
|
||||
return server
|
||||
}
|
||||
|
||||
servers.value.push(server)
|
||||
newServer.value = { hostname: "", description: "" }
|
||||
|
|
@ -101,9 +117,9 @@ async function createServer() {
|
|||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadOwners()
|
||||
loadServers()
|
||||
onMounted(async () => {
|
||||
if (isUnauthorized(await loadOwners())) return
|
||||
if (isUnauthorized(await loadServers())) return
|
||||
})
|
||||
</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