82f3091f2e
Papierkorb: - Dateien/Ordner werden beim Loeschen in den Papierkorb verschoben (Soft-Delete) statt sofort geloescht - Papierkorb-Seite in der Sidebar mit Tabelle aller geloeschten Elemente - Pro Element: Wiederherstellen (am Originalort) oder endgueltig loeschen - "Papierkorb leeren" Button loescht alles unwiderruflich - Backend: is_trashed, trashed_at, original_parent_id Felder im File-Model - Getrashte Dateien erscheinen nicht in der normalen Dateiliste Bestaetigungsdialoge (vorher fehlend): - Kontakte: "Moechtest du XY wirklich loeschen?" - Kalender Events: Bestaetigung vor dem Loeschen - Kalender: Bestaetigung vor dem Loeschen (mit Hinweis auf Events) - E-Mail Nachrichten: Bestaetigung mit Betreff-Vorschau - Share-Link Dateien: Bestaetigung beim Loeschen aus geteiltem Ordner - Admin SFTP-Backup-Ziele: Bestaetigung - Admin Email-Konten: Bestaetigung Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
226 lines
7.7 KiB
Vue
226 lines
7.7 KiB
Vue
<template>
|
|
<div class="view-container">
|
|
<div class="view-header">
|
|
<h2>Kontakte</h2>
|
|
<div class="header-actions">
|
|
<Button icon="pi pi-book" label="Neues Adressbuch" size="small" outlined @click="showNewBook = true" />
|
|
<Button icon="pi pi-user-plus" label="Neuer Kontakt" size="small" @click="openNewContact" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="contacts-layout">
|
|
<aside class="books-sidebar">
|
|
<div v-for="book in addressBooks" :key="book.id"
|
|
class="book-item" :class="{ active: selectedBookId === book.id }"
|
|
@click="selectBook(book.id)">
|
|
<i class="pi pi-book"></i>
|
|
<span>{{ book.name }}</span>
|
|
<span class="count">{{ book.contact_count }}</span>
|
|
</div>
|
|
</aside>
|
|
|
|
<div class="contacts-main">
|
|
<div class="search-bar">
|
|
<InputText v-model="searchQuery" placeholder="Kontakte suchen..." fluid @input="loadContacts" />
|
|
</div>
|
|
|
|
<DataTable :value="contacts" :loading="loading" striped-rows @row-click="openEditContact">
|
|
<template #empty><p class="empty">Keine Kontakte</p></template>
|
|
<Column field="display_name" header="Name" sortable />
|
|
<Column field="email" header="E-Mail" sortable />
|
|
<Column field="phone" header="Telefon" />
|
|
<Column header="" style="width: 80px">
|
|
<template #body="{ data }">
|
|
<Button icon="pi pi-trash" text size="small" severity="danger" @click.stop="confirmDeleteContact(data)" />
|
|
</template>
|
|
</Column>
|
|
</DataTable>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- New Address Book -->
|
|
<Dialog v-model:visible="showNewBook" header="Neues Adressbuch" modal :style="{ width: '400px' }">
|
|
<div class="field">
|
|
<label>Name</label>
|
|
<InputText v-model="newBookName" fluid autofocus @keyup.enter="createBook" />
|
|
</div>
|
|
<template #footer>
|
|
<Button label="Abbrechen" text @click="showNewBook = false" />
|
|
<Button label="Erstellen" @click="createBook" />
|
|
</template>
|
|
</Dialog>
|
|
|
|
<!-- Contact Form -->
|
|
<Dialog v-model:visible="showContactForm" :header="editingContact ? 'Kontakt bearbeiten' : 'Neuer Kontakt'" modal :style="{ width: '500px' }">
|
|
<div class="field">
|
|
<label>Name</label>
|
|
<InputText v-model="contactForm.display_name" fluid autofocus />
|
|
</div>
|
|
<div class="field">
|
|
<label>E-Mail</label>
|
|
<InputText v-model="contactForm.email" type="email" fluid />
|
|
</div>
|
|
<div class="field">
|
|
<label>Telefon</label>
|
|
<InputText v-model="contactForm.phone" fluid />
|
|
</div>
|
|
<div class="field">
|
|
<label>Organisation</label>
|
|
<InputText v-model="contactForm.organization" fluid />
|
|
</div>
|
|
<div class="field">
|
|
<label>Notizen</label>
|
|
<Textarea v-model="contactForm.notes" rows="3" fluid />
|
|
</div>
|
|
<template #footer>
|
|
<Button label="Abbrechen" text @click="showContactForm = false" />
|
|
<Button :label="editingContact ? 'Speichern' : 'Erstellen'" @click="saveContact" />
|
|
</template>
|
|
</Dialog>
|
|
|
|
<!-- Delete Confirm -->
|
|
<Dialog v-model:visible="showDeleteConfirm" header="Kontakt loeschen" modal :style="{ width: '400px' }">
|
|
<p>Moechtest du <strong>{{ deleteTarget?.display_name }}</strong> wirklich loeschen?</p>
|
|
<template #footer>
|
|
<Button label="Abbrechen" text @click="showDeleteConfirm = false" />
|
|
<Button label="Loeschen" severity="danger" @click="doDeleteContact" />
|
|
</template>
|
|
</Dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { useToast } from 'primevue/usetoast'
|
|
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 DataTable from 'primevue/datatable'
|
|
import Column from 'primevue/column'
|
|
|
|
const toast = useToast()
|
|
const addressBooks = ref([])
|
|
const contacts = ref([])
|
|
const selectedBookId = ref(null)
|
|
const loading = ref(false)
|
|
const searchQuery = ref('')
|
|
|
|
const showNewBook = ref(false)
|
|
const newBookName = ref('')
|
|
|
|
const showContactForm = ref(false)
|
|
const editingContact = ref(null)
|
|
const contactForm = ref({ display_name: '', email: '', phone: '', organization: '', notes: '' })
|
|
|
|
async function loadBooks() {
|
|
const res = await apiClient.get('/addressbooks')
|
|
addressBooks.value = res.data
|
|
if (addressBooks.value.length && !selectedBookId.value) {
|
|
selectedBookId.value = addressBooks.value[0].id
|
|
await loadContacts()
|
|
}
|
|
if (!addressBooks.value.length) {
|
|
await apiClient.post('/addressbooks', { name: 'Kontakte' })
|
|
await loadBooks()
|
|
}
|
|
}
|
|
|
|
async function loadContacts() {
|
|
if (!selectedBookId.value) return
|
|
loading.value = true
|
|
try {
|
|
const params = searchQuery.value ? { search: searchQuery.value } : {}
|
|
const res = await apiClient.get(`/addressbooks/${selectedBookId.value}/contacts`, { params })
|
|
contacts.value = res.data
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function selectBook(id) {
|
|
selectedBookId.value = id
|
|
loadContacts()
|
|
}
|
|
|
|
async function createBook() {
|
|
if (!newBookName.value.trim()) return
|
|
await apiClient.post('/addressbooks', { name: newBookName.value.trim() })
|
|
showNewBook.value = false
|
|
newBookName.value = ''
|
|
await loadBooks()
|
|
}
|
|
|
|
function openNewContact() {
|
|
editingContact.value = null
|
|
contactForm.value = { display_name: '', email: '', phone: '', organization: '', notes: '' }
|
|
showContactForm.value = true
|
|
}
|
|
|
|
function openEditContact(event) {
|
|
const c = event.data
|
|
editingContact.value = c
|
|
contactForm.value = {
|
|
display_name: c.display_name || '',
|
|
email: c.email || '',
|
|
phone: c.phone || '',
|
|
organization: '',
|
|
notes: '',
|
|
}
|
|
showContactForm.value = true
|
|
}
|
|
|
|
async function saveContact() {
|
|
if (!contactForm.value.display_name.trim()) return
|
|
try {
|
|
if (editingContact.value) {
|
|
await apiClient.put(`/contacts/${editingContact.value.id}`, contactForm.value)
|
|
} else {
|
|
await apiClient.post(`/addressbooks/${selectedBookId.value}/contacts`, contactForm.value)
|
|
}
|
|
showContactForm.value = false
|
|
await loadContacts()
|
|
await loadBooks()
|
|
} catch (err) {
|
|
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
|
}
|
|
}
|
|
|
|
const showDeleteConfirm = ref(false)
|
|
const deleteTarget = ref(null)
|
|
|
|
function confirmDeleteContact(contact) {
|
|
deleteTarget.value = contact
|
|
showDeleteConfirm.value = true
|
|
}
|
|
|
|
async function doDeleteContact() {
|
|
if (!deleteTarget.value) return
|
|
await apiClient.delete(`/contacts/${deleteTarget.value.id}`)
|
|
showDeleteConfirm.value = false
|
|
await loadContacts()
|
|
await loadBooks()
|
|
}
|
|
|
|
onMounted(loadBooks)
|
|
</script>
|
|
|
|
<style scoped>
|
|
.view-container { padding: 1.5rem; }
|
|
.view-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
|
|
.view-header h2 { margin: 0; }
|
|
.header-actions { display: flex; gap: 0.5rem; }
|
|
.contacts-layout { display: flex; gap: 1rem; }
|
|
.books-sidebar { width: 220px; flex-shrink: 0; }
|
|
.book-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; border-radius: 6px; cursor: pointer; font-size: 0.875rem; }
|
|
.book-item:hover { background: var(--p-surface-100); }
|
|
.book-item.active { background: var(--p-primary-50); color: var(--p-primary-color); }
|
|
.book-item .count { margin-left: auto; color: var(--p-text-muted-color); font-size: 0.75rem; }
|
|
.contacts-main { flex: 1; }
|
|
.search-bar { margin-bottom: 1rem; }
|
|
.field { margin-bottom: 1rem; }
|
|
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
|
|
.empty { text-align: center; color: var(--p-text-muted-color); padding: 2rem; }
|
|
</style>
|