myCRM/assets/js/views/ContactManagement.vue

812 lines
27 KiB
Vue

<template>
<div class="contact-management">
<CrudDataTable
ref="tableRef"
title="Kontakte"
entity-name="Kontakt"
:columns="contactColumns"
data-source="/api/contacts"
storage-key="contactTableColumns"
:show-view-button="canView"
:show-edit-button="canEdit"
:show-delete-button="canDelete"
@view="viewContact"
@create="openNewContactDialog"
@edit="editContact"
@delete="confirmDelete"
@data-loaded="onDataLoaded"
>
<!-- Custom Filter Buttons -->
<template #filter-buttons="{ loadData }">
<div class="flex gap-2 mb-4">
<Button
label="Alle"
:outlined="typeFilter !== 'all'"
@click="filterByType('all', loadData)"
size="small"
/>
<Button
label="Debitoren"
:outlined="typeFilter !== 'debtor'"
@click="filterByType('debtor', loadData)"
size="small"
/>
<Button
label="Kreditoren"
:outlined="typeFilter !== 'creditor'"
@click="filterByType('creditor', loadData)"
size="small"
/>
</div>
</template>
<!-- Custom Column Templates -->
<template #body-companyName="{ data }">
<div class="font-semibold">{{ data.companyName }}</div>
</template>
<template #body-street="{ data }">
{{ data.street }}
</template>
<template #body-zipCode="{ data }">
{{ data.zipCode }}
</template>
<template #body-city="{ data }">
{{ data.city }}
</template>
<template #body-country="{ data }">
{{ data.country }}
</template>
<template #body-contactPersons="{ data }">
<div v-if="data.contactPersons?.length > 0">
<div v-for="person in data.contactPersons.slice(0, 2)" :key="person.id" class="mb-1">
<div class="font-medium">
{{ person.firstName }} {{ person.lastName }}
<Tag v-if="person.isPrimary" value="Primär" severity="info" class="ml-1" />
</div>
<div v-if="person.position" class="text-sm text-500">{{ person.position }}</div>
</div>
<div v-if="data.contactPersons.length > 2" class="text-sm text-500">
+{{ data.contactPersons.length - 2 }} weitere
</div>
</div>
<span v-else class="text-500">Keine Ansprechpartner</span>
</template>
<template #body-phone="{ data }">
<a v-if="data.phone" :href="'tel:' + data.phone" class="text-primary">{{ data.phone }}</a>
</template>
<template #body-fax="{ data }">
{{ data.fax }}
</template>
<template #body-email="{ data }">
<a v-if="data.email" :href="'mailto:' + data.email" class="text-primary">{{ data.email }}</a>
</template>
<template #body-website="{ data }">
<a v-if="data.website" :href="data.website" target="_blank" class="text-primary">
{{ data.website }}
</a>
</template>
<template #body-type="{ data }">
<div class="flex gap-1">
<Tag v-if="data.isDebtor" value="Debitor" severity="success" />
<Tag v-if="data.isCreditor" value="Kreditor" severity="warning" />
</div>
</template>
<template #body-status="{ data }">
<Tag :value="data.isActive ? 'Aktiv' : 'Inaktiv'" :severity="data.isActive ? 'success' : 'secondary'" />
</template>
<template #body-notes="{ data }">
<div v-if="data.notes" class="text-sm text-500 line-clamp-2">{{ data.notes }}</div>
</template>
<template #body-createdAt="{ data }">
{{ formatDate(data.createdAt) }}
</template>
<template #body-updatedAt="{ data }">
{{ formatDate(data.updatedAt) }}
</template>
</CrudDataTable>
<!-- Contact Dialog -->
<Dialog
v-model:visible="contactDialog"
:header="editingContact?.id ? 'Kontakt bearbeiten' : 'Neuer Kontakt'"
:modal="true"
:style="{ width: '800px' }"
:closable="!saving"
>
<div class="flex flex-col gap-4">
<!-- Company Information -->
<div class="font-semibold">Firmeninformationen</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label for="companyName">Firmenname *</label>
<InputText
id="companyName"
v-model="editingContact.companyName"
:class="{ 'p-invalid': submitted && !editingContact.companyName }"
:disabled="saving"
/>
<small v-if="submitted && !editingContact.companyName" class="p-error">Firmenname ist erforderlich</small>
</div>
<div class="flex flex-col gap-2">
<label for="companyNumber">Kundennummer</label>
<InputText id="companyNumber" v-model="editingContact.companyNumber" :disabled="saving" />
</div>
</div>
<!-- Address -->
<Divider />
<div class="font-semibold">Adresse</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2 col-span-2">
<label for="street">Straße</label>
<InputText id="street" v-model="editingContact.street" :disabled="saving" />
</div>
<div class="flex flex-col gap-2">
<label for="zipCode">PLZ</label>
<InputText id="zipCode" v-model="editingContact.zipCode" :disabled="saving" />
</div>
<div class="flex flex-col gap-2">
<label for="city">Ort</label>
<InputText id="city" v-model="editingContact.city" :disabled="saving" />
</div>
<div class="flex flex-col gap-2 col-span-2">
<label for="country">Land</label>
<InputText id="country" v-model="editingContact.country" :disabled="saving" />
</div>
</div>
<!-- Contact Information -->
<Divider />
<div class="font-semibold">Kontaktinformationen</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label for="phone">Telefon</label>
<InputText id="phone" v-model="editingContact.phone" :disabled="saving" />
</div>
<div class="flex flex-col gap-2">
<label for="fax">Fax</label>
<InputText id="fax" v-model="editingContact.fax" :disabled="saving" />
</div>
<div class="flex flex-col gap-2">
<label for="email">E-Mail</label>
<InputText id="email" v-model="editingContact.email" type="email" :disabled="saving" />
</div>
<div class="flex flex-col gap-2">
<label for="website">Website</label>
<InputText id="website" v-model="editingContact.website" :disabled="saving" />
</div>
</div>
<!-- Tax Information -->
<Divider />
<div class="font-semibold">Steuerinformationen</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label for="taxNumber">Steuernummer</label>
<InputText id="taxNumber" v-model="editingContact.taxNumber" :disabled="saving" />
</div>
<div class="flex flex-col gap-2">
<label for="vatNumber">USt-IdNr.</label>
<InputText id="vatNumber" v-model="editingContact.vatNumber" :disabled="saving" />
</div>
</div>
<!-- Type and Status -->
<Divider />
<div class="font-semibold">Typ und Status</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex items-center gap-2">
<Checkbox inputId="isDebtor" v-model="editingContact.isDebtor" :binary="true" :disabled="saving" />
<label for="isDebtor">Debitor</label>
</div>
<div class="flex items-center gap-2">
<Checkbox inputId="isCreditor" v-model="editingContact.isCreditor" :binary="true" :disabled="saving" />
<label for="isCreditor">Kreditor</label>
</div>
<div class="flex items-center gap-2 col-span-2">
<Checkbox inputId="isActive" v-model="editingContact.isActive" :binary="true" :disabled="saving" />
<label for="isActive">Aktiv</label>
</div>
</div>
<!-- Notes -->
<Divider />
<div class="flex flex-col gap-2">
<label for="notes">Notizen</label>
<Textarea id="notes" v-model="editingContact.notes" rows="3" :disabled="saving" />
</div>
<!-- Contact Persons -->
<Divider />
<div class="flex justify-between items-center">
<div class="font-semibold">Ansprechpartner</div>
<Button
label="Hinzufügen"
icon="pi pi-plus"
size="small"
outlined
@click="addContactPerson"
:disabled="saving"
/>
</div>
<div v-for="(person, index) in editingContact.contactPersons" :key="index" class="border p-3 rounded-md">
<div class="flex justify-between items-start mb-3">
<div class="font-medium">Ansprechpartner {{ index + 1 }}</div>
<Button
icon="pi pi-trash"
size="small"
text
rounded
severity="danger"
@click="removeContactPerson(index)"
:disabled="saving"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-2">
<label :for="'salutation-' + index">Anrede</label>
<Select
:id="'salutation-' + index"
v-model="person.salutation"
:options="salutations"
placeholder="Auswählen"
:disabled="saving"
/>
</div>
<div class="flex flex-col gap-2">
<label :for="'title-' + index">Titel</label>
<InputText :id="'title-' + index" v-model="person.title" :disabled="saving" />
</div>
<div class="flex flex-col gap-2">
<label :for="'firstName-' + index">Vorname</label>
<InputText :id="'firstName-' + index" v-model="person.firstName" :disabled="saving" />
</div>
<div class="flex flex-col gap-2">
<label :for="'lastName-' + index">Nachname</label>
<InputText :id="'lastName-' + index" v-model="person.lastName" :disabled="saving" />
</div>
<div class="flex flex-col gap-2">
<label :for="'position-' + index">Position</label>
<InputText :id="'position-' + index" v-model="person.position" :disabled="saving" />
</div>
<div class="flex flex-col gap-2">
<label :for="'department-' + index">Abteilung</label>
<InputText :id="'department-' + index" v-model="person.department" :disabled="saving" />
</div>
<div class="flex flex-col gap-2">
<label :for="'person-phone-' + index">Telefon</label>
<InputText :id="'person-phone-' + index" v-model="person.phone" :disabled="saving" />
</div>
<div class="flex flex-col gap-2">
<label :for="'mobile-' + index">Mobil</label>
<InputText :id="'mobile-' + index" v-model="person.mobile" :disabled="saving" />
</div>
<div class="flex flex-col gap-2 col-span-2">
<label :for="'person-email-' + index">E-Mail</label>
<InputText :id="'person-email-' + index" v-model="person.email" type="email" :disabled="saving" />
</div>
<div class="flex items-center gap-2 col-span-2">
<Checkbox
:inputId="'isPrimary-' + index"
v-model="person.isPrimary"
:binary="true"
@change="setPrimaryContact(index)"
:disabled="saving"
/>
<label :for="'isPrimary-' + index">Primärer Ansprechpartner</label>
</div>
</div>
</div>
</div>
<template #footer>
<Button label="Abbrechen" outlined @click="closeContactDialog" :disabled="saving" />
<Button label="Speichern" @click="saveContact" :loading="saving" />
</template>
</Dialog>
<!-- Delete Confirmation Dialog -->
<Dialog
v-model:visible="deleteDialog"
header="Kontakt löschen"
:modal="true"
:style="{ width: '450px' }"
:closable="!deleting"
>
<div class="flex items-center gap-3">
<i class="pi pi-exclamation-triangle text-3xl text-red-500"></i>
<span>Möchten Sie diesen Kontakt wirklich löschen?</span>
</div>
<template #footer>
<Button label="Abbrechen" outlined @click="deleteDialog = false" :disabled="deleting" />
<Button label="Löschen" severity="danger" @click="deleteContact" :loading="deleting" />
</template>
</Dialog>
<!-- View Contact Dialog (Read-Only) -->
<Dialog
v-model:visible="viewDialog"
header="Kontakt anzeigen"
:modal="true"
:style="{ width: '800px' }"
>
<div v-if="viewingContact" class="flex flex-col gap-4">
<!-- Company Information -->
<div class="font-semibold">Firmeninformationen</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Firmenname</label>
<div>{{ viewingContact.companyName }}</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Kundennummer</label>
<div>{{ viewingContact.companyNumber || '-' }}</div>
</div>
</div>
<!-- Address -->
<Divider />
<div class="font-semibold">Adresse</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2 col-span-2">
<label class="font-medium text-sm text-500">Straße</label>
<div>{{ viewingContact.street || '-' }}</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">PLZ</label>
<div>{{ viewingContact.zipCode || '-' }}</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Ort</label>
<div>{{ viewingContact.city || '-' }}</div>
</div>
<div class="flex flex-col gap-2 col-span-2">
<label class="font-medium text-sm text-500">Land</label>
<div>{{ viewingContact.country || '-' }}</div>
</div>
</div>
<!-- Contact Information -->
<Divider />
<div class="font-semibold">Kontaktinformationen</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Telefon</label>
<div>
<a v-if="viewingContact.phone" :href="'tel:' + viewingContact.phone" class="text-primary">
{{ viewingContact.phone }}
</a>
<span v-else>-</span>
</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Fax</label>
<div>{{ viewingContact.fax || '-' }}</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">E-Mail</label>
<div>
<a v-if="viewingContact.email" :href="'mailto:' + viewingContact.email" class="text-primary">
{{ viewingContact.email }}
</a>
<span v-else>-</span>
</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Website</label>
<div>
<a v-if="viewingContact.website" :href="viewingContact.website" target="_blank" class="text-primary">
{{ viewingContact.website }}
</a>
<span v-else>-</span>
</div>
</div>
</div>
<!-- Tax Information -->
<Divider />
<div class="font-semibold">Steuerinformationen</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Steuernummer</label>
<div>{{ viewingContact.taxNumber || '-' }}</div>
</div>
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">USt-IdNr.</label>
<div>{{ viewingContact.vatNumber || '-' }}</div>
</div>
</div>
<!-- Type and Status -->
<Divider />
<div class="font-semibold">Typ und Status</div>
<div class="flex flex-wrap gap-2">
<Tag v-if="viewingContact.isDebtor" value="Debitor" severity="success" />
<Tag v-if="viewingContact.isCreditor" value="Kreditor" severity="warning" />
<Tag :value="viewingContact.isActive ? 'Aktiv' : 'Inaktiv'"
:severity="viewingContact.isActive ? 'success' : 'secondary'" />
</div>
<!-- Notes -->
<Divider />
<div class="flex flex-col gap-2">
<label class="font-medium text-sm text-500">Notizen</label>
<div class="whitespace-pre-wrap">{{ viewingContact.notes || '-' }}</div>
</div>
<!-- Contact Persons -->
<Divider />
<div class="font-semibold">Ansprechpartner</div>
<div v-if="viewingContact.contactPersons?.length > 0" class="flex flex-col gap-3">
<div v-for="(person, index) in viewingContact.contactPersons" :key="index"
class="border p-3 rounded-md">
<div class="flex justify-between items-start mb-3">
<div class="font-medium">
{{ person.salutation }} {{ person.title }} {{ person.firstName }} {{ person.lastName }}
<Tag v-if="person.isPrimary" value="Primär" severity="info" class="ml-2" />
</div>
</div>
<div class="grid grid-cols-2 gap-3 text-sm">
<div v-if="person.position">
<span class="text-500">Position:</span> {{ person.position }}
</div>
<div v-if="person.department">
<span class="text-500">Abteilung:</span> {{ person.department }}
</div>
<div v-if="person.phone">
<span class="text-500">Telefon:</span>
<a :href="'tel:' + person.phone" class="text-primary">{{ person.phone }}</a>
</div>
<div v-if="person.mobile">
<span class="text-500">Mobil:</span>
<a :href="'tel:' + person.mobile" class="text-primary">{{ person.mobile }}</a>
</div>
<div v-if="person.email" class="col-span-2">
<span class="text-500">E-Mail:</span>
<a :href="'mailto:' + person.email" class="text-primary">{{ person.email }}</a>
</div>
</div>
</div>
</div>
<div v-else class="text-500">Keine Ansprechpartner</div>
<!-- Timestamps -->
<Divider />
<div class="grid grid-cols-2 gap-4 text-sm text-500">
<div>
<span class="font-medium">Erstellt am:</span> {{ formatDate(viewingContact.createdAt) }}
</div>
<div>
<span class="font-medium">Zuletzt geändert:</span> {{ formatDate(viewingContact.updatedAt) }}
</div>
</div>
</div>
<template #footer>
<Button label="Schließen" @click="viewDialog = false" />
<Button
v-if="canEdit"
label="Bearbeiten"
icon="pi pi-pencil"
@click="editFromView"
/>
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import { usePermissionStore } from '../stores/permissions'
import CrudDataTable from '../components/CrudDataTable.vue'
import Dialog from 'primevue/dialog'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import Tag from 'primevue/tag'
import Checkbox from 'primevue/checkbox'
import Select from 'primevue/select'
import Divider from 'primevue/divider'
const toast = useToast()
const tableRef = ref(null)
const permissionStore = usePermissionStore()
// Permissions
const canView = computed(() => permissionStore.canView('contacts'))
const canCreate = computed(() => permissionStore.canCreate('contacts'))
const canEdit = computed(() => permissionStore.canEdit('contacts'))
const canDelete = computed(() => permissionStore.canDelete('contacts'))
// Column definitions
const contactColumns = [
{ key: 'companyName', label: 'Firma', field: 'companyName', sortable: true, style: 'min-width: 200px', default: true },
{ key: 'companyNumber', label: 'Kundennummer', field: 'companyNumber', default: false },
{ key: 'street', label: 'Straße', field: 'street', default: false },
{ key: 'zipCode', label: 'PLZ', field: 'zipCode', default: false },
{ key: 'city', label: 'Ort', field: 'city', sortable: true, default: true },
{ key: 'country', label: 'Land', field: 'country', default: false },
{ key: 'contactPersons', label: 'Ansprechpartner', style: 'min-width: 200px', default: true },
{ key: 'phone', label: 'Telefon', field: 'phone', default: true },
{ key: 'fax', label: 'Fax', field: 'fax', default: false },
{ key: 'email', label: 'E-Mail', field: 'email', default: true },
{ key: 'website', label: 'Website', field: 'website', default: true },
{ key: 'taxNumber', label: 'Steuernummer', field: 'taxNumber', default: false },
{ key: 'vatNumber', label: 'USt-IdNr.', field: 'vatNumber', default: false },
{ key: 'type', label: 'Typ (Debitor/Kreditor)', default: true },
{ key: 'status', label: 'Status', default: true },
{ key: 'notes', label: 'Notizen', field: 'notes', default: false },
{ key: 'createdAt', label: 'Erstellt am', field: 'createdAt', default: false },
{ key: 'updatedAt', label: 'Zuletzt geändert', field: 'updatedAt', default: false }
]
// State
const contactDialog = ref(false)
const viewDialog = ref(false)
const deleteDialog = ref(false)
const editingContact = ref(null)
const viewingContact = ref(null)
const submitted = ref(false)
const saving = ref(false)
const deleting = ref(false)
const typeFilter = ref('all')
const salutations = ref(['Herr', 'Frau', 'Divers'])
// Helper functions
const emptyContact = () => ({
companyName: '',
companyNumber: '',
street: '',
zipCode: '',
city: '',
country: 'Deutschland',
phone: '',
fax: '',
email: '',
website: '',
taxNumber: '',
vatNumber: '',
isDebtor: false,
isCreditor: false,
isActive: true,
notes: '',
contactPersons: []
})
const emptyContactPerson = () => ({
salutation: null,
title: '',
firstName: '',
lastName: '',
position: '',
department: '',
phone: '',
mobile: '',
email: '',
isPrimary: false
})
const formatDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
}
// Filter by type
const filterByType = (type, loadData) => {
typeFilter.value = type
const params = {}
if (type === 'debtor') {
params.isDebtor = 'true'
} else if (type === 'creditor') {
params.isCreditor = 'true'
}
loadData(params)
}
// CRUD Operations
const openNewContactDialog = () => {
editingContact.value = emptyContact()
submitted.value = false
contactDialog.value = true
}
const viewContact = (contact) => {
viewingContact.value = { ...contact }
viewDialog.value = true
}
const editContact = (contact) => {
editingContact.value = { ...contact }
submitted.value = false
contactDialog.value = true
}
const closeContactDialog = () => {
contactDialog.value = false
editingContact.value = null
submitted.value = false
}
const saveContact = async () => {
submitted.value = true
if (!editingContact.value.companyName) {
return
}
saving.value = true
try {
const isEdit = !!editingContact.value.id
const url = isEdit ? `/api/contacts/${editingContact.value.id}` : '/api/contacts'
const method = isEdit ? 'PUT' : 'POST'
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/ld+json',
'Accept': 'application/ld+json'
},
credentials: 'include',
body: JSON.stringify(editingContact.value)
})
if (!response.ok) {
const error = await response.json()
if (error.violations && error.violations.length > 0) {
const errorMessages = error.violations
.map(v => `${v.propertyPath}: ${v.message}`)
.join('\n')
throw new Error(errorMessages)
}
throw new Error(error['hydra:description'] || error.message || 'Fehler beim Speichern')
}
toast.add({
severity: 'success',
summary: 'Erfolg',
detail: `Kontakt wurde ${isEdit ? 'aktualisiert' : 'erstellt'}`,
life: 3000
})
closeContactDialog()
tableRef.value?.loadData()
} catch (error) {
console.error('Error saving contact:', error)
toast.add({
severity: 'error',
summary: 'Fehler',
detail: error.message || 'Kontakt konnte nicht gespeichert werden',
life: 5000
})
} finally {
saving.value = false
}
}
const confirmDelete = (contact) => {
editingContact.value = contact
deleteDialog.value = true
}
const deleteContact = async () => {
deleting.value = true
try {
const response = await fetch(`/api/contacts/${editingContact.value.id}`, {
method: 'DELETE',
credentials: 'include'
})
if (!response.ok) {
throw new Error('Fehler beim Löschen des Kontakts')
}
toast.add({
severity: 'success',
summary: 'Erfolg',
detail: 'Kontakt wurde gelöscht',
life: 3000
})
deleteDialog.value = false
tableRef.value?.loadData()
} catch (error) {
console.error('Error deleting contact:', error)
toast.add({
severity: 'error',
summary: 'Fehler',
detail: 'Kontakt konnte nicht gelöscht werden',
life: 3000
})
} finally {
deleting.value = false
}
}
// Contact Person Management
const addContactPerson = () => {
editingContact.value.contactPersons.push(emptyContactPerson())
}
const removeContactPerson = (index) => {
editingContact.value.contactPersons.splice(index, 1)
}
const setPrimaryContact = (index) => {
if (editingContact.value.contactPersons[index].isPrimary) {
editingContact.value.contactPersons.forEach((person, i) => {
if (i !== index) {
person.isPrimary = false
}
})
}
}
const editFromView = () => {
editingContact.value = { ...viewingContact.value }
viewDialog.value = false
submitted.value = false
contactDialog.value = true
}
const onDataLoaded = (data) => {
// Optional: Do something when data is loaded
}
</script>