968 lines
31 KiB
Python
968 lines
31 KiB
Python
"""
|
|
Erweitertes Tabellen-System für das MYP-System
|
|
=============================================
|
|
|
|
Dieses Modul stellt erweiterte Tabellen-Funktionalität bereit:
|
|
- Sortierung nach allen Spalten
|
|
- Erweiterte Filter-Optionen
|
|
- Pagination mit anpassbaren Seitengrößen
|
|
- Spalten-Auswahl und -anpassung
|
|
- Export-Funktionen
|
|
- Responsive Design
|
|
"""
|
|
|
|
import re
|
|
import json
|
|
import math
|
|
from datetime import datetime, timedelta
|
|
from typing import Dict, List, Any, Optional, Tuple, Union, Callable
|
|
from dataclasses import dataclass, asdict
|
|
from enum import Enum
|
|
from flask import request, jsonify
|
|
from sqlalchemy import func, text, or_, and_
|
|
from sqlalchemy.orm import Query
|
|
|
|
from utils.logging_config import get_logger
|
|
from models import Job, User, Printer, GuestRequest, get_db_session
|
|
|
|
logger = get_logger("advanced_tables")
|
|
|
|
class SortDirection(Enum):
|
|
ASC = "asc"
|
|
DESC = "desc"
|
|
|
|
class FilterOperator(Enum):
|
|
EQUALS = "eq"
|
|
NOT_EQUALS = "ne"
|
|
CONTAINS = "contains"
|
|
NOT_CONTAINS = "not_contains"
|
|
STARTS_WITH = "starts_with"
|
|
ENDS_WITH = "ends_with"
|
|
GREATER_THAN = "gt"
|
|
LESS_THAN = "lt"
|
|
GREATER_EQUAL = "gte"
|
|
LESS_EQUAL = "lte"
|
|
BETWEEN = "between"
|
|
IN = "in"
|
|
NOT_IN = "not_in"
|
|
IS_NULL = "is_null"
|
|
IS_NOT_NULL = "is_not_null"
|
|
|
|
@dataclass
|
|
class SortConfig:
|
|
"""Sortierung-Konfiguration"""
|
|
column: str
|
|
direction: SortDirection = SortDirection.ASC
|
|
|
|
@dataclass
|
|
class FilterConfig:
|
|
"""Filter-Konfiguration"""
|
|
column: str
|
|
operator: FilterOperator
|
|
value: Any = None
|
|
values: List[Any] = None
|
|
|
|
@dataclass
|
|
class PaginationConfig:
|
|
"""Pagination-Konfiguration"""
|
|
page: int = 1
|
|
page_size: int = 25
|
|
max_page_size: int = 100
|
|
|
|
@dataclass
|
|
class ColumnConfig:
|
|
"""Spalten-Konfiguration"""
|
|
key: str
|
|
label: str
|
|
sortable: bool = True
|
|
filterable: bool = True
|
|
searchable: bool = True
|
|
visible: bool = True
|
|
width: Optional[str] = None
|
|
align: str = "left" # left, center, right
|
|
format_type: str = "text" # text, number, date, datetime, boolean, currency
|
|
format_options: Dict[str, Any] = None
|
|
|
|
@dataclass
|
|
class TableConfig:
|
|
"""Gesamt-Tabellen-Konfiguration"""
|
|
table_id: str
|
|
columns: List[ColumnConfig]
|
|
default_sort: List[SortConfig] = None
|
|
default_filters: List[FilterConfig] = None
|
|
pagination: PaginationConfig = None
|
|
searchable: bool = True
|
|
exportable: bool = True
|
|
selectable: bool = False
|
|
row_actions: List[Dict[str, Any]] = None
|
|
|
|
class AdvancedTableQuery:
|
|
"""Builder für erweiterte Tabellen-Abfragen"""
|
|
|
|
def __init__(self, base_query: Query, model_class):
|
|
self.base_query = base_query
|
|
self.model_class = model_class
|
|
self.filters = []
|
|
self.sorts = []
|
|
self.search_term = None
|
|
self.search_columns = []
|
|
|
|
def add_filter(self, filter_config: FilterConfig):
|
|
"""Fügt einen Filter hinzu"""
|
|
self.filters.append(filter_config)
|
|
return self
|
|
|
|
def add_sort(self, sort_config: SortConfig):
|
|
"""Fügt eine Sortierung hinzu"""
|
|
self.sorts.append(sort_config)
|
|
return self
|
|
|
|
def set_search(self, term: str, columns: List[str]):
|
|
"""Setzt globale Suche"""
|
|
self.search_term = term
|
|
self.search_columns = columns
|
|
return self
|
|
|
|
def build_query(self) -> Query:
|
|
"""Erstellt die finale Query"""
|
|
query = self.base_query
|
|
|
|
# Filter anwenden
|
|
for filter_config in self.filters:
|
|
query = self._apply_filter(query, filter_config)
|
|
|
|
# Globale Suche anwenden
|
|
if self.search_term and self.search_columns:
|
|
query = self._apply_search(query)
|
|
|
|
# Sortierung anwenden
|
|
for sort_config in self.sorts:
|
|
query = self._apply_sort(query, sort_config)
|
|
|
|
return query
|
|
|
|
def _apply_filter(self, query: Query, filter_config: FilterConfig) -> Query:
|
|
"""Wendet einen Filter auf die Query an"""
|
|
column = getattr(self.model_class, filter_config.column, None)
|
|
if not column:
|
|
logger.warning(f"Spalte {filter_config.column} nicht gefunden in {self.model_class}")
|
|
return query
|
|
|
|
op = filter_config.operator
|
|
value = filter_config.value
|
|
values = filter_config.values
|
|
|
|
if op == FilterOperator.EQUALS:
|
|
return query.filter(column == value)
|
|
elif op == FilterOperator.NOT_EQUALS:
|
|
return query.filter(column != value)
|
|
elif op == FilterOperator.CONTAINS:
|
|
return query.filter(column.ilike(f"%{value}%"))
|
|
elif op == FilterOperator.NOT_CONTAINS:
|
|
return query.filter(~column.ilike(f"%{value}%"))
|
|
elif op == FilterOperator.STARTS_WITH:
|
|
return query.filter(column.ilike(f"{value}%"))
|
|
elif op == FilterOperator.ENDS_WITH:
|
|
return query.filter(column.ilike(f"%{value}"))
|
|
elif op == FilterOperator.GREATER_THAN:
|
|
return query.filter(column > value)
|
|
elif op == FilterOperator.LESS_THAN:
|
|
return query.filter(column < value)
|
|
elif op == FilterOperator.GREATER_EQUAL:
|
|
return query.filter(column >= value)
|
|
elif op == FilterOperator.LESS_EQUAL:
|
|
return query.filter(column <= value)
|
|
elif op == FilterOperator.BETWEEN and values and len(values) >= 2:
|
|
return query.filter(column.between(values[0], values[1]))
|
|
elif op == FilterOperator.IN and values:
|
|
return query.filter(column.in_(values))
|
|
elif op == FilterOperator.NOT_IN and values:
|
|
return query.filter(~column.in_(values))
|
|
elif op == FilterOperator.IS_NULL:
|
|
return query.filter(column.is_(None))
|
|
elif op == FilterOperator.IS_NOT_NULL:
|
|
return query.filter(column.isnot(None))
|
|
|
|
return query
|
|
|
|
def _apply_search(self, query: Query) -> Query:
|
|
"""Wendet globale Suche an"""
|
|
if not self.search_term or not self.search_columns:
|
|
return query
|
|
|
|
search_conditions = []
|
|
for column_name in self.search_columns:
|
|
column = getattr(self.model_class, column_name, None)
|
|
if column:
|
|
# Konvertiere zu String für Suche in numerischen Spalten
|
|
search_conditions.append(
|
|
func.cast(column, sqlalchemy.String).ilike(f"%{self.search_term}%")
|
|
)
|
|
|
|
if search_conditions:
|
|
return query.filter(or_(*search_conditions))
|
|
|
|
return query
|
|
|
|
def _apply_sort(self, query: Query, sort_config: SortConfig) -> Query:
|
|
"""Wendet Sortierung an"""
|
|
column = getattr(self.model_class, sort_config.column, None)
|
|
if not column:
|
|
logger.warning(f"Spalte {sort_config.column} für Sortierung nicht gefunden")
|
|
return query
|
|
|
|
if sort_config.direction == SortDirection.DESC:
|
|
return query.order_by(column.desc())
|
|
else:
|
|
return query.order_by(column.asc())
|
|
|
|
class TableDataProcessor:
|
|
"""Verarbeitet Tabellendaten für die Ausgabe"""
|
|
|
|
def __init__(self, config: TableConfig):
|
|
self.config = config
|
|
|
|
def process_data(self, data: List[Any]) -> List[Dict[str, Any]]:
|
|
"""Verarbeitet rohe Daten für Tabellen-Ausgabe"""
|
|
processed_rows = []
|
|
|
|
for item in data:
|
|
row = {}
|
|
for column in self.config.columns:
|
|
if not column.visible:
|
|
continue
|
|
|
|
# Wert extrahieren
|
|
value = self._extract_value(item, column.key)
|
|
|
|
# Formatieren
|
|
formatted_value = self._format_value(value, column)
|
|
|
|
row[column.key] = {
|
|
'raw': value,
|
|
'formatted': formatted_value,
|
|
'sortable': column.sortable,
|
|
'filterable': column.filterable
|
|
}
|
|
|
|
# Row Actions hinzufügen
|
|
if self.config.row_actions:
|
|
row['_actions'] = self._get_row_actions(item)
|
|
|
|
# Row Metadata
|
|
row['_id'] = getattr(item, 'id', None)
|
|
row['_type'] = item.__class__.__name__.lower()
|
|
|
|
processed_rows.append(row)
|
|
|
|
return processed_rows
|
|
|
|
def _extract_value(self, item: Any, key: str) -> Any:
|
|
"""Extrahiert Wert aus einem Objekt"""
|
|
try:
|
|
# Unterstützung für verschachtelte Attribute (z.B. "user.name")
|
|
if '.' in key:
|
|
obj = item
|
|
for part in key.split('.'):
|
|
obj = getattr(obj, part, None)
|
|
if obj is None:
|
|
break
|
|
return obj
|
|
else:
|
|
return getattr(item, key, None)
|
|
except AttributeError:
|
|
return None
|
|
|
|
def _format_value(self, value: Any, column: ColumnConfig) -> str:
|
|
"""Formatiert einen Wert basierend auf dem Spaltentyp"""
|
|
if value is None:
|
|
return ""
|
|
|
|
format_type = column.format_type
|
|
options = column.format_options or {}
|
|
|
|
if format_type == "date" and isinstance(value, datetime):
|
|
date_format = options.get('format', '%d.%m.%Y')
|
|
return value.strftime(date_format)
|
|
|
|
elif format_type == "datetime" and isinstance(value, datetime):
|
|
datetime_format = options.get('format', '%d.%m.%Y %H:%M')
|
|
return value.strftime(datetime_format)
|
|
|
|
elif format_type == "number" and isinstance(value, (int, float)):
|
|
decimals = options.get('decimals', 0)
|
|
return f"{value:.{decimals}f}"
|
|
|
|
elif format_type == "currency" and isinstance(value, (int, float)):
|
|
currency = options.get('currency', '€')
|
|
decimals = options.get('decimals', 2)
|
|
return f"{value:.{decimals}f} {currency}"
|
|
|
|
elif format_type == "boolean":
|
|
true_text = options.get('true_text', 'Ja')
|
|
false_text = options.get('false_text', 'Nein')
|
|
return true_text if value else false_text
|
|
|
|
elif format_type == "truncate":
|
|
max_length = options.get('max_length', 50)
|
|
text = str(value)
|
|
if len(text) > max_length:
|
|
return text[:max_length-3] + "..."
|
|
return text
|
|
|
|
return str(value)
|
|
|
|
def _get_row_actions(self, item: Any) -> List[Dict[str, Any]]:
|
|
"""Generiert verfügbare Aktionen für eine Zeile"""
|
|
actions = []
|
|
|
|
for action_config in self.config.row_actions:
|
|
# Prüfe Bedingungen für Aktion
|
|
if self._check_action_condition(item, action_config):
|
|
actions.append({
|
|
'type': action_config['type'],
|
|
'label': action_config['label'],
|
|
'icon': action_config.get('icon'),
|
|
'url': self._build_action_url(item, action_config),
|
|
'method': action_config.get('method', 'GET'),
|
|
'confirm': action_config.get('confirm'),
|
|
'class': action_config.get('class', '')
|
|
})
|
|
|
|
return actions
|
|
|
|
def _check_action_condition(self, item: Any, action_config: Dict[str, Any]) -> bool:
|
|
"""Prüft ob eine Aktion für ein Item verfügbar ist"""
|
|
condition = action_config.get('condition')
|
|
if not condition:
|
|
return True
|
|
|
|
try:
|
|
# Einfache Bedingungsprüfung
|
|
if isinstance(condition, dict):
|
|
for key, expected_value in condition.items():
|
|
actual_value = self._extract_value(item, key)
|
|
if actual_value != expected_value:
|
|
return False
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
def _build_action_url(self, item: Any, action_config: Dict[str, Any]) -> str:
|
|
"""Erstellt URL für eine Aktion"""
|
|
url_template = action_config.get('url', '')
|
|
|
|
# Ersetze Platzhalter in URL
|
|
try:
|
|
return url_template.format(id=getattr(item, 'id', ''))
|
|
except Exception:
|
|
return url_template
|
|
|
|
def parse_table_request(request_data: Dict[str, Any]) -> Tuple[List[SortConfig], List[FilterConfig], PaginationConfig, str]:
|
|
"""Parst Tabellen-Request-Parameter"""
|
|
|
|
# Sortierung parsen
|
|
sorts = []
|
|
sort_data = request_data.get('sort', [])
|
|
if isinstance(sort_data, dict):
|
|
sort_data = [sort_data]
|
|
|
|
for sort_item in sort_data:
|
|
if isinstance(sort_item, dict):
|
|
column = sort_item.get('column')
|
|
direction = SortDirection(sort_item.get('direction', 'asc'))
|
|
if column:
|
|
sorts.append(SortConfig(column=column, direction=direction))
|
|
|
|
# Filter parsen
|
|
filters = []
|
|
filter_data = request_data.get('filters', [])
|
|
if isinstance(filter_data, dict):
|
|
filter_data = [filter_data]
|
|
|
|
for filter_item in filter_data:
|
|
if isinstance(filter_item, dict):
|
|
column = filter_item.get('column')
|
|
operator = FilterOperator(filter_item.get('operator', 'eq'))
|
|
value = filter_item.get('value')
|
|
values = filter_item.get('values')
|
|
|
|
if column:
|
|
filters.append(FilterConfig(
|
|
column=column,
|
|
operator=operator,
|
|
value=value,
|
|
values=values
|
|
))
|
|
|
|
# Pagination parsen
|
|
page = int(request_data.get('page', 1))
|
|
page_size = min(int(request_data.get('page_size', 25)), 100)
|
|
pagination = PaginationConfig(page=page, page_size=page_size)
|
|
|
|
# Suche parsen
|
|
search = request_data.get('search', '')
|
|
|
|
return sorts, filters, pagination, search
|
|
|
|
def get_advanced_table_javascript() -> str:
|
|
"""JavaScript für erweiterte Tabellen"""
|
|
return """
|
|
class AdvancedTable {
|
|
constructor(tableId, config = {}) {
|
|
this.tableId = tableId;
|
|
this.config = {
|
|
apiUrl: '/api/table-data',
|
|
pageSize: 25,
|
|
searchDelay: 500,
|
|
sortable: true,
|
|
filterable: true,
|
|
searchable: true,
|
|
...config
|
|
};
|
|
|
|
this.currentSort = [];
|
|
this.currentFilters = [];
|
|
this.currentPage = 1;
|
|
this.currentSearch = '';
|
|
this.totalPages = 1;
|
|
this.totalItems = 0;
|
|
|
|
this.searchTimeout = null;
|
|
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.setupTable();
|
|
this.setupEventListeners();
|
|
this.loadData();
|
|
}
|
|
|
|
setupTable() {
|
|
const table = document.getElementById(this.tableId);
|
|
if (!table) return;
|
|
|
|
table.classList.add('advanced-table');
|
|
|
|
// Add table wrapper
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'table-wrapper';
|
|
table.parentNode.insertBefore(wrapper, table);
|
|
wrapper.appendChild(table);
|
|
|
|
// Add controls
|
|
this.createControls(wrapper);
|
|
}
|
|
|
|
createControls(wrapper) {
|
|
const controls = document.createElement('div');
|
|
controls.className = 'table-controls';
|
|
controls.innerHTML = `
|
|
<div class="table-controls-left">
|
|
<div class="search-box">
|
|
<input type="text" id="${this.tableId}-search" placeholder="Suchen..." class="search-input">
|
|
<span class="search-icon">🔍</span>
|
|
</div>
|
|
<div class="page-size-selector">
|
|
<label>Einträge pro Seite:</label>
|
|
<select id="${this.tableId}-page-size">
|
|
<option value="10">10</option>
|
|
<option value="25" selected>25</option>
|
|
<option value="50">50</option>
|
|
<option value="100">100</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="table-controls-right">
|
|
<button class="btn-filter" id="${this.tableId}-filter-btn">Filter</button>
|
|
<button class="btn-export" id="${this.tableId}-export-btn">Export</button>
|
|
<button class="btn-refresh" id="${this.tableId}-refresh-btn">↻</button>
|
|
</div>
|
|
`;
|
|
|
|
wrapper.insertBefore(controls, wrapper.firstChild);
|
|
|
|
// Add pagination
|
|
const pagination = document.createElement('div');
|
|
pagination.className = 'table-pagination';
|
|
pagination.id = `${this.tableId}-pagination`;
|
|
wrapper.appendChild(pagination);
|
|
}
|
|
|
|
setupEventListeners() {
|
|
// Search
|
|
const searchInput = document.getElementById(`${this.tableId}-search`);
|
|
searchInput?.addEventListener('input', (e) => {
|
|
clearTimeout(this.searchTimeout);
|
|
this.searchTimeout = setTimeout(() => {
|
|
this.currentSearch = e.target.value;
|
|
this.currentPage = 1;
|
|
this.loadData();
|
|
}, this.config.searchDelay);
|
|
});
|
|
|
|
// Page size
|
|
const pageSizeSelect = document.getElementById(`${this.tableId}-page-size`);
|
|
pageSizeSelect?.addEventListener('change', (e) => {
|
|
this.config.pageSize = parseInt(e.target.value);
|
|
this.currentPage = 1;
|
|
this.loadData();
|
|
});
|
|
|
|
// Refresh
|
|
const refreshBtn = document.getElementById(`${this.tableId}-refresh-btn`);
|
|
refreshBtn?.addEventListener('click', () => {
|
|
this.loadData();
|
|
});
|
|
|
|
// Export
|
|
const exportBtn = document.getElementById(`${this.tableId}-export-btn`);
|
|
exportBtn?.addEventListener('click', () => {
|
|
this.exportData();
|
|
});
|
|
|
|
// Table header clicks (sorting)
|
|
const table = document.getElementById(this.tableId);
|
|
table?.addEventListener('click', (e) => {
|
|
const th = e.target.closest('th[data-sortable="true"]');
|
|
if (th) {
|
|
const column = th.dataset.column;
|
|
this.toggleSort(column);
|
|
}
|
|
});
|
|
}
|
|
|
|
toggleSort(column) {
|
|
const existingSort = this.currentSort.find(s => s.column === column);
|
|
|
|
if (existingSort) {
|
|
if (existingSort.direction === 'asc') {
|
|
existingSort.direction = 'desc';
|
|
} else {
|
|
// Remove sort
|
|
this.currentSort = this.currentSort.filter(s => s.column !== column);
|
|
}
|
|
} else {
|
|
this.currentSort.push({ column, direction: 'asc' });
|
|
}
|
|
|
|
this.updateSortHeaders();
|
|
this.loadData();
|
|
}
|
|
|
|
updateSortHeaders() {
|
|
const table = document.getElementById(this.tableId);
|
|
const headers = table?.querySelectorAll('th[data-column]');
|
|
|
|
headers?.forEach(th => {
|
|
const column = th.dataset.column;
|
|
const sort = this.currentSort.find(s => s.column === column);
|
|
|
|
th.classList.remove('sort-asc', 'sort-desc');
|
|
|
|
if (sort) {
|
|
th.classList.add(`sort-${sort.direction}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
async loadData() {
|
|
try {
|
|
const params = {
|
|
page: this.currentPage,
|
|
page_size: this.config.pageSize,
|
|
search: this.currentSearch,
|
|
sort: this.currentSort,
|
|
filters: this.currentFilters
|
|
};
|
|
|
|
const response = await fetch(this.config.apiUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(params)
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
this.renderTable(data.data);
|
|
this.updatePagination(data.pagination);
|
|
} else {
|
|
console.error('Table data loading failed:', data.error);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Table data loading error:', error);
|
|
}
|
|
}
|
|
|
|
renderTable(data) {
|
|
const table = document.getElementById(this.tableId);
|
|
const tbody = table?.querySelector('tbody');
|
|
|
|
if (!tbody) return;
|
|
|
|
tbody.innerHTML = '';
|
|
|
|
data.forEach(row => {
|
|
const tr = document.createElement('tr');
|
|
tr.dataset.id = row._id;
|
|
|
|
// Render cells
|
|
Object.keys(row).forEach(key => {
|
|
if (key.startsWith('_')) return; // Skip metadata
|
|
|
|
const td = document.createElement('td');
|
|
const cellData = row[key];
|
|
|
|
if (typeof cellData === 'object' && cellData.formatted !== undefined) {
|
|
td.innerHTML = cellData.formatted;
|
|
td.dataset.raw = cellData.raw;
|
|
} else {
|
|
td.textContent = cellData;
|
|
}
|
|
|
|
tr.appendChild(td);
|
|
});
|
|
|
|
// Add actions column if exists
|
|
if (row._actions && row._actions.length > 0) {
|
|
const actionsTd = document.createElement('td');
|
|
actionsTd.className = 'actions-cell';
|
|
actionsTd.innerHTML = this.renderActions(row._actions);
|
|
tr.appendChild(actionsTd);
|
|
}
|
|
|
|
tbody.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
renderActions(actions) {
|
|
return actions.map(action => {
|
|
const confirmAttr = action.confirm ? `onclick="return confirm('${action.confirm}')"` : '';
|
|
const icon = action.icon ? `<span class="action-icon">${action.icon}</span>` : '';
|
|
|
|
return `<a href="${action.url}" class="action-btn ${action.class}" ${confirmAttr}>
|
|
${icon}${action.label}
|
|
</a>`;
|
|
}).join(' ');
|
|
}
|
|
|
|
updatePagination(pagination) {
|
|
this.currentPage = pagination.page;
|
|
this.totalPages = pagination.total_pages;
|
|
this.totalItems = pagination.total_items;
|
|
|
|
const paginationEl = document.getElementById(`${this.tableId}-pagination`);
|
|
if (!paginationEl) return;
|
|
|
|
paginationEl.innerHTML = `
|
|
<div class="pagination-info">
|
|
Zeige ${pagination.start_item}-${pagination.end_item} von ${pagination.total_items} Einträgen
|
|
</div>
|
|
<div class="pagination-controls">
|
|
${this.renderPaginationButtons()}
|
|
</div>
|
|
`;
|
|
|
|
// Event listeners für Pagination
|
|
paginationEl.querySelectorAll('.page-btn').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
const page = parseInt(btn.dataset.page);
|
|
if (page !== this.currentPage) {
|
|
this.currentPage = page;
|
|
this.loadData();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
renderPaginationButtons() {
|
|
const buttons = [];
|
|
const maxButtons = 7;
|
|
|
|
// Previous button
|
|
buttons.push(`
|
|
<button class="page-btn ${this.currentPage === 1 ? 'disabled' : ''}"
|
|
data-page="${this.currentPage - 1}" ${this.currentPage === 1 ? 'disabled' : ''}>
|
|
←
|
|
</button>
|
|
`);
|
|
|
|
// Page number buttons
|
|
let startPage = Math.max(1, this.currentPage - Math.floor(maxButtons / 2));
|
|
let endPage = Math.min(this.totalPages, startPage + maxButtons - 1);
|
|
|
|
if (endPage - startPage + 1 < maxButtons) {
|
|
startPage = Math.max(1, endPage - maxButtons + 1);
|
|
}
|
|
|
|
for (let i = startPage; i <= endPage; i++) {
|
|
buttons.push(`
|
|
<button class="page-btn ${i === this.currentPage ? 'active' : ''}"
|
|
data-page="${i}">
|
|
${i}
|
|
</button>
|
|
`);
|
|
}
|
|
|
|
// Next button
|
|
buttons.push(`
|
|
<button class="page-btn ${this.currentPage === this.totalPages ? 'disabled' : ''}"
|
|
data-page="${this.currentPage + 1}" ${this.currentPage === this.totalPages ? 'disabled' : ''}>
|
|
→
|
|
</button>
|
|
`);
|
|
|
|
return buttons.join('');
|
|
}
|
|
|
|
exportData() {
|
|
const params = new URLSearchParams({
|
|
search: this.currentSearch,
|
|
sort: JSON.stringify(this.currentSort),
|
|
filters: JSON.stringify(this.currentFilters),
|
|
format: 'csv'
|
|
});
|
|
|
|
window.open(`${this.config.apiUrl}/export?${params}`, '_blank');
|
|
}
|
|
}
|
|
|
|
// Auto-initialize tables with data-advanced-table attribute
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
document.querySelectorAll('[data-advanced-table]').forEach(table => {
|
|
const config = JSON.parse(table.dataset.advancedTable || '{}');
|
|
new AdvancedTable(table.id, config);
|
|
});
|
|
});
|
|
"""
|
|
|
|
def get_advanced_table_css() -> str:
|
|
"""CSS für erweiterte Tabellen"""
|
|
return """
|
|
.table-wrapper {
|
|
background: white;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.table-controls {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 1rem;
|
|
background: #f8f9fa;
|
|
border-bottom: 1px solid #e9ecef;
|
|
}
|
|
|
|
.table-controls-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.search-box {
|
|
position: relative;
|
|
}
|
|
|
|
.search-input {
|
|
padding: 0.5rem 0.75rem;
|
|
padding-right: 2rem;
|
|
border: 1px solid #d1d5db;
|
|
border-radius: 6px;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.search-icon {
|
|
position: absolute;
|
|
right: 0.5rem;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
color: #6b7280;
|
|
}
|
|
|
|
.page-size-selector {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.table-controls-right {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.advanced-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
.advanced-table th {
|
|
background: #f8f9fa;
|
|
padding: 0.75rem;
|
|
text-align: left;
|
|
font-weight: 600;
|
|
border-bottom: 2px solid #e9ecef;
|
|
position: relative;
|
|
}
|
|
|
|
.advanced-table th[data-sortable="true"] {
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
|
|
.advanced-table th[data-sortable="true"]:hover {
|
|
background: #e9ecef;
|
|
}
|
|
|
|
.advanced-table th.sort-asc::after {
|
|
content: " ↑";
|
|
color: #3b82f6;
|
|
}
|
|
|
|
.advanced-table th.sort-desc::after {
|
|
content: " ↓";
|
|
color: #3b82f6;
|
|
}
|
|
|
|
.advanced-table td {
|
|
padding: 0.75rem;
|
|
border-bottom: 1px solid #e9ecef;
|
|
}
|
|
|
|
.advanced-table tbody tr:hover {
|
|
background: #f8f9fa;
|
|
}
|
|
|
|
.actions-cell {
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.action-btn {
|
|
display: inline-block;
|
|
padding: 0.25rem 0.5rem;
|
|
margin: 0 0.125rem;
|
|
font-size: 0.75rem;
|
|
text-decoration: none;
|
|
border-radius: 4px;
|
|
background: #e5e7eb;
|
|
color: #374151;
|
|
}
|
|
|
|
.action-btn:hover {
|
|
background: #d1d5db;
|
|
}
|
|
|
|
.action-btn.btn-primary {
|
|
background: #3b82f6;
|
|
color: white;
|
|
}
|
|
|
|
.action-btn.btn-danger {
|
|
background: #ef4444;
|
|
color: white;
|
|
}
|
|
|
|
.table-pagination {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 1rem;
|
|
background: #f8f9fa;
|
|
border-top: 1px solid #e9ecef;
|
|
}
|
|
|
|
.pagination-controls {
|
|
display: flex;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
.page-btn {
|
|
padding: 0.5rem 0.75rem;
|
|
border: 1px solid #d1d5db;
|
|
background: white;
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.page-btn:hover:not(.disabled) {
|
|
background: #f3f4f6;
|
|
}
|
|
|
|
.page-btn.active {
|
|
background: #3b82f6;
|
|
color: white;
|
|
border-color: #3b82f6;
|
|
}
|
|
|
|
.page-btn.disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.table-controls {
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.table-controls-left,
|
|
.table-controls-right {
|
|
justify-content: center;
|
|
}
|
|
|
|
.advanced-table {
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.advanced-table th,
|
|
.advanced-table td {
|
|
padding: 0.5rem;
|
|
}
|
|
|
|
.table-pagination {
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
}
|
|
"""
|
|
|
|
def create_table_config(table_id: str, columns: List[ColumnConfig], **kwargs) -> TableConfig:
|
|
"""
|
|
Erstellt eine neue Tabellen-Konfiguration.
|
|
|
|
Args:
|
|
table_id: Eindeutige ID für die Tabelle
|
|
columns: Liste der Spalten-Konfigurationen
|
|
**kwargs: Zusätzliche Konfigurationsoptionen
|
|
|
|
Returns:
|
|
TableConfig: Konfiguration für die erweiterte Tabelle
|
|
"""
|
|
return TableConfig(
|
|
table_id=table_id,
|
|
columns=columns,
|
|
default_sort=kwargs.get('default_sort', []),
|
|
default_filters=kwargs.get('default_filters', []),
|
|
pagination=kwargs.get('pagination', PaginationConfig()),
|
|
searchable=kwargs.get('searchable', True),
|
|
exportable=kwargs.get('exportable', True),
|
|
selectable=kwargs.get('selectable', False),
|
|
row_actions=kwargs.get('row_actions', [])
|
|
)
|
|
|
|
def get_advanced_tables_js() -> str:
|
|
"""Alias für die bestehende Funktion"""
|
|
return get_advanced_table_javascript()
|
|
|
|
def get_advanced_tables_css() -> str:
|
|
"""Alias für die bestehende Funktion"""
|
|
return get_advanced_table_css() |