feat: Aufgaben (Tasks) mit CalDAV VTODO-Sync
Neuer Menuepunkt "Aufgaben" unterhalb Kontakte. Backend: - TaskList + Task + TaskListShare Models - REST-API: CRUD, Teilen, my-color, Import/Export (.ics mit VTODO, CSV) - CalDAV: Task-Listen tauchen als Calendar-Collection mit supported-calendar-component-set=VTODO im autodiscovery auf - PROPFIND/REPORT/GET/PUT/DELETE/PROPPATCH/MKCOL fuer /dav/<user>/tl-<id>/ - SSE-Notifications bei Aenderungen Frontend: - TasksView mit Listen-Sidebar, Suche, "Erledigte ausblenden" - Mehrfachauswahl + Bulk-Loeschen, Status-Toggle per Checkbox - Editor mit Titel/Beschreibung/Faellig/Prioritaet/Status/Fortschritt - Teilen, Farbe persoenlich anpassen, Import/Export Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,11 @@ const routes = [
|
||||
name: 'Contacts',
|
||||
component: () => import('../views/ContactsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'tasks',
|
||||
name: 'Tasks',
|
||||
component: () => import('../views/TasksView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'email',
|
||||
name: 'Email',
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
<span>Kontakte</span>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/tasks" class="nav-item" active-class="active">
|
||||
<i class="pi pi-check-square"></i>
|
||||
<span>Aufgaben</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
v-if="auth.hasEmailAccounts"
|
||||
to="/email"
|
||||
|
||||
@@ -0,0 +1,632 @@
|
||||
<template>
|
||||
<div class="view-container">
|
||||
<div class="view-header">
|
||||
<h2>Aufgaben</h2>
|
||||
<div class="header-actions">
|
||||
<Button icon="pi pi-list" label="Neue Liste" size="small" outlined @click="showNewList = true" />
|
||||
<Button icon="pi pi-upload" label="Import" size="small" outlined @click="triggerImport" />
|
||||
<input ref="importInput" type="file" accept=".ics,.ical,.csv" hidden @change="onImportFile" />
|
||||
<Button icon="pi pi-download" label="Export" size="small" outlined
|
||||
:disabled="!selectedListId" @click="showExportDialog = true" />
|
||||
<Button icon="pi pi-plus" label="Neue Aufgabe" size="small"
|
||||
:disabled="!selectedListId" @click="openNewTask" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tasks-layout">
|
||||
<aside class="lists-sidebar">
|
||||
<h4>Listen</h4>
|
||||
<div v-for="tl in lists" :key="tl.id"
|
||||
class="list-item" :class="{ active: selectedListId === tl.id }"
|
||||
@click="selectedListId = tl.id">
|
||||
<span class="list-color" :style="{ background: tl.color }"></span>
|
||||
<span class="list-name">{{ tl.name }}</span>
|
||||
<span v-if="tl.permission !== 'owner'" class="shared-label">(geteilt)</span>
|
||||
<span class="count">{{ tl.task_count }}</span>
|
||||
<Button icon="pi pi-ellipsis-v" text size="small" class="list-menu"
|
||||
@click.stop="openListMenu(tl)" />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="tasks-main">
|
||||
<div class="toolbar">
|
||||
<InputText v-model="search" placeholder="Aufgaben suchen..." fluid />
|
||||
<label class="toggle"><Checkbox v-model="hideDone" :binary="true" /> Erledigte ausblenden</label>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedTaskIds.length" class="bulk-bar">
|
||||
<span>{{ selectedTaskIds.length }} ausgewaehlt</span>
|
||||
<Button icon="pi pi-trash" :label="`${selectedTaskIds.length} loeschen`"
|
||||
severity="danger" size="small" @click="bulkDelete" />
|
||||
<Button label="Auswahl aufheben" size="small" text @click="selectedTaskIds = []" />
|
||||
</div>
|
||||
|
||||
<table class="task-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-check">
|
||||
<Checkbox v-model="allSelected" :binary="true" @change="toggleAll" />
|
||||
</th>
|
||||
<th class="col-done"></th>
|
||||
<th>Titel</th>
|
||||
<th>Faellig</th>
|
||||
<th>Prio</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in filteredTasks" :key="t.id" class="task-row"
|
||||
:class="{ done: t.status === 'COMPLETED', selected: selectedTaskIds.includes(t.id) }"
|
||||
@click="openEditTask(t)">
|
||||
<td class="col-check" @click.stop>
|
||||
<Checkbox :modelValue="selectedTaskIds.includes(t.id)" :binary="true"
|
||||
@update:modelValue="toggleSelect(t.id, $event)" />
|
||||
</td>
|
||||
<td class="col-done" @click.stop>
|
||||
<Checkbox :modelValue="t.status === 'COMPLETED'" :binary="true"
|
||||
@update:modelValue="toggleDone(t, $event)" title="Erledigt" />
|
||||
</td>
|
||||
<td class="col-title">
|
||||
<span>{{ t.summary || '(ohne Titel)' }}</span>
|
||||
<small v-if="t.description" class="meta">{{ shortDesc(t.description) }}</small>
|
||||
</td>
|
||||
<td class="col-date">{{ formatDue(t.due) }}</td>
|
||||
<td>{{ formatPrio(t.priority) }}</td>
|
||||
<td><span class="status-badge" :class="statusClass(t.status)">{{ statusLabel(t.status) }}</span></td>
|
||||
<td class="col-actions" @click.stop>
|
||||
<Button icon="pi pi-trash" text size="small" severity="danger" @click="confirmDelete(t)" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!filteredTasks.length">
|
||||
<td colspan="7" class="empty-row">Keine Aufgaben.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New List Dialog -->
|
||||
<Dialog v-model:visible="showNewList" header="Neue Aufgabenliste" modal :style="{ width: '400px' }">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<InputText v-model="newListName" fluid autofocus @keyup.enter="createList" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Farbe</label>
|
||||
<InputText v-model="newListColor" type="color" style="width: 60px; height: 36px" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showNewList = false" />
|
||||
<Button label="Erstellen" @click="createList" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- List Menu -->
|
||||
<Dialog v-model:visible="showListMenu" header="Listen-Optionen" modal :style="{ width: '480px' }">
|
||||
<div v-if="menuList">
|
||||
<p><strong>{{ menuList.name }}</strong></p>
|
||||
<div class="field">
|
||||
<label>Farbe</label>
|
||||
<InputText :modelValue="menuList.color" @change="onListColor($event)" type="color" style="width:60px; height:36px" />
|
||||
</div>
|
||||
<div v-if="menuList.permission === 'owner'" class="field">
|
||||
<label>Mit Benutzer teilen</label>
|
||||
<div class="share-row">
|
||||
<InputText v-model="shareUsername" placeholder="Benutzername" fluid />
|
||||
<Select v-model="sharePermission" :options="permOptions" optionLabel="label" optionValue="value" />
|
||||
<Button label="Teilen" size="small" @click="doShare" />
|
||||
</div>
|
||||
<div v-if="listShares.length" class="existing-shares">
|
||||
<div v-for="s in listShares" :key="s.id" class="share-perm-item">
|
||||
<i class="pi pi-user"></i> <span>{{ s.username }}</span>
|
||||
<span class="perm-label">{{ s.permission === 'readwrite' ? 'Lesen+Schreiben' : 'Lesen' }}</span>
|
||||
<Button icon="pi pi-trash" text size="small" severity="danger" @click="removeShare(s.id)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="menuList.permission === 'owner'" class="field" style="border-top:1px solid var(--p-surface-200); padding-top:1rem">
|
||||
<Button label="Liste loeschen" severity="danger" outlined size="small" @click="confirmDeleteList = true" />
|
||||
</div>
|
||||
<div class="field" style="border-top:1px solid var(--p-surface-200); padding-top:1rem">
|
||||
<label><i class="pi pi-info-circle"></i> CalDAV-Zugang (Handy / DAVx5)</label>
|
||||
<div class="caldav-hint">In DAVx5 unter demselben Konto sichtbar wie Kalender. Aufgabenlisten sind mit "OpenTasks" synchronisierbar.</div>
|
||||
<div class="url-row">
|
||||
<strong>Listen-URL:</strong>
|
||||
<code>{{ origin }}/dav/{{ username }}/tl-{{ menuList.id }}/</code>
|
||||
<Button icon="pi pi-copy" text size="small" @click="copy(`${origin}/dav/${username}/tl-${menuList.id}/`)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<!-- Task Dialog -->
|
||||
<Dialog v-model:visible="showTaskDialog" :header="editingTaskId ? 'Aufgabe bearbeiten' : 'Neue Aufgabe'"
|
||||
modal :style="{ width: '560px' }">
|
||||
<div class="field">
|
||||
<label>Titel</label>
|
||||
<InputText v-model="taskForm.summary" fluid autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Beschreibung</label>
|
||||
<Textarea v-model="taskForm.description" rows="3" fluid />
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Faellig</label>
|
||||
<InputText v-model="taskForm.due" type="datetime-local" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Status</label>
|
||||
<Select v-model="taskForm.status" :options="statusOptions" optionLabel="label" optionValue="value" fluid />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Prioritaet</label>
|
||||
<Select v-model="taskForm.priority" :options="prioOptions" optionLabel="label" optionValue="value" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Fortschritt %</label>
|
||||
<InputText v-model.number="taskForm.percent_complete" type="number" min="0" max="100" fluid />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Kategorien (kommagetrennt)</label>
|
||||
<InputText v-model="taskForm.categories" fluid />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button v-if="editingTaskId" label="Loeschen" text severity="danger" @click="deleteCurrent" />
|
||||
<Button label="Abbrechen" text @click="showTaskDialog = false" />
|
||||
<Button :label="editingTaskId ? 'Speichern' : 'Erstellen'" @click="saveTask" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="confirmDeleteList" header="Liste loeschen" modal :style="{ width: '400px' }">
|
||||
<p>Liste <strong>{{ menuList?.name }}</strong> mit allen Aufgaben loeschen?</p>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="confirmDeleteList = false" />
|
||||
<Button label="Loeschen" severity="danger" @click="deleteList" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Export Dialog -->
|
||||
<Dialog v-model:visible="showExportDialog" header="Aufgaben exportieren" modal :style="{ width: '420px' }">
|
||||
<p>Aus Liste <strong>{{ currentList?.name }}</strong></p>
|
||||
<div class="field">
|
||||
<label>Format</label>
|
||||
<Select v-model="exportFormat" :options="exportFormats" optionLabel="label" optionValue="value" fluid />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showExportDialog = false" />
|
||||
<Button label="Herunterladen" icon="pi pi-download" @click="doExport" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import apiClient from '../api/client'
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import Select from 'primevue/select'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
|
||||
const toast = useToast()
|
||||
const auth = useAuthStore()
|
||||
const origin = computed(() => window.location.origin)
|
||||
const username = computed(() => auth.user?.username || '')
|
||||
|
||||
const lists = ref([])
|
||||
const selectedListId = ref(null)
|
||||
const tasks = ref([])
|
||||
const search = ref('')
|
||||
const hideDone = ref(false)
|
||||
const selectedTaskIds = ref([])
|
||||
|
||||
const showNewList = ref(false)
|
||||
const newListName = ref('')
|
||||
const newListColor = ref('#10b981')
|
||||
|
||||
const showListMenu = ref(false)
|
||||
const menuList = ref(null)
|
||||
const shareUsername = ref('')
|
||||
const sharePermission = ref('read')
|
||||
const listShares = ref([])
|
||||
const permOptions = [
|
||||
{ label: 'Lesen', value: 'read' },
|
||||
{ label: 'Lesen+Schreiben', value: 'readwrite' },
|
||||
]
|
||||
|
||||
const confirmDeleteList = ref(false)
|
||||
|
||||
const showTaskDialog = ref(false)
|
||||
const editingTaskId = ref(null)
|
||||
const taskForm = reactive({
|
||||
summary: '', description: '',
|
||||
due: '', status: 'NEEDS-ACTION', priority: null, percent_complete: null,
|
||||
categories: '',
|
||||
})
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'Offen', value: 'NEEDS-ACTION' },
|
||||
{ label: 'In Arbeit', value: 'IN-PROCESS' },
|
||||
{ label: 'Erledigt', value: 'COMPLETED' },
|
||||
{ label: 'Abgebrochen', value: 'CANCELLED' },
|
||||
]
|
||||
const prioOptions = [
|
||||
{ label: '—', value: null },
|
||||
{ label: 'Hoch (1)', value: 1 },
|
||||
{ label: 'Mittel (5)', value: 5 },
|
||||
{ label: 'Niedrig (9)', value: 9 },
|
||||
]
|
||||
|
||||
const showExportDialog = ref(false)
|
||||
const exportFormat = ref('ics')
|
||||
const exportFormats = [
|
||||
{ label: 'iCalendar (.ics)', value: 'ics' },
|
||||
{ label: 'CSV (.csv)', value: 'csv' },
|
||||
]
|
||||
const importInput = ref(null)
|
||||
|
||||
const currentList = computed(() => lists.value.find(l => l.id === selectedListId.value))
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
return tasks.value.filter(t => {
|
||||
if (hideDone.value && t.status === 'COMPLETED') return false
|
||||
if (q && !(t.summary || '').toLowerCase().includes(q)
|
||||
&& !(t.description || '').toLowerCase().includes(q)) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const allSelected = computed({
|
||||
get: () => filteredTasks.value.length > 0 && filteredTasks.value.every(t => selectedTaskIds.value.includes(t.id)),
|
||||
set: () => {},
|
||||
})
|
||||
|
||||
function toggleAll() {
|
||||
const ids = filteredTasks.value.map(t => t.id)
|
||||
const allSel = ids.every(id => selectedTaskIds.value.includes(id))
|
||||
if (allSel) selectedTaskIds.value = selectedTaskIds.value.filter(id => !ids.includes(id))
|
||||
else {
|
||||
const set = new Set(selectedTaskIds.value); ids.forEach(id => set.add(id))
|
||||
selectedTaskIds.value = [...set]
|
||||
}
|
||||
}
|
||||
function toggleSelect(id, checked) {
|
||||
if (checked && !selectedTaskIds.value.includes(id)) selectedTaskIds.value = [...selectedTaskIds.value, id]
|
||||
else if (!checked) selectedTaskIds.value = selectedTaskIds.value.filter(x => x !== id)
|
||||
}
|
||||
|
||||
function shortDesc(s) { return s.length > 80 ? s.slice(0, 80) + '…' : s }
|
||||
|
||||
function formatDue(d) {
|
||||
if (!d) return ''
|
||||
return new Date(d).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
function formatPrio(p) {
|
||||
if (p === null || p === undefined) return ''
|
||||
if (p <= 3) return 'Hoch'
|
||||
if (p >= 7) return 'Niedrig'
|
||||
return 'Mittel'
|
||||
}
|
||||
function statusLabel(s) {
|
||||
return ({ 'NEEDS-ACTION': 'Offen', 'IN-PROCESS': 'In Arbeit', 'COMPLETED': 'Erledigt', 'CANCELLED': 'Abgebrochen' })[s] || 'Offen'
|
||||
}
|
||||
function statusClass(s) {
|
||||
return { 'NEEDS-ACTION': 'todo', 'IN-PROCESS': 'progress', 'COMPLETED': 'done', 'CANCELLED': 'cancelled' }[s] || 'todo'
|
||||
}
|
||||
|
||||
async function loadLists() {
|
||||
const res = await apiClient.get('/tasklists')
|
||||
lists.value = res.data
|
||||
if (!selectedListId.value && lists.value.length) selectedListId.value = lists.value[0].id
|
||||
if (!lists.value.length) {
|
||||
await apiClient.post('/tasklists', { name: 'Meine Aufgaben', color: '#10b981' })
|
||||
await loadLists()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
if (!selectedListId.value) { tasks.value = []; return }
|
||||
try {
|
||||
const res = await apiClient.get(`/tasklists/${selectedListId.value}/tasks`)
|
||||
tasks.value = res.data
|
||||
} catch { tasks.value = [] }
|
||||
}
|
||||
|
||||
async function createList() {
|
||||
if (!newListName.value.trim()) return
|
||||
await apiClient.post('/tasklists', { name: newListName.value.trim(), color: newListColor.value })
|
||||
showNewList.value = false
|
||||
newListName.value = ''
|
||||
await loadLists()
|
||||
}
|
||||
|
||||
function openListMenu(tl) {
|
||||
menuList.value = tl
|
||||
shareUsername.value = ''
|
||||
showListMenu.value = true
|
||||
loadShares()
|
||||
}
|
||||
|
||||
async function loadShares() {
|
||||
if (!menuList.value || menuList.value.permission !== 'owner') { listShares.value = []; return }
|
||||
try {
|
||||
const res = await apiClient.get(`/tasklists/${menuList.value.id}/shares`)
|
||||
listShares.value = res.data
|
||||
} catch { listShares.value = [] }
|
||||
}
|
||||
|
||||
async function doShare() {
|
||||
if (!menuList.value || !shareUsername.value.trim()) return
|
||||
try {
|
||||
await apiClient.post(`/tasklists/${menuList.value.id}/share`, {
|
||||
username: shareUsername.value.trim(), permission: sharePermission.value,
|
||||
})
|
||||
toast.add({ severity: 'success', summary: 'Geteilt', life: 2500 })
|
||||
shareUsername.value = ''
|
||||
await loadShares()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: err.response?.data?.error || 'Fehler', life: 4000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function removeShare(id) {
|
||||
await apiClient.delete(`/tasklists/${menuList.value.id}/shares/${id}`)
|
||||
await loadShares()
|
||||
}
|
||||
|
||||
async function onListColor(ev) {
|
||||
const color = ev.target.value
|
||||
await apiClient.put(`/tasklists/${menuList.value.id}/my-color`, { color })
|
||||
menuList.value.color = color
|
||||
await loadLists()
|
||||
}
|
||||
|
||||
async function deleteList() {
|
||||
if (!menuList.value) return
|
||||
await apiClient.delete(`/tasklists/${menuList.value.id}`)
|
||||
confirmDeleteList.value = false
|
||||
showListMenu.value = false
|
||||
if (selectedListId.value === menuList.value.id) selectedListId.value = null
|
||||
await loadLists()
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
function openNewTask() {
|
||||
editingTaskId.value = null
|
||||
Object.assign(taskForm, {
|
||||
summary: '', description: '', due: '',
|
||||
status: 'NEEDS-ACTION', priority: null, percent_complete: null,
|
||||
categories: '',
|
||||
})
|
||||
showTaskDialog.value = true
|
||||
}
|
||||
|
||||
function openEditTask(t) {
|
||||
editingTaskId.value = t.id
|
||||
Object.assign(taskForm, {
|
||||
summary: t.summary || '',
|
||||
description: t.description || '',
|
||||
due: t.due ? t.due.slice(0, 16) : '',
|
||||
status: t.status || 'NEEDS-ACTION',
|
||||
priority: t.priority,
|
||||
percent_complete: t.percent_complete,
|
||||
categories: (t.categories || []).join(', '),
|
||||
})
|
||||
showTaskDialog.value = true
|
||||
}
|
||||
|
||||
async function saveTask() {
|
||||
if (!taskForm.summary.trim()) return
|
||||
const payload = {
|
||||
summary: taskForm.summary.trim(),
|
||||
description: taskForm.description,
|
||||
due: taskForm.due ? new Date(taskForm.due).toISOString() : null,
|
||||
status: taskForm.status,
|
||||
priority: taskForm.priority,
|
||||
percent_complete: taskForm.percent_complete,
|
||||
categories: taskForm.categories.split(',').map(s => s.trim()).filter(Boolean),
|
||||
}
|
||||
try {
|
||||
if (editingTaskId.value) {
|
||||
await apiClient.put(`/tasks/${editingTaskId.value}`, payload)
|
||||
} else {
|
||||
await apiClient.post(`/tasklists/${selectedListId.value}/tasks`, payload)
|
||||
}
|
||||
showTaskDialog.value = false
|
||||
await loadLists()
|
||||
await loadTasks()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 4000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDone(t, checked) {
|
||||
try {
|
||||
await apiClient.put(`/tasks/${t.id}`, { status: checked ? 'COMPLETED' : 'NEEDS-ACTION' })
|
||||
await loadTasks()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', life: 3000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCurrent() {
|
||||
if (!editingTaskId.value) return
|
||||
if (!confirm('Aufgabe wirklich loeschen?')) return
|
||||
await apiClient.delete(`/tasks/${editingTaskId.value}`)
|
||||
showTaskDialog.value = false
|
||||
await loadLists()
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
async function confirmDelete(t) {
|
||||
if (!confirm(`"${t.summary || '(ohne Titel)'}" loeschen?`)) return
|
||||
await apiClient.delete(`/tasks/${t.id}`)
|
||||
await loadLists()
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
async function bulkDelete() {
|
||||
const ids = [...selectedTaskIds.value]
|
||||
if (!ids.length || !confirm(`${ids.length} Aufgabe(n) loeschen?`)) return
|
||||
let ok = 0, fail = 0
|
||||
for (const id of ids) {
|
||||
try { await apiClient.delete(`/tasks/${id}`); ok++ } catch { fail++ }
|
||||
}
|
||||
selectedTaskIds.value = []
|
||||
toast.add({
|
||||
severity: fail ? 'warn' : 'success',
|
||||
summary: `${ok} geloescht${fail ? `, ${fail} fehlgeschlagen` : ''}`, life: 3000,
|
||||
})
|
||||
await loadLists()
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
function triggerImport() {
|
||||
if (!selectedListId.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Keine Liste ausgewaehlt', life: 3000 })
|
||||
return
|
||||
}
|
||||
importInput.value?.click()
|
||||
}
|
||||
|
||||
async function onImportFile(ev) {
|
||||
const file = ev.target.files?.[0]
|
||||
ev.target.value = ''
|
||||
if (!file) return
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
try {
|
||||
const res = await apiClient.post(`/tasklists/${selectedListId.value}/import`, fd,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: `${res.data.imported} importiert`,
|
||||
detail: res.data.skipped ? `${res.data.skipped} uebersprungen` : undefined,
|
||||
life: 4000,
|
||||
})
|
||||
await loadLists()
|
||||
await loadTasks()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Import fehlgeschlagen', detail: err.response?.data?.error, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function doExport() {
|
||||
if (!selectedListId.value) return
|
||||
try {
|
||||
const res = await apiClient.get(`/tasklists/${selectedListId.value}/export`,
|
||||
{ params: { format: exportFormat.value }, responseType: 'blob' })
|
||||
const ext = exportFormat.value === 'csv' ? 'csv' : 'ics'
|
||||
const url = URL.createObjectURL(new Blob([res.data]))
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${currentList.value?.name || 'aufgaben'}.${ext}`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
showExportDialog.value = false
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Export fehlgeschlagen', life: 4000 })
|
||||
}
|
||||
}
|
||||
|
||||
function copy(text) {
|
||||
navigator.clipboard.writeText(text)
|
||||
toast.add({ severity: 'info', summary: 'Kopiert', life: 1500 })
|
||||
}
|
||||
|
||||
// --- Live refresh via SSE ---
|
||||
let eventSource = null
|
||||
let reloadTimer = null
|
||||
function scheduleReload() {
|
||||
if (reloadTimer) return
|
||||
reloadTimer = setTimeout(async () => {
|
||||
reloadTimer = null
|
||||
await loadLists()
|
||||
await loadTasks()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadLists()
|
||||
await loadTasks()
|
||||
if (auth.accessToken) {
|
||||
try {
|
||||
eventSource = new EventSource(`/api/sync/events?token=${encodeURIComponent(auth.accessToken)}`)
|
||||
eventSource.addEventListener('tasklist', scheduleReload)
|
||||
eventSource.addEventListener('message', scheduleReload)
|
||||
eventSource.onerror = () => {}
|
||||
} catch {}
|
||||
}
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (reloadTimer) clearTimeout(reloadTimer)
|
||||
if (eventSource) eventSource.close()
|
||||
})
|
||||
|
||||
watch(selectedListId, loadTasks)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-container { padding: 1.5rem; }
|
||||
.view-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
|
||||
.view-header h2 { margin: 0; }
|
||||
.header-actions { display: flex; gap: 0.5rem; }
|
||||
.tasks-layout { display: flex; gap: 1rem; align-items: flex-start; }
|
||||
.lists-sidebar { width: 260px; flex-shrink: 0; }
|
||||
.lists-sidebar h4 { margin: 0 0 0.5rem; font-size: 0.85rem; text-transform: uppercase; color: var(--p-text-muted-color); }
|
||||
.list-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; border-radius: 4px;
|
||||
cursor: pointer; font-size: 0.875rem; }
|
||||
.list-item:hover { background: var(--p-surface-50); }
|
||||
.list-item.active { background: var(--p-primary-50); }
|
||||
.list-color { width: 12px; height: 12px; border-radius: 3px; flex-shrink: 0; }
|
||||
.list-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.shared-label { color: var(--p-text-muted-color); font-size: 0.7rem; }
|
||||
.count { color: var(--p-text-muted-color); font-size: 0.8rem; }
|
||||
.list-menu { opacity: 0; transition: opacity .15s; }
|
||||
.list-item:hover .list-menu { opacity: 1; }
|
||||
.tasks-main { flex: 1; min-width: 0; }
|
||||
.toolbar { display: flex; gap: 0.75rem; align-items: center; margin-bottom: 0.75rem; }
|
||||
.toggle { display: flex; align-items: center; gap: 0.35rem; font-size: 0.875rem; white-space: nowrap; }
|
||||
.bulk-bar { display: flex; gap: 0.5rem; align-items: center; padding: 0.5rem 0.75rem;
|
||||
background: var(--p-primary-50); border-radius: 6px; margin-bottom: 0.5rem; font-size: 0.875rem; }
|
||||
.task-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
|
||||
.task-table th { text-align: left; padding: 0.5rem; border-bottom: 2px solid var(--p-surface-200); font-weight: 600; }
|
||||
.task-table td { padding: 0.5rem; border-bottom: 1px solid var(--p-surface-100); vertical-align: top; }
|
||||
.task-row { cursor: pointer; }
|
||||
.task-row:hover { background: var(--p-surface-50); }
|
||||
.task-row.done .col-title span { text-decoration: line-through; color: var(--p-text-muted-color); }
|
||||
.task-row.selected { background: var(--p-primary-50); }
|
||||
.col-check, .col-done { width: 36px; }
|
||||
.col-actions { width: 60px; text-align: right; }
|
||||
.col-date { white-space: nowrap; }
|
||||
.col-title { }
|
||||
.meta { display: block; color: var(--p-text-muted-color); font-size: 0.75rem; margin-top: 0.1rem; }
|
||||
.empty-row { text-align: center; color: var(--p-text-muted-color); padding: 2rem !important; }
|
||||
.status-badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 10px; font-size: 0.72rem; }
|
||||
.status-badge.todo { background: var(--p-surface-100); }
|
||||
.status-badge.progress { background: var(--p-blue-100); color: var(--p-blue-700); }
|
||||
.status-badge.done { background: var(--p-green-100); color: var(--p-green-700); }
|
||||
.status-badge.cancelled { background: var(--p-red-100); color: var(--p-red-700); }
|
||||
.field { margin-bottom: 0.75rem; }
|
||||
.field label { display: block; margin-bottom: 0.25rem; font-weight: 500; font-size: 0.875rem; }
|
||||
.field-row { display: flex; gap: 0.75rem; }
|
||||
.field-row .field { flex: 1; }
|
||||
.share-row { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.existing-shares { margin-top: 0.5rem; }
|
||||
.share-perm-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.875rem; }
|
||||
.perm-label { color: var(--p-text-muted-color); font-size: 0.75rem; }
|
||||
.url-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
||||
.url-row strong { min-width: 110px; font-size: 0.8rem; }
|
||||
.url-row code { background: var(--p-surface-100); padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.8rem; flex: 1; word-break: break-all; }
|
||||
.caldav-hint { font-size: 0.8rem; color: var(--p-text-muted-color); margin: 0 0 0.5rem; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user