feat(board): master-task board, non-destructive stall watchdog, review flow
Board is now a clean master-task view:
- GetBoardAsync returns only top-level (master) tasks; child-tasks render
nested inside their parent card instead of as separate column cards, so a
big task split into many sub-tasks stays one card (orphans treated as master)
- New DoneChildTaskCount on the DTO for real progress bars
- Child/detail consumers (GetChildren endpoint, TaskBridgeService) query
children directly instead of scraping the flat board
Stall watchdog (replaces destructive auto-reset):
- StaleTaskRecoveryService.FlagStalledInProgressTasksAsync marks In-progress
tasks with no activity past the threshold as stalled (activity event +
Iris notification) WITHOUT resetting the column — no work is discarded.
Idempotent: a task is not re-flagged until real progress happens
- BackgroundService now runs this watchdog (TaskRecovery:StalledMinutes=40,
interval 10m); hard reset kept only on the explicit manual endpoint
Review flow (Bao/Iris only):
- POST tasks/{id}/approve (Review -> Done)
- POST tasks/{id}/request-changes (Review -> target, mandatory comment,
ExpectedFrom=iris, notifies Iris)
Frontend:
- BoardCard component: master card with ball chip (who has it), progress from
children, expand to show children grouped by agent with per-child state +
stalled marker, stalled chip on the master, review action buttons
- Request-changes modal; tasks store approveReview/requestChanges actions
Tests: watchdog flag/idempotency + review threshold; 135 backend tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,12 +13,17 @@
|
||||
* - Waiting section for Iris overview
|
||||
*/
|
||||
import { computed, onBeforeUnmount, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import { Plus, X, CalendarDays, Clock3, ExternalLink, Link2, ListChecks, Save, AlertTriangle, Eye, Bot, ShieldBan, MessageSquareText } from '@lucide/vue'
|
||||
import { Plus, X, CalendarDays, Clock3, ExternalLink, Link2, ListChecks, Save, AlertTriangle, Eye, Bot, ShieldBan, MessageSquareText, RotateCcw } from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useTaskStore } from '../stores/tasks'
|
||||
import { useTaskStore, type DashboardTaskDto } from '../stores/tasks'
|
||||
import { useLiveSyncStore } from '../stores/liveSync'
|
||||
import { TASK_AGENT_LABELS, TASK_AGENT_OPTIONS } from '../constants/agentPool'
|
||||
import BoardCard from '../components/board/BoardCard.vue'
|
||||
|
||||
/** Schwelle (min) ohne Aktivität, ab der ein In-Bearbeitung-Task als „hängt" gilt.
|
||||
* Spiegelt die Backend-Watchdog-Schwelle (TaskRecovery:StalledMinutes). */
|
||||
const STALL_THRESHOLD_MIN = 40
|
||||
|
||||
type BoardTask = ReturnType<typeof flattenBoard>[number]
|
||||
|
||||
@@ -95,6 +100,46 @@ async function handleCreateTask() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Review-Aktionen (Bao/Iris) ───────────────────── */
|
||||
const reviewError = ref('')
|
||||
const showChangesModal = ref(false)
|
||||
const changesTask = ref<DashboardTaskDto | null>(null)
|
||||
const changesComment = ref('')
|
||||
const changesTarget = ref('In progress')
|
||||
const changesSubmitting = ref(false)
|
||||
|
||||
async function handleApprove(id: string) {
|
||||
reviewError.value = ''
|
||||
try {
|
||||
await taskStore.approveReview(id)
|
||||
} catch (err) {
|
||||
reviewError.value = err instanceof Error ? err.message : 'Abnahme fehlgeschlagen'
|
||||
}
|
||||
}
|
||||
|
||||
function openRequestChanges(task: DashboardTaskDto) {
|
||||
changesTask.value = task
|
||||
changesComment.value = ''
|
||||
changesTarget.value = 'In progress'
|
||||
reviewError.value = ''
|
||||
showChangesModal.value = true
|
||||
}
|
||||
|
||||
async function submitRequestChanges() {
|
||||
if (!changesTask.value || !changesComment.value.trim()) return
|
||||
changesSubmitting.value = true
|
||||
reviewError.value = ''
|
||||
try {
|
||||
await taskStore.requestChanges(changesTask.value.id, changesComment.value.trim(), changesTarget.value)
|
||||
showChangesModal.value = false
|
||||
changesTask.value = null
|
||||
} catch (err) {
|
||||
reviewError.value = err instanceof Error ? err.message : 'Konnte nicht zurückgegeben werden'
|
||||
} finally {
|
||||
changesSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Drag & Drop ────────────────────────────────── */
|
||||
const draggedTaskId = ref<string | null>(null)
|
||||
|
||||
@@ -141,20 +186,6 @@ async function onDrop(e: DragEvent, targetState: string) {
|
||||
}
|
||||
|
||||
/* ── Helpers ──────────────────────────────────────── */
|
||||
function priorityLabel(p: string): string {
|
||||
const lower = p.toLowerCase()
|
||||
if (lower === 'high') return 'High'
|
||||
if (lower === 'low') return 'Low'
|
||||
return 'Med'
|
||||
}
|
||||
|
||||
function priorityColor(p: string): string {
|
||||
const lower = p.toLowerCase()
|
||||
if (lower === 'high') return '#f87171'
|
||||
if (lower === 'low') return '#60a5fa'
|
||||
return '#facc15'
|
||||
}
|
||||
|
||||
function statusTone(state: string): string {
|
||||
switch (state.toLowerCase()) {
|
||||
case 'done': return 'is-done'
|
||||
@@ -202,6 +233,15 @@ const canSaveDetail = computed(() => detailForm.title.trim().length > 0 && !deta
|
||||
*/
|
||||
const canChangeState = computed(() => authStore.isIris || authStore.isBao)
|
||||
|
||||
/* Spalten-Konfiguration — Board zeigt nur Master-Tasks (Children nested in der Karte). */
|
||||
const columns = computed(() => [
|
||||
{ key: 'offen', name: 'Offen', tasks: taskStore.board.offen, dot: 'var(--st-queue)', ring: 'rgba(251,191,36,.25)' },
|
||||
{ key: 'inProgress', name: 'In Bearbeitung', tasks: taskStore.board.inProgress, dot: 'var(--st-work)', ring: 'rgba(61,220,151,.25)' },
|
||||
{ key: 'review', name: 'Review', tasks: taskStore.board.review, dot: '#fb923c', ring: 'rgba(251,146,60,.25)' },
|
||||
{ key: 'done', name: 'Erledigt', tasks: taskStore.board.done, dot: 'var(--st-work)', ring: 'rgba(61,220,151,.25)' },
|
||||
{ key: 'blocked', name: 'Blockiert', tasks: taskStore.board.blocked, dot: 'var(--st-block)', ring: 'rgba(251,113,133,.25)' },
|
||||
])
|
||||
|
||||
function hydrateDetailForm(task: BoardTask | null) {
|
||||
detailError.value = ''
|
||||
detailSuccess.value = ''
|
||||
@@ -283,18 +323,6 @@ function delegationBadge(task: BoardTask): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function assigneeLabel(assignedTo: string | null | undefined): string {
|
||||
return expectedFromLabel(assignedTo)
|
||||
}
|
||||
|
||||
function assigneeClass(assignedTo: string | null | undefined): string {
|
||||
if (!assignedTo) return ''
|
||||
const lower = assignedTo.toLowerCase()
|
||||
if (lower === 'iris') return 'assignee-iris'
|
||||
if (lower === 'bao') return 'assignee-bao'
|
||||
return 'assignee-agent'
|
||||
}
|
||||
|
||||
/* ── Task Navigation ───────────────────────────── */
|
||||
function navigateToTask(taskId: string) {
|
||||
router.push('/tasks/' + taskId)
|
||||
@@ -306,15 +334,6 @@ async function openQuickPeek(taskId: string) {
|
||||
await loadDetailContext(taskId)
|
||||
}
|
||||
|
||||
function handleCardClick(event: MouseEvent, taskId: string) {
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||
event.preventDefault()
|
||||
openQuickPeek(taskId)
|
||||
} else {
|
||||
navigateToTask(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetailPanel() {
|
||||
showDetailPanel.value = false
|
||||
selectedTaskId.value = null
|
||||
@@ -527,254 +546,34 @@ onUnmounted(() => {
|
||||
|
||||
<div v-else class="board-columns">
|
||||
<div
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
class="col"
|
||||
:class="{ 'drag-over': dragOverColumn === 'offen' }"
|
||||
@dragover="onDragOver($event, 'offen')"
|
||||
:class="{ 'drag-over': dragOverColumn === col.key, 'col-blocked': col.key === 'blocked' }"
|
||||
@dragover="onDragOver($event, col.key)"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onDrop($event, 'offen')"
|
||||
@drop="onDrop($event, col.key)"
|
||||
>
|
||||
<div class="col-header">
|
||||
<span class="col-icon" style="background: var(--st-queue); box-shadow: 0 0 0 2px rgba(251,191,36,.25);"></span>
|
||||
<span class="col-name">Offen</span>
|
||||
<span class="col-count">{{ taskStore.board.offen.length }}</span>
|
||||
<span class="col-icon" :style="{ background: col.dot, boxShadow: `0 0 0 2px ${col.ring}` }"></span>
|
||||
<span class="col-name">{{ col.name }}</span>
|
||||
<span class="col-count">{{ col.tasks.length }}</span>
|
||||
</div>
|
||||
<div class="col-cards">
|
||||
<button
|
||||
v-for="task in taskStore.board.offen"
|
||||
<BoardCard
|
||||
v-for="task in col.tasks"
|
||||
:key="task.id"
|
||||
type="button"
|
||||
class="card"
|
||||
:class="{ 'card-agent': task.isAgentTask }"
|
||||
draggable="true"
|
||||
@click="handleCardClick($event, task.id)"
|
||||
@dragstart="onDragStart($event, task.id)"
|
||||
:task="task"
|
||||
:column="col.key"
|
||||
:can-review="canChangeState"
|
||||
:stall-threshold-min="STALL_THRESHOLD_MIN"
|
||||
@open="openQuickPeek"
|
||||
@approve="handleApprove"
|
||||
@request-changes="openRequestChanges"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<div class="card-top">
|
||||
<span class="prio-badge" :style="{ color: priorityColor(task.priority), borderColor: priorityColor(task.priority) }">
|
||||
{{ priorityLabel(task.priority) }}
|
||||
</span>
|
||||
<span v-if="task.isAgentTask" class="agent-badge" title="Agent-Task">🤖</span>
|
||||
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
|
||||
⏳ {{ task.expectedFrom }}
|
||||
</span>
|
||||
<span v-if="delegationBadge(task)" class="expected-badge expected-badge--delegation" title="Sichtbare Delegation im Board">
|
||||
↳ {{ delegationBadge(task) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.assignedTo"
|
||||
class="assignee"
|
||||
:class="assigneeClass(task.assignedTo)"
|
||||
>
|
||||
{{ assigneeLabel(task.assignedTo) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-title">{{ task.title }}</div>
|
||||
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
|
||||
<div v-if="task.isAgentTask || hasChildTasks(task.id)" class="card-progress-hint">{{ activityHint(task) }}</div>
|
||||
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
|
||||
</button>
|
||||
<div v-if="!taskStore.board.offen.length" class="empty-col">Keine Aufgaben</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="col"
|
||||
:class="{ 'drag-over': dragOverColumn === 'inProgress' }"
|
||||
@dragover="onDragOver($event, 'inProgress')"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onDrop($event, 'inProgress')"
|
||||
>
|
||||
<div class="col-header">
|
||||
<span class="col-icon" style="background: var(--st-work); box-shadow: 0 0 0 2px rgba(61,220,151,.25);"></span>
|
||||
<span class="col-name">In Bearbeitung</span>
|
||||
<span class="col-count">{{ taskStore.board.inProgress.length }}</span>
|
||||
</div>
|
||||
<div class="col-cards">
|
||||
<button
|
||||
v-for="task in taskStore.board.inProgress"
|
||||
:key="task.id"
|
||||
type="button"
|
||||
class="card"
|
||||
:class="{ 'card-agent': task.isAgentTask }"
|
||||
draggable="true"
|
||||
@click="handleCardClick($event, task.id)"
|
||||
@dragstart="onDragStart($event, task.id)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<div class="card-top">
|
||||
<span class="prio-badge" :style="{ color: priorityColor(task.priority), borderColor: priorityColor(task.priority) }">
|
||||
{{ priorityLabel(task.priority) }}
|
||||
</span>
|
||||
<span v-if="task.isAgentTask" class="agent-badge" title="Agent-Task">🤖</span>
|
||||
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
|
||||
⏳ {{ task.expectedFrom }}
|
||||
</span>
|
||||
<span v-if="delegationBadge(task)" class="expected-badge expected-badge--delegation" title="Sichtbare Delegation im Board">
|
||||
↳ {{ delegationBadge(task) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.assignedTo"
|
||||
class="assignee"
|
||||
:class="assigneeClass(task.assignedTo)"
|
||||
>
|
||||
{{ assigneeLabel(task.assignedTo) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-title">{{ task.title }}</div>
|
||||
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
|
||||
<div v-if="task.isAgentTask || hasChildTasks(task.id)" class="card-progress-hint">{{ activityHint(task) }}</div>
|
||||
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
|
||||
</button>
|
||||
<div v-if="!taskStore.board.inProgress.length" class="empty-col">Keine Aufgaben</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="col"
|
||||
:class="{ 'drag-over': dragOverColumn === 'review' }"
|
||||
@dragover="onDragOver($event, 'review')"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onDrop($event, 'review')"
|
||||
>
|
||||
<div class="col-header">
|
||||
<span class="col-icon" style="background: #fb923c; box-shadow: 0 0 0 2px rgba(251,146,60,.25);"></span>
|
||||
<span class="col-name">Review</span>
|
||||
<span class="col-count">{{ taskStore.board.review.length }}</span>
|
||||
</div>
|
||||
<div class="col-cards">
|
||||
<button
|
||||
v-for="task in taskStore.board.review"
|
||||
:key="task.id"
|
||||
type="button"
|
||||
class="card"
|
||||
:class="{ 'card-agent': task.isAgentTask }"
|
||||
draggable="true"
|
||||
@click="handleCardClick($event, task.id)"
|
||||
@dragstart="onDragStart($event, task.id)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<div class="card-top">
|
||||
<span class="prio-badge" :style="{ color: priorityColor(task.priority), borderColor: priorityColor(task.priority) }">
|
||||
{{ priorityLabel(task.priority) }}
|
||||
</span>
|
||||
<span v-if="task.isAgentTask" class="agent-badge" title="Agent-Task">🤖</span>
|
||||
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
|
||||
⏳ {{ task.expectedFrom }}
|
||||
</span>
|
||||
<span v-if="delegationBadge(task)" class="expected-badge expected-badge--delegation" title="Sichtbare Delegation im Board">
|
||||
↳ {{ delegationBadge(task) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.assignedTo"
|
||||
class="assignee"
|
||||
:class="assigneeClass(task.assignedTo)"
|
||||
>
|
||||
{{ assigneeLabel(task.assignedTo) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-title">{{ task.title }}</div>
|
||||
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
|
||||
<div v-if="task.isAgentTask || hasChildTasks(task.id)" class="card-progress-hint">{{ activityHint(task) }}</div>
|
||||
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
|
||||
</button>
|
||||
<div v-if="!taskStore.board.review.length" class="empty-col">Keine Aufgaben</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="col"
|
||||
:class="{ 'drag-over': dragOverColumn === 'done' }"
|
||||
@dragover="onDragOver($event, 'done')"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onDrop($event, 'done')"
|
||||
>
|
||||
<div class="col-header">
|
||||
<span class="col-icon" style="background: var(--st-work); box-shadow: 0 0 0 2px rgba(61,220,151,.25);"></span>
|
||||
<span class="col-name">Erledigt</span>
|
||||
<span class="col-count">{{ taskStore.board.done.length }}</span>
|
||||
</div>
|
||||
<div class="col-cards">
|
||||
<button
|
||||
v-for="task in taskStore.board.done"
|
||||
:key="task.id"
|
||||
type="button"
|
||||
class="card"
|
||||
draggable="true"
|
||||
@click="handleCardClick($event, task.id)"
|
||||
@dragstart="onDragStart($event, task.id)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<div class="card-top">
|
||||
<span class="prio-badge" :style="{ color: priorityColor(task.priority), borderColor: priorityColor(task.priority) }">
|
||||
{{ priorityLabel(task.priority) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.assignedTo"
|
||||
class="assignee"
|
||||
:class="assigneeClass(task.assignedTo)"
|
||||
>
|
||||
{{ assigneeLabel(task.assignedTo) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-title">{{ task.title }}</div>
|
||||
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
|
||||
<div v-if="task.isAgentTask || hasChildTasks(task.id)" class="card-progress-hint">{{ activityHint(task) }}</div>
|
||||
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
|
||||
</button>
|
||||
<div v-if="!taskStore.board.done.length" class="empty-col">Keine Aufgaben</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="col col-blocked"
|
||||
:class="{ 'drag-over': dragOverColumn === 'blocked' }"
|
||||
@dragover="onDragOver($event, 'blocked')"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onDrop($event, 'blocked')"
|
||||
>
|
||||
<div class="col-header">
|
||||
<span class="col-icon" style="background: var(--st-block); box-shadow: 0 0 0 2px rgba(251,113,133,.25);"></span>
|
||||
<span class="col-name">Blockiert</span>
|
||||
<span class="col-count">{{ taskStore.board.blocked.length }}</span>
|
||||
</div>
|
||||
<div class="col-cards">
|
||||
<button
|
||||
v-for="task in taskStore.board.blocked"
|
||||
:key="task.id"
|
||||
type="button"
|
||||
class="card card-blocked"
|
||||
:class="{ 'card-agent': task.isAgentTask }"
|
||||
draggable="true"
|
||||
@click="handleCardClick($event, task.id)"
|
||||
@dragstart="onDragStart($event, task.id)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<div class="card-top">
|
||||
<span class="prio-badge" :style="{ color: priorityColor(task.priority), borderColor: priorityColor(task.priority) }">
|
||||
{{ priorityLabel(task.priority) }}
|
||||
</span>
|
||||
<span v-if="task.isAgentTask" class="agent-badge" title="Agent-Task">🤖</span>
|
||||
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
|
||||
⏳ {{ task.expectedFrom }}
|
||||
</span>
|
||||
<span v-if="delegationBadge(task)" class="expected-badge expected-badge--delegation" title="Sichtbare Delegation im Board">
|
||||
↳ {{ delegationBadge(task) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.assignedTo"
|
||||
class="assignee"
|
||||
:class="assigneeClass(task.assignedTo)"
|
||||
>
|
||||
{{ assigneeLabel(task.assignedTo) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-title">{{ task.title }}</div>
|
||||
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
|
||||
<div v-if="task.isAgentTask || hasChildTasks(task.id)" class="card-progress-hint">{{ activityHint(task) }}</div>
|
||||
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
|
||||
</button>
|
||||
<div v-if="!taskStore.board.blocked.length" class="empty-col">Keine Blockierer</div>
|
||||
/>
|
||||
<div v-if="!col.tasks.length" class="empty-col">Keine Aufgaben</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -841,6 +640,45 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="showChangesModal && changesTask" class="modal-overlay" @click.self="showChangesModal = false">
|
||||
<div class="modal-card">
|
||||
<div class="modal-header">
|
||||
<h2><RotateCcw :size="16" /> Änderung anfordern</h2>
|
||||
<button class="modal-close" @click="showChangesModal = false">×</button>
|
||||
</div>
|
||||
<form @submit.prevent="submitRequestChanges" class="modal-form">
|
||||
<p class="changes-task-title">{{ changesTask.title }}</p>
|
||||
<div class="field">
|
||||
<label for="changes-comment">Was soll geändert werden? <span class="req">*</span></label>
|
||||
<textarea
|
||||
id="changes-comment"
|
||||
v-model="changesComment"
|
||||
class="field-input field-textarea"
|
||||
placeholder="Konkretes Feedback für Iris — sie arbeitet autonom daran weiter…"
|
||||
rows="4"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="changes-target">Zurück nach</label>
|
||||
<select id="changes-target" v-model="changesTarget" class="field-input field-select">
|
||||
<option value="In progress">In Bearbeitung</option>
|
||||
<option value="Backlog">Offen</option>
|
||||
<option value="Blocked">Blockiert</option>
|
||||
</select>
|
||||
</div>
|
||||
<p v-if="reviewError" class="form-error">{{ reviewError }}</p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-cancel" @click="showChangesModal = false">Abbrechen</button>
|
||||
<button type="submit" class="btn-submit" :disabled="changesSubmitting || !changesComment.trim()">
|
||||
{{ changesSubmitting ? 'Sende…' : 'Zurückgeben' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="showDetailPanel && selectedTask" class="detail-overlay" @click.self="closeDetailPanel">
|
||||
<aside class="detail-panel">
|
||||
|
||||
Reference in New Issue
Block a user