1221 lines
34 KiB
Vue
1221 lines
34 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* TaskDetailView – URL-basierte Task-Detailansicht (V2)
|
||
*
|
||
* Galaxy-theme konsistent mit TaskBoardView, aber als eigene Route.
|
||
* Bietet:
|
||
* 1. URL-basierte Task-Details (/tasks/:id)
|
||
* 2. Verbesserte Kommentare/Aktivität (mit Typ + Timestamp)
|
||
* 3. Subtasks direkt anlegen/bearbeiten
|
||
*/
|
||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import {
|
||
ArrowLeft, Clock3, CalendarDays, ListChecks, Save, Plus,
|
||
X, Link2, MessageSquare, Trash2, CheckCircle, AlertCircle, ShieldBan,
|
||
} from '@lucide/vue'
|
||
import { apiFetch } from '../services/api'
|
||
import { useAuthStore } from '../stores/auth'
|
||
import { TASK_AGENT_LABELS, TASK_AGENT_OPTIONS } from '../constants/agentPool'
|
||
|
||
/* ── Types ──────────────────────────────────── */
|
||
interface TaskDto {
|
||
id: string
|
||
title: string
|
||
detail: string | null
|
||
source: string
|
||
state: string
|
||
priority: string
|
||
assignedTo: string | null
|
||
parentTaskId: string | null
|
||
dueDate: string | null
|
||
createdAt: string
|
||
updatedAt: string
|
||
isAgentTask?: boolean
|
||
expectedFrom?: string | null
|
||
lastActivityMessage?: string | null
|
||
lastActivityAt?: string | null
|
||
}
|
||
|
||
interface ActivityEntry {
|
||
id?: string
|
||
type?: string
|
||
message?: string
|
||
createdAt?: string
|
||
timestamp?: string
|
||
}
|
||
|
||
/* ── State ───────────────────────────────────── */
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const authStore = useAuthStore()
|
||
const taskId = computed(() => route.params.id as string)
|
||
|
||
const task = ref<TaskDto | null>(null)
|
||
const loading = ref(true)
|
||
const error = ref('')
|
||
const saving = ref(false)
|
||
const saveSuccess = ref(false)
|
||
|
||
const children = ref<TaskDto[]>([])
|
||
const activity = ref<ActivityEntry[]>([])
|
||
const detailsLoading = ref(false)
|
||
|
||
/* ── Edit Form ──────────────────────────────── */
|
||
const form = reactive({
|
||
title: '',
|
||
detail: '',
|
||
state: 'Backlog',
|
||
priority: 'Medium',
|
||
assignedTo: '',
|
||
dueDate: '',
|
||
})
|
||
|
||
/* ── Subtask creation ───────────────────────── */
|
||
const showNewSubtask = ref(false)
|
||
const subtaskTitle = ref('')
|
||
const subtaskDetail = ref('')
|
||
const subtaskPriority = ref('Medium')
|
||
const subtaskAssign = ref('')
|
||
const creatingSubtask = ref(false)
|
||
const subtaskError = ref('')
|
||
|
||
/* ── Activity / Comments ────────────────────── */
|
||
const newComment = ref('')
|
||
const postingComment = ref(false)
|
||
|
||
/* ── Delete state ───────────────────────────── */
|
||
const deletingTask = ref('') // id of subtask being deleted
|
||
const canChangeState = computed(() => authStore.isIris || authStore.isBao)
|
||
|
||
/* ── Helpers ────────────────────────────────── */
|
||
function statusLabel(state: string): string {
|
||
const map: Record<string, string> = {
|
||
'Backlog': 'Offen',
|
||
'In progress': 'In Bearbeitung',
|
||
'Review': 'Review',
|
||
'Blocked': 'Blockiert',
|
||
'Done': 'Erledigt',
|
||
}
|
||
return map[state] || state
|
||
}
|
||
|
||
function statusClass(state: string): string {
|
||
const s = state.toLowerCase()
|
||
if (s === 'done') return 'is-done'
|
||
if (s === 'blocked') return 'is-blocked'
|
||
if (s === 'review') return 'is-review'
|
||
if (s === 'in progress') return 'is-progress'
|
||
return 'is-backlog'
|
||
}
|
||
|
||
function priorityColor(p: string): string {
|
||
const lower = p.toLowerCase()
|
||
if (lower === 'high') return '#f87171'
|
||
if (lower === 'low') return '#60a5fa'
|
||
return '#facc15'
|
||
}
|
||
|
||
function priorityLabel(p: string): string {
|
||
const lower = p.toLowerCase()
|
||
if (lower === 'high') return 'High'
|
||
if (lower === 'low') return 'Low'
|
||
return 'Med'
|
||
}
|
||
|
||
function formatDate(date?: string | null, withTime = false): string {
|
||
if (!date) return '—'
|
||
return new Date(date).toLocaleString('de-DE', withTime
|
||
? { dateStyle: 'medium', timeStyle: 'short' }
|
||
: { dateStyle: 'medium' })
|
||
}
|
||
|
||
function toDateInput(date?: string | null): string {
|
||
if (!date) return ''
|
||
return new Date(date).toISOString().slice(0, 10)
|
||
}
|
||
|
||
function relativeTime(date?: string | null): string {
|
||
if (!date) return 'keine Updates'
|
||
const diffMs = Date.now() - new Date(date).getTime()
|
||
const mins = Math.max(0, Math.round(diffMs / 60000))
|
||
if (mins < 1) return 'gerade eben'
|
||
if (mins < 60) return `vor ${mins} min`
|
||
const hours = Math.round(mins / 60)
|
||
if (hours < 24) return `vor ${hours} h`
|
||
const days = Math.round(hours / 24)
|
||
return `vor ${days} d`
|
||
}
|
||
|
||
function childStatusSummary(taskId: string): string {
|
||
const childItems = children.value.filter(child => child.parentTaskId === taskId)
|
||
if (!childItems.length) return ''
|
||
|
||
const counts = {
|
||
inProgress: childItems.filter(child => child.state === 'In progress').length,
|
||
review: childItems.filter(child => child.state === 'Review').length,
|
||
blocked: childItems.filter(child => child.state === 'Blocked').length,
|
||
done: childItems.filter(child => child.state === 'Done').length,
|
||
}
|
||
|
||
const parts = [] as string[]
|
||
if (counts.inProgress) parts.push(`${counts.inProgress} in Arbeit`)
|
||
if (counts.review) parts.push(`${counts.review} im Review`)
|
||
if (counts.blocked) parts.push(`${counts.blocked} blockiert`)
|
||
if (counts.done) parts.push(`${counts.done} erledigt`)
|
||
return parts.length ? `Child-Tasks: ${parts.join(' · ')}` : `Child-Tasks: ${childItems.length}`
|
||
}
|
||
|
||
function progressHint(taskLike: Pick<TaskDto, 'id' | 'lastActivityMessage' | 'expectedFrom'>): string {
|
||
return taskLike.lastActivityMessage?.trim()
|
||
|| childStatusSummary(taskLike.id)
|
||
|| (taskLike.expectedFrom ? `Wartet auf ${TASK_AGENT_LABELS[taskLike.expectedFrom.toLowerCase()] ?? taskLike.expectedFrom}` : 'Noch kein relevanter Progress-Status')
|
||
}
|
||
|
||
function delegationSummary(taskLike: TaskDto): string | null {
|
||
if (taskLike.parentTaskId) return 'Sichtbare Child-Delegation'
|
||
if (children.value.length) return `${children.value.length} sichtbare Child-Tasks`
|
||
if (taskLike.isAgentTask) return 'Delegation im Board sichtbar'
|
||
return null
|
||
}
|
||
|
||
/* ── API calls ───────────────────────────────── */
|
||
async function loadTask() {
|
||
loading.value = true
|
||
error.value = ''
|
||
try {
|
||
const res = await apiFetch(`/api/dashboard/tasks/${taskId.value}`)
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||
const data: TaskDto = await res.json()
|
||
task.value = data
|
||
form.title = data.title
|
||
form.detail = data.detail ?? ''
|
||
form.state = data.state
|
||
form.priority = data.priority
|
||
form.assignedTo = data.assignedTo ?? ''
|
||
form.dueDate = toDateInput(data.dueDate)
|
||
await loadDetails()
|
||
} catch (e) {
|
||
error.value = e instanceof Error ? e.message : 'Aufgabe konnte nicht geladen werden'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadDetails() {
|
||
detailsLoading.value = true
|
||
try {
|
||
const [childrenRes, activityRes] = await Promise.all([
|
||
apiFetch(`/api/dashboard/tasks/${taskId.value}/children`),
|
||
apiFetch(`/api/dashboard/tasks/${taskId.value}/activity`),
|
||
])
|
||
if (childrenRes.ok) children.value = await childrenRes.json()
|
||
if (activityRes.ok) activity.value = await activityRes.json()
|
||
} catch {
|
||
// silent — partial load ok
|
||
} finally {
|
||
detailsLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function saveTask() {
|
||
if (!task.value || !form.title.trim()) return
|
||
saving.value = true
|
||
saveSuccess.value = false
|
||
error.value = ''
|
||
try {
|
||
const res = await apiFetch(`/api/dashboard/tasks/${taskId.value}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
title: form.title.trim(),
|
||
detail: form.detail.trim() || null,
|
||
priority: form.priority,
|
||
assignedTo: form.assignedTo || null,
|
||
dueDate: form.dueDate || null,
|
||
}),
|
||
})
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||
// Also update state if changed
|
||
if (form.state !== task.value.state) {
|
||
if (!canChangeState.value) {
|
||
form.state = task.value.state
|
||
} else {
|
||
const stateRes = await apiFetch(`/api/dashboard/tasks/${taskId.value}/status`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ status: form.state }),
|
||
})
|
||
if (!stateRes.ok) throw new Error('Status-Update fehlgeschlagen')
|
||
}
|
||
}
|
||
saveSuccess.value = true
|
||
await loadTask()
|
||
} catch (e) {
|
||
error.value = e instanceof Error ? e.message : 'Speichern fehlgeschlagen'
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
async function createSubtask() {
|
||
if (!subtaskTitle.value.trim()) return
|
||
creatingSubtask.value = true
|
||
subtaskError.value = ''
|
||
try {
|
||
const res = await apiFetch('/api/dashboard/tasks', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
title: subtaskTitle.value.trim(),
|
||
detail: subtaskDetail.value.trim() || null,
|
||
priority: subtaskPriority.value,
|
||
assignedTo: subtaskAssign.value || null,
|
||
parentTaskId: taskId.value,
|
||
source: 'bao',
|
||
}),
|
||
})
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||
showNewSubtask.value = false
|
||
subtaskTitle.value = ''
|
||
subtaskDetail.value = ''
|
||
subtaskPriority.value = 'Medium'
|
||
subtaskAssign.value = ''
|
||
await loadDetails()
|
||
} catch (e) {
|
||
subtaskError.value = e instanceof Error ? e.message : 'Fehler beim Anlegen'
|
||
} finally {
|
||
creatingSubtask.value = false
|
||
}
|
||
}
|
||
|
||
async function updateSubtaskState(subtaskId: string, newState: string) {
|
||
if (!canChangeState.value) return
|
||
try {
|
||
await apiFetch(`/api/dashboard/tasks/${subtaskId}/status`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ status: newState }),
|
||
})
|
||
await loadDetails()
|
||
} catch {
|
||
// silent
|
||
}
|
||
}
|
||
|
||
async function deleteSubtask(subtaskId: string) {
|
||
deletingTask.value = subtaskId
|
||
try {
|
||
await apiFetch(`/api/dashboard/tasks/${subtaskId}`, { method: 'DELETE' })
|
||
await loadDetails()
|
||
} catch {
|
||
// silent
|
||
} finally {
|
||
deletingTask.value = ''
|
||
}
|
||
}
|
||
|
||
async function postComment() {
|
||
if (!newComment.value.trim()) return
|
||
postingComment.value = true
|
||
try {
|
||
// Use activity endpoint to log a comment
|
||
await apiFetch(`/api/dashboard/tasks/${taskId.value}/activity`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
type: 'comment',
|
||
message: newComment.value.trim(),
|
||
}),
|
||
})
|
||
newComment.value = ''
|
||
await loadDetails()
|
||
} catch {
|
||
// silent
|
||
} finally {
|
||
postingComment.value = false
|
||
}
|
||
}
|
||
|
||
/* ── Lifecycle ───────────────────────────────── */
|
||
watch(taskId, () => {
|
||
if (taskId.value) loadTask()
|
||
}, { immediate: true })
|
||
|
||
function goBack() {
|
||
router.push('/tasks')
|
||
}
|
||
|
||
onMounted(() => {
|
||
window.addEventListener('keydown', handleKeydown)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener('keydown', handleKeydown)
|
||
})
|
||
|
||
function handleKeydown(e: KeyboardEvent) {
|
||
if (e.key === 'Escape') goBack()
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="detail-wrap">
|
||
<!-- Back -->
|
||
<button class="back-btn" @click="goBack">
|
||
<ArrowLeft :size="16" />
|
||
Zurück zum Board
|
||
</button>
|
||
|
||
<!-- Loading -->
|
||
<div v-if="loading" class="loading-state">
|
||
<div class="spinner"></div>
|
||
<span>Lade Aufgabe…</span>
|
||
</div>
|
||
|
||
<!-- Error -->
|
||
<div v-else-if="error" class="error-state">
|
||
<AlertCircle :size="32" />
|
||
<p>{{ error }}</p>
|
||
<button class="btn-primary" @click="loadTask">Erneut versuchen</button>
|
||
</div>
|
||
|
||
<!-- Task Detail -->
|
||
<template v-else-if="task">
|
||
<div class="detail-header">
|
||
<div class="detail-meta-top">
|
||
<span class="state-badge" :class="statusClass(task.state)">{{ statusLabel(task.state) }}</span>
|
||
<span class="meta-chip" v-if="task.source">Quelle: {{ task.source }}</span>
|
||
<span class="meta-chip">{{ task.priority }} Priorität</span>
|
||
<span v-if="task.isAgentTask" class="meta-chip">🤖 Agent-Task</span>
|
||
<span v-if="task.expectedFrom" class="meta-chip">⏳ Erwartet: {{ task.expectedFrom }}</span>
|
||
<span v-if="task.parentTaskId" class="meta-chip">↳ Sichtbare Child-Task</span>
|
||
<span v-if="delegationSummary(task)" class="meta-chip">{{ delegationSummary(task) }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="!canChangeState" class="readonly-banner">
|
||
<ShieldBan :size="14" />
|
||
Inhalte sind editierbar, Statuswechsel bleiben Bao/Iris vorbehalten.
|
||
</div>
|
||
|
||
<div class="detail-body">
|
||
<!-- Main Column -->
|
||
<div class="detail-main">
|
||
<input v-model="form.title" class="title-input" maxlength="240" placeholder="Titel der Aufgabe" />
|
||
|
||
<div class="meta-row">
|
||
<span>
|
||
<Clock3 :size="13" />
|
||
Erstellt {{ formatDate(task.createdAt) }}
|
||
</span>
|
||
<span>
|
||
<CalendarDays :size="13" />
|
||
Aktualisiert {{ formatDate(task.updatedAt, true) }}
|
||
</span>
|
||
<span v-if="task.isAgentTask">
|
||
<MessageSquare :size="13" />
|
||
Letzter Status {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}
|
||
</span>
|
||
</div>
|
||
|
||
<div v-if="task.isAgentTask || childStatusSummary(task.id) || delegationSummary(task)" class="progress-banner">
|
||
<strong>Letzter Fortschritt:</strong> {{ progressHint(task) }}
|
||
<span v-if="delegationSummary(task)" class="delegation-inline">· {{ delegationSummary(task) }}</span>
|
||
</div>
|
||
|
||
<!-- Description -->
|
||
<section class="section-card">
|
||
<div class="section-head">Beschreibung</div>
|
||
<textarea
|
||
v-model="form.detail"
|
||
class="detail-textarea"
|
||
rows="8"
|
||
placeholder="Kontext, Akzeptanzkriterien, Notizen…"
|
||
></textarea>
|
||
</section>
|
||
|
||
<!-- Subtasks -->
|
||
<section class="section-card">
|
||
<div class="section-head">
|
||
<ListChecks :size="14" />
|
||
Unteraufgaben
|
||
<button class="btn-icon-sm" @click="showNewSubtask = !showNewSubtask" title="Unteraufgabe anlegen">
|
||
<Plus :size="14" />
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="showNewSubtask" class="new-subtask-form">
|
||
<input v-model="subtaskTitle" class="galaxy-input" placeholder="Unteraufgabe Titel" maxlength="240" />
|
||
<textarea v-model="subtaskDetail" class="galaxy-input mini-textarea" placeholder="Details (optional)" rows="2"></textarea>
|
||
<div class="subtask-form-row">
|
||
<select v-model="subtaskPriority" class="galaxy-input galaxy-select narrow">
|
||
<option value="High">High</option>
|
||
<option value="Medium">Medium</option>
|
||
<option value="Low">Low</option>
|
||
</select>
|
||
<select v-model="subtaskAssign" class="galaxy-input galaxy-select narrow">
|
||
<option v-for="option in TASK_AGENT_OPTIONS" :key="option.id || 'unassigned'" :value="option.id">
|
||
{{ option.label }}
|
||
</option>
|
||
</select>
|
||
<button class="btn-primary btn-sm" @click="createSubtask" :disabled="creatingSubtask">
|
||
{{ creatingSubtask ? 'Erstelle…' : 'Anlegen' }}
|
||
</button>
|
||
</div>
|
||
<p v-if="subtaskError" class="msg error">{{ subtaskError }}</p>
|
||
</div>
|
||
|
||
<div v-if="detailsLoading" class="loading-mini">
|
||
<div class="spinner-sm"></div>
|
||
Lade Unteraufgaben…
|
||
</div>
|
||
<div v-else-if="children.length === 0" class="empty-section">
|
||
<p>Keine Unteraufgaben vorhanden.</p>
|
||
<p class="hint">Klicke auf <Plus :size="12" /> um eine anzulegen.</p>
|
||
</div>
|
||
<div v-else class="subtask-list">
|
||
<div v-for="child in children" :key="child.id" class="subtask-row">
|
||
<div class="subtask-info">
|
||
<div class="subtask-title">{{ child.title }}</div>
|
||
<p v-if="child.detail" class="subtask-detail">{{ child.detail }}</p>
|
||
</div>
|
||
<div class="subtask-actions">
|
||
<select
|
||
:value="child.state"
|
||
class="state-select-mini"
|
||
:disabled="!canChangeState"
|
||
@change="updateSubtaskState(child.id, ($event.target as HTMLSelectElement).value)"
|
||
>
|
||
<option value="Backlog">Offen</option>
|
||
<option value="In progress">In Arbeit</option>
|
||
<option value="Review">Review</option>
|
||
<option value="Blocked">Blockiert</option>
|
||
<option value="Done">Erledigt</option>
|
||
</select>
|
||
<button
|
||
class="btn-icon-sm danger"
|
||
@click="deleteSubtask(child.id)"
|
||
:disabled="deletingTask === child.id"
|
||
title="Löschen"
|
||
>
|
||
<Trash2 :size="13" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Activity / Comments -->
|
||
<section class="section-card">
|
||
<div class="section-head">
|
||
<MessageSquare :size="14" />
|
||
Aktivität & Kommentare
|
||
</div>
|
||
|
||
<div class="comment-input-row">
|
||
<input
|
||
v-model="newComment"
|
||
class="galaxy-input"
|
||
placeholder="Kommentar oder Notiz hinzufügen…"
|
||
@keydown.enter.prevent="postComment"
|
||
/>
|
||
<button class="btn-primary btn-sm" @click="postComment" :disabled="postingComment || !newComment.trim()">
|
||
Senden
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="detailsLoading" class="loading-mini">
|
||
<div class="spinner-sm"></div>
|
||
Lade Aktivität…
|
||
</div>
|
||
<div v-else-if="activity.length === 0" class="empty-section">
|
||
Noch keine Aktivität vorhanden.
|
||
</div>
|
||
<div v-else class="activity-list">
|
||
<div v-for="(entry, idx) in activity" :key="entry.id ?? idx" class="activity-item">
|
||
<div class="activity-icon">
|
||
<MessageSquare v-if="entry.type === 'comment'" :size="12" />
|
||
<Link2 v-else :size="12" />
|
||
</div>
|
||
<div class="activity-body">
|
||
<div class="activity-msg">{{ entry.message ?? '—' }}</div>
|
||
<div class="activity-time">{{ formatDate(entry.createdAt ?? entry.timestamp ?? null, true) }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<!-- Sidebar -->
|
||
<div class="detail-sidebar">
|
||
<section class="sidebar-card">
|
||
<div class="sidebar-head">Eigenschaften</div>
|
||
<label class="sidebar-field">
|
||
<span>Status</span>
|
||
<select v-model="form.state" class="galaxy-input galaxy-select" :disabled="!canChangeState">
|
||
<option value="Backlog">Offen</option>
|
||
<option value="In progress">In Bearbeitung</option>
|
||
<option value="Review">Review</option>
|
||
<option value="Blocked">Blockiert</option>
|
||
<option value="Done">Erledigt</option>
|
||
</select>
|
||
</label>
|
||
<label class="sidebar-field">
|
||
<span>Priorität</span>
|
||
<select v-model="form.priority" class="galaxy-input galaxy-select">
|
||
<option value="High">High</option>
|
||
<option value="Medium">Medium</option>
|
||
<option value="Low">Low</option>
|
||
</select>
|
||
</label>
|
||
<label class="sidebar-field">
|
||
<span>Zuständig</span>
|
||
<select v-model="form.assignedTo" class="galaxy-input galaxy-select">
|
||
<option v-for="option in TASK_AGENT_OPTIONS" :key="option.id || 'unassigned'" :value="option.id">
|
||
{{ option.label }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
<label class="sidebar-field">
|
||
<span>Fällig am</span>
|
||
<input v-model="form.dueDate" type="date" class="galaxy-input" />
|
||
</label>
|
||
</section>
|
||
|
||
<section class="sidebar-card subtle">
|
||
<div class="sidebar-head">Informationen</div>
|
||
<dl class="info-list">
|
||
<div><dt>ID</dt><dd>#{{ task.id.slice(0, 8) }}</dd></div>
|
||
<div><dt>Quelle</dt><dd>{{ task.source || '—' }}</dd></div>
|
||
<div v-if="delegationSummary(task)"><dt>Delegation</dt><dd>{{ delegationSummary(task) }}</dd></div>
|
||
<div><dt>Erstellt</dt><dd>{{ formatDate(task.createdAt) }}</dd></div>
|
||
<div><dt>Geändert</dt><dd>{{ formatDate(task.updatedAt, true) }}</dd></div>
|
||
<div v-if="task.isAgentTask"><dt>Letzter Status</dt><dd>{{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</dd></div>
|
||
<div v-if="task.expectedFrom"><dt>Erwartet von</dt><dd>{{ TASK_AGENT_LABELS[task.expectedFrom.toLowerCase()] ?? task.expectedFrom }}</dd></div>
|
||
<div v-if="task.parentTaskId"><dt>Task-Typ</dt><dd>Sichtbare Child-Task</dd></div>
|
||
</dl>
|
||
</section>
|
||
|
||
<p v-if="error" class="msg error">{{ error }}</p>
|
||
<p v-if="saveSuccess" class="msg success">
|
||
<CheckCircle :size="14" /> Gespeichert.
|
||
</p>
|
||
|
||
<div class="detail-actions">
|
||
<button class="btn-ghost" @click="goBack">Schließen</button>
|
||
<button class="btn-primary" :disabled="saving || !form.title.trim()" @click="saveTask">
|
||
<Save :size="14" />
|
||
{{ saving ? 'Speichert…' : 'Speichern' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.detail-wrap {
|
||
width: 100%;
|
||
max-width: 1120px;
|
||
margin: 0 auto;
|
||
padding: 20px;
|
||
animation: fadeIn 0.3s ease-out;
|
||
}
|
||
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; transform: translateY(8px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
/* ── Back ───────────────────────────────────── */
|
||
.back-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 8px 14px;
|
||
border: 1px solid rgba(150, 140, 255, 0.12);
|
||
border-radius: 10px;
|
||
background: transparent;
|
||
color: #a8a3d6;
|
||
font-size: 12px;
|
||
font-family: 'Manrope', sans-serif;
|
||
cursor: pointer;
|
||
transition: background 0.15s, color 0.15s;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.back-btn:hover {
|
||
background: rgba(124, 108, 255, 0.08);
|
||
color: #ece9ff;
|
||
}
|
||
|
||
/* ── Loading / Error ────────────────────────── */
|
||
.loading-state, .error-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 16px;
|
||
padding: 80px 20px;
|
||
color: #6f6aa0;
|
||
}
|
||
|
||
.spinner {
|
||
width: 24px;
|
||
height: 24px;
|
||
border: 2.5px solid rgba(150, 140, 255, 0.15);
|
||
border-top-color: #7c6cff;
|
||
border-radius: 50%;
|
||
animation: spin 0.6s linear infinite;
|
||
}
|
||
|
||
.spinner-sm {
|
||
width: 16px;
|
||
height: 16px;
|
||
border: 2px solid rgba(150, 140, 255, 0.15);
|
||
border-top-color: #7c6cff;
|
||
border-radius: 50%;
|
||
animation: spin 0.6s linear infinite;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
.error-state p {
|
||
font-size: 14px;
|
||
margin: 0;
|
||
color: #fda4af;
|
||
}
|
||
|
||
/* ── Detail Header ──────────────────────────── */
|
||
.detail-header {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.detail-meta-top {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.state-badge {
|
||
display: inline-block;
|
||
padding: 5px 12px;
|
||
border-radius: 999px;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.03em;
|
||
border: 1px solid transparent;
|
||
}
|
||
|
||
.state-badge.is-backlog { color: #fde68a; background: rgba(251,191,36,.12); border-color: rgba(251,191,36,.25); }
|
||
.state-badge.is-progress { color: #86efac; background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); }
|
||
.state-badge.is-review { color: #fdba74; background: rgba(249,115,22,.12); border-color: rgba(249,115,22,.25); }
|
||
.state-badge.is-blocked { color: #fda4af; background: rgba(244,63,94,.12); border-color: rgba(244,63,94,.25); }
|
||
.state-badge.is-done { color: #86efac; background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); }
|
||
|
||
.meta-chip {
|
||
font-size: 10px;
|
||
color: #6f6aa0;
|
||
background: rgba(10, 9, 24, 0.35);
|
||
padding: 3px 10px;
|
||
border-radius: 6px;
|
||
border: 1px solid rgba(150, 140, 255, 0.08);
|
||
}
|
||
|
||
.readonly-banner {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 16px;
|
||
padding: 10px 12px;
|
||
border-radius: 10px;
|
||
background: rgba(147,51,234,.08);
|
||
border: 1px solid rgba(147,51,234,.18);
|
||
color: #c084fc;
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* ── Detail Body Grid ────────────────────────── */
|
||
.detail-body {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) 300px;
|
||
gap: 24px;
|
||
align-items: start;
|
||
}
|
||
|
||
.detail-main {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20px;
|
||
}
|
||
|
||
.title-input {
|
||
width: 100%;
|
||
background: transparent;
|
||
border: none;
|
||
border-bottom: 2px solid transparent;
|
||
padding: 4px 0;
|
||
color: #ece9ff;
|
||
font-family: 'Space Grotesk', sans-serif;
|
||
font-size: 28px;
|
||
font-weight: 700;
|
||
letter-spacing: -0.03em;
|
||
outline: none;
|
||
transition: border-color 0.2s;
|
||
}
|
||
|
||
.title-input:focus {
|
||
border-bottom-color: rgba(124, 108, 255, 0.3);
|
||
}
|
||
|
||
.meta-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 16px;
|
||
font-size: 12px;
|
||
color: #6f6aa0;
|
||
}
|
||
|
||
.meta-row span {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
}
|
||
|
||
.progress-banner {
|
||
padding: 12px 14px;
|
||
border-radius: 12px;
|
||
background: rgba(124, 108, 255, 0.08);
|
||
border: 1px solid rgba(124, 108, 255, 0.14);
|
||
color: #a8a3d6;
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* ── Sections ──────────────────────────── */
|
||
.section-card {
|
||
border: 1px solid rgba(150, 140, 255, 0.10);
|
||
border-radius: 16px;
|
||
padding: 18px;
|
||
background: rgba(10, 9, 24, 0.25);
|
||
}
|
||
|
||
.section-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 14px;
|
||
color: #a8a3d6;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
}
|
||
|
||
.section-head button {
|
||
margin-left: auto;
|
||
}
|
||
|
||
.detail-textarea {
|
||
width: 100%;
|
||
min-height: 160px;
|
||
padding: 14px;
|
||
border: 1px solid rgba(150, 140, 255, 0.10);
|
||
border-radius: 12px;
|
||
background: rgba(10, 9, 24, 0.45);
|
||
color: #ece9ff;
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
font-family: 'Manrope', sans-serif;
|
||
outline: none;
|
||
resize: vertical;
|
||
box-sizing: border-box;
|
||
transition: border-color 0.2s;
|
||
}
|
||
|
||
.detail-textarea:focus {
|
||
border-color: rgba(124, 108, 255, 0.3);
|
||
}
|
||
|
||
/* ── Subtasks ──────────────────────────── */
|
||
.new-subtask-form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
padding: 14px;
|
||
margin-bottom: 14px;
|
||
border: 1px solid rgba(124, 108, 255, 0.15);
|
||
border-radius: 12px;
|
||
background: rgba(124, 108, 255, 0.04);
|
||
}
|
||
|
||
.subtask-form-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.subtask-form-row .narrow {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.mini-textarea {
|
||
resize: vertical;
|
||
}
|
||
|
||
.subtask-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.subtask-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 12px 14px;
|
||
border-radius: 12px;
|
||
background: rgba(10, 9, 24, 0.35);
|
||
border: 1px solid rgba(150, 140, 255, 0.06);
|
||
transition: background 0.15s;
|
||
}
|
||
|
||
.subtask-row:hover {
|
||
background: rgba(10, 9, 24, 0.5);
|
||
border-color: rgba(150, 140, 255, 0.1);
|
||
}
|
||
|
||
.subtask-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.subtask-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #ece9ff;
|
||
}
|
||
|
||
.subtask-detail {
|
||
margin: 4px 0 0;
|
||
font-size: 11.5px;
|
||
color: #6f6aa0;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.subtask-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.state-select-mini {
|
||
padding: 4px 8px;
|
||
border: 1px solid rgba(150, 140, 255, 0.12);
|
||
border-radius: 6px;
|
||
background: rgba(10, 9, 24, 0.5);
|
||
color: #a8a3d6;
|
||
font-size: 10px;
|
||
font-family: 'Manrope', sans-serif;
|
||
cursor: pointer;
|
||
outline: none;
|
||
}
|
||
|
||
.state-select-mini option {
|
||
background: #141130;
|
||
color: #ece9ff;
|
||
}
|
||
|
||
.state-select-mini:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.loading-mini {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 16px;
|
||
color: #6f6aa0;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.empty-section {
|
||
padding: 20px;
|
||
text-align: center;
|
||
color: #6f6aa0;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.empty-section p { margin: 0; }
|
||
.empty-section .hint {
|
||
margin-top: 6px;
|
||
font-size: 11px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
/* ── Activity / Comments ─────────────────── */
|
||
.comment-input-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.comment-input-row .galaxy-input {
|
||
flex: 1;
|
||
}
|
||
|
||
.activity-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.activity-item {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: flex-start;
|
||
padding: 10px 12px;
|
||
border-radius: 10px;
|
||
background: rgba(10, 9, 24, 0.2);
|
||
border: 1px solid rgba(150, 140, 255, 0.04);
|
||
}
|
||
|
||
.activity-icon {
|
||
width: 26px;
|
||
height: 26px;
|
||
border-radius: 8px;
|
||
display: grid;
|
||
place-items: center;
|
||
background: rgba(124, 108, 255, 0.08);
|
||
color: #7c6cff;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.activity-body {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.activity-msg {
|
||
font-size: 12.5px;
|
||
color: #a8a3d6;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.activity-time {
|
||
font-size: 10px;
|
||
color: #6f6aa0;
|
||
margin-top: 3px;
|
||
}
|
||
|
||
/* ── Sidebar ──────────────────────────── */
|
||
.detail-sidebar {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
position: sticky;
|
||
top: 20px;
|
||
}
|
||
|
||
.sidebar-card {
|
||
border: 1px solid rgba(150, 140, 255, 0.10);
|
||
border-radius: 16px;
|
||
padding: 18px;
|
||
background: rgba(10, 9, 24, 0.35);
|
||
}
|
||
|
||
.sidebar-card.subtle {
|
||
background: rgba(255, 255, 255, 0.01);
|
||
}
|
||
|
||
.sidebar-head {
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
color: #a8a3d6;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.sidebar-field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.sidebar-field:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.sidebar-field span {
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
color: #6f6aa0;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
|
||
.info-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
margin: 0;
|
||
}
|
||
|
||
.info-list div {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
|
||
.info-list dt {
|
||
color: #6f6aa0;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.info-list dd {
|
||
margin: 0;
|
||
color: #a8a3d6;
|
||
font-size: 11.5px;
|
||
text-align: right;
|
||
}
|
||
|
||
.detail-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
/* ── Shared Components ───────────────────── */
|
||
.galaxy-input {
|
||
width: 100%;
|
||
padding: 10px 14px;
|
||
border: 1px solid rgba(150, 140, 255, 0.12);
|
||
border-radius: 10px;
|
||
background: rgba(10, 9, 24, 0.55);
|
||
color: #ece9ff;
|
||
font-size: 13.5px;
|
||
font-family: 'Manrope', sans-serif;
|
||
outline: none;
|
||
transition: border-color 0.2s;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.galaxy-input:focus {
|
||
border-color: rgba(124, 108, 255, 0.5);
|
||
box-shadow: 0 0 0 3px rgba(124, 108, 255, 0.12);
|
||
}
|
||
|
||
.galaxy-select {
|
||
cursor: pointer;
|
||
appearance: none;
|
||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236f6aa0' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||
background-repeat: no-repeat;
|
||
background-position: right 12px center;
|
||
padding-right: 36px;
|
||
}
|
||
|
||
.galaxy-select option {
|
||
background: #141130;
|
||
color: #ece9ff;
|
||
}
|
||
|
||
.galaxy-select:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.btn-primary {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 9px 16px;
|
||
border: none;
|
||
border-radius: 10px;
|
||
background: linear-gradient(135deg, #4f7cff, #7c6cff, #b557f6);
|
||
color: #fff;
|
||
font-size: 12.5px;
|
||
font-weight: 600;
|
||
font-family: 'Manrope', sans-serif;
|
||
cursor: pointer;
|
||
transition: opacity 0.2s;
|
||
}
|
||
|
||
.btn-primary:hover:not(:disabled) { opacity: 0.9; }
|
||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
|
||
.btn-sm {
|
||
padding: 6px 12px;
|
||
font-size: 11px;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.btn-ghost {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 8px 14px;
|
||
border: 1px solid rgba(150, 140, 255, 0.12);
|
||
border-radius: 8px;
|
||
background: transparent;
|
||
color: #a8a3d6;
|
||
font-size: 11px;
|
||
font-family: 'Manrope', sans-serif;
|
||
cursor: pointer;
|
||
transition: background 0.15s;
|
||
}
|
||
|
||
.btn-ghost:hover { background: rgba(124, 108, 255, 0.08); }
|
||
|
||
.btn-icon-sm {
|
||
width: 28px;
|
||
height: 28px;
|
||
display: grid;
|
||
place-items: center;
|
||
border: none;
|
||
border-radius: 7px;
|
||
background: rgba(124, 108, 255, 0.06);
|
||
color: #a8a3d6;
|
||
cursor: pointer;
|
||
transition: background 0.15s, color 0.15s;
|
||
}
|
||
|
||
.btn-icon-sm:hover { background: rgba(124, 108, 255, 0.12); color: #ece9ff; }
|
||
.btn-icon-sm.danger:hover { background: rgba(244, 63, 94, 0.12); color: #fda4af; }
|
||
.btn-icon-sm:disabled { opacity: 0.4; cursor: not-allowed; }
|
||
|
||
.msg {
|
||
margin: 0;
|
||
font-size: 11px;
|
||
padding: 8px 12px;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.msg.error { background: rgba(244, 63, 94, 0.08); border: 1px solid rgba(244, 63, 94, 0.15); color: #fda4af; }
|
||
.msg.success { background: rgba(34, 197, 94, 0.08); border: 1px solid rgba(34, 197, 94, 0.15); color: #86efac; display: flex; align-items: center; gap: 6px; }
|
||
|
||
/* ── Responsive ──────────────────────────────── */
|
||
@media (max-width: 860px) {
|
||
.detail-body {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.detail-sidebar {
|
||
position: static;
|
||
}
|
||
.title-input {
|
||
font-size: 22px;
|
||
}
|
||
}
|
||
</style>
|