631 lines
20 KiB
Vue
631 lines
20 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, ref, watch } from 'vue'
|
|
import { Bot, CheckCircle2, Clock3, MessageSquareText, Send, ShieldAlert, Zap, ChevronLeft, ChevronRight, Edit2, Save, X, Trash2 } from '@lucide/vue'
|
|
import type { AgentInfo, OperationsSnapshot, RoutingTarget } from '../types'
|
|
import { TASK_STATES } from '../types'
|
|
import { apiFetch } from '../services/api'
|
|
import { useOperationsStore } from '../stores/operations'
|
|
|
|
const props = defineProps<{ view: string; snapshot: OperationsSnapshot; routing: RoutingTarget[] }>()
|
|
const emit = defineEmits<{
|
|
createProject: [name: string]
|
|
createTask: [title: string, priority: string]
|
|
updateTaskState: [id: string, state: string]
|
|
}>()
|
|
const store = useOperationsStore()
|
|
const agents = ref<AgentInfo[]>([])
|
|
const agentsLoading = ref(false)
|
|
|
|
async function loadAgents() {
|
|
if (agentsLoading.value) return
|
|
agentsLoading.value = true
|
|
agents.value = await store.fetchAgents()
|
|
agentsLoading.value = false
|
|
}
|
|
|
|
onMounted(() => {
|
|
if (props.view === 'Agents') loadAgents()
|
|
})
|
|
|
|
watch(() => props.view, (v) => {
|
|
if (v === 'Agents') loadAgents()
|
|
})
|
|
|
|
const newProject = ref('')
|
|
const newTask = ref('')
|
|
const message = ref('')
|
|
const chatMessages = ref<Array<{ role: 'owner' | 'iris' | 'error'; content: string }>>([])
|
|
const chatPending = ref(false)
|
|
const conversationId = ref(localStorage.getItem('nexus-conversation-id') ?? crypto.randomUUID())
|
|
localStorage.setItem('nexus-conversation-id', conversationId.value)
|
|
|
|
// Task editing state
|
|
const editingTaskId = ref<string | null>(null)
|
|
|
|
// Task approval / rejection state
|
|
const approvingTaskId = ref<string | null>(null)
|
|
const taskActionError = ref('')
|
|
|
|
async function handleApproveTask(id: string) {
|
|
approvingTaskId.value = id
|
|
taskActionError.value = ''
|
|
try {
|
|
await store.approveTask(id)
|
|
} catch (e) {
|
|
taskActionError.value = e instanceof Error ? e.message : 'Failed to approve task'
|
|
} finally {
|
|
approvingTaskId.value = null
|
|
}
|
|
}
|
|
|
|
async function handleRejectTask(id: string) {
|
|
approvingTaskId.value = id
|
|
taskActionError.value = ''
|
|
try {
|
|
await store.rejectTask(id)
|
|
} catch (e) {
|
|
taskActionError.value = e instanceof Error ? e.message : 'Failed to reject task'
|
|
} finally {
|
|
approvingTaskId.value = null
|
|
}
|
|
}
|
|
|
|
// Task deletion state
|
|
const deletingTaskId = ref<string | null>(null)
|
|
const deleteError = ref('')
|
|
|
|
async function confirmDeleteTask(id: string) {
|
|
deleteError.value = ''
|
|
try {
|
|
await store.deleteTask(id)
|
|
deletingTaskId.value = null
|
|
} catch (e) {
|
|
deleteError.value = e instanceof Error ? e.message : 'Failed to delete task'
|
|
}
|
|
}
|
|
|
|
function cancelDeleteTask() {
|
|
deletingTaskId.value = null
|
|
deleteError.value = ''
|
|
}
|
|
const editTaskTitle = ref('')
|
|
const editTaskPriority = ref('')
|
|
const editTaskProjectId = ref<string | null>(null)
|
|
|
|
// Activity filtering and pagination
|
|
const activityTypeFilter = ref('')
|
|
const activitySort = ref('newest')
|
|
const activityPage = ref(1)
|
|
const activityPageSize = 20
|
|
const activityTotalPages = ref(1)
|
|
const activityTotalCount = ref(0)
|
|
|
|
const columns = computed(() =>
|
|
TASK_STATES.map(state => ({ name: state, items: props.snapshot.tasks.filter(x => x.state === state) })))
|
|
|
|
const availableTypes = computed(() => {
|
|
const types = new Set(props.snapshot.activity.map(e => e.type))
|
|
return Array.from(types)
|
|
})
|
|
|
|
const filteredActivity = computed(() => {
|
|
let items = [...props.snapshot.activity]
|
|
if (activityTypeFilter.value) {
|
|
items = items.filter(e => e.type === activityTypeFilter.value)
|
|
}
|
|
if (activitySort.value === 'oldest') {
|
|
items.reverse()
|
|
}
|
|
const total = items.length
|
|
activityTotalCount.value = total
|
|
activityTotalPages.value = Math.max(1, Math.ceil(total / activityPageSize))
|
|
const start = (activityPage.value - 1) * activityPageSize
|
|
return items.slice(start, start + activityPageSize)
|
|
})
|
|
|
|
watch(activityTypeFilter, () => { activityPage.value = 1 })
|
|
watch(activitySort, () => { activityPage.value = 1 })
|
|
|
|
function startEditTask(task: { id: string; title: string; priority: string; projectId?: string | null }) {
|
|
editingTaskId.value = task.id
|
|
editTaskTitle.value = task.title
|
|
editTaskPriority.value = task.priority
|
|
editTaskProjectId.value = task.projectId ?? null
|
|
}
|
|
|
|
async function saveEditTask(id: string) {
|
|
try {
|
|
await store.updateTask(id, {
|
|
title: editTaskTitle.value.trim() || undefined,
|
|
priority: editTaskPriority.value || undefined,
|
|
projectId: editTaskProjectId.value || undefined,
|
|
})
|
|
editingTaskId.value = null
|
|
} catch (e) {
|
|
console.error('Failed to update task', e)
|
|
}
|
|
}
|
|
|
|
function cancelEditTask() {
|
|
editingTaskId.value = null
|
|
}
|
|
|
|
async function sendMessage() {
|
|
const value = message.value.trim()
|
|
if (!value || chatPending.value) return
|
|
chatMessages.value.push({ role: 'owner', content: value })
|
|
message.value = ''
|
|
chatPending.value = true
|
|
try {
|
|
const response = await apiFetch('/api/v1/chat', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ message: value, conversationId: conversationId.value, agentId: 'iris' }),
|
|
})
|
|
const payload = await response.json()
|
|
if (!response.ok) throw new Error(payload.detail ?? 'Iris is currently unavailable.')
|
|
conversationId.value = payload.conversationId
|
|
localStorage.setItem('nexus-conversation-id', payload.conversationId)
|
|
chatMessages.value.push({ role: 'iris', content: payload.content })
|
|
} catch (error) {
|
|
chatMessages.value.push({ role: 'error', content: error instanceof Error ? error.message : 'Iris is currently unavailable.' })
|
|
} finally {
|
|
chatPending.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<form v-if="view === 'Projects'" class="quick-create" @submit.prevent="newProject.trim() && (emit('createProject', newProject.trim()), newProject = '')"><input v-model="newProject" placeholder="New project name" /><button>Create project</button></form>
|
|
<div v-if="view === 'Projects'" class="module-grid">
|
|
<article v-for="project in snapshot.projects" :key="project.id" class="module-card project-card" @click="$router.push(`/projects/${project.id}`)">
|
|
<div class="module-card-head"><span class="project-letter">{{ project.name[0] }}</span><span class="badge positive">{{ project.status }}</span></div>
|
|
<h3>{{ project.name }}</h3><p>Operational workspace managed through Nexus.</p>
|
|
<div class="progress"><i :style="{ width: `${project.progress}%` }"></i></div>
|
|
<footer><span>Progress</span><strong>{{ project.progress }}%</strong></footer>
|
|
</article>
|
|
</div>
|
|
|
|
<form v-else-if="view === 'Task Board'" class="quick-create" @submit.prevent="newTask.trim() && (emit('createTask', newTask.trim(), 'Normal'), newTask = '')"><input v-model="newTask" placeholder="New task title" /><button>Create task</button></form>
|
|
<div v-if="view === 'Task Board'" class="kanban">
|
|
<section v-for="column in columns" :key="column.name" class="kanban-column">
|
|
<header><span>{{ column.name }}</span><b>{{ column.items.length }}</b></header>
|
|
<article v-for="task in column.items" :key="task.id" class="task-card">
|
|
<template v-if="editingTaskId === task.id">
|
|
<input v-model="editTaskTitle" class="task-edit-input" placeholder="Task title" maxlength="240" />
|
|
<div class="task-edit-row">
|
|
<select v-model="editTaskPriority">
|
|
<option value="Critical">Critical</option>
|
|
<option value="High">High</option>
|
|
<option value="Normal">Normal</option>
|
|
<option value="Low">Low</option>
|
|
</select>
|
|
<select v-model="editTaskProjectId">
|
|
<option :value="null">No project</option>
|
|
<option v-for="p in snapshot.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="task-edit-actions">
|
|
<button class="task-edit-save" @click="saveEditTask(task.id)"><Save :size="13" /> Save</button>
|
|
<button class="task-edit-cancel" @click="cancelEditTask"><X :size="13" /> Cancel</button>
|
|
</div>
|
|
</template>
|
|
<template v-else>
|
|
<div class="task-card-head">
|
|
<span :class="['priority', task.priority.toLowerCase()]">{{ task.priority }}</span>
|
|
<div class="task-card-actions">
|
|
<template v-if="task.state === 'In progress'">
|
|
<button
|
|
class="task-approve-btn"
|
|
title="Approve"
|
|
:disabled="approvingTaskId === task.id"
|
|
@click="handleApproveTask(task.id)"
|
|
><CheckCircle2 :size="13" /></button>
|
|
<button
|
|
class="task-reject-btn"
|
|
title="Reject"
|
|
:disabled="approvingTaskId === task.id"
|
|
@click="handleRejectTask(task.id)"
|
|
><X :size="13" /></button>
|
|
</template>
|
|
<button class="task-edit-btn" @click="startEditTask(task)" title="Edit task"><Edit2 :size="12" /></button>
|
|
<button
|
|
v-if="task.state === 'Done' || task.state === 'Backlog'"
|
|
class="task-delete-btn"
|
|
title="Delete task"
|
|
@click="deletingTaskId = task.id; deleteError = ''"
|
|
><Trash2 :size="12" /></button>
|
|
</div>
|
|
</div>
|
|
<h3>{{ task.title }}</h3>
|
|
<select :value="task.state" @change="emit('updateTaskState', task.id, ($event.target as HTMLSelectElement).value)">
|
|
<option v-for="state in TASK_STATES" :key="state" :value="state">{{ state }}</option>
|
|
</select>
|
|
<footer><Clock3 :size="13" /> {{ new Date(task.updatedAt).toLocaleString() }}</footer>
|
|
</template>
|
|
</article>
|
|
<div v-if="!column.items.length" class="empty-state">No tasks</div>
|
|
</section>
|
|
</div>
|
|
|
|
<div v-else-if="view === 'Agents'" class="module-grid">
|
|
<div v-if="agentsLoading" class="loading-agents">Loading agents…</div>
|
|
<article v-for="agent in agents" :key="agent.id" class="module-card agent-card">
|
|
<div class="agent-avatar" :class="agent.role === 'orchestrator' ? 'violet' : ''">
|
|
<Bot v-if="agent.role === 'orchestrator'" :size="22" />
|
|
<Zap v-else :size="22" />
|
|
</div>
|
|
<div>
|
|
<span class="kicker">{{ agent.role.toUpperCase() }}</span>
|
|
<h3>{{ agent.name }}</h3>
|
|
<p>{{ agent.description || agent.model }}</p>
|
|
</div>
|
|
<div class="agent-status-group">
|
|
<span v-if="agent.model" class="agent-model-tag">{{ agent.model.replace(/^[^/]*\//, '') }}</span>
|
|
<span :class="['badge', agent.status === 'Online' ? 'positive' : agent.status === 'Degraded' ? 'warning' : 'negative']">{{ agent.status }}</span>
|
|
</div>
|
|
</article>
|
|
<div v-if="!agentsLoading && !agents.length" class="empty-state">No agents available</div>
|
|
</div>
|
|
|
|
<div v-else-if="view === 'Models'" class="module-list panel">
|
|
<div v-for="model in routing" :key="model.model" class="model-detail">
|
|
<div class="route-rank">0{{ model.priority }}</div><div><span class="kicker">{{ model.purpose }}</span><h3>{{ model.model }}</h3><p>{{ model.provider }} · {{ model.detail }}</p></div><span :class="['badge', model.status === 'Online' ? 'positive' : 'warning']">{{ model.status }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="view === 'Activity'" class="activity-panel panel">
|
|
<div class="activity-filters">
|
|
<div class="filter-group">
|
|
<label>Type</label>
|
|
<select v-model="activityTypeFilter">
|
|
<option value="">All types</option>
|
|
<option v-for="type in availableTypes" :key="type" :value="type">{{ type }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="filter-group">
|
|
<label>Sort</label>
|
|
<select v-model="activitySort">
|
|
<option value="newest">Newest first</option>
|
|
<option value="oldest">Oldest first</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="timeline">
|
|
<article v-for="event in filteredActivity" :key="event.message + event.at">
|
|
<div :class="['timeline-icon', event.type]">
|
|
<CheckCircle2 v-if="event.type !== 'security'" :size="15" />
|
|
<ShieldAlert v-else :size="15" />
|
|
</div>
|
|
<div>
|
|
<span class="kicker">{{ event.type }}</span>
|
|
<h3>{{ event.message }}</h3>
|
|
<p>{{ new Date(event.at).toLocaleString() }}</p>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
<div v-if="activityTotalPages > 1" class="activity-pagination">
|
|
<button :disabled="activityPage <= 1" @click="activityPage--"><ChevronLeft :size="14" /></button>
|
|
<span>{{ activityPage }} / {{ activityTotalPages }}</span>
|
|
<button :disabled="activityPage >= activityTotalPages" @click="activityPage++"><ChevronRight :size="14" /></button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="view === 'Settings'" class="settings-redirect">
|
|
<p>Use the <router-link to="/settings">full Settings page</router-link> for profile management and password changes.</p>
|
|
</div>
|
|
|
|
<div v-else-if="view === 'Mobile Chat'" class="chat-shell panel">
|
|
<header><div class="agent-avatar"><MessageSquareText :size="20" /></div><div><h3>Iris Mobile</h3><p>Secure owner operations channel</p></div><span class="badge warning">Preview</span></header>
|
|
<div class="messages"><div class="message iris"><strong>Iris</strong><p>Nexus is online. Messages are routed through the OpenClaw runtime.</p></div><div v-for="(item, index) in chatMessages" :key="index" :class="['message', item.role]"><strong>{{ item.role === 'owner' ? 'Owner' : item.role === 'iris' ? 'Iris' : 'Runtime' }}</strong><p>{{ item.content }}</p></div><div v-if="chatPending" class="message iris pending"><strong>Iris</strong><p>Working...</p></div></div>
|
|
<form @submit.prevent="sendMessage"><input v-model="message" :disabled="chatPending" placeholder="Ask for status or create a task..." /><button :disabled="chatPending"><Send :size="15" /></button></form>
|
|
</div>
|
|
|
|
<!-- Task deletion confirmation dialog -->
|
|
<Teleport to="body">
|
|
<div v-if="deletingTaskId" class="delete-overlay" @click.self="cancelDeleteTask">
|
|
<div class="delete-dialog">
|
|
<h3>Delete Task?</h3>
|
|
<p>This action cannot be undone. The task will be permanently removed.</p>
|
|
<p v-if="deleteError" class="delete-error">{{ deleteError }}</p>
|
|
<div class="delete-actions">
|
|
<button class="delete-cancel" @click="cancelDeleteTask">Cancel</button>
|
|
<button class="delete-confirm" @click="confirmDeleteTask(deletingTaskId)">Delete</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.task-card-head {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
}
|
|
.task-card-actions {
|
|
display: flex;
|
|
gap: 0.15rem;
|
|
align-items: center;
|
|
}
|
|
.task-edit-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-muted);
|
|
cursor: pointer;
|
|
padding: 0.15rem;
|
|
opacity: 0;
|
|
transition: opacity 0.15s;
|
|
}
|
|
.task-card:hover .task-edit-btn {
|
|
opacity: 1;
|
|
}
|
|
.task-edit-btn:hover {
|
|
color: var(--nx-accent);
|
|
}
|
|
.task-delete-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-muted);
|
|
cursor: pointer;
|
|
padding: 0.15rem;
|
|
opacity: 0;
|
|
transition: opacity 0.15s, color 0.15s;
|
|
}
|
|
.task-card:hover .task-delete-btn {
|
|
opacity: 1;
|
|
}
|
|
.task-delete-btn:hover {
|
|
color: var(--danger, #e74c3c);
|
|
}
|
|
.task-edit-input {
|
|
width: 100%;
|
|
padding: 0.35rem 0.5rem;
|
|
background: var(--surface-raised);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
font-size: 0.9rem;
|
|
color: var(--text-primary);
|
|
margin-bottom: 0.4rem;
|
|
}
|
|
.task-edit-row {
|
|
display: flex;
|
|
gap: 0.35rem;
|
|
margin-bottom: 0.4rem;
|
|
}
|
|
.task-edit-row select {
|
|
flex: 1;
|
|
padding: 0.25rem 0.4rem;
|
|
background: var(--surface-raised);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
font-size: 0.8rem;
|
|
color: var(--text-primary);
|
|
}
|
|
.task-edit-actions {
|
|
display: flex;
|
|
gap: 0.35rem;
|
|
}
|
|
.task-edit-save {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
padding: 0.25rem 0.5rem;
|
|
background: var(--nx-accent);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 4px;
|
|
font-size: 0.78rem;
|
|
cursor: pointer;
|
|
}
|
|
.task-edit-cancel {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
padding: 0.25rem 0.5rem;
|
|
background: var(--surface-raised);
|
|
color: var(--text-secondary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
font-size: 0.78rem;
|
|
cursor: pointer;
|
|
}
|
|
.activity-panel {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
.activity-filters {
|
|
display: flex;
|
|
gap: 1rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.filter-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
}
|
|
.filter-group label {
|
|
font-size: 0.78rem;
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
}
|
|
.filter-group select {
|
|
padding: 0.35rem 0.5rem;
|
|
background: var(--surface-raised);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
font-size: 0.85rem;
|
|
color: var(--text-primary);
|
|
}
|
|
.activity-pagination {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.75rem;
|
|
padding-top: 0.75rem;
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
.activity-pagination button {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0.3rem 0.5rem;
|
|
background: var(--surface-raised);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
}
|
|
.activity-pagination button:disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
}
|
|
.activity-pagination span {
|
|
font-size: 0.85rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
.project-card {
|
|
cursor: pointer;
|
|
transition: transform 0.15s, box-shadow 0.15s;
|
|
}
|
|
.project-card:hover {
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
|
}
|
|
.settings-redirect {
|
|
padding: 2rem;
|
|
text-align: center;
|
|
color: var(--text-secondary);
|
|
}
|
|
.settings-redirect a {
|
|
color: var(--nx-accent);
|
|
text-decoration: none;
|
|
}
|
|
.settings-redirect a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
/* Task deletion confirmation */
|
|
.delete-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1000;
|
|
}
|
|
.delete-dialog {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
padding: 1.5rem;
|
|
max-width: 380px;
|
|
width: 90%;
|
|
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
|
}
|
|
.delete-dialog h3 {
|
|
margin: 0 0 0.5rem;
|
|
font-size: 1.1rem;
|
|
}
|
|
.delete-dialog p {
|
|
margin: 0 0 1rem;
|
|
color: var(--text-secondary);
|
|
font-size: 0.9rem;
|
|
line-height: 1.4;
|
|
}
|
|
.delete-error {
|
|
color: var(--danger, #e74c3c) !important;
|
|
font-weight: 600;
|
|
}
|
|
.delete-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
justify-content: flex-end;
|
|
}
|
|
.delete-cancel {
|
|
padding: 0.45rem 1rem;
|
|
background: var(--surface-raised);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
font-size: 0.85rem;
|
|
}
|
|
.delete-confirm {
|
|
padding: 0.45rem 1rem;
|
|
background: var(--danger, #e74c3c);
|
|
border: none;
|
|
border-radius: 6px;
|
|
color: #fff;
|
|
cursor: pointer;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
}
|
|
.delete-confirm:hover {
|
|
opacity: 0.9;
|
|
}
|
|
|
|
/* Agent card enhancements */
|
|
.agent-status-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-end;
|
|
gap: 0.3rem;
|
|
}
|
|
.agent-model-tag {
|
|
font-size: 0.7rem;
|
|
background: var(--surface-raised);
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
padding: 0.1rem 0.35rem;
|
|
color: var(--text-muted);
|
|
max-width: 120px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.loading-agents {
|
|
grid-column: 1 / -1;
|
|
text-align: center;
|
|
padding: 2rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
/* Task approve/reject buttons */
|
|
.task-approve-btn,
|
|
.task-reject-btn {
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
padding: 0.15rem;
|
|
display: flex;
|
|
align-items: center;
|
|
opacity: 0;
|
|
transition: opacity 0.15s, color 0.15s;
|
|
}
|
|
.task-card:hover .task-approve-btn,
|
|
.task-card:hover .task-reject-btn {
|
|
opacity: 1;
|
|
}
|
|
.task-approve-btn {
|
|
color: var(--success, #27ae60);
|
|
}
|
|
.task-approve-btn:hover {
|
|
color: var(--success, #27ae60);
|
|
filter: brightness(1.2);
|
|
}
|
|
.task-reject-btn {
|
|
color: var(--warning, #f39c12);
|
|
}
|
|
.task-reject-btn:hover {
|
|
color: var(--danger, #e74c3c);
|
|
}
|
|
.task-approve-btn:disabled,
|
|
.task-reject-btn:disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
}
|
|
</style>
|