feat: Add export functionality to CrudDataTable with clipboard, CSV, and Excel options
This commit is contained in:
parent
684d0deaaa
commit
b3e42b5eb5
0
.scannerwork/.sonar_lock
Normal file
0
.scannerwork/.sonar_lock
Normal file
6
.scannerwork/report-task.txt
Normal file
6
.scannerwork/report-task.txt
Normal 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
|
||||
@ -6,6 +6,15 @@
|
||||
<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"
|
||||
@ -28,11 +37,13 @@
|
||||
|
||||
<!-- 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>
|
||||
@ -145,7 +156,9 @@ 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: {
|
||||
@ -184,6 +197,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showExportButton: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
emptyMessage: {
|
||||
type: String,
|
||||
default: 'Keine Daten gefunden.'
|
||||
@ -197,9 +214,32 @@ const props = defineProps({
|
||||
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()
|
||||
|
||||
// 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(() => {
|
||||
@ -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
|
||||
onMounted(() => {
|
||||
visibleColumns.value = loadColumnConfig()
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
:show-view-button="canView"
|
||||
:show-edit-button="canEdit"
|
||||
:show-delete-button="canDelete"
|
||||
:show-export-button="canExport"
|
||||
@view="viewContact"
|
||||
@create="openNewContactDialog"
|
||||
@edit="editContact"
|
||||
@ -562,6 +563,7 @@ const canView = computed(() => permissionStore.canView('contacts'))
|
||||
const canCreate = computed(() => permissionStore.canCreate('contacts'))
|
||||
const canEdit = computed(() => permissionStore.canEdit('contacts'))
|
||||
const canDelete = computed(() => permissionStore.canDelete('contacts'))
|
||||
const canExport = computed(() => permissionStore.canExport('contacts'))
|
||||
|
||||
// Column definitions
|
||||
const contactColumns = [
|
||||
@ -578,8 +580,23 @@ const contactColumns = [
|
||||
{ key: 'website', label: 'Website', field: 'website', default: true },
|
||||
{ key: 'taxNumber', label: 'Steuernummer', field: 'taxNumber', 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: 'createdAt', label: 'Erstellt am', field: 'createdAt', default: false },
|
||||
{ key: 'updatedAt', label: 'Zuletzt geändert', field: 'updatedAt', default: false }
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
/* Sakai Layout Styles */
|
||||
@import 'primeicons/primeicons.css';
|
||||
@import './layout/layout.scss';
|
||||
@use 'primeicons/primeicons.css';
|
||||
@use './layout/layout.scss';
|
||||
|
||||
106
package-lock.json
generated
106
package-lock.json
generated
@ -10,7 +10,8 @@
|
||||
"pinia": "^2.2.0",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.3.0",
|
||||
"vue-router": "^4.5.0"
|
||||
"vue-router": "^4.5.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.17.0",
|
||||
@ -3878,6 +3879,15 @@
|
||||
"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": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
@ -4179,6 +4189,19 @@
|
||||
],
|
||||
"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": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
|
||||
@ -4251,6 +4274,15 @@
|
||||
"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": {
|
||||
"version": "1.9.3",
|
||||
"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": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@ -5062,6 +5106,15 @@
|
||||
"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": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||
@ -7381,6 +7434,18 @@
|
||||
"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": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
|
||||
@ -8350,6 +8415,45 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@ -24,7 +24,8 @@
|
||||
"pinia": "^2.2.0",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.3.0",
|
||||
"vue-router": "^4.5.0"
|
||||
"vue-router": "^4.5.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
|
||||
@ -60,7 +60,9 @@ Encore
|
||||
.enableSassLoader()
|
||||
|
||||
// Enable Vue.js support
|
||||
.enableVueLoader()
|
||||
.enableVueLoader(() => {}, {
|
||||
runtimeCompilerBuild: false
|
||||
})
|
||||
|
||||
// uncomment if you use TypeScript
|
||||
//.enableTypeScriptLoader()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user