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