Compare commits

..

19 commits

Author SHA1 Message Date
6a78f8b1ec
frontend: createServer moved to useServer 2026-01-25 20:15:58 +01:00
43de7bc9b5
frontend: useServers(), useOwners() 2026-01-25 19:55:55 +01:00
9eb9be7318
frontend: Workload pages 2026-01-25 19:47:11 +01:00
c24a5ce480
backend: configureable Token-Expire 2026-01-25 19:05:47 +01:00
9fda6b5a5c
frontend: hide/show ServerCreate Form 2026-01-25 18:12:35 +01:00
c8b6e4757f
frontend: Login-Redirect for unauth requests 2026-01-25 17:58:04 +01:00
ffbbd3d0ac
backend: ServerUpdate support 2026-01-25 17:42:00 +01:00
034d676463
Frontend: Owner selection for Server 2026-01-25 17:34:05 +01:00
f4ef04dbf7
frontend: Server Page added 2026-01-25 17:08:25 +01:00
994ba0bc01
frontend: Owner management 2026-01-25 17:00:59 +01:00
9df2ee08d2
backend: CORS Support 2026-01-25 17:00:35 +01:00
d4921043c2
default layout 2026-01-25 11:14:28 +01:00
389939a55c
composables moved to app/ 2026-01-25 11:14:03 +01:00
44d7a0f87a
pages moved to app/pages 2026-01-25 11:10:43 +01:00
8c81fd3bf7
asset location fixed 2026-01-25 11:00:59 +01:00
cf983ce274
first pages 2026-01-25 10:58:20 +01:00
a0db4c1de4
tailwindcss configuration and API endpoint 2026-01-25 10:56:31 +01:00
138935c2d0
nuctjs/tailwindcss added 2026-01-25 10:52:37 +01:00
0b964b4779
nuxi init 2026-01-25 10:51:16 +01:00
27 changed files with 12789 additions and 1 deletions

View file

@ -13,7 +13,7 @@ from .config import settings
SECRET_KEY = settings.secret_key
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60
ACCESS_TOKEN_EXPIRE_MINUTES = settings.access_token_expire_minuted
pwd_context = CryptContext(schemes=["bcrypt_sha256"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")

View file

@ -14,6 +14,8 @@ class Settings(BaseModel):
secret_key: str = os.getenv("SECRET_KEY", "CHANGE_ME")
access_token_expire_minuted: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
@property
def database_url(self):
return (

View file

@ -10,12 +10,24 @@ from .auth import (
create_access_token, get_password_hash,
)
from fastapi.security import OAuth2PasswordRequestForm
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="Server Worklog")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # oder deine echte Domain
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Nur für lokale Entwicklung, in Produktion via Alembic
Base.metadata.create_all(bind=engine)
@app.options("/{path:path}")
def preflight_handler(path: str):
return {}
@app.post("/auth/register", response_model=schemas.UserRead)
def register_user(user_in: schemas.UserCreate, db: Session = Depends(get_db)):
existing = db.query(models.User).filter(models.User.username == user_in.username).first()
@ -86,6 +98,25 @@ def create_server(
db.refresh(server)
return server
@app.put("/servers/{server_id}", response_model=schemas.ServerRead)
def update_server(
server_id: int,
server: schemas.ServerUpdate,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
db_server = db.query(models.Server).filter(models.Server.id == server_id).first()
if not db_server:
raise HTTPException(status_code=404, detail="Server not found")
db_server.hostname = server.hostname
db_server.description = server.description
db_server.owner_id = server.owner_id
db.commit()
db.refresh(db_server)
return db_server
@app.get("/servers", response_model=List[schemas.ServerRead])
def list_servers(
db: Session = Depends(get_db),

View file

@ -21,6 +21,9 @@ class ServerBase(BaseModel):
class ServerCreate(ServerBase):
pass
class ServerUpdate(ServerBase):
pass
class ServerRead(ServerBase):
id: int
owner: OwnerRead | None = None

24
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

75
frontend/README.md Normal file
View file

@ -0,0 +1,75 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

18
frontend/app/app.vue Normal file
View file

@ -0,0 +1,18 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity .2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View file

@ -0,0 +1,4 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -0,0 +1,59 @@
export function useApi() {
const config = useRuntimeConfig()
const { token, logout } = useAuth()
async function apiFetch<T>(url: string, options: any = {}) {
try {
return await $fetch<T>(url, {
baseURL: config.public.apiBase,
headers: {
...(options.headers || {}),
...(token.value ? { Authorization: `Bearer ${token.value}` } : {})
},
...options
})
} catch (error: any) {
// Netzwerkfehler oder Server nicht erreichbar
if (!error.response) {
console.error("API unreachable:", error)
throw new Error("Der Server ist nicht erreichbar.")
}
const status = error.response.status
const data = error.response._data
// 401 → Token ungültig oder abgelaufen
if (status === 401) {
console.warn("API returned 401 — logging out")
logout()
return { unauthorized: true }
}
// 403 → keine Berechtigung
if (status === 403) {
throw new Error("Du hast keine Berechtigung für diese Aktion.")
}
// 400 → Validierungsfehler
if (status === 400) {
throw new Error(data?.detail || "Ungültige Eingabe.")
}
// 404 → nicht gefunden
if (status === 404) {
throw new Error("Die angeforderte Ressource wurde nicht gefunden.")
}
// 500 → Serverfehler
if (status >= 500) {
console.error("Server error:", data)
throw new Error("Interner Serverfehler. Bitte später erneut versuchen.")
}
// Fallback
throw new Error(data?.detail || "Unbekannter Fehler.")
}
}
return { apiFetch }
}

View file

@ -0,0 +1,46 @@
function decodeJwt(token: string) {
try {
const payload = token.split(".")[1]
const decoded = JSON.parse(atob(payload))
return decoded
} catch {
return null
}
}
export function useAuth() {
const token = useState<string | null>("token", () => null)
function setToken(t: string) {
token.value = t
if (typeof window !== "undefined") {
localStorage.setItem("token", t)
}
}
function loadToken() {
if (typeof window !== "undefined") {
const stored = localStorage.getItem("token")
if (!stored) return
const payload = decodeJwt(stored)
if( !payload || !payload.exp || payload.exp * 1000 < Date.now()) {
console.info("Token expired - removing it from storage")
localStorage.removeItem("token")
token.value = null
}
token.value = stored
}
}
function logout() {
token.value = null
if (typeof window !== "undefined") {
localStorage.removeItem("token")
}
}
return { token, setToken, loadToken, logout }
}

View file

@ -0,0 +1,33 @@
import { ref } from "vue"
import { isUnauthorized } from "~/utils/auth"
export function useOwners() {
const owners = ref([])
const loading = ref(false)
const error = ref("")
const { apiFetch } = useApi()
async function loadOwners() {
loading.value = true
error.value = ""
const result = await apiFetch("/owners")
if (isUnauthorized(result)) {
loading.value = false
return result
}
owners.value = result
loading.value = false
return result
}
return {
owners,
loading,
error,
loadOwners
}
}

View file

@ -0,0 +1,60 @@
import { ref } from "vue"
import { isUnauthorized } from "~/utils/auth"
export function useServers() {
const servers = ref([])
const loading = ref(false)
const error = ref("")
const { apiFetch } = useApi()
async function loadServers() {
loading.value = true
error.value = ""
const result = await apiFetch("/servers")
if (isUnauthorized(result)) {
loading.value = false
return result
}
servers.value = result
loading.value = false
return result
}
async function createServer(hostname: string, description: string, owner_id: number | null) {
const payload = {
hostname: hostname,
description: description,
owner_id: owner_id
}
loading.value = true
error.value = ""
const result = await apiFetch("/servers", {
method: "POST",
body: payload
})
if (isUnauthorized(result)) {
loading.value = false
return result
}
// Optional: direkt in die Liste einfügen
servers.value.push(result)
loading.value = false
return result
}
return {
servers,
loading,
error,
loadServers,
createServer
}
}

View file

@ -0,0 +1,52 @@
<script setup>
const { token, loadToken, logout } = useAuth()
const router = useRouter()
const showWorkMenu = ref(false)
if (process.client) {
loadToken()
}
function doLogout() {
logout()
router.push("/login")
}
</script>
<template>
<div>
<header class="p-4 bg-white shadow flex justify-between">
<nav class="flex gap-4 items-center">
<NuxtLink to="/">Dashboard</NuxtLink>
<NuxtLink to="/owners">Owners</NuxtLink>
<NuxtLink to="/servers">Servers</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">
Logout
</button>
</div>
</header>
<main class="p-6">
<NuxtPage />
</main>
</div>
</template>

View file

@ -0,0 +1,15 @@
export default defineNuxtRouteMiddleware((to, from) => {
if (import.meta.server) return // SSR ignorieren
// allow login-page
if (to.meta.requiresAuth !== true) {
return
}
const { token, loadToken } = useAuth()
loadToken()
if (!token.value && to.path !== "/login") {
return navigateTo("/login")
}
})

View file

@ -0,0 +1,7 @@
<template>
<div class="p-6">
<h1 class="text-3xl font-bold">Worklog Dashboard</h1>
<p class="mt-4">Willkommen im internen Worklog-System.</p>
</div>
</template>

View file

@ -0,0 +1,64 @@
<script setup lang="ts">
import { ref } from "vue"
const username = ref("")
const password = ref("")
const error = ref("")
const { setToken } = useAuth()
const { apiFetch } = useApi()
const router = useRouter()
async function login() {
error.value = ""
try {
const data = await apiFetch("/auth/token", {
method: "POST",
body: new URLSearchParams({
username: username.value,
password: password.value
}),
headers: {
"Content-Type": "application/x-www-form-urlencoded"
}
})
setToken(data.access_token)
router.push("/")
} catch (e: any) {
error.value = "Login fehlgeschlagen"
}
}
</script>
<template>
<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>
<form @submit.prevent="login" class="space-y-4">
<input
v-model="username"
type="text"
placeholder="Username"
class="w-full p-2 border rounded"
/>
<input
v-model="password"
type="password"
placeholder="Password"
class="w-full p-2 border rounded"
/>
<button
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>
</form>
</div>
</template>

View file

@ -0,0 +1,96 @@
<script setup lang="ts">
definePageMeta({
requiresAuth: true
})
import { isUnauthorized } from "~/utils/auth"
import { ref, onMounted } from "vue"
const { loadToken } = useAuth()
const { apiFetch } = useApi()
const { owners, loadOwners } = useOwners()
const newOwnerName = ref("")
const loading = ref(false)
const error = ref("")
async function createOwner() {
if (!newOwnerName.value.trim()) return
try {
const result = await apiFetch("/owners", {
method: "POST",
body: {
name: newOwnerName.value
}
})
if (isUnauthorized(result)) {
return result
}
owners.value.push(result)
newOwnerName.value = ""
return result
} catch (e: any) {
error.value = e.message
}
}
onMounted( async () => {
loadToken()
if (isUnauthorized(await loadOwners())) return
})
</script>
<template>
<div class="space-y-6">
<h1 class="text-3xl font-bold">Owner Verwaltung</h1>
<div class="p-4 bg-white shadow rounded space-y-4">
<h2 class="text-xl font-semibold">Neuen Owner anlegen</h2>
<div class="flex gap-4">
<input
v-model="newOwnerName"
type="text"
placeholder="Owner Name"
class="border p-2 rounded flex-1"
/>
<button
@click="createOwner"
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Speichern
</button>
</div>
</div>
<div class="p-4 bg-white shadow rounded">
<h2 class="text-xl font-semibold mb-4">Owner Liste</h2>
<div v-if="loading">Lade Owner</div>
<div v-if="error" class="text-red-600">{{ error }}</div>
<table class="w-full border-collapse">
<thead>
<tr class="border-b">
<th class="text-left p-2">ID</th>
<th class="text-left p-2">Name</th>
</tr>
</thead>
<tbody>
<tr
v-for="owner in owners"
:key="owner.id"
class="border-b hover:bg-gray-50"
>
<td class="p-2">{{ owner.id }}</td>
<td class="p-2">{{ owner.name }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>

View file

@ -0,0 +1,231 @@
<script setup lang="ts">
definePageMeta({
requiresAuth: true
})
import { ref, onMounted } from "vue"
import { isUnauthorized } from "~/utils/auth"
const { apiFetch } = useApi()
const { servers, loadServers, createServer } = useServers()
const { owners, loadOwners } = useOwners()
const newServer = ref({
hostname: "",
description: "",
owner_id: null
})
const loading = ref(false)
const error = ref("")
const showCreateForm = ref(false)
const editingId = ref<number | null>(null)
const editServer = ref({
hostname: "",
description: "",
owner_id: null
})
function startEdit(server: any) {
editingId.value = Number(server.id)
editServer.value = {
hostname: server.hostname,
description: server.description,
owner_id: server.owner_id
}
}
function cancelEdit() {
editingId.value = null
}
async function saveEdit(serverId: number) {
try {
const updated = await apiFetch(`/servers/${serverId}`, {
method: "PUT",
body: {
hostname: editServer.value.hostname,
description: editServer.value.description,
owner_id: editServer.value.owner_id
}
})
if (isUnauthorized(updated)) {
return updated
}
// Liste aktualisieren
const idx = servers.value.findIndex(s => s.id === serverId)
if (idx !== -1) servers.value[idx] = updated
editingId.value = null
} catch (e: any) {
error.value = e.message
}
}
async function save() {
if (!newServer.value.hostname.trim()) return
try {
const server = await createServer(
newServer.value.hostname,
newServer.value.description,
newServer.value.owner_id
)
if (isUnauthorized(server)) {
return server
}
// clear form
newServer.value = { hostname: "", description: "" }
} catch (e: any) {
error.value = e.message
}
}
onMounted(async () => {
if (isUnauthorized(await loadOwners())) return
if (isUnauthorized(await loadServers())) return
})
</script>
<template>
<div class="space-y-6">
<h1 class="text-3xl font-bold">Server Verwaltung</h1>
<!-- Server-Liste -->
<div class="p-4 bg-white shadow rounded">
<h2 class="text-xl font-semibold mb-4">Server Liste</h2>
<div v-if="loading">Lade Server</div>
<div v-if="error" class="text-red-600">{{ error }}</div>
<table class="w-full border-collapse">
<thead>
<tr class="border-b">
<th class="text-left p-2">ID</th>
<th class="text-left p-2">Name</th>
<th class="text-left p-2">Beschreibung</th>
<th class="text-left p-2">Eigentümer</th>
</tr>
</thead>
<tbody>
<tr v-for="server in servers" :key="server.id" class="border-b hover:bg-gray-50">
<td class="p-2">{{ server.id }}</td>
<td class="p-2">
<template v-if="editingId === server.id">
<input
v-model="editServer.hostname"
class="border p-1 rounded w-full"
/>
</template>
<template v-else>
{{ server.hostname }}
</template>
</td>
<td class="p-2">
<template v-if="editingId === server.id">
<textarea
v-model="editServer.description"
class="border p-1 rounded w-full"
/>
</template>
<template v-else>
{{ server.description }}
</template>
</td>
<td class="p-2">
<template v-if="editingId === server.id">
<select v-model="editServer.owner_id" class="border p-1 rounded">
<option :value="null">Kein Owner</option>
<option
v-for="owner in owners"
:key="owner.id"
:value="owner.id"
>
{{ owner.name }}
</option>
</select>
</template>
<template v-else>
{{ server.owner ? server.owner.name : "—" }}
</template>
</td>
<td class="p-2">
<template v-if="editingId === server.id">
<button
@click="saveEdit(server.id)"
class="bg-green-600 text-white px-2 py-1 rounded mr-2"
>
<Icon name="heroicons:check" class="w-5 h-5" />
</button>
<button
@click="cancelEdit"
class="bg-gray-400 text-white px-2 py-1 rounded"
>
<Icon name="heroicons:x-mark" class="w-5 h-5" />
</button>
</template>
<template v-else>
<button
@click="startEdit(server)"
class="bg-blue-600 text-white px-2 py-1 rounded"
>
<Icon name="heroicons:pencil-square" class="w-5 h-5" />
</button>
</template>
</td>
</tr>
</tbody>
</table>
</div>
<button
@click="showCreateForm = !showCreateForm"
class="flex items-center gap-2 text-blue-600 hover:text-blue-800"
>
<Icon
v-if="showCreateForm"
name="heroicons:eye-slash"
class="w-5 h-5"
/>
<Icon
v-else
name="heroicons:document-plus"
class="w-5 h-5"
/>
</button>
<!-- Neues Server-Formular -->
<Transition name="fade">
<div v-if="showCreateForm" class="p-4 bg-white shadow rounded space-y-4">
<h2 class="text-xl font-semibold">Neuen Server anlegen</h2>
<div class="flex flex-col gap-4">
<input v-model="newServer.hostname" type="text" placeholder="Server Name" class="border p-2 rounded" />
<textarea v-model="newServer.description" placeholder="Beschreibung (optional)"
class="border p-2 rounded" />
<select v-model="newServer.owner_id" class="border p-2 rounded">
<option :value="null">Kein Owner</option>
<option v-for="owner in owners" :key="owner.id" :value="owner.id">
{{ owner.name }}
</option>
</select>
<button @click="save"
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 self-start">
Speichern
</button>
</div>
</div>
</Transition>
</div>
</template>

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,147 @@
<script setup lang="ts">
definePageMeta({
requiresAuth: true
})
import { ref, onMounted } from "vue"
import { isUnauthorized } from "~/utils/auth"
const { apiFetch } = useApi()
const { servers, loadServers } = useServers()
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 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
}

19
frontend/nuxt.config.ts Normal file
View file

@ -0,0 +1,19 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
modules: [
"@nuxtjs/tailwindcss",
"@nuxt/icon"
],
srr: true,
nitro: {
preset: "static"
},
css: ["~/assets/css/tailwind.css"],
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
runtimeConfig: {
public: {
apiBase: "http://localhost:8000" // später per ENV überschreibbar
}
}
})

11609
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

21
frontend/package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "frontend",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@nuxt/icon": "^2.2.1",
"nuxt": "^4.3.0",
"vue": "^3.5.27",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@nuxtjs/tailwindcss": "^6.14.0"
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -0,0 +1,2 @@
User-Agent: *
Disallow:

18
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,18 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"references": [
{
"path": "./.nuxt/tsconfig.app.json"
},
{
"path": "./.nuxt/tsconfig.server.json"
},
{
"path": "./.nuxt/tsconfig.shared.json"
},
{
"path": "./.nuxt/tsconfig.node.json"
}
]
}