feat: Add export functionality to CrudDataTable with clipboard, CSV, and Excel options

This commit is contained in:
olli 2025-11-10 16:41:37 +01:00
parent 684d0deaaa
commit b3e42b5eb5
8 changed files with 387 additions and 7 deletions

0
.scannerwork/.sonar_lock Normal file
View File

View File

@ -0,0 +1,6 @@
projectKey=myCRM
serverUrl=http://localhost:9000
serverVersion=25.5.0.107428
dashboardUrl=http://localhost:9000/dashboard?id=myCRM
ceTaskId=b09db7ad-e11d-41a0-a032-52de4d0ca960
ceTaskUrl=http://localhost:9000/api/ce/task?id=b09db7ad-e11d-41a0-a032-52de4d0ca960

View File

@ -6,6 +6,15 @@
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<div class="font-semibold text-xl">{{ title }}</div> <div class="font-semibold text-xl">{{ title }}</div>
<div class="flex gap-2"> <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 <Button
v-if="enableColumnConfig" v-if="enableColumnConfig"
label="Spalten anpassen" label="Spalten anpassen"
@ -28,11 +37,13 @@
<!-- DataTable --> <!-- DataTable -->
<DataTable <DataTable
ref="dt"
v-model:filters="internalFilters" v-model:filters="internalFilters"
:value="data" :value="data"
:loading="loading" :loading="loading"
v-bind="mergedTableProps" v-bind="mergedTableProps"
filterDisplay="menu" filterDisplay="menu"
@filter="onFilter"
> >
<!-- Header mit Globaler Suche --> <!-- Header mit Globaler Suche -->
<template #header> <template #header>
@ -145,7 +156,9 @@ import InputText from 'primevue/inputtext'
import Card from 'primevue/card' import Card from 'primevue/card'
import IconField from 'primevue/iconfield' import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon' import InputIcon from 'primevue/inputicon'
import Menu from 'primevue/menu'
import ColumnConfigDialog from './ColumnConfigDialog.vue' import ColumnConfigDialog from './ColumnConfigDialog.vue'
import * as XLSX from 'xlsx'
const props = defineProps({ const props = defineProps({
title: { title: {
@ -184,6 +197,10 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: true default: true
}, },
showExportButton: {
type: Boolean,
default: false
},
emptyMessage: { emptyMessage: {
type: String, type: String,
default: 'Keine Daten gefunden.' default: 'Keine Daten gefunden.'
@ -197,9 +214,32 @@ const props = defineProps({
const emit = defineEmits(['create', 'edit', 'delete', 'view', 'data-loaded']) const emit = defineEmits(['create', 'edit', 'delete', 'view', 'data-loaded'])
const toast = useToast() const toast = useToast()
const dt = ref()
const data = ref([]) const data = ref([])
const filteredData = ref([])
const loading = ref(false) const loading = ref(false)
const columnConfigDialog = ref(false) const columnConfigDialog = ref(false)
const exportMenu = ref()
const exportButton = ref()
// 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 // Computed create button label
const createLabel = computed(() => { const createLabel = computed(() => {
@ -343,6 +383,216 @@ async function loadData(params = {}) {
} }
} }
// Track filtered data from DataTable
function onFilter(event) {
// PrimeVue's @filter event provides filteredValue
filteredData.value = event.filteredValue || []
}
// 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
})
}
}
// Initialize // Initialize
onMounted(() => { onMounted(() => {
visibleColumns.value = loadColumnConfig() visibleColumns.value = loadColumnConfig()

View File

@ -10,6 +10,7 @@
:show-view-button="canView" :show-view-button="canView"
:show-edit-button="canEdit" :show-edit-button="canEdit"
:show-delete-button="canDelete" :show-delete-button="canDelete"
:show-export-button="canExport"
@view="viewContact" @view="viewContact"
@create="openNewContactDialog" @create="openNewContactDialog"
@edit="editContact" @edit="editContact"
@ -562,6 +563,7 @@ const canView = computed(() => permissionStore.canView('contacts'))
const canCreate = computed(() => permissionStore.canCreate('contacts')) const canCreate = computed(() => permissionStore.canCreate('contacts'))
const canEdit = computed(() => permissionStore.canEdit('contacts')) const canEdit = computed(() => permissionStore.canEdit('contacts'))
const canDelete = computed(() => permissionStore.canDelete('contacts')) const canDelete = computed(() => permissionStore.canDelete('contacts'))
const canExport = computed(() => permissionStore.canExport('contacts'))
// Column definitions // Column definitions
const contactColumns = [ const contactColumns = [
@ -578,8 +580,23 @@ const contactColumns = [
{ key: 'website', label: 'Website', field: 'website', default: true }, { key: 'website', label: 'Website', field: 'website', default: true },
{ key: 'taxNumber', label: 'Steuernummer', field: 'taxNumber', default: false }, { key: 'taxNumber', label: 'Steuernummer', field: 'taxNumber', default: false },
{ key: 'vatNumber', label: 'USt-IdNr.', field: 'vatNumber', default: false }, { key: 'vatNumber', label: 'USt-IdNr.', field: 'vatNumber', default: false },
{ key: 'type', label: 'Typ (Debitor/Kreditor)', default: true }, {
{ key: 'status', label: 'Status', default: true }, key: 'type',
label: 'Typ (Debitor/Kreditor)',
default: true,
exportFormatter: (data) => {
const types = []
if (data.isDebtor) types.push('Debitor')
if (data.isCreditor) types.push('Kreditor')
return types.join(', ')
}
},
{
key: 'status',
label: 'Status',
default: true,
exportFormatter: (data) => data.isActive ? 'Aktiv' : 'Inaktiv'
},
{ key: 'notes', label: 'Notizen', field: 'notes', default: false }, { key: 'notes', label: 'Notizen', field: 'notes', default: false },
{ key: 'createdAt', label: 'Erstellt am', field: 'createdAt', default: false }, { key: 'createdAt', label: 'Erstellt am', field: 'createdAt', default: false },
{ key: 'updatedAt', label: 'Zuletzt geändert', field: 'updatedAt', default: false } { key: 'updatedAt', label: 'Zuletzt geändert', field: 'updatedAt', default: false }

View File

@ -1,3 +1,3 @@
/* Sakai Layout Styles */ /* Sakai Layout Styles */
@import 'primeicons/primeicons.css'; @use 'primeicons/primeicons.css';
@import './layout/layout.scss'; @use './layout/layout.scss';

106
package-lock.json generated
View File

@ -10,7 +10,8 @@
"pinia": "^2.2.0", "pinia": "^2.2.0",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primevue": "^4.3.0", "primevue": "^4.3.0",
"vue-router": "^4.5.0" "vue-router": "^4.5.0",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.17.0", "@babel/core": "^7.17.0",
@ -3878,6 +3879,15 @@
"node": ">=8.9" "node": ">=8.9"
} }
}, },
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "8.17.1", "version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
@ -4179,6 +4189,19 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
@ -4251,6 +4274,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "1.9.3", "version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
@ -4362,6 +4394,18 @@
} }
} }
}, },
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -5062,6 +5106,15 @@
"flat": "cli.js" "flat": "cli.js"
} }
}, },
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fraction.js": { "node_modules/fraction.js": {
"version": "4.3.7", "version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@ -7381,6 +7434,18 @@
"source-map": "^0.6.0" "source-map": "^0.6.0"
} }
}, },
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stackframe": { "node_modules/stackframe": {
"version": "1.3.4", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
@ -8350,6 +8415,45 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@ -24,7 +24,8 @@
"pinia": "^2.2.0", "pinia": "^2.2.0",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primevue": "^4.3.0", "primevue": "^4.3.0",
"vue-router": "^4.5.0" "vue-router": "^4.5.0",
"xlsx": "^0.18.5"
}, },
"license": "UNLICENSED", "license": "UNLICENSED",
"private": true, "private": true,

View File

@ -60,7 +60,9 @@ Encore
.enableSassLoader() .enableSassLoader()
// Enable Vue.js support // Enable Vue.js support
.enableVueLoader() .enableVueLoader(() => {}, {
runtimeCompilerBuild: false
})
// uncomment if you use TypeScript // uncomment if you use TypeScript
//.enableTypeScriptLoader() //.enableTypeScriptLoader()