- 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.
344 lines
9.7 KiB
Vue
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>
|