myCRM/assets/js/components/CrudDataTable.vue
olli 3e30d958b3 feat: Implement password reset functionality
- Added PasswordResetController to handle password reset requests and confirmations.
- Created views for password reset request, confirmation, and invalid link scenarios.
- Implemented email sending for password reset requests with security measures.
- Added form validation for password reset confirmation, including password strength requirements.
- Enhanced user experience with success and error messages during the password reset process.
2025-11-11 14:31:40 +01:00

772 lines
23 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="showExportButton"
label="Exportieren"
icon="pi pi-download"
outlined
@click="exportMenu.toggle($event)"
ref="exportButton"
/>
<Menu ref="exportMenu" :model="exportItems" :popup="true" />
<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>
<!-- Filter Active Indicator -->
<div v-if="hasActiveFilters" class="mb-3 p-3 bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg flex items-center justify-between">
<div class="flex items-center gap-2">
<i class="pi pi-filter text-primary-600 dark:text-primary-400"></i>
<span class="text-sm text-primary-900 dark:text-primary-100">
<strong>{{ filterCount }}</strong> {{ filterCount === 1 ? 'Filter aktiv' : 'Filter aktiv' }}
<span v-if="filteredData.length > 0" class="text-primary-700 dark:text-primary-300">
- {{ filteredData.length }} von {{ data.length }} {{ data.length === 1 ? 'Datensatz' : 'Datensätze' }} angezeigt
</span>
</span>
</div>
<Button
label="Filter zurücksetzen"
icon="pi pi-filter-slash"
text
size="small"
severity="secondary"
@click="clearAllFilters"
/>
</div>
<!-- DataTable -->
<DataTable
ref="dt"
v-model:filters="internalFilters"
:value="data"
:loading="loading"
v-bind="mergedTableProps"
filterDisplay="menu"
@filter="onFilter"
>
<!-- 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, index) 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"
:frozen="freezeFirstColumn && index === 0"
:alignFrozen="freezeFirstColumn && index === 0 ? 'left' : undefined"
>
<!-- 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
v-if="showViewButton"
icon="pi pi-eye"
outlined
rounded
@click="$emit('view', slotProps.data)"
size="small"
severity="secondary"
/>
<Button
v-if="showEditButton"
icon="pi pi-pencil"
outlined
rounded
@click="$emit('edit', slotProps.data)"
size="small"
/>
<Button
v-if="showDeleteButton"
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"
v-model:freeze-first-column="freezeFirstColumn"
@save="saveColumnConfig"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } 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 Menu from 'primevue/menu'
import ColumnConfigDialog from './ColumnConfigDialog.vue'
import * as XLSX from 'xlsx'
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
},
showViewButton: {
type: Boolean,
default: false
},
showEditButton: {
type: Boolean,
default: true
},
showDeleteButton: {
type: Boolean,
default: true
},
showExportButton: {
type: Boolean,
default: false
},
emptyMessage: {
type: String,
default: 'Keine Daten gefunden.'
},
tableProps: {
type: Object,
default: () => ({})
}
})
const emit = defineEmits(['create', 'edit', 'delete', 'view', 'data-loaded'])
const toast = useToast()
const dt = ref()
const data = ref([])
const filteredData = ref([])
const loading = ref(false)
const columnConfigDialog = ref(false)
const exportMenu = ref()
const exportButton = ref()
const freezeFirstColumn = ref(false)
// Check if any filters are active
const hasActiveFilters = computed(() => {
// Check global filter
if (internalFilters.value.global?.value) {
return true
}
// Check column filters
return Object.keys(internalFilters.value).some(key => {
if (key === 'global') return false
const filter = internalFilters.value[key]
if (filter?.constraints) {
return filter.constraints.some(c => c.value !== null && c.value !== undefined && c.value !== '')
}
return false
})
})
const filterCount = computed(() => {
let count = 0
// Count global filter
if (internalFilters.value.global?.value) {
count++
}
// Count column filters
Object.keys(internalFilters.value).forEach(key => {
if (key === 'global') return
const filter = internalFilters.value[key]
if (filter?.constraints) {
const hasValue = filter.constraints.some(c => c.value !== null && c.value !== undefined && c.value !== '')
if (hasValue) count++
}
})
return count
})
// Export Menu Items
const exportItems = computed(() => [
{
label: 'In Zwischenablage kopieren',
icon: 'pi pi-copy',
command: () => exportToClipboard()
},
{
label: 'Als CSV exportieren',
icon: 'pi pi-file',
command: () => exportToCSV()
},
{
label: 'Als Excel exportieren',
icon: 'pi pi-file-excel',
command: () => exportToExcel()
}
])
// 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)
// Load filters if available - will be applied in onMounted
if (config.filters) {
// Deep merge filters into internalFilters
Object.keys(config.filters).forEach(key => {
const savedFilter = config.filters[key]
if (savedFilter) {
// Handle both constraint-based (menu) and value-based (global) filters
if (savedFilter.constraints) {
// Menu filter with constraints
const hasValue = savedFilter.constraints.some(c => c.value !== null && c.value !== undefined && c.value !== '')
if (hasValue) {
internalFilters.value[key] = JSON.parse(JSON.stringify(savedFilter))
}
} else if (key === 'global' && (savedFilter.value !== null && savedFilter.value !== undefined && savedFilter.value !== '')) {
// Global filter
internalFilters.value[key] = { ...savedFilter }
}
}
})
}
// Load freeze first column setting
if (config.freezeFirstColumn !== undefined) {
freezeFirstColumn.value = config.freezeFirstColumn
}
// 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),
filters: internalFilters.value,
freezeFirstColumn: freezeFirstColumn.value
}
localStorage.setItem(props.storageKey, JSON.stringify(config))
columnConfigDialog.value = false
toast.add({
severity: 'success',
summary: 'Gespeichert',
detail: 'Spaltenkonfiguration wurde gespeichert',
life: 3000
})
}
// Save filters to localStorage whenever they change
function saveFilters() {
const saved = localStorage.getItem(props.storageKey)
const config = saved ? JSON.parse(saved) : {
visibility: visibleColumns.value,
order: availableColumns.value.map(col => col.key)
}
// Clean up filters - only save non-empty values
const cleanedFilters = {}
Object.keys(internalFilters.value).forEach(key => {
const filter = internalFilters.value[key]
if (filter) {
if (filter.constraints) {
// Menu filter - only save if has actual values
const hasValue = filter.constraints.some(c => c.value !== null && c.value !== undefined && c.value !== '')
if (hasValue) {
cleanedFilters[key] = filter
}
} else if (key === 'global' && filter.value !== null && filter.value !== undefined && filter.value !== '') {
// Global filter
cleanedFilters[key] = filter
}
}
})
config.filters = cleanedFilters
localStorage.setItem(props.storageKey, JSON.stringify(config))
}
function clearAllFilters() {
// Reset global filter
internalFilters.value.global = { value: null, matchMode: FilterMatchMode.CONTAINS }
// Reset all column filters
Object.keys(internalFilters.value).forEach(key => {
if (key !== 'global' && internalFilters.value[key]?.constraints) {
internalFilters.value[key].constraints = [{ value: null, matchMode: FilterMatchMode.STARTS_WITH }]
}
})
// Clear filtered data
filteredData.value = []
// Save cleared filters
saveFilters()
toast.add({
severity: 'info',
summary: 'Filter zurückgesetzt',
detail: 'Alle Filter wurden entfernt',
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
}
}
// Track filtered data from DataTable
function onFilter(event) {
// PrimeVue's @filter event provides filteredValue
filteredData.value = event.filteredValue || []
// Save filters to localStorage
saveFilters()
}
// Export Functions
function getExportData() {
// Get only visible columns
const visibleCols = visibleColumnsOrdered.value
// Use filtered data if available, otherwise use all data
const dataToExport = filteredData.value.length > 0 || Object.keys(internalFilters.value).some(k => k !== 'global' && internalFilters.value[k]?.value) || internalFilters.value.global?.value
? filteredData.value
: data.value
// Helper function to format objects for export
const formatObject = (obj) => {
if (!obj || typeof obj !== 'object') return obj
// For contact persons, format as "Salutation FirstName LastName (Position)"
if (obj.firstName && obj.lastName) {
const parts = []
if (obj.salutation) parts.push(obj.salutation)
if (obj.firstName) parts.push(obj.firstName)
if (obj.lastName) parts.push(obj.lastName)
if (obj.position) parts.push(`(${obj.position})`)
return parts.join(' ')
}
// For other objects, try to find a meaningful representation
if (obj.name) return obj.name
if (obj.title) return obj.title
if (obj.label) return obj.label
// Fallback: return first non-id property value
const keys = Object.keys(obj).filter(k => !k.startsWith('@') && k !== 'id')
if (keys.length > 0) return obj[keys[0]]
return ''
}
// Prepare export data from filtered data
return dataToExport.map(row => {
const exportRow = {}
visibleCols.forEach(col => {
// Check if column has custom export formatter
if (col.exportFormatter && typeof col.exportFormatter === 'function') {
exportRow[col.label] = col.exportFormatter(row)
return
}
const value = getNestedValue(row, col.field || col.key)
// Format arrays (e.g., contactPersons)
if (Array.isArray(value)) {
exportRow[col.label] = value.map(v => formatObject(v)).filter(v => v).join(', ')
} else if (typeof value === 'boolean') {
exportRow[col.label] = value ? 'Ja' : 'Nein'
} else if (value instanceof Date) {
exportRow[col.label] = value.toLocaleDateString('de-DE')
} else if (typeof value === 'object' && value !== null) {
exportRow[col.label] = formatObject(value)
} else {
exportRow[col.label] = value ?? ''
}
})
return exportRow
})
}
function exportToClipboard() {
try {
const exportData = getExportData()
// Convert to TSV (Tab-Separated Values) for better Excel paste compatibility
const headers = Object.keys(exportData[0] || {})
const tsv = [
headers.join('\t'),
...exportData.map(row => headers.map(h => row[h]).join('\t'))
].join('\n')
navigator.clipboard.writeText(tsv).then(() => {
toast.add({
severity: 'success',
summary: 'Kopiert',
detail: `${exportData.length} Zeilen in die Zwischenablage kopiert`,
life: 3000
})
})
} catch (error) {
console.error('Export error:', error)
toast.add({
severity: 'error',
summary: 'Fehler',
detail: 'Fehler beim Kopieren in die Zwischenablage',
life: 3000
})
}
}
function exportToCSV() {
try {
const exportData = getExportData()
if (exportData.length === 0) {
toast.add({
severity: 'warn',
summary: 'Keine Daten',
detail: 'Keine Daten zum Exportieren vorhanden',
life: 3000
})
return
}
// Convert to CSV
const headers = Object.keys(exportData[0])
const csvContent = [
headers.join(';'),
...exportData.map(row =>
headers.map(h => {
const value = row[h]
// Escape quotes and wrap in quotes if contains comma or newline
if (typeof value === 'string' && (value.includes(';') || value.includes('\n') || value.includes('"'))) {
return `"${value.replace(/"/g, '""')}"`
}
return value
}).join(';')
)
].join('\n')
// Add BOM for Excel UTF-8 compatibility
const BOM = '\uFEFF'
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `${props.title.replace(/\s+/g, '_')}_${new Date().toISOString().split('T')[0]}.csv`)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
toast.add({
severity: 'success',
summary: 'Exportiert',
detail: `${exportData.length} Zeilen als CSV exportiert`,
life: 3000
})
} catch (error) {
console.error('Export error:', error)
toast.add({
severity: 'error',
summary: 'Fehler',
detail: 'Fehler beim CSV-Export',
life: 3000
})
}
}
function exportToExcel() {
try {
const exportData = getExportData()
if (exportData.length === 0) {
toast.add({
severity: 'warn',
summary: 'Keine Daten',
detail: 'Keine Daten zum Exportieren vorhanden',
life: 3000
})
return
}
// Create worksheet
const ws = XLSX.utils.json_to_sheet(exportData)
// Auto-size columns
const colWidths = Object.keys(exportData[0]).map(key => ({
wch: Math.max(
key.length,
...exportData.map(row => String(row[key] ?? '').length)
)
}))
ws['!cols'] = colWidths
// Create workbook
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, props.title.substring(0, 31)) // Excel sheet name max 31 chars
// Generate Excel file
XLSX.writeFile(wb, `${props.title.replace(/\s+/g, '_')}_${new Date().toISOString().split('T')[0]}.xlsx`)
toast.add({
severity: 'success',
summary: 'Exportiert',
detail: `${exportData.length} Zeilen als Excel exportiert`,
life: 3000
})
} catch (error) {
console.error('Export error:', error)
toast.add({
severity: 'error',
summary: 'Fehler',
detail: 'Fehler beim Excel-Export',
life: 3000
})
}
}
// Watch for changes in global filter to save automatically
watch(() => internalFilters.value.global?.value, () => {
saveFilters()
}, { deep: true })
// 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]) {
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>