Files
nexus/frontend/src/views/TaskDetailView.vue
T
devops 95495a8332
CI - Build & Test / Backend (.NET) (push) Failing after 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 20s
CI - Build & Test / Security Check (push) Successful in 3s
feat: complete task board workflow gates
2026-06-24 01:23:49 +02:00

1221 lines
34 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 &amp; 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>