feat: Liste/Kalender/Adressbuch beim Anlegen waehlbar (nur Schreibrecht)

- ContactsView: Adressbuch-Auswahl im Kontakt-Dialog (versteckt bei nur
  einem beschreibbaren Buch). Neuer-Kontakt-Button disabled wenn keiner.
- TasksView: gleiches fuer Aufgabenlisten.
- CalendarView: writableCalendars (eigene + Schreibfreigaben) ersetzt
  ownCalendars in Event-Dialog und Import-Auswahl. Auswahlfeld nur ab 2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker 2026-04-14 15:44:44 +02:00
parent 4d67819cac
commit 2ef186e262
3 changed files with 64 additions and 12 deletions

View File

@ -9,7 +9,8 @@
<input ref="calImportInput" type="file" accept=".ics,.ical,.csv" hidden @change="onCalImportFile" />
<Button icon="pi pi-download" label="Export" size="small" outlined
:disabled="!exportableCalendars.length" @click="showCalExportDialog = true" />
<Button icon="pi pi-plus" label="Neuer Termin" size="small" @click="openNewEvent()" />
<Button icon="pi pi-plus" label="Neuer Termin" size="small"
:disabled="!writableCalendars.length" @click="openNewEvent()" />
</div>
</div>
@ -118,7 +119,7 @@
<Dialog v-model:visible="showCalImportDialog" header="In welchen Kalender importieren?" modal :style="{ width: '420px' }">
<div class="field">
<label>Kalender</label>
<Select v-model="importTargetCalId" :options="ownCalendars" optionLabel="name" optionValue="id" fluid />
<Select v-model="importTargetCalId" :options="writableCalendars" optionLabel="name" optionValue="id" fluid />
</div>
<p class="hint" style="font-size:0.85rem;color:var(--p-text-muted-color)">Datei: {{ pendingImportFile?.name }}</p>
<template #footer>
@ -168,9 +169,9 @@
<label>Titel</label>
<InputText v-model="eventForm.summary" fluid autofocus />
</div>
<div class="field">
<div v-if="writableCalendars.length > 1" class="field">
<label>Kalender</label>
<Select v-model="eventForm.calendar_id" :options="ownCalendars" optionLabel="name" optionValue="id" fluid />
<Select v-model="eventForm.calendar_id" :options="writableCalendars" optionLabel="name" optionValue="id" fluid />
</div>
<div class="field-row">
<div class="field">
@ -616,6 +617,10 @@ const currentEditScope = ref(null)
const permOptions = [{ label: 'Lesen', value: 'read' }, { label: 'Lesen+Schreiben', value: 'readwrite' }]
const ownCalendars = computed(() => calendars.value.filter(c => c.permission === 'owner'))
// Beschreibbar = eigener Kalender ODER Freigabe mit Schreibrecht.
const writableCalendars = computed(() =>
calendars.value.filter(c => c.permission === 'owner' || c.permission === 'readwrite')
)
const exportableCalendars = computed(() => calendars.value)
// --- Calendar Import / Export ---
@ -638,8 +643,8 @@ watch(showCalExportDialog, (v) => {
})
function triggerCalImport() {
if (!ownCalendars.value.length) {
toast.add({ severity: 'warn', summary: 'Kein eigener Kalender', life: 3000 })
if (!writableCalendars.value.length) {
toast.add({ severity: 'warn', summary: 'Kein beschreibbarer Kalender', life: 3000 })
return
}
calImportInput.value?.click()
@ -650,7 +655,7 @@ function onCalImportFile(ev) {
ev.target.value = ''
if (!file) return
pendingImportFile.value = file
importTargetCalId.value = ownCalendars.value[0]?.id
importTargetCalId.value = writableCalendars.value[0]?.id
showCalImportDialog.value = true
}
@ -969,6 +974,10 @@ async function createCalendar() {
}
function openNewEvent(start, end, allDay = false) {
if (!writableCalendars.value.length) {
toast.add({ severity: 'warn', summary: 'Kein beschreibbarer Kalender vorhanden', life: 3000 })
return
}
editingEvent.value = null
const now = start || new Date()
const later = end || new Date(now.getTime() + 3600000)
@ -976,7 +985,7 @@ function openNewEvent(start, end, allDay = false) {
summary: '',
description: '',
location: '',
calendar_id: ownCalendars.value[0]?.id,
calendar_id: writableCalendars.value[0].id,
dtstart: toLocalISO(now, allDay),
dtend: toLocalISO(later, allDay),
all_day: allDay,

View File

@ -8,7 +8,8 @@
<input ref="importInput" type="file" accept=".vcf,.vcard,.csv" hidden @change="onImportFile" />
<Button icon="pi pi-download" label="Export" size="small" outlined
:disabled="!selectedBookId" @click="showExportDialog = true" />
<Button icon="pi pi-user-plus" label="Neuer Kontakt" size="small" @click="openNewContact" />
<Button icon="pi pi-user-plus" label="Neuer Kontakt" size="small"
:disabled="!writableBooks.length" @click="openNewContact" />
</div>
</div>
@ -179,6 +180,11 @@
</TabList>
<TabPanels>
<TabPanel value="general">
<div v-if="writableBooks.length > 1" class="field" style="max-width:400px">
<label>Adressbuch</label>
<Select v-model="contactTargetBookId" :options="writableBooks"
optionLabel="name" optionValue="id" fluid />
</div>
<div class="photo-row">
<div class="avatar large" :style="{ background: avatarColor(contactForm) }">
<img v-if="contactForm.photo" :src="contactForm.photo" />
@ -391,6 +397,10 @@ const auth = useAuthStore()
const addressBooks = ref([])
const contacts = ref([])
const selectedBookId = ref(null)
const contactTargetBookId = ref(null)
const writableBooks = computed(() =>
addressBooks.value.filter(b => b.permission === 'owner' || b.permission === 'readwrite')
)
const loading = ref(false)
const searchQuery = ref('')
let searchTimer = null
@ -615,10 +625,17 @@ function copyText(t) {
}
function openNewContact() {
if (!writableBooks.value.length) {
toast.add({ severity: 'warn', summary: 'Kein beschreibbares Adressbuch vorhanden', life: 3000 })
return
}
editingContactId.value = null
Object.assign(contactForm, emptyContact())
categoriesString.value = ''
activeTab.value = 'general'
// Default: aktuell markiertes Buch, falls beschreibbar, sonst erstes beschreibbares
const selectedBook = writableBooks.value.find(b => b.id === selectedBookId.value)
contactTargetBookId.value = selectedBook ? selectedBook.id : writableBooks.value[0].id
showContactDialog.value = true
}
@ -685,7 +702,12 @@ async function saveContact() {
if (editingContactId.value) {
await apiClient.put(`/contacts/${editingContactId.value}`, payload)
} else {
await apiClient.post(`/addressbooks/${selectedBookId.value}/contacts`, payload)
const target = contactTargetBookId.value || selectedBookId.value
if (!target) {
toast.add({ severity: 'error', summary: 'Bitte Adressbuch waehlen', life: 3000 })
return
}
await apiClient.post(`/addressbooks/${target}/contacts`, payload)
}
showContactDialog.value = false
await loadBooks()

View File

@ -9,7 +9,7 @@
<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" />
:disabled="!writableLists.length" @click="openNewTask" />
</div>
</div>
@ -166,6 +166,11 @@
<!-- Task Dialog -->
<Dialog v-model:visible="showTaskDialog" :header="editingTaskId ? 'Aufgabe bearbeiten' : 'Neue Aufgabe'"
modal :style="{ width: '560px' }">
<div v-if="writableLists.length > 1" class="field">
<label>Liste</label>
<Select v-model="taskTargetListId" :options="writableLists"
optionLabel="name" optionValue="id" fluid />
</div>
<div class="field">
<label>Titel</label>
<InputText v-model="taskForm.summary" fluid autofocus />
@ -247,6 +252,10 @@ const username = computed(() => auth.user?.username || '')
const lists = ref([])
const selectedListId = ref(null)
const taskTargetListId = ref(null)
const writableLists = computed(() =>
lists.value.filter(l => l.permission === 'owner' || l.permission === 'readwrite')
)
const tasks = ref([])
const search = ref('')
const hideDone = ref(false)
@ -464,12 +473,19 @@ async function deleteList() {
}
function openNewTask() {
if (!writableLists.value.length) {
toast.add({ severity: 'warn', summary: 'Keine beschreibbare Liste', life: 3000 })
return
}
editingTaskId.value = null
Object.assign(taskForm, {
summary: '', description: '', due: '',
status: 'NEEDS-ACTION', priority: null, percent_complete: null,
categories: '',
})
// Default-Liste: aktuell markierte falls beschreibbar, sonst erste beschreibbare
const sel = writableLists.value.find(l => l.id === selectedListId.value)
taskTargetListId.value = sel ? sel.id : writableLists.value[0].id
showTaskDialog.value = true
}
@ -502,7 +518,12 @@ async function saveTask() {
if (editingTaskId.value) {
await apiClient.put(`/tasks/${editingTaskId.value}`, payload)
} else {
await apiClient.post(`/tasklists/${selectedListId.value}/tasks`, payload)
const target = taskTargetListId.value || selectedListId.value
if (!target) {
toast.add({ severity: 'error', summary: 'Bitte Liste waehlen', life: 3000 })
return
}
await apiClient.post(`/tasklists/${target}/tasks`, payload)
}
showTaskDialog.value = false
await loadLists()