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.
This commit is contained in:
parent
b417fdbf4c
commit
47b7099ba6
108
assets/js/components/ColumnConfigDialog.vue
Normal file
108
assets/js/components/ColumnConfigDialog.vue
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
:visible="visible"
|
||||||
|
@update:visible="$emit('update:visible', $event)"
|
||||||
|
header="Spalten anpassen"
|
||||||
|
:modal="true"
|
||||||
|
:style="{ width: '600px' }"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<p class="text-500 mb-2">Wählen Sie die anzuzeigenden Spalten aus und ziehen Sie sie, um die Reihenfolge zu ändern:</p>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div
|
||||||
|
v-for="(column, index) in localColumns"
|
||||||
|
:key="column.key"
|
||||||
|
class="flex items-center gap-2 p-3 border rounded-md hover-row cursor-move"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onDragStart(index)"
|
||||||
|
@dragover.prevent
|
||||||
|
@drop="onDrop(index)"
|
||||||
|
>
|
||||||
|
<i class="pi pi-bars text-500"></i>
|
||||||
|
<Checkbox
|
||||||
|
:inputId="'col-' + column.key"
|
||||||
|
v-model="localVisibility[column.key]"
|
||||||
|
:binary="true"
|
||||||
|
/>
|
||||||
|
<label :for="'col-' + column.key" class="cursor-pointer flex-1">{{ column.label }}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button label="Abbrechen" outlined @click="cancel" />
|
||||||
|
<Button label="Speichern" @click="save" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import Dialog from 'primevue/dialog'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import Checkbox from 'primevue/checkbox'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
visibleColumns: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:visible', 'update:columns', 'update:visibleColumns', 'save'])
|
||||||
|
|
||||||
|
// Local copies for editing
|
||||||
|
const localColumns = ref([...props.columns])
|
||||||
|
const localVisibility = ref({ ...props.visibleColumns })
|
||||||
|
|
||||||
|
// Watch for prop changes
|
||||||
|
watch(() => props.columns, (newVal) => {
|
||||||
|
localColumns.value = [...newVal]
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
watch(() => props.visibleColumns, (newVal) => {
|
||||||
|
localVisibility.value = { ...newVal }
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// Drag and drop for column reordering
|
||||||
|
let draggedIndex = null
|
||||||
|
|
||||||
|
const onDragStart = (index) => {
|
||||||
|
draggedIndex = index
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDrop = (dropIndex) => {
|
||||||
|
if (draggedIndex === null || draggedIndex === dropIndex) return
|
||||||
|
|
||||||
|
const columns = [...localColumns.value]
|
||||||
|
const draggedItem = columns[draggedIndex]
|
||||||
|
|
||||||
|
columns.splice(draggedIndex, 1)
|
||||||
|
columns.splice(dropIndex, 0, draggedItem)
|
||||||
|
|
||||||
|
localColumns.value = columns
|
||||||
|
draggedIndex = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
emit('update:columns', localColumns.value)
|
||||||
|
emit('update:visibleColumns', localVisibility.value)
|
||||||
|
emit('save')
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
// Reset local changes
|
||||||
|
localColumns.value = [...props.columns]
|
||||||
|
localVisibility.value = { ...props.visibleColumns }
|
||||||
|
emit('update:visible', false)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
343
assets/js/components/CrudDataTable.vue
Normal file
343
assets/js/components/CrudDataTable.vue
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
<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>
|
||||||
File diff suppressed because it is too large
Load Diff
1065
assets/js/views/ContactManagement_old.vue.backup
Normal file
1065
assets/js/views/ContactManagement_old.vue.backup
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user