- Added ProjectTask entity with fields for name, description, budget, hour contingent, hourly rate, and total price. - Created ProjectTaskRepository with methods for querying tasks by project and user access. - Implemented ProjectTaskVoter for fine-grained access control based on user roles and project membership. - Developed ProjectTaskSecurityListener to enforce permission checks during task creation. - Introduced custom ProjectTaskProjectFilter for filtering tasks based on project existence. - Integrated ProjectTask management in the frontend with Vue.js components, including CRUD operations and filtering capabilities. - Added API endpoints for ProjectTask with appropriate security measures. - Created migration for project_tasks table in the database. - Updated documentation to reflect new module features and usage.
794 lines
24 KiB
Vue
794 lines
24 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
|
|
v-if="showCreateButton"
|
|
: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"
|
|
:filterField="column.filterField || column.field || column.key"
|
|
>
|
|
<!-- 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: ''
|
|
},
|
|
entityNameArticle: {
|
|
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
|
|
},
|
|
showCreateButton: {
|
|
type: Boolean,
|
|
default: true
|
|
},
|
|
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) {
|
|
// If custom article is provided, use it
|
|
if (props.entityNameArticle) {
|
|
const suffix = props.entityNameArticle === 'ein' ? 'es' :
|
|
props.entityNameArticle === 'eine' ? 'e' : 'er'
|
|
return `Neu${suffix} ${props.entityName}`
|
|
}
|
|
// Fallback: Simple heuristic
|
|
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) {
|
|
// Use filterField if specified, otherwise use field or key
|
|
const filterKey = col.filterField || col.field || col.key
|
|
|
|
if (!internalFilters.value[filterKey]) {
|
|
internalFilters.value[filterKey] = {
|
|
operator: FilterOperator.AND,
|
|
constraints: [{ value: null, matchMode: FilterMatchMode.STARTS_WITH }]
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
// Auto-load data
|
|
loadData()
|
|
})
|
|
|
|
// Expose loadData for parent component
|
|
defineExpose({ loadData })
|
|
</script>
|