Files
ams-frontend/src/views/TaskDetailView.vue
2026-02-19 14:03:01 +00:00

894 lines
24 KiB
Vue

<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api'
import { useToast } from 'primevue/usetoast'
import AttachmentList from '../components/AttachmentList.vue'
import CommentList from '../components/CommentList.vue'
import TaskChangelog from '../components/TaskChangelog.vue'
import TaskCommits from '../components/TaskCommits.vue'
import SpeechToText from '../components/SpeechToText.vue'
interface Reminder {
enabled: boolean
datetime: string
interval: 'once' | 'daily' | 'hourly' | 'minutes'
intervalValue?: number
lastNotified?: string
}
interface Task {
_id: string
title: string
description?: string
status: string
priority: string
assignee?: string
project?: string
labels: string[]
reminder?: Reminder
}
interface Agent {
_id: string
name: string
emoji: string
}
interface Project {
_id: string
name: string
color: string
}
interface Label {
_id: string
name: string
color: string
}
const route = useRoute()
const router = useRouter()
const toast = useToast()
const taskId = computed(() => route.params.id as string)
const task = ref<Task | null>(null)
const editForm = ref({
title: '',
description: '',
status: 'todo',
priority: 'medium',
assignee: '',
project: '',
labels: [] as string[]
})
const agents = ref<Agent[]>([])
const projects = ref<Project[]>([])
const labels = ref<Label[]>([])
const loading = ref(true)
const saving = ref(false)
// Reminder state
const showReminderForm = ref(false)
const reminderDate = ref('')
const reminderTime = ref('')
const reminderInterval = ref<'once' | 'daily' | 'hourly' | 'minutes'>('once')
const reminderMinutes = ref(30)
const savingReminder = ref(false)
const statusOptions = [
{ value: 'backlog', label: 'Backlog', color: '#64748b' },
{ value: 'todo', label: 'To Do', color: '#3b82f6' },
{ value: 'in_progress', label: 'In Progress', color: '#f59e0b' },
{ value: 'review', label: 'Review', color: '#8b5cf6' },
{ value: 'done', label: 'Done', color: '#22c55e' }
]
const priorityOptions = [
{ value: 'low', label: 'Niedrig', color: '#64748b' },
{ value: 'medium', label: 'Mittel', color: '#3b82f6' },
{ value: 'high', label: 'Hoch', color: '#f59e0b' },
{ value: 'urgent', label: 'Dringend', color: '#ef4444' }
]
const intervalOptions = [
{ value: 'once', label: 'Einmalig' },
{ value: 'daily', label: 'Täglich' },
{ value: 'hourly', label: 'Stündlich' },
{ value: 'minutes', label: 'Alle X Minuten' }
]
const hasReminder = computed(() => task.value?.reminder?.enabled)
const reminderDisplay = computed(() => {
if (!task.value?.reminder) return null
const r = task.value.reminder
const dt = new Date(r.datetime)
const dateStr = dt.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
const timeStr = dt.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
let intervalStr = ''
switch (r.interval) {
case 'once': intervalStr = 'Einmalig'; break
case 'daily': intervalStr = 'Täglich'; break
case 'hourly': intervalStr = 'Stündlich'; break
case 'minutes': intervalStr = `Alle ${r.intervalValue} Min`; break
}
return { date: dateStr, time: timeStr, interval: intervalStr, enabled: r.enabled }
})
// Status-Reihenfolge für "Nächster Status" Button
const statusOrder = ['backlog', 'todo', 'in_progress', 'review', 'done']
const initialStatus = ref('')
function getNextStatus(current: string): string | null {
const idx = statusOrder.indexOf(current)
if (idx < 0 || idx >= statusOrder.length - 1) return null
return statusOrder[idx + 1]
}
const nextStatusLabel = computed(() => {
const next = getNextStatus(editForm.value.status)
if (!next) return null
return statusOptions.find(o => o.value === next)?.label || next
})
async function advanceStatus() {
const next = getNextStatus(editForm.value.status)
if (!next) return
try {
await api.put(`/tasks/${taskId.value}`, { status: next })
editForm.value.status = next
if (task.value) task.value.status = next
toast.add({ severity: 'success', summary: 'Status geändert', detail: `${statusOptions.find(o => o.value === next)?.label}`, life: 2000 })
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unbekannter Fehler'
toast.add({ severity: 'error', summary: 'Fehler', detail: message, life: 5000 })
}
}
async function goToNextTask() {
if (!task.value?.project || !initialStatus.value) return
try {
const res = await api.get<{ tasks: Task[] }>('/tasks')
const sameTasks = res.tasks
.filter(t => t.project === task.value!.project && t.status === initialStatus.value && t._id !== taskId.value)
.sort((a, b) => a.title.localeCompare(b.title))
if (sameTasks.length > 0) {
router.push(`/tasks/${sameTasks[0]._id}`)
} else {
toast.add({ severity: 'info', summary: 'Kein weiterer Task', detail: 'Kein weiterer Task mit gleichem Status im Projekt', life: 3000 })
}
} catch {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Tasks konnten nicht geladen werden', life: 5000 })
}
}
async function loadTask() {
loading.value = true
try {
const [taskRes, agentsRes, projectsRes, labelsRes] = await Promise.all([
api.get<{ task: Task }>(`/tasks/${taskId.value}`),
api.get<{ agents: Agent[] }>('/agents'),
api.get<{ projects: Project[] }>('/tasks/projects/list'),
api.get<{ labels: Label[] }>('/labels')
])
task.value = taskRes.task
agents.value = agentsRes.agents
projects.value = projectsRes.projects
labels.value = labelsRes.labels
// Fill edit form
initialStatus.value = taskRes.task.status
editForm.value = {
title: taskRes.task.title,
description: taskRes.task.description || '',
status: taskRes.task.status,
priority: taskRes.task.priority,
assignee: taskRes.task.assignee || '',
project: taskRes.task.project || '',
labels: taskRes.task.labels || []
}
// Fill reminder form if exists
if (taskRes.task.reminder) {
const dt = new Date(taskRes.task.reminder.datetime)
reminderDate.value = dt.toISOString().split('T')[0]
reminderTime.value = dt.toTimeString().slice(0, 5)
reminderInterval.value = taskRes.task.reminder.interval
reminderMinutes.value = taskRes.task.reminder.intervalValue || 30
}
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Task nicht gefunden', life: 5000 })
router.push('/tasks')
} finally {
loading.value = false
}
}
async function saveTask() {
if (!editForm.value.title.trim()) {
toast.add({ severity: 'warn', summary: 'Hinweis', detail: 'Titel ist erforderlich', life: 3000 })
return
}
saving.value = true
try {
await api.put(`/tasks/${taskId.value}`, editForm.value)
toast.add({ severity: 'success', summary: 'Gespeichert', detail: 'Task aktualisiert', life: 3000 })
if (task.value) {
task.value = { ...task.value, ...editForm.value }
}
} catch (err: any) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
} finally {
saving.value = false
}
}
async function saveReminder() {
if (!reminderDate.value || !reminderTime.value) {
toast.add({ severity: 'warn', summary: 'Hinweis', detail: 'Datum und Uhrzeit sind erforderlich', life: 3000 })
return
}
savingReminder.value = true
try {
const datetime = new Date(`${reminderDate.value}T${reminderTime.value}`)
await api.patch(`/tasks/${taskId.value}/reminder`, {
datetime: datetime.toISOString(),
interval: reminderInterval.value,
intervalValue: reminderInterval.value === 'minutes' ? reminderMinutes.value : undefined
})
// Update local task
if (task.value) {
task.value.reminder = {
enabled: true,
datetime: datetime.toISOString(),
interval: reminderInterval.value,
intervalValue: reminderMinutes.value
}
}
showReminderForm.value = false
toast.add({ severity: 'success', summary: 'Reminder gesetzt', detail: 'Du wirst erinnert', life: 3000 })
} catch (err: any) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
} finally {
savingReminder.value = false
}
}
async function deleteReminder() {
if (!confirm('Reminder löschen?')) return
try {
await api.delete(`/tasks/${taskId.value}/reminder`)
if (task.value) {
task.value.reminder = undefined
}
toast.add({ severity: 'success', summary: 'Entfernt', detail: 'Reminder gelöscht', life: 3000 })
} catch (err: any) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
}
}
function openReminderForm() {
// Set default values
if (!reminderDate.value) {
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
reminderDate.value = tomorrow.toISOString().split('T')[0]
reminderTime.value = '09:00'
}
showReminderForm.value = true
}
function getStatusColor(status: string) {
return statusOptions.find(s => s.value === status)?.color || '#64748b'
}
function getPriorityColor(priority: string) {
return priorityOptions.find(p => p.value === priority)?.color || '#64748b'
}
onMounted(loadTask)
// Wenn sich die Route ändert (z.B. "Nächster Task"), Task neu laden
watch(() => route.params.id, (newId, oldId) => {
if (newId && newId !== oldId) {
loadTask()
}
})
</script>
<template>
<div>
<div class="back-link">
<router-link to="/tasks"> Zurück zu Tasks</router-link>
</div>
<div v-if="loading" class="loading">
<i class="pi pi-spin pi-spinner" style="font-size: 2rem;"></i>
</div>
<div v-else-if="task" class="task-detail-layout">
<!-- LEFT: Edit Form -->
<div class="edit-panel">
<h2><i class="pi pi-pencil"></i> Task bearbeiten</h2>
<div class="form-group">
<label>Titel</label>
<div class="input-with-stt">
<input v-model="editForm.title" type="text" placeholder="Task-Titel" />
<SpeechToText @transcribed="(t: string) => editForm.title += (editForm.title ? ' ' : '') + t" />
</div>
</div>
<div class="form-group">
<label>Beschreibung</label>
<div class="input-with-stt">
<textarea v-model="editForm.description" rows="4" placeholder="Beschreibung (optional)"></textarea>
<SpeechToText @transcribed="(t: string) => editForm.description += (editForm.description ? ' ' : '') + t" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Status</label>
<select v-model="editForm.status">
<option v-for="opt in statusOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<div class="status-indicator" :style="{ background: getStatusColor(editForm.status) }"></div>
</div>
<div class="form-group">
<label>Priorität</label>
<select v-model="editForm.priority">
<option v-for="opt in priorityOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<div class="status-indicator" :style="{ background: getPriorityColor(editForm.priority) }"></div>
</div>
</div>
<div class="form-group">
<label>Agent</label>
<select v-model="editForm.assignee">
<option value="">Nicht zugewiesen</option>
<option v-for="agent in agents" :key="agent._id" :value="agent._id">
{{ agent.emoji }} {{ agent.name }}
</option>
</select>
</div>
<div class="form-group">
<label>Projekt</label>
<select v-model="editForm.project">
<option value="">Kein Projekt</option>
<option v-for="project in projects" :key="project._id" :value="project._id">
{{ project.name }}
</option>
</select>
</div>
<div class="form-group">
<label>Labels</label>
<select v-model="editForm.labels" multiple class="multi-select">
<option v-for="label in labels" :key="label._id" :value="label._id">
{{ label.name }}
</option>
</select>
<div class="labels-preview">
<span v-for="labelId in editForm.labels" :key="labelId"
class="label-badge"
:style="{ background: labels.find(l => l._id === labelId)?.color }">
{{ labels.find(l => l._id === labelId)?.name }}
</span>
</div>
</div>
<button @click="saveTask" class="save-btn" :disabled="saving">
<i class="pi" :class="saving ? 'pi-spin pi-spinner' : 'pi-check'"></i>
{{ saving ? 'Speichert...' : 'Änderungen speichern' }}
</button>
<!-- Quick Actions -->
<div class="quick-actions">
<button
v-if="nextStatusLabel"
@click="advanceStatus"
class="quick-btn next-status"
>
<i class="pi pi-arrow-right"></i>
{{ nextStatusLabel }}
</button>
<button
v-if="task?.project"
@click="goToNextTask"
class="quick-btn next-task"
>
<i class="pi pi-forward"></i>
Nächster Task
</button>
</div>
<!-- Reminder Section -->
<div class="reminder-section">
<h3><i class="pi pi-bell"></i> Reminder</h3>
<!-- Current Reminder Display -->
<div v-if="hasReminder && reminderDisplay" class="reminder-display">
<div class="reminder-info">
<span class="reminder-datetime">
<i class="pi pi-calendar"></i> {{ reminderDisplay.date }}
<i class="pi pi-clock"></i> {{ reminderDisplay.time }}
</span>
<span class="reminder-interval">{{ reminderDisplay.interval }}</span>
</div>
<div class="reminder-actions">
<button @click="openReminderForm" class="icon-btn" title="Bearbeiten">
<i class="pi pi-pencil"></i>
</button>
<button @click="deleteReminder" class="icon-btn delete" title="Löschen">
<i class="pi pi-trash"></i>
</button>
</div>
</div>
<!-- No Reminder -->
<div v-else-if="!showReminderForm" class="no-reminder">
<button @click="openReminderForm" class="add-reminder-btn">
<i class="pi pi-plus"></i> Reminder hinzufügen
</button>
</div>
<!-- Reminder Form -->
<div v-if="showReminderForm" class="reminder-form">
<div class="form-row">
<div class="form-group">
<label>Datum</label>
<input v-model="reminderDate" type="date" />
</div>
<div class="form-group">
<label>Uhrzeit</label>
<input v-model="reminderTime" type="time" />
</div>
</div>
<div class="form-group">
<label>Wiederholung</label>
<select v-model="reminderInterval">
<option v-for="opt in intervalOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
<div v-if="reminderInterval === 'minutes'" class="form-group">
<label>Alle X Minuten</label>
<input v-model.number="reminderMinutes" type="number" min="1" max="1440" />
</div>
<div class="reminder-form-actions">
<button @click="showReminderForm = false" class="cancel-btn">Abbrechen</button>
<button @click="saveReminder" class="save-reminder-btn" :disabled="savingReminder">
<i class="pi" :class="savingReminder ? 'pi-spin pi-spinner' : 'pi-bell'"></i>
{{ savingReminder ? 'Speichert...' : 'Reminder setzen' }}
</button>
</div>
</div>
</div>
<!-- GitLab Commits Section -->
<TaskCommits
v-if="editForm.project"
:taskId="taskId"
:projectId="editForm.project"
/>
<!-- Attachments Section (Universal Component) -->
<div class="attachments-section">
<AttachmentList parentType="task" :parentId="taskId" :enablePaste="true" />
</div>
</div>
<!-- RIGHT: Comments + Changelog -->
<div class="comments-panel">
<CommentList parentType="task" :parentId="taskId" />
<TaskChangelog
:taskId="taskId"
:agents="agents"
:labels="labels"
:projects="projects"
/>
</div>
</div>
</div>
</template>
<style scoped>
.back-link {
margin-bottom: 1.5rem;
}
.back-link a {
color: #818cf8;
text-decoration: none;
font-weight: 500;
}
.back-link a:hover {
text-decoration: underline;
}
.loading {
text-align: center;
padding: 4rem;
}
/* Two-Column Layout */
.task-detail-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
min-height: calc(100vh - 200px);
}
@media (max-width: 1024px) {
.task-detail-layout {
grid-template-columns: 1fr;
}
}
/* Left Panel: Edit Form */
.edit-panel {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1.5rem;
}
.edit-panel h2 {
margin: 0 0 1.5rem;
font-size: 1.25rem;
display: flex;
align-items: center;
gap: 0.5rem;
color: #fff;
}
.form-group {
margin-bottom: 1.25rem;
position: relative;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.85rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.7);
}
.input-with-stt {
display: flex;
gap: 0.5rem;
align-items: flex-start;
}
.input-with-stt input,
.input-with-stt textarea {
flex: 1;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: #fff;
font-family: inherit;
font-size: 0.95rem;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
}
.form-group select option {
background: #1a1a2e;
}
.form-group textarea {
resize: vertical;
min-height: 100px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.status-indicator {
position: absolute;
right: 12px;
top: 38px;
width: 10px;
height: 10px;
border-radius: 50%;
}
.save-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.875rem 1.5rem;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
border: none;
border-radius: 8px;
color: #fff;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s;
}
.save-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.4);
}
.quick-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.quick-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.65rem 0.75rem;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.quick-btn.next-status {
background: rgba(34, 197, 94, 0.15);
border: 1px solid rgba(34, 197, 94, 0.3);
color: #4ade80;
}
.quick-btn.next-status:hover {
background: rgba(34, 197, 94, 0.25);
color: #fff;
}
.quick-btn.next-task {
background: rgba(59, 130, 246, 0.15);
border: 1px solid rgba(59, 130, 246, 0.3);
color: #60a5fa;
}
.quick-btn.next-task:hover {
background: rgba(59, 130, 246, 0.25);
color: #fff;
}
.save-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Reminder Section */
.reminder-section {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.reminder-section h3 {
margin: 0 0 1rem;
font-size: 1rem;
color: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
gap: 0.5rem;
}
.reminder-display {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(99, 102, 241, 0.1);
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: 8px;
padding: 0.75rem 1rem;
}
.reminder-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.reminder-datetime {
color: #fff;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.reminder-datetime i {
color: rgba(255, 255, 255, 0.5);
font-size: 0.8rem;
}
.reminder-interval {
color: rgba(255, 255, 255, 0.6);
font-size: 0.8rem;
}
.reminder-actions {
display: flex;
gap: 0.25rem;
}
.no-reminder {
text-align: center;
}
.add-reminder-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: rgba(99, 102, 241, 0.2);
border: 1px dashed rgba(99, 102, 241, 0.4);
border-radius: 8px;
color: #818cf8;
cursor: pointer;
font-size: 0.875rem;
}
.add-reminder-btn:hover {
background: rgba(99, 102, 241, 0.3);
}
.reminder-form {
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
padding: 1rem;
}
.reminder-form-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
.cancel-btn {
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
}
.cancel-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.save-reminder-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: linear-gradient(135deg, #f59e0b, #d97706);
border: none;
border-radius: 6px;
color: #fff;
font-weight: 500;
cursor: pointer;
}
.save-reminder-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Attachments */
.attachments-section {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
/* Right Panel: Comments */
.comments-panel {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1.5rem;
display: flex;
flex-direction: column;
}
/* Icon buttons */
.icon-btn {
padding: 0.375rem;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
border-radius: 4px;
}
.icon-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.icon-btn.delete:hover {
color: #f87171;
background: rgba(239, 68, 68, 0.2);
}
/* Labels */
.multi-select {
min-height: 100px;
padding: 0.5rem;
}
.multi-select option {
padding: 0.5rem;
margin: 0.25rem 0;
border-radius: 4px;
cursor: pointer;
}
.labels-preview {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.label-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
color: #fff;
font-weight: 500;
}
</style>