myCRM/assets/js/components/CrudDataTable.vue
olli 47b7099ba6 feat: Implement reusable CrudDataTable component for contact management
- Created a new CrudDataTable.vue component to handle data display and interactions.
- Integrated column configuration, filtering, and sorting functionalities.
- Added support for dynamic column visibility and order persistence using localStorage.
- Enhanced data loading with error handling and user feedback via toast notifications.
- Updated ContactManagement_old.vue to utilize the new CrudDataTable component, improving code organization and maintainability.
2025-11-10 10:06:10 +01:00

344 lines
9.7 KiB
Vue

<template>
<div class="crud-datatable">
<Card>
<template #content>
<!-- Header mit Buttons -->
<div class="flex justify-between items-center mb-4">
<div class="font-semibold text-xl">{{ title }}</div>
<div class="flex gap-2">
<Button
v-if="enableColumnConfig"
label="Spalten anpassen"
icon="pi pi-cog"
outlined
@click="columnConfigDialog = true"
/>
<slot name="header-actions">
<Button
:label="createLabel"
icon="pi pi-plus"
@click="$emit('create')"
/>
</slot>
</div>
</div>
<!-- Optional: Custom Filter Buttons -->
<slot name="filter-buttons" :load-data="loadData"></slot>
<!-- DataTable -->
<DataTable
v-model:filters="internalFilters"
:value="data"
:loading="loading"
v-bind="mergedTableProps"
filterDisplay="menu"
>
<!-- Header mit Globaler Suche -->
<template #header>
<div class="flex justify-between flex-wrap gap-2">
<slot name="filter-row"></slot>
<IconField>
<InputIcon><i class="pi pi-search" /></InputIcon>
<InputText
v-model="internalFilters['global'].value"
placeholder="Suchen..."
/>
</IconField>
</div>
</template>
<!-- Dynamische Spalten basierend auf Config -->
<template v-for="column in visibleColumnsOrdered" :key="column.key">
<Column
:field="column.field || column.key"
:header="column.label"
:sortable="column.sortable !== false"
:style="column.style"
:dataType="column.dataType"
:showFilterMatchModes="column.showFilterMatchModes !== false"
:showFilterOperator="column.showFilterOperator !== false"
>
<!-- Custom Body Template (via Slot) -->
<template v-if="$slots[`body-${column.key}`]" #body="slotProps">
<slot :name="`body-${column.key}`" v-bind="slotProps" />
</template>
<!-- Default Body Template -->
<template v-else #body="{ data }">
{{ getNestedValue(data, column.field || column.key) }}
</template>
<!-- Custom Filter Template -->
<template v-if="column.filterable !== false" #filter="{ filterModel }">
<slot :name="`filter-${column.key}`" :filterModel="filterModel">
<InputText
v-model="filterModel.value"
type="text"
:placeholder="'Suche nach ' + column.label"
/>
</slot>
</template>
</Column>
</template>
<!-- Actions Column (immer sichtbar) -->
<Column :exportable="false" style="min-width: 120px" header="Aktionen">
<template #body="slotProps">
<slot name="actions" v-bind="slotProps">
<div class="flex gap-2">
<Button
icon="pi pi-pencil"
outlined
rounded
@click="$emit('edit', slotProps.data)"
size="small"
/>
<Button
icon="pi pi-trash"
outlined
rounded
severity="danger"
@click="$emit('delete', slotProps.data)"
size="small"
/>
</div>
</slot>
</template>
</Column>
<template #empty>{{ emptyMessage }}</template>
<template #loading>Lädt Daten. Bitte warten...</template>
</DataTable>
</template>
</Card>
<!-- Column Config Dialog (wiederverwendbar) -->
<ColumnConfigDialog
v-model:visible="columnConfigDialog"
v-model:columns="availableColumns"
v-model:visible-columns="visibleColumns"
@save="saveColumnConfig"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import { FilterMatchMode, FilterOperator } from '@primevue/core/api'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Card from 'primevue/card'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import ColumnConfigDialog from './ColumnConfigDialog.vue'
const props = defineProps({
title: {
type: String,
required: true
},
entityName: {
type: String,
default: ''
},
columns: {
type: Array,
required: true
},
dataSource: {
type: String,
required: true
},
storageKey: {
type: String,
required: true
},
enableColumnConfig: {
type: Boolean,
default: true
},
emptyMessage: {
type: String,
default: 'Keine Daten gefunden.'
},
tableProps: {
type: Object,
default: () => ({})
}
})
const emit = defineEmits(['create', 'edit', 'delete', 'data-loaded'])
const toast = useToast()
const data = ref([])
const loading = ref(false)
const columnConfigDialog = ref(false)
// Computed create button label
const createLabel = computed(() => {
if (props.entityName) {
return `Neue${props.entityName.endsWith('e') ? 'r' : 'n'} ${props.entityName}`
}
return 'Neu'
})
// Default table props
const defaultTableProps = {
paginator: true,
rows: 10,
rowsPerPageOptions: [10, 25, 50, 100],
rowHover: true,
showGridlines: true,
scrollable: true,
scrollHeight: 'flex',
paginatorTemplate: 'FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown',
currentPageReportTemplate: 'Zeige {first} bis {last} von {totalRecords} Einträgen'
}
// Merge user props with defaults
const mergedTableProps = computed(() => ({
...defaultTableProps,
...props.tableProps
}))
// Column Management
const availableColumns = ref([])
const visibleColumns = ref({})
const visibleColumnsOrdered = computed(() =>
availableColumns.value.filter(col => visibleColumns.value[col.key])
)
// Filter Setup
const internalFilters = ref({
global: { value: null, matchMode: FilterMatchMode.CONTAINS }
})
// Helper function to get nested object values
function getNestedValue(obj, path) {
return path.split('.').reduce((current, prop) => current?.[prop], obj)
}
function loadColumnConfig() {
const saved = localStorage.getItem(props.storageKey)
if (saved) {
const config = JSON.parse(saved)
// Handle both old and new format
if (config.visibility) {
// Apply saved order
if (config.order) {
const ordered = []
config.order.forEach(key => {
const col = props.columns.find(c => c.key === key)
if (col) ordered.push(col)
})
// Add new columns not in saved order
props.columns.forEach(col => {
if (!ordered.find(c => c.key === col.key)) {
ordered.push(col)
}
})
availableColumns.value = ordered
} else {
availableColumns.value = [...props.columns]
}
return config.visibility
} else {
// Old format - just visibility object
availableColumns.value = [...props.columns]
return config
}
}
// Default: Use column defaults
availableColumns.value = [...props.columns]
return props.columns.reduce((acc, col) => {
acc[col.key] = col.default !== false
return acc
}, {})
}
function saveColumnConfig() {
const config = {
visibility: visibleColumns.value,
order: availableColumns.value.map(col => col.key)
}
localStorage.setItem(props.storageKey, JSON.stringify(config))
columnConfigDialog.value = false
toast.add({
severity: 'success',
summary: 'Gespeichert',
detail: 'Spaltenkonfiguration wurde gespeichert',
life: 3000
})
}
async function loadData(params = {}) {
loading.value = true
try {
const url = new URL(props.dataSource, window.location.origin)
url.searchParams.append('itemsPerPage', '5000')
// Add custom params
Object.entries(params).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
url.searchParams.append(key, value)
}
})
const response = await fetch(url, {
credentials: 'include',
headers: {
'Accept': 'application/ld+json'
}
})
if (!response.ok) {
const error = await response.json()
throw new Error(error['hydra:description'] || 'Fehler beim Laden der Daten')
}
const json = await response.json()
data.value = json['hydra:member'] || json.member || json
emit('data-loaded', data.value)
} catch (error) {
console.error('Error loading data:', error)
toast.add({
severity: 'error',
summary: 'Fehler',
detail: error.message || 'Daten konnten nicht geladen werden',
life: 3000
})
} finally {
loading.value = false
}
}
// Initialize
onMounted(() => {
visibleColumns.value = loadColumnConfig()
// Initialize column-specific filters with operator structure for menu mode
props.columns.forEach(col => {
if (col.filterable !== false) {
internalFilters.value[col.key] = {
operator: FilterOperator.AND,
constraints: [{ value: null, matchMode: FilterMatchMode.STARTS_WITH }]
}
}
})
// Auto-load data
loadData()
})
// Expose loadData for parent component
defineExpose({ loadData })
</script>