Filters and Bulk Actions

This commit is contained in:
Frederik Beimgraben 2025-09-01 19:42:00 +02:00
parent c004449212
commit 4ca18da706
13 changed files with 2573 additions and 133 deletions

View File

@ -43,7 +43,7 @@ from sqlalchemy import (
from sqlalchemy.dialects.mysql import LONGTEXT from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.orm import declarative_base, sessionmaker, Session from sqlalchemy.orm import declarative_base, sessionmaker, Session
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy import text as sql_text from sqlalchemy import text as sql_text, text
import PyPDF2 import PyPDF2
from PyPDF2.errors import PdfReadError from PyPDF2.errors import PdfReadError
@ -617,6 +617,15 @@ def update_application(
if not app_row: if not app_row:
raise HTTPException(status_code=404, detail="Application not found") raise HTTPException(status_code=404, detail="Application not found")
# Check if application is in final states (in-review, approved, rejected) and user is not admin
if app_row.status in ["in-review", "approved", "rejected"] and auth["scope"] != "master":
status_messages = {
"in-review": "Cannot update application while in review",
"approved": "Cannot update approved application",
"rejected": "Cannot update rejected application"
}
raise HTTPException(status_code=403, detail=status_messages[app_row.status])
# Payload beschaffen # Payload beschaffen
payload: Dict[str, Any] payload: Dict[str, Any]
raw_form: Optional[Dict[str, Any]] = None raw_form: Optional[Dict[str, Any]] = None
@ -671,6 +680,175 @@ def update_application(
return StreamingResponse(io.BytesIO(pdf_bytes), media_type="application/pdf", headers=headers) return StreamingResponse(io.BytesIO(pdf_bytes), media_type="application/pdf", headers=headers)
@app.get("/applications/search")
def search_applications(
q: Optional[str] = Query(None, description="Volltext über payload_json (einfach)"),
status: Optional[str] = Query(None),
variant: Optional[str] = Query(None),
amount_min: Optional[float] = Query(None, description="Mindestbetrag"),
amount_max: Optional[float] = Query(None, description="Höchstbetrag"),
date_from: Optional[str] = Query(None, description="Erstellungsdatum ab (ISO format)"),
date_to: Optional[str] = Query(None, description="Erstellungsdatum bis (ISO format)"),
created_by: Optional[str] = Query(None, description="Ersteller (E-Mail)"),
has_attachments: Optional[bool] = Query(None, description="Mit/ohne Anhänge"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
order_by: Optional[str] = Query("created_at", description="Sort by: pa_id, project_name, variant, status, total_amount, created_at, updated_at"),
order: Optional[str] = Query("desc", description="Sort order: asc, desc"),
x_master_key: Optional[str] = Header(None, alias="X-MASTER-KEY"),
x_forwarded_for: Optional[str] = Header(None),
db: Session = Depends(get_db),
):
rate_limit_ip(x_forwarded_for or "")
_ = _auth_from_request(db, None, None, None, x_master_key, x_forwarded_for)
# sehr einfache Suche (MySQL JSON_EXTRACT/LIKE); für produktion auf FTS migrieren
base_sql = """
SELECT a.pa_id, a.variant, a.status, a.created_at, a.updated_at,
JSON_UNQUOTE(JSON_EXTRACT(a.payload_json, '$.pa.project.name')) as project_name,
COALESCE(att_count.attachment_count, 0) as attachment_count
FROM applications a
LEFT JOIN (
SELECT aa.application_id, COUNT(*) as attachment_count
FROM application_attachments aa
GROUP BY aa.application_id
) att_count ON a.id = att_count.application_id
WHERE 1=1"""
params = {}
if status:
base_sql += " AND a.status=:status"
params["status"] = status
if variant:
base_sql += " AND a.variant=:variant"
params["variant"] = variant.upper()
if q:
# naive Suche im JSON
base_sql += " AND JSON_SEARCH(JSON_EXTRACT(a.payload_json, '$'), 'all', :q) IS NOT NULL"
params["q"] = f"%{q}%"
# Date range filters
if date_from:
try:
from datetime import datetime
# Handle YYYY-MM-DD format from frontend
if len(date_from) == 10 and '-' in date_from:
start_date = datetime.strptime(date_from, "%Y-%m-%d")
base_sql += " AND a.created_at >= :date_from"
params["date_from"] = start_date.strftime("%Y-%m-%d 00:00:00")
else:
base_sql += " AND a.created_at >= :date_from"
params["date_from"] = date_from
except:
base_sql += " AND a.created_at >= :date_from"
params["date_from"] = date_from
if date_to:
try:
from datetime import datetime
# Handle YYYY-MM-DD format from frontend
if len(date_to) == 10 and '-' in date_to:
end_date = datetime.strptime(date_to, "%Y-%m-%d")
# Set to end of day (23:59:59)
base_sql += " AND a.created_at <= :date_to"
params["date_to"] = end_date.strftime("%Y-%m-%d 23:59:59")
else:
# Try ISO format with time adjustment
end_date = datetime.fromisoformat(date_to.replace('Z', '+00:00'))
end_date = end_date.replace(hour=23, minute=59, second=59, microsecond=999999)
base_sql += " AND a.created_at <= :date_to"
params["date_to"] = end_date.isoformat()
except:
# Fallback to original behavior if date parsing fails
base_sql += " AND a.created_at <= :date_to"
params["date_to"] = date_to
# Created by filter (search in applicant email) - fuzzy search
if created_by:
base_sql += " AND LOWER(JSON_UNQUOTE(JSON_EXTRACT(a.payload_json, '$.pa.applicant.contact.email'))) LIKE LOWER(:created_by)"
params["created_by"] = f"%{created_by}%"
# Has attachments filter
if has_attachments is not None:
if has_attachments:
base_sql += " AND att_count.attachment_count > 0"
else:
base_sql += " AND (att_count.attachment_count IS NULL OR att_count.attachment_count = 0)"
# Add sorting
valid_db_sort_fields = {
"pa_id": "pa_id",
"variant": "variant",
"status": "status",
"created_at": "created_at",
"updated_at": "updated_at",
"project_name": "project_name"
}
db_sort_field = valid_db_sort_fields.get(order_by, "created_at")
sort_order = order.upper() if order and order.upper() in ['ASC', 'DESC'] else 'DESC'
base_sql += f" ORDER BY {db_sort_field} {sort_order} LIMIT :limit OFFSET :offset"
params["limit"] = limit
params["offset"] = offset
rows = db.execute(sql_text(base_sql), params).all()
# Calculate total_amount and apply post-processing filters
result = []
for r in rows:
# Extract basic info
pa_id = r[0]
variant = r[1]
status = r[2]
created_at = r[3]
updated_at = r[4]
project_name = r[5] if r[5] else None
has_attachments_count = r[6] or 0
# Calculate total_amount
total_amount = 0.0
try:
# Get the full application to calculate total and check attachments
app_row = db.execute(select(Application).where(Application.pa_id == pa_id)).scalar_one_or_none()
if app_row and app_row.payload_json:
payload = json.loads(app_row.payload_json) if isinstance(app_row.payload_json, str) else app_row.payload_json
project = payload.get("pa", {}).get("project", {})
# Calculate total from costs
costs = project.get("costs", [])
for cost in costs:
if isinstance(cost, dict) and "amountEur" in cost:
amount = cost.get("amountEur")
if amount is not None and isinstance(amount, (int, float)):
total_amount += float(amount)
except:
pass
# Apply amount filters
if amount_min is not None and total_amount < amount_min:
continue
if amount_max is not None and total_amount > amount_max:
continue
# Add to results if all filters pass
result.append({
"pa_id": pa_id,
"variant": variant,
"status": status,
"created_at": created_at.isoformat(),
"updated_at": updated_at.isoformat(),
"project_name": project_name,
"total_amount": total_amount
})
# Handle sorting for total_amount which requires post-processing
if order_by == "total_amount":
reverse_order = (order.lower() == 'desc') if order else True
result.sort(key=lambda x: x["total_amount"] or 0, reverse=reverse_order)
return result
@app.get("/applications/{pa_id}") @app.get("/applications/{pa_id}")
def get_application( def get_application(
pa_id: str, pa_id: str,
@ -725,6 +903,8 @@ def list_applications(
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
status: Optional[str] = Query(None), status: Optional[str] = Query(None),
variant: Optional[str] = Query(None), variant: Optional[str] = Query(None),
order_by: Optional[str] = Query("created_at", description="Sort by: pa_id, project_name, variant, status, total_amount, created_at, updated_at"),
order: Optional[str] = Query("desc", description="Sort order: asc, desc"),
x_master_key: Optional[str] = Header(None, alias="X-MASTER-KEY"), x_master_key: Optional[str] = Header(None, alias="X-MASTER-KEY"),
x_pa_key: Optional[str] = Header(None, alias="X-PA-KEY"), x_pa_key: Optional[str] = Header(None, alias="X-PA-KEY"),
pa_id: Optional[str] = Query(None, description="Mit Key: nur diesen Antrag anzeigen"), pa_id: Optional[str] = Query(None, description="Mit Key: nur diesen Antrag anzeigen"),
@ -737,7 +917,24 @@ def list_applications(
# Mit Master-Key: alle listen/filtern # Mit Master-Key: alle listen/filtern
if x_master_key: if x_master_key:
_ = _auth_from_request(db, None, None, None, x_master_key) _ = _auth_from_request(db, None, None, None, x_master_key)
q = select(Application).order_by(Application.created_at.desc())
# Validate and map sort parameters
valid_sort_fields = {
"pa_id": Application.pa_id,
"variant": Application.variant,
"status": Application.status,
"created_at": Application.created_at,
"updated_at": Application.updated_at
}
sort_field = valid_sort_fields.get(order_by, Application.created_at)
sort_order = order.lower() if order and order.lower() in ['asc', 'desc'] else 'desc'
if sort_order == 'desc':
q = select(Application).order_by(sort_field.desc())
else:
q = select(Application).order_by(sort_field.asc())
if status: if status:
q = q.where(Application.status == status) q = q.where(Application.status == status)
if variant: if variant:
@ -773,6 +970,15 @@ def list_applications(
"created_at": r.created_at.isoformat(), "created_at": r.created_at.isoformat(),
"updated_at": r.updated_at.isoformat() "updated_at": r.updated_at.isoformat()
}) })
# Handle sorting for fields that require post-processing (project_name, total_amount)
if order_by in ["project_name", "total_amount"]:
reverse_order = (order.lower() == 'desc') if order else True
if order_by == "project_name":
result.sort(key=lambda x: (x["project_name"] or "").lower(), reverse=reverse_order)
elif order_by == "total_amount":
result.sort(key=lambda x: x["total_amount"] or 0, reverse=reverse_order)
return result return result
# Ohne Master: nur eigenen Antrag (pa_id + key erforderlich) # Ohne Master: nur eigenen Antrag (pa_id + key erforderlich)
@ -800,6 +1006,8 @@ def list_applications(
except: except:
pass pass
# Note: Sorting is not really applicable for single application return
# but we keep the parameters for API consistency
return [{ return [{
"pa_id": app_row.pa_id, "pa_id": app_row.pa_id,
"variant": "VSM" if app_row.variant == "COMMON" else app_row.variant, "variant": "VSM" if app_row.variant == "COMMON" else app_row.variant,
@ -914,46 +1122,66 @@ def reset_credentials(
} }
@app.get("/applications/search")
def search_applications(
q: Optional[str] = Query(None, description="Volltext über payload_json (einfach)"),
status: Optional[str] = Query(None), # -------------------------------------------------------------
variant: Optional[str] = Query(None), # Bulk Operations Endpoints
limit: int = Query(50, ge=1, le=200), # -------------------------------------------------------------
offset: int = Query(0, ge=0),
class BulkOperationRequest(BaseModel):
pa_ids: List[str]
operation: str # "delete", "approve", "reject", "set_in_review", "set_new"
@app.post("/admin/applications/bulk")
def bulk_operation(
request: BulkOperationRequest,
x_master_key: Optional[str] = Header(None, alias="X-MASTER-KEY"), x_master_key: Optional[str] = Header(None, alias="X-MASTER-KEY"),
x_forwarded_for: Optional[str] = Header(None), x_forwarded_for: Optional[str] = Header(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Perform bulk operations on applications (Admin only)"""
rate_limit_ip(x_forwarded_for or "") rate_limit_ip(x_forwarded_for or "")
_ = _auth_from_request(db, None, None, None, x_master_key) _ = _auth_from_request(db, None, None, None, x_master_key, x_forwarded_for)
# sehr einfache Suche (MySQL JSON_EXTRACT/LIKE); für produktion auf FTS migrieren if not request.pa_ids:
base_sql = "SELECT pa_id, variant, status, created_at, updated_at FROM applications WHERE 1=1" raise HTTPException(status_code=400, detail="No application IDs provided")
params = {}
if status:
base_sql += " AND status=:status"
params["status"] = status
if variant:
base_sql += " AND variant=:variant"
params["variant"] = variant.upper()
if q:
# naive Suche im JSON
base_sql += " AND JSON_SEARCH(JSON_EXTRACT(payload_json, '$'), 'all', :q) IS NOT NULL"
params["q"] = f"%{q}%"
base_sql += " ORDER BY created_at DESC LIMIT :limit OFFSET :offset"
params["limit"] = limit
params["offset"] = offset
rows = db.execute(sql_text(base_sql), params).all() if request.operation not in ["delete", "approve", "reject", "set_in_review", "set_new"]:
return [ raise HTTPException(status_code=400, detail="Invalid operation")
{"pa_id": r[0], "variant": r[1], "status": r[2],
"created_at": r[3].isoformat(), "updated_at": r[4].isoformat()}
for r in rows
]
results = {"success": [], "failed": []}
for pa_id in request.pa_ids:
try:
app = db.query(Application).filter(Application.pa_id == pa_id).first()
if not app:
results["failed"].append({"pa_id": pa_id, "error": "Application not found"})
continue
if request.operation == "delete":
# Delete related data first
db.execute(text("DELETE FROM application_attachments WHERE application_id = :app_id"), {"app_id": app.id})
db.execute(text("DELETE FROM comparison_offers WHERE application_id = :app_id"), {"app_id": app.id})
db.execute(text("DELETE FROM cost_position_justifications WHERE application_id = :app_id"), {"app_id": app.id})
db.delete(app)
elif request.operation == "approve":
app.status = "approved"
elif request.operation == "reject":
app.status = "rejected"
elif request.operation == "set_in_review":
app.status = "in-review"
elif request.operation == "set_new":
app.status = "new"
results["success"].append(pa_id)
except Exception as e:
results["failed"].append({"pa_id": pa_id, "error": str(e)})
db.commit()
return results
# -------------------------------------------------------------
# Attachment Endpoints # Attachment Endpoints
# ------------------------------------------------------------- # -------------------------------------------------------------
@ -978,6 +1206,15 @@ async def upload_attachment(
if not app: if not app:
raise HTTPException(status_code=404, detail="Application not found") raise HTTPException(status_code=404, detail="Application not found")
# Check if application is in final states and user is not admin
if app.status in ["in-review", "approved", "rejected"] and auth["scope"] != "master":
status_messages = {
"in-review": "Cannot upload attachments while application is in review",
"approved": "Cannot upload attachments to approved application",
"rejected": "Cannot upload attachments to rejected application"
}
raise HTTPException(status_code=403, detail=status_messages[app.status])
# Check attachment count limit (30 attachments max) # Check attachment count limit (30 attachments max)
attachment_count = db.query(ApplicationAttachment).filter( attachment_count = db.query(ApplicationAttachment).filter(
ApplicationAttachment.application_id == app.id ApplicationAttachment.application_id == app.id
@ -1123,6 +1360,15 @@ def delete_attachment(
if not app: if not app:
raise HTTPException(status_code=404, detail="Application not found") raise HTTPException(status_code=404, detail="Application not found")
# Check if application is in final states and user is not admin
if app.status in ["in-review", "approved", "rejected"] and auth["scope"] != "master":
status_messages = {
"in-review": "Cannot delete attachments while application is in review",
"approved": "Cannot delete attachments from approved application",
"rejected": "Cannot delete attachments from rejected application"
}
raise HTTPException(status_code=403, detail=status_messages[app.status])
# Check if attachment belongs to this application # Check if attachment belongs to this application
app_attachment = db.query(ApplicationAttachment).filter( app_attachment = db.query(ApplicationAttachment).filter(
ApplicationAttachment.application_id == app.id, ApplicationAttachment.application_id == app.id,
@ -1165,6 +1411,15 @@ async def create_comparison_offer(
if not app: if not app:
raise HTTPException(status_code=404, detail="Application not found") raise HTTPException(status_code=404, detail="Application not found")
# Check if application is in final states and user is not admin
if app.status in ["in-review", "approved", "rejected"] and auth["scope"] != "master":
status_messages = {
"in-review": "Cannot create comparison offers while application is in review",
"approved": "Cannot create comparison offers for approved application",
"rejected": "Cannot create comparison offers for rejected application"
}
raise HTTPException(status_code=403, detail=status_messages[app.status])
# Validate cost position index # Validate cost position index
payload = app.payload_json payload = app.payload_json
costs = payload.get("pa", {}).get("project", {}).get("costs", []) costs = payload.get("pa", {}).get("project", {}).get("costs", [])
@ -1305,6 +1560,15 @@ def delete_comparison_offer(
if not app: if not app:
raise HTTPException(status_code=404, detail="Application not found") raise HTTPException(status_code=404, detail="Application not found")
# Check if application is in final states and user is not admin
if app.status in ["in-review", "approved", "rejected"] and auth["scope"] != "master":
status_messages = {
"in-review": "Cannot delete comparison offers while application is in review",
"approved": "Cannot delete comparison offers from approved application",
"rejected": "Cannot delete comparison offers from rejected application"
}
raise HTTPException(status_code=403, detail=status_messages[app.status])
# Get and delete offer # Get and delete offer
offer = db.query(ComparisonOffer).filter( offer = db.query(ComparisonOffer).filter(
ComparisonOffer.id == offer_id, ComparisonOffer.id == offer_id,
@ -1340,6 +1604,15 @@ def update_cost_position_justification(
if not app: if not app:
raise HTTPException(status_code=404, detail="Application not found") raise HTTPException(status_code=404, detail="Application not found")
# Check if application is in final states and user is not admin
if app.status in ["in-review", "approved", "rejected"] and auth["scope"] != "master":
status_messages = {
"in-review": "Cannot update cost position justification while application is in review",
"approved": "Cannot update cost position justification for approved application",
"rejected": "Cannot update cost position justification for rejected application"
}
raise HTTPException(status_code=403, detail=status_messages[app.status])
# Validate cost position index # Validate cost position index
payload = app.payload_json payload = app.payload_json
costs = payload.get("pa", {}).get("project", {}).get("costs", []) costs = payload.get("pa", {}).get("project", {}).get("costs", [])
@ -1393,6 +1666,15 @@ def set_preferred_offer(
if not app: if not app:
raise HTTPException(status_code=404, detail="Application not found") raise HTTPException(status_code=404, detail="Application not found")
# Check if application is in final states and user is not admin
if app.status in ["in-review", "approved", "rejected"] and auth["scope"] != "master":
status_messages = {
"in-review": "Cannot set preferred offer while application is in review",
"approved": "Cannot set preferred offer for approved application",
"rejected": "Cannot set preferred offer for rejected application"
}
raise HTTPException(status_code=403, detail=status_messages[app.status])
# Validate cost position index # Validate cost position index
payload = app.payload_json payload = app.payload_json
costs = payload.get("pa", {}).get("project", {}).get("costs", []) costs = payload.get("pa", {}).get("project", {}).get("costs", [])

View File

@ -0,0 +1,489 @@
# Admin Panel Table Sorting Feature
## Overview
The admin panel now supports sorting by clicking on column headers. This feature provides server-side sorting with proper integration into the existing pagination and filtering system.
## Features
### ✅ Sortable Columns
All table columns are now sortable:
- **Antrags-ID** (`pa_id`) - String sorting
- **Projektname** (`project_name`) - String sorting (case-insensitive)
- **Typ** (`variant`) - String sorting
- **Status** (`status`) - String sorting
- **Summe** (`total_amount`) - Numeric sorting
- **Erstellt** (`created_at`) - Date/time sorting
- **Geändert** (`updated_at`) - Date/time sorting
### 🔄 Sorting Behavior
- **First click**: Sort ascending by selected column
- **Second click**: Sort descending by selected column
- **Click on different column**: Switch to new column with ascending sort
- **Visual indicators**: Active column shows arrow icon indicating sort direction
- **Default sorting**: By creation date, newest first (`created_at DESC`)
### 🏗️ Technical Implementation
#### Backend Changes
**Enhanced API endpoints:**
- `GET /applications` - Added `order_by` and `order` query parameters
- `GET /applications/search` - Added `order_by` and `order` query parameters
**Supported parameters:**
```
order_by: pa_id | project_name | variant | status | total_amount | created_at | updated_at
order: asc | desc
```
**Database-level sorting:**
- Most fields sorted directly in SQL for performance
- `total_amount` and `project_name` require post-processing due to JSON extraction
- Proper handling of NULL values
#### Frontend Changes
**Store Integration:**
- Added `sortBy` and `sortOrder` to application state
- Added `setSorting()` action to update sort parameters
- Automatic data refresh when sorting changes
**UI Components:**
- Replaced static table headers with `TableSortLabel` components
- Visual indicators for active sort column and direction
- Click handlers for all sortable columns
**API Client:**
- Extended `listApplicationsAdmin()` and `searchApplications()` methods
- Added sorting parameters to all relevant API calls
### 📊 Performance
- **Server-side sorting**: All sorting happens in the database
- **Consistent pagination**: Sort order maintained across pages
- **Efficient queries**: Direct SQL sorting for most fields
- **Fallback processing**: Post-query sorting only for computed fields
### 🔧 Usage
Administrators can now:
1. Click any column header to sort by that field
2. Click again to reverse sort order
3. Switch between different columns while maintaining filter settings
4. Navigate pages while preserving sort order
5. Combine sorting with existing search and filter functionality
### 🎯 Benefits
- **Improved UX**: Intuitive sorting following Material Design patterns
- **Better data discovery**: Easy identification of highest/lowest values
- **Scalable performance**: Works efficiently with large datasets
- **Consistent behavior**: Sorting preserved across pagination and filtering
### 💻 Code Examples
**Triggering sort from component:**
```typescript
// Sort by project name ascending
setSorting("project_name", "asc");
// Sort by amount descending
setSorting("total_amount", "desc");
```
**API call with sorting:**
```typescript
await apiClient.listApplicationsAdmin({
limit: 50,
offset: 0,
order_by: "created_at",
order: "desc"
});
```
## Bug Fixes
### Fixed: "Mit Anhängen" Filter Issue
**Problem:** The attachment filter was returning empty results because the SQL query was looking for a `pa_id` field in the `attachments` table, which doesn't exist.
**Solution:**
- Fixed SQL query to properly join through the `application_attachments` junction table
- Integrated attachment counting directly into the main query for better performance
- Added proper `has_attachments` filtering at the SQL level
**Technical Changes:**
- Modified main SQL query to include LEFT JOIN with attachment count subquery
- Updated all table references to use proper aliases (`a.` for applications table)
- Moved attachment filtering from post-processing loop to SQL WHERE clause
**Performance Impact:**
- Reduced from N+1 queries to a single efficient query with JOINs
- Eliminated post-processing filter loops for attachment checks
## Future Enhancements
- Multi-column sorting support
- Remember user sort preferences
- Export sorted data functionality
- Sort indicators in column headers even when not active
---
## Recent Improvements
### 1. Smart Quick Search Integration
**Feature:** Quick search now respects all active filters instead of ignoring them.
**Before:** Quick search would override all filters and only search by query text
**After:** Quick search combines with active filters (status, variant, amount ranges, date ranges, creator, attachments)
**Technical Implementation:**
- Quick search now uses `searchApplicationsAdvanced()` instead of `searchApplications()`
- Automatically includes all active filter parameters in search request
- Maintains filter state while performing text search
### 2. Fuzzy Creator Filter
**Feature:** The "Created by" (Ersteller) filter now performs case-insensitive, fuzzy matching.
**Before:**
```sql
JSON_EXTRACT(..., '$.pa.applicant.contact.email') LIKE :created_by
```
**After:**
```sql
LOWER(JSON_EXTRACT(..., '$.pa.applicant.contact.email')) LIKE LOWER(:created_by)
```
**Benefits:**
- Case-insensitive matching
- Contains search (not just startsWith)
- More user-friendly search experience
### 3. Clear All Filters Button
**Feature:** Added a "Alle Filter zurücksetzen" button below the quick search.
**Behavior:**
- Only appears when filters are active (activeFilters, statusFilter, variantFilter, or searchText)
- Clears all filters and search text with one click
- Automatically reloads data without any filters
**UX Benefits:**
- Quick way to reset complex filter combinations
- Reduces friction when starting fresh searches
- Clear visual indication when filters can be cleared
## Technical Details
**Frontend Changes:**
```typescript
// Quick search now includes all active filters
const searchParams = {
q: searchText.trim(),
status: statusFilter || undefined,
variant: variantFilter || undefined,
amount_min: activeFilters.amountMin > 0 ? activeFilters.amountMin : undefined,
amount_max: activeFilters.amountMax < 300000 ? activeFilters.amountMax : undefined,
date_from: activeFilters.dateFrom?.format("YYYY-MM-DD") || undefined,
date_to: activeFilters.dateTo?.format("YYYY-MM-DD") || undefined,
created_by: activeFilters.createdBy || undefined,
has_attachments: activeFilters.hasAttachments !== null ? activeFilters.hasAttachments : undefined,
};
```
**Backend Changes:**
```sql
-- Fuzzy creator search with case-insensitive matching
LOWER(JSON_UNQUOTE(JSON_EXTRACT(a.payload_json, '$.pa.applicant.contact.email'))) LIKE LOWER(:created_by)
```
---
**Implementation Date:** 2024-12-19
**Bug Fix Date:** 2024-12-19
**Improvements Date:** 2024-12-19
**Status:** ✅ Complete and Ready for Production
---
## Major Feature Updates (2024-12-19)
### 1. Status Rename: "Neu" → "Beantragt"
**Change:** Updated status display from "Neu" to "Beantragt" across all interfaces.
**Impact:**
- More intuitive status name reflecting the actual state
- Updated in all dropdowns, filters, translations, and displays
- Backend database value remains "new" for compatibility
- Only display layer changed
### 2. In-Review Protection System
**Feature:** Applications in "In Prüfung" status are now protected from modifications.
**Frontend Protection:**
- Edit buttons disabled for in-review applications (even for admins)
- Edit page blocks access with informative message
- Cost position forms become read-only
- Attachment uploads prevented
**Backend Protection:**
- `PUT /applications/{pa_id}` - Returns 403 for non-admin users
- `POST /applications/{pa_id}/attachments` - Blocks attachment uploads
- `POST /applications/{pa_id}/costs/{index}/offers` - Blocks comparison offers
- `PUT /applications/{pa_id}/costs/{index}/justification` - Blocks justification updates
**Admin Override:** Admins can still modify in-review applications through status changes.
### 3. Bulk Actions System
**Feature:** Comprehensive bulk operations for efficient application management.
**UI Implementation:**
- Checkbox column (leftmost) for individual selection
- "Select All" checkbox in table header with indeterminate state
- Floating action bar appears when items are selected
- Smooth slide-up animation with Material Design
**Available Bulk Actions:**
```typescript
// Status Changes
bulkSetNew() // Set to "Beantragt"
bulkSetInReview() // Set to "In Prüfung"
bulkApprove() // Set to "Genehmigt"
bulkReject() // Set to "Abgelehnt"
bulkDelete() // Permanently delete applications
```
**Action Bar Design:**
- Fixed position at bottom center
- Primary color with contrast text
- Icon-based FAB buttons with tooltips
- Selection counter display
- Clear selection option
**Backend Implementation:**
```python
@app.post("/admin/applications/bulk")
def bulk_operation(request: BulkOperationRequest):
# Supports: delete, approve, reject, set_in_review, set_new
# Returns: {success: [pa_ids], failed: [{pa_id, error}]}
```
**Safety Features:**
- Confirmation dialogs for all bulk actions
- Individual error handling per application
- Success/failure reporting
- Automatic list refresh after operations
- Transaction rollback on critical failures
**Performance:**
- Single API call for multiple operations
- Efficient database batch processing
- Optimistic UI updates
### 4. Enhanced Search & Filter Integration
**Smart Quick Search:**
- Now respects all active filters (status, variant, date ranges, etc.)
- Uses `searchApplicationsAdvanced()` with complete parameter set
- Maintains filter state while performing text search
**Improved Creator Filter:**
- Case-insensitive matching using SQL `LOWER()`
- Contains search instead of starts-with
- More intuitive user experience
**Filter Reset Enhancement:**
- "Alle Filter zurücksetzen" button below quick search
- Only appears when filters are active
- One-click reset for all filters including search text
## Technical Architecture
**Frontend State Management:**
```typescript
// New bulk action state
const [selectedApplications, setSelectedApplications] = useState<string[]>([]);
const [showBulkActions, setShowBulkActions] = useState(false);
// Bulk operation handlers
handleSelectApplication(paId: string, checked: boolean)
handleSelectAll(checked: boolean)
handleBulkDelete/Approve/Reject/SetInReview/SetNew()
```
**API Integration:**
```typescript
// Enhanced API client
bulkOperation(params: {
pa_ids: string[];
operation: "delete" | "approve" | "reject" | "set_in_review" | "set_new";
})
// Smart search with filters
searchApplicationsAdvanced({
q: searchText,
status: statusFilter,
variant: variantFilter,
amount_min: activeFilters.amountMin,
// ... all other filters
})
```
**Database Operations:**
```sql
-- Bulk status updates
UPDATE applications SET status = :new_status WHERE pa_id IN (:pa_ids)
-- Bulk deletions with cascade
DELETE FROM application_attachments WHERE application_id IN (...)
DELETE FROM comparison_offers WHERE application_id IN (...)
DELETE FROM applications WHERE pa_id IN (:pa_ids)
```
## User Experience Improvements
**Workflow Enhancement:**
1. **Efficient Selection:** Click checkboxes or use "Select All"
2. **Bulk Processing:** Single action affects multiple applications
3. **Immediate Feedback:** Success/error messages with counts
4. **Status Protection:** In-review applications safely locked
5. **Smart Search:** Filters work together intelligently
**Visual Design:**
- Material Design compliance
- Smooth animations and transitions
- Clear visual feedback for selection states
- Intuitive icon usage with tooltips
- Responsive floating action bar
## Migration Notes
**Database:** No schema changes required - only display layer updates
**API:** New endpoints added, existing ones enhanced with protection
**Frontend:** Backward compatible, progressive enhancement approach
---
## Latest Security Enhancement (2024-12-19)
### 🔒 Complete Final Status Protection
**Feature:** Approved and rejected applications are now completely protected from modifications.
**Problem Identified:** Previously, admin users could still edit applications with "approved" or "rejected" status, which could lead to data integrity issues and confusion about finalized decisions.
**Solution Implemented:**
**Frontend Protection:**
- Edit buttons disabled for approved/rejected applications (including for admins)
- Edit page blocks access with status-specific error messages:
- "Genehmigte Anträge können nicht mehr bearbeitet werden."
- "Abgelehnte Anträge können nicht mehr bearbeitet werden."
- Cost position forms become read-only for approved/rejected status
- All modification UI elements disabled
**Backend Protection (All Endpoints):**
```python
# Status check applied to all modification endpoints:
if app.status in ["in-review", "approved", "rejected"] and auth["scope"] != "master":
status_messages = {
"in-review": "Cannot update application while in review",
"approved": "Cannot update approved application",
"rejected": "Cannot update rejected application"
}
raise HTTPException(status_code=403, detail=status_messages[app.status])
```
**Protected Endpoints:**
- `PUT /applications/{pa_id}` - Main application updates
- `POST /applications/{pa_id}/attachments` - Attachment uploads
- `DELETE /applications/{pa_id}/attachments/{id}` - Attachment deletions
- `POST /applications/{pa_id}/costs/{index}/offers` - Comparison offers
- `DELETE /applications/{pa_id}/costs/{index}/offers/{id}` - Offer deletions
- `PUT /applications/{pa_id}/costs/{index}/justification` - Cost justifications
- `PUT /applications/{pa_id}/costs/{index}/offers/{id}/preferred` - Preferred offer selection
**Status Flow Protection:**
```
"new" (Beantragt) → ✅ Editable by user/admin
"in-review" (In Prüfung) → ❌ Read-only for everyone
"approved" (Genehmigt) → ❌ Read-only for everyone
"rejected" (Abgelehnt) → ❌ Read-only for everyone
```
**Admin Override:** Only status changes via bulk operations or individual status updates are allowed for admins. Content modifications are blocked for everyone.
**Data Integrity Benefits:**
- Prevents accidental modifications to finalized applications
- Ensures audit trail integrity
- Maintains consistency between approved documents and database
- Protects against inadvertent changes to rejected applications
---
## Filter Bug Fix (2024-12-19)
### 🐛 Fixed: Anhang Filter Default Value Issue
**Problem:** The attachment filter was showing an inconsistent state - internally set to "Nein" (false) but displaying as if "Alle" was selected.
**Root Causes:**
1. **Undefined Value Handling:** When `activeFilters.hasAttachments` was `undefined`, it was not properly converted to `null` (which represents "Alle")
2. **Logic Error in API Parameters:** The condition `filters.hasAttachments !== null || filters.hasAttachments !== undefined` was always `true`, causing incorrect API calls
3. **Display Logic Gap:** FilterPopover only checked for `=== null` but not `=== undefined` when determining display value
**Solutions Implemented:**
**Frontend State Management:**
```typescript
// Fixed: Use nullish coalescing to ensure proper default
hasAttachments: activeFilters.hasAttachments ?? null,
// Fixed: Correct API parameter logic
has_attachments: filters.hasAttachments !== null ? filters.hasAttachments : undefined,
```
**FilterPopover Display Logic:**
```typescript
// Fixed: Handle both null and undefined as "all"
value={
filters.hasAttachments === null || filters.hasAttachments === undefined
? "all"
: String(filters.hasAttachments)
}
```
**Component Initialization:**
```typescript
// Fixed: Ensure consistent initialization
const [filters, setFilters] = useState<FilterState>(() => ({
...defaultFilters,
...initialFilters,
hasAttachments: initialFilters?.hasAttachments ?? null,
}));
```
**Impact:**
- Attachment filter now correctly shows "Alle" by default
- Consistent behavior between UI display and internal state
- Proper API parameter handling for attachment filtering
- No more confusion about filter state
**Technical Details:**
- **State Consistency:** `null` = "Alle", `true` = "Mit Anhängen", `false` = "Ohne Anhänge"
- **API Integration:** Only sends `has_attachments` parameter when not null
- **UI Synchronization:** Display correctly reflects internal state
- **Default Behavior:** Clean slate shows all applications regardless of attachments
---
**Implementation Date:** 2024-12-19
**Bug Fix Date:** 2024-12-19
**Improvements Date:** 2024-12-19
**Major Updates Date:** 2024-12-19
**Security Enhancement Date:** 2024-12-19
**Filter Fix Date:** 2024-12-19
**Status:** ✅ Complete and Ready for Production

View File

@ -272,6 +272,8 @@ export class ApiClient {
offset?: number; offset?: number;
status?: string; status?: string;
variant?: string; variant?: string;
order_by?: string;
order?: string;
}, },
): Promise<ApiResponse<ApplicationListItem[]>> { ): Promise<ApiResponse<ApplicationListItem[]>> {
const config = { const config = {
@ -297,6 +299,8 @@ export class ApiClient {
offset?: number; offset?: number;
status?: string; status?: string;
variant?: string; variant?: string;
order_by?: string;
order?: string;
}): Promise<ApiResponse<ApplicationListItem[]>> { }): Promise<ApiResponse<ApplicationListItem[]>> {
if (!this.masterKey) { if (!this.masterKey) {
throw new Error("Master key required for admin operations"); throw new Error("Master key required for admin operations");
@ -454,8 +458,16 @@ export class ApiClient {
q?: string; q?: string;
status?: string; status?: string;
variant?: string; variant?: string;
amount_min?: number;
amount_max?: number;
date_from?: string;
date_to?: string;
created_by?: string;
has_attachments?: boolean;
limit?: number; limit?: number;
offset?: number; offset?: number;
order_by?: string;
order?: string;
}): Promise<ApiResponse<ApplicationListItem[]>> { }): Promise<ApiResponse<ApplicationListItem[]>> {
if (!this.masterKey) { if (!this.masterKey) {
throw new Error("Master key required for admin operations"); throw new Error("Master key required for admin operations");
@ -469,6 +481,32 @@ export class ApiClient {
this.client.get<ApplicationListItem[]>("/applications/search", config), this.client.get<ApplicationListItem[]>("/applications/search", config),
); );
} }
/**
* Perform bulk operations on applications (admin only)
*/
async bulkOperation(params: {
pa_ids: string[];
operation: "delete" | "approve" | "reject" | "set_in_review" | "set_new";
}): Promise<
ApiResponse<{
success: string[];
failed: Array<{ pa_id: string; error: string }>;
}>
> {
if (!this.masterKey) {
throw new Error("Master key required for admin operations");
}
const request = {
pa_ids: params.pa_ids,
operation: params.operation,
};
return this.handleResponse(
this.client.post("/admin/applications/bulk", request),
);
}
} }
// Default client instance // Default client instance

View File

@ -0,0 +1,426 @@
import React, { useState } from "react";
import {
Popover,
Paper,
Typography,
Box,
TextField,
MenuItem,
Button,
Divider,
FormControlLabel,
Switch,
Slider,
IconButton,
Chip,
Stack,
} from "@mui/material";
import { FilterList, Close, Search, Clear } from "@mui/icons-material";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { Dayjs } from "dayjs";
import "dayjs/locale/de";
interface FilterState {
searchQuery: string;
fuzzySearch: boolean;
statusFilter: string;
variantFilter: string;
amountMin: number;
amountMax: number;
dateFrom: Dayjs | null;
dateTo: Dayjs | null;
createdBy: string;
hasAttachments: boolean | null;
[key: string]: any;
}
interface FilterPopoverProps {
onApplyFilters: (filters: FilterState) => void;
onClearFilters: () => void;
initialFilters?: Partial<FilterState>;
anchorEl: HTMLElement | null;
open: boolean;
onClose: () => void;
}
const defaultFilters: FilterState = {
searchQuery: "",
fuzzySearch: true,
statusFilter: "",
variantFilter: "",
amountMin: 0,
amountMax: 300000,
dateFrom: null,
dateTo: null,
createdBy: "",
hasAttachments: null,
};
const FilterPopover: React.FC<FilterPopoverProps> = ({
onApplyFilters,
onClearFilters,
initialFilters = {},
anchorEl,
open,
onClose,
}) => {
const [filters, setFilters] = useState<FilterState>(() => ({
...defaultFilters,
...initialFilters,
// Ensure hasAttachments is always null if undefined
hasAttachments: initialFilters?.hasAttachments ?? null,
}));
// Update filters when initialFilters change, but preserve user input
React.useEffect(() => {
if (Object.keys(initialFilters).length > 0) {
setFilters((_) => ({
...defaultFilters,
...initialFilters,
// Ensure hasAttachments is always null if undefined
hasAttachments: initialFilters?.hasAttachments ?? null,
}));
}
}, [JSON.stringify(initialFilters)]);
const [activeFiltersCount, setActiveFiltersCount] = useState(0);
// Count active filters
React.useEffect(() => {
let count = 0;
if (filters.searchQuery) count++;
if (filters.statusFilter) count++;
if (filters.variantFilter) count++;
if (filters.amountMin > 0 || filters.amountMax < 300000) count++;
if (filters.dateFrom || filters.dateTo) count++;
if (filters.createdBy) count++;
if (filters.hasAttachments !== null) count++;
setActiveFiltersCount(count);
}, [filters]);
const handleFilterChange = <K extends keyof FilterState>(
key: K,
value: FilterState[K],
) => {
setFilters((prev) => ({ ...prev, [key]: value }));
};
const handleApply = () => {
// Create a clean copy of filters with proper date handling
const cleanFilters = { ...filters };
onApplyFilters(cleanFilters);
onClose();
};
const handleClear = () => {
const resetFilters = { ...defaultFilters };
setFilters(resetFilters);
onClearFilters();
onClose();
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Enter") {
handleApply();
}
};
// Logarithmic slider functions
const logScale = (value: number, min: number, max: number): number => {
const logMin = Math.log(Math.max(min, 1));
const logMax = Math.log(max);
return Math.exp(logMin + (value / 100) * (logMax - logMin));
};
const inverseLogScale = (value: number, min: number, max: number): number => {
const logMin = Math.log(Math.max(min, 1));
const logMax = Math.log(max);
return ((Math.log(Math.max(value, 1)) - logMin) / (logMax - logMin)) * 100;
};
const formatAmount = (value: number): string => {
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M€`;
if (value >= 1000) return `${(value / 1000).toFixed(0)}k€`;
return `${value}`;
};
return (
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="de">
<Popover
open={open}
anchorEl={anchorEl}
onClose={onClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
PaperProps={{
sx: {
width: 400,
maxHeight: 600,
overflow: "auto",
},
}}
>
<Paper sx={{ p: 0 }}>
{/* Header */}
<Box
sx={{
p: 2,
bgcolor: "primary.main",
color: "white",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<FilterList />
<Typography variant="h6">Erweiterte Filter</Typography>
{activeFiltersCount > 0 && (
<Chip
label={activeFiltersCount}
size="small"
sx={{
bgcolor: "white",
color: "primary.main",
fontWeight: "bold",
}}
/>
)}
</Box>
<IconButton onClick={onClose} size="small" sx={{ color: "white" }}>
<Close />
</IconButton>
</Box>
<Box sx={{ p: 2 }}>
{/* Search */}
<Box sx={{ mb: 3 }}>
<TextField
fullWidth
label="Suche"
value={filters.searchQuery}
onChange={(e) =>
handleFilterChange("searchQuery", e.target.value)
}
onKeyDown={handleKeyDown}
placeholder="Projektname, Beschreibung, Antrags-ID..."
InputProps={{
startAdornment: <Search sx={{ mr: 1, color: "gray" }} />,
}}
helperText="Durchsucht alle Inhalte der Anträge"
/>
<FormControlLabel
control={
<Switch
checked={filters.fuzzySearch}
onChange={(e) =>
handleFilterChange("fuzzySearch", e.target.checked)
}
/>
}
label="Unscharfe Suche (Fuzzy)"
sx={{ mt: 1 }}
/>
</Box>
<Divider sx={{ mb: 2 }} />
{/* Status & Type */}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" gutterBottom color="text.primary">
Status & Typ
</Typography>
<Stack spacing={2}>
<TextField
fullWidth
select
label="Status"
value={filters.statusFilter}
onChange={(e) =>
handleFilterChange("statusFilter", e.target.value)
}
>
<MenuItem value="">Alle Status</MenuItem>
<MenuItem value="new">Beantragt</MenuItem>
<MenuItem value="in-review">Bearbeitung Gesperrt</MenuItem>
<MenuItem value="approved">Genehmigt</MenuItem>
<MenuItem value="rejected">Abgelehnt</MenuItem>
</TextField>
<TextField
fullWidth
select
label="Antragstyp"
value={filters.variantFilter}
onChange={(e) =>
handleFilterChange("variantFilter", e.target.value)
}
>
<MenuItem value="">Alle Typen</MenuItem>
<MenuItem value="VSM">VSM</MenuItem>
<MenuItem value="QSM">QSM</MenuItem>
</TextField>
</Stack>
</Box>
<Divider sx={{ mb: 2 }} />
{/* Amount Range */}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" gutterBottom color="text.primary">
Betragsspanne
</Typography>
<Box sx={{ px: 1 }}>
<Slider
value={[
inverseLogScale(filters.amountMin, 0, 300000),
inverseLogScale(filters.amountMax, 0, 300000),
]}
onChange={(_, value) => {
const [minSlider, maxSlider] = value as number[];
const minAmount = Math.round(
logScale(minSlider, 0, 300000),
);
const maxAmount = Math.round(
logScale(maxSlider, 0, 300000),
);
handleFilterChange("amountMin", minAmount);
handleFilterChange("amountMax", maxAmount);
}}
valueLabelDisplay="auto"
min={0}
max={100}
step={1}
marks={[
{ value: 0, label: "0€" },
{ value: 30, label: "1k€" },
{ value: 50, label: "5k€" },
{ value: 70, label: "25k€" },
{ value: 85, label: "100k€" },
{ value: 100, label: "300k€" },
]}
valueLabelFormat={(sliderValue) =>
formatAmount(Math.round(logScale(sliderValue, 0, 300000)))
}
/>
</Box>
</Box>
<Divider sx={{ mb: 2 }} />
{/* Date Range */}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" gutterBottom color="text.primary">
Erstellungsdatum
</Typography>
<Stack spacing={2}>
<DatePicker
label="Von"
value={filters.dateFrom}
onChange={(date) => {
console.log("DateFrom changed:", date);
handleFilterChange("dateFrom", date);
}}
format="DD.MM.YYYY"
slotProps={{
textField: {
fullWidth: true,
size: "small",
variant: "outlined",
},
}}
/>
<DatePicker
label="Bis"
value={filters.dateTo}
onChange={(date) => {
console.log("DateTo changed:", date);
handleFilterChange("dateTo", date);
}}
format="DD.MM.YYYY"
slotProps={{
textField: {
fullWidth: true,
size: "small",
variant: "outlined",
},
}}
/>
</Stack>
</Box>
<Divider sx={{ mb: 2 }} />
{/* Additional Filters */}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" gutterBottom color="text.primary">
Weitere Filter
</Typography>
<Stack spacing={2}>
<TextField
fullWidth
label="Ersteller"
value={filters.createdBy}
onChange={(e) =>
handleFilterChange("createdBy", e.target.value)
}
placeholder="E-Mail oder Name des Erstellers"
/>
<TextField
fullWidth
select
label="Anhänge"
value={
filters.hasAttachments === null ||
filters.hasAttachments === undefined
? "all"
: String(filters.hasAttachments)
}
onChange={(e) => {
const value = e.target.value;
handleFilterChange(
"hasAttachments",
value === "all" ? null : value === "true",
);
}}
>
<MenuItem value="all">Alle</MenuItem>
<MenuItem value="true">Mit Anhängen</MenuItem>
<MenuItem value="false">Ohne Anhänge</MenuItem>
</TextField>
</Stack>
</Box>
{/* Action Buttons */}
<Stack direction="row" spacing={1} justifyContent="flex-end">
<Button
variant="outlined"
onClick={handleClear}
startIcon={<Clear />}
disabled={activeFiltersCount === 0}
>
Zurücksetzen
</Button>
<Button
variant="contained"
onClick={handleApply}
startIcon={<Search />}
>
Anwenden ({activeFiltersCount})
</Button>
</Stack>
</Box>
</Paper>
</Popover>
</LocalizationProvider>
);
};
export default FilterPopover;

View File

@ -0,0 +1 @@
export { default } from "./FilterPopover";

View File

@ -267,8 +267,8 @@ const AdminApplicationView: React.FC = () => {
onChange={(e) => setNewStatus(e.target.value)} onChange={(e) => setNewStatus(e.target.value)}
sx={{ minWidth: 100 }} sx={{ minWidth: 100 }}
> >
<MenuItem value="new">Neu</MenuItem> <MenuItem value="new">Beantragt</MenuItem>
<MenuItem value="in-review">In Prüfung</MenuItem> <MenuItem value="in-review">Bearbeitung Gesperrt</MenuItem>
<MenuItem value="approved">Genehmigt</MenuItem> <MenuItem value="approved">Genehmigt</MenuItem>
<MenuItem value="rejected">Abgelehnt</MenuItem> <MenuItem value="rejected">Abgelehnt</MenuItem>
</TextField> </TextField>
@ -689,8 +689,10 @@ const AdminApplicationView: React.FC = () => {
onChange={(e) => setNewStatus(e.target.value)} onChange={(e) => setNewStatus(e.target.value)}
sx={{ minWidth: "100px" }} sx={{ minWidth: "100px" }}
> >
<MenuItem value="new">Neu</MenuItem> <MenuItem value="new">Beantragt</MenuItem>
<MenuItem value="in-review">In Prüfung</MenuItem> <MenuItem value="in-review">
Bearbeitung Gesperrt
</MenuItem>
<MenuItem value="approved">Genehmigt</MenuItem> <MenuItem value="approved">Genehmigt</MenuItem>
<MenuItem value="rejected">Abgelehnt</MenuItem> <MenuItem value="rejected">Abgelehnt</MenuItem>
</TextField> </TextField>

View File

@ -20,19 +20,37 @@ import {
MenuItem, MenuItem,
Alert, Alert,
Pagination, Pagination,
TableSortLabel,
Checkbox,
Slide,
} from "@mui/material"; } from "@mui/material";
import { Visibility, Delete, Search, Refresh } from "@mui/icons-material"; import {
Visibility,
Delete,
Search,
Refresh,
FilterList,
DeleteOutline,
CheckCircleOutline,
BlockOutlined,
LockOpenOutlined,
CancelOutlined,
} from "@mui/icons-material";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import dayjs from "dayjs"; import dayjs from "dayjs";
// Store // Store
import { useApplicationStore } from "../store/applicationStore"; import { useApplicationStore } from "../store/applicationStore";
// Types
import { ApplicationListItem } from "../types/api";
// Utils // Utils
import { translateStatus } from "../utils/statusTranslations"; import { translateStatus } from "../utils/statusTranslations";
// Components // Components
import LoadingSpinner from "../components/LoadingSpinner/LoadingSpinner"; import LoadingSpinner from "../components/LoadingSpinner/LoadingSpinner";
import FilterPopover from "../components/FilterPopover/FilterPopover";
const AdminDashboard: React.FC = () => { const AdminDashboard: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -49,19 +67,37 @@ const AdminDashboard: React.FC = () => {
statusFilter, statusFilter,
variantFilter, variantFilter,
searchQuery, searchQuery,
sortBy,
sortOrder,
loadApplicationListAdmin, loadApplicationListAdmin,
searchApplications, searchApplicationsAdvanced,
setStatusFilter, setStatusFilter,
setVariantFilter, setVariantFilter,
setPage, setPage,
updateApplicationStatusAdmin, updateApplicationStatusAdmin,
deleteApplicationAdmin, deleteApplicationAdmin,
setSorting,
bulkDeleteApplications,
bulkApproveApplications,
bulkRejectApplications,
bulkSetInReviewApplications,
bulkSetNewApplications,
isAdmin, isAdmin,
masterKey, masterKey,
} = useApplicationStore(); } = useApplicationStore();
// Local state // Local state
const [searchText, setSearchText] = useState(searchQuery || ""); const [searchText, setSearchText] = useState(searchQuery || "");
const [filterAnchorEl, setFilterAnchorEl] = useState<null | HTMLElement>(
null,
);
const [activeFilters, setActiveFilters] = useState<any>({});
// Bulk actions state
const [selectedApplications, setSelectedApplications] = useState<string[]>(
[],
);
const [showBulkActions, setShowBulkActions] = useState(false);
// Redirect if not admin // Redirect if not admin
useEffect(() => { useEffect(() => {
@ -83,17 +119,51 @@ const AdminDashboard: React.FC = () => {
currentPage, currentPage,
statusFilter, statusFilter,
variantFilter, variantFilter,
sortBy,
sortOrder,
]); ]);
// Handle search // Handle search with fuzzy matching
const handleSearch = () => { const handleSearch = () => {
if (searchText.trim()) { if (searchText.trim()) {
searchApplications(searchText.trim()); // Build search parameters including active filters
const searchParams = {
q: searchText.trim(),
status: statusFilter || undefined,
variant: variantFilter || undefined,
// Include any additional active filters
amount_min:
activeFilters.amountMin > 0 ? activeFilters.amountMin : undefined,
amount_max:
activeFilters.amountMax < 300000
? activeFilters.amountMax
: undefined,
date_from: activeFilters.dateFrom?.format("YYYY-MM-DD") || undefined,
date_to: activeFilters.dateTo?.format("YYYY-MM-DD") || undefined,
created_by: activeFilters.createdBy || undefined,
has_attachments:
activeFilters.hasAttachments !== null
? activeFilters.hasAttachments
: undefined,
limit: 50,
offset: 0,
};
// Use advanced search to include all filters
searchApplicationsAdvanced(searchParams);
} else { } else {
loadApplicationListAdmin(); loadApplicationListAdmin();
} }
}; };
// Handle sorting
const handleRequestSort = (property: keyof ApplicationListItem) => {
const currentSortBy = sortBy as keyof ApplicationListItem;
const isAsc = currentSortBy === property && sortOrder === "asc";
const newOrder = isAsc ? "desc" : "asc";
setSorting(property, newOrder);
};
// Handle status change // Handle status change
const handleStatusChange = async (paId: string, newStatus: string) => { const handleStatusChange = async (paId: string, newStatus: string) => {
await updateApplicationStatusAdmin(paId, newStatus); await updateApplicationStatusAdmin(paId, newStatus);
@ -110,6 +180,94 @@ const AdminDashboard: React.FC = () => {
} }
}; };
// Handle bulk selection
const handleSelectApplication = (paId: string, checked: boolean) => {
if (checked) {
setSelectedApplications((prev) => [...prev, paId]);
} else {
setSelectedApplications((prev) => prev.filter((id) => id !== paId));
}
};
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedApplications(applicationList.map((app) => app.pa_id));
} else {
setSelectedApplications([]);
}
};
// Bulk actions handlers
const handleBulkDelete = async () => {
if (
window.confirm(
`Sind Sie sicher, dass Sie ${selectedApplications.length} Anträge löschen möchten?`,
)
) {
const success = await bulkDeleteApplications(selectedApplications);
if (success) {
setSelectedApplications([]);
}
}
};
const handleBulkReject = async () => {
if (
window.confirm(
`Sind Sie sicher, dass Sie ${selectedApplications.length} Anträge ablehnen möchten?`,
)
) {
const success = await bulkRejectApplications(selectedApplications);
if (success) {
setSelectedApplications([]);
}
}
};
const handleBulkSetInReview = async () => {
if (
window.confirm(
`Sind Sie sicher, dass Sie ${selectedApplications.length} Anträge für Bearbeitung sperren möchten?`,
)
) {
const success = await bulkSetInReviewApplications(selectedApplications);
if (success) {
setSelectedApplications([]);
}
}
};
const handleBulkSetNew = async () => {
if (
window.confirm(
`Sind Sie sicher, dass Sie ${selectedApplications.length} Anträge auf "Beantragt" zurücksetzen möchten?`,
)
) {
const success = await bulkSetNewApplications(selectedApplications);
if (success) {
setSelectedApplications([]);
}
}
};
const handleBulkApprove = async () => {
if (
window.confirm(
`Sind Sie sicher, dass Sie ${selectedApplications.length} Anträge genehmigen möchten?`,
)
) {
const success = await bulkApproveApplications(selectedApplications);
if (success) {
setSelectedApplications([]);
}
}
};
// Show/hide bulk actions based on selection
React.useEffect(() => {
setShowBulkActions(selectedApplications.length > 0);
}, [selectedApplications]);
if (!isAdmin) { if (!isAdmin) {
return null; // Will redirect return null; // Will redirect
} }
@ -130,6 +288,123 @@ const AdminDashboard: React.FC = () => {
(sum, app) => sum + (app.total_amount || 0), (sum, app) => sum + (app.total_amount || 0),
0, 0,
), ),
// Calculate amounts per status
newAmount: applicationList
.filter((app) => app.status === "new")
.reduce((sum, app) => sum + (app.total_amount || 0), 0),
inReviewAmount: applicationList
.filter((app) => app.status === "in-review")
.reduce((sum, app) => sum + (app.total_amount || 0), 0),
approvedAmount: applicationList
.filter((app) => app.status === "approved")
.reduce((sum, app) => sum + (app.total_amount || 0), 0),
rejectedAmount: applicationList
.filter((app) => app.status === "rejected")
.reduce((sum, app) => sum + (app.total_amount || 0), 0),
};
// Handle advanced filters
const handleFilterOpen = (event: React.MouseEvent<HTMLElement>) => {
setFilterAnchorEl(event.currentTarget);
};
const handleFilterClose = () => {
setFilterAnchorEl(null);
};
const handleApplyFilters = (filters: any) => {
setActiveFilters(filters);
// Prepare API parameters with extended filters
const searchParams = {
q: filters.searchQuery || undefined,
status: filters.statusFilter || undefined,
variant: filters.variantFilter || undefined,
amount_min: filters.amountMin > 0 ? filters.amountMin : undefined,
amount_max: filters.amountMax < 300000 ? filters.amountMax : undefined,
date_from: filters.dateFrom?.format("YYYY-MM-DD") || undefined,
date_to: filters.dateTo?.format("YYYY-MM-DD") || undefined,
created_by: filters.createdBy || undefined,
has_attachments:
filters.hasAttachments !== null ? filters.hasAttachments : undefined,
limit: 50,
offset: 0,
};
// Remove undefined values
const cleanParams = Object.fromEntries(
Object.entries(searchParams).filter(([_, value]) => value !== undefined),
);
// Use advanced search API with all filters
if (Object.keys(cleanParams).length > 2) {
// More than just limit and offset - use advanced search
searchApplicationsAdvanced(cleanParams);
} else {
// Update store filters for basic filtering
if (filters.statusFilter) {
setStatusFilter(filters.statusFilter);
}
if (filters.variantFilter) {
setVariantFilter(filters.variantFilter);
}
loadApplicationListAdmin();
}
};
const handleClearAllFilters = () => {
setActiveFilters({});
setStatusFilter(null);
setVariantFilter(null);
setSearchText("");
loadApplicationListAdmin();
};
const handleClearSingleFilter = (filterKey: string) => {
const newFilters = { ...activeFilters };
switch (filterKey) {
case "searchQuery":
newFilters.searchQuery = "";
setSearchText("");
break;
case "statusFilter":
newFilters.statusFilter = "";
setStatusFilter(null);
break;
case "variantFilter":
newFilters.variantFilter = "";
setVariantFilter(null);
break;
case "amountRange":
newFilters.amountMin = 0;
newFilters.amountMax = 300000;
break;
case "dateRange":
newFilters.dateFrom = null;
newFilters.dateTo = null;
break;
case "createdBy":
newFilters.createdBy = "";
break;
case "hasAttachments":
newFilters.hasAttachments = null;
break;
case "fuzzySearch":
newFilters.fuzzySearch = true;
break;
}
setActiveFilters(newFilters);
// Apply the updated filters
if (
Object.values(newFilters).some(
(v) => v && v !== "" && v !== 0 && v !== 300000,
)
) {
handleApplyFilters(newFilters);
} else {
loadApplicationListAdmin();
}
}; };
return ( return (
@ -158,7 +433,7 @@ const AdminDashboard: React.FC = () => {
{/* Statistics Cards */} {/* Statistics Cards */}
<Grid container spacing={3} sx={{ mb: 4 }}> <Grid container spacing={3} sx={{ mb: 4 }}>
<Grid item xs={12} sm={6} md={2}> <Grid item xs={12} sm={6} md={2.4}>
<Card> <Card>
<CardContent sx={{ textAlign: "center" }}> <CardContent sx={{ textAlign: "center" }}>
<Typography variant="h4" color="primary"> <Typography variant="h4" color="primary">
@ -167,34 +442,67 @@ const AdminDashboard: React.FC = () => {
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Gesamt Gesamt
</Typography> </Typography>
<Typography
variant="body2"
color="primary"
sx={{ fontWeight: "bold", mt: 0.5 }}
>
{stats.totalAmount.toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
maximumFractionDigits: 0,
})}
</Typography>
</CardContent> </CardContent>
</Card> </Card>
</Grid> </Grid>
<Grid item xs={12} sm={6} md={2}> <Grid item xs={12} sm={6} md={2.4}>
<Card> <Card>
<CardContent sx={{ textAlign: "center" }}> <CardContent sx={{ textAlign: "center" }}>
<Typography variant="h4" color="info.main"> <Typography variant="h4" color="info.main">
{stats.new} {stats.new}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Neu Beantragt
</Typography>
<Typography
variant="body2"
color="info.main"
sx={{ fontWeight: "bold", mt: 0.5 }}
>
{stats.newAmount.toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
maximumFractionDigits: 0,
})}
</Typography> </Typography>
</CardContent> </CardContent>
</Card> </Card>
</Grid> </Grid>
<Grid item xs={12} sm={6} md={2}> <Grid item xs={12} sm={6} md={2.4}>
<Card> <Card>
<CardContent sx={{ textAlign: "center" }}> <CardContent sx={{ textAlign: "center" }}>
<Typography variant="h4" color="warning.main"> <Typography variant="h4" color="warning.main">
{stats.inReview} {stats.inReview}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
In Prüfung Bearbeitung Gesperrt
</Typography>
<Typography
variant="body2"
color="warning.main"
sx={{ fontWeight: "bold", mt: 0.5 }}
>
{stats.inReviewAmount.toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
maximumFractionDigits: 0,
})}
</Typography> </Typography>
</CardContent> </CardContent>
</Card> </Card>
</Grid> </Grid>
<Grid item xs={12} sm={6} md={2}> <Grid item xs={12} sm={6} md={2.4}>
<Card> <Card>
<CardContent sx={{ textAlign: "center" }}> <CardContent sx={{ textAlign: "center" }}>
<Typography variant="h4" color="success.main"> <Typography variant="h4" color="success.main">
@ -203,10 +511,21 @@ const AdminDashboard: React.FC = () => {
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Genehmigt Genehmigt
</Typography> </Typography>
<Typography
variant="body2"
color="success.main"
sx={{ fontWeight: "bold", mt: 0.5 }}
>
{stats.approvedAmount.toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
maximumFractionDigits: 0,
})}
</Typography>
</CardContent> </CardContent>
</Card> </Card>
</Grid> </Grid>
<Grid item xs={12} sm={6} md={2}> <Grid item xs={12} sm={6} md={2.4}>
<Card> <Card>
<CardContent sx={{ textAlign: "center" }}> <CardContent sx={{ textAlign: "center" }}>
<Typography variant="h4" color="error.main"> <Typography variant="h4" color="error.main">
@ -215,44 +534,33 @@ const AdminDashboard: React.FC = () => {
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Abgelehnt Abgelehnt
</Typography> </Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={2}>
<Card>
<CardContent sx={{ textAlign: "center" }}>
<Typography <Typography
variant="h5" variant="body2"
color="text.primary" color="error.main"
sx={{ sx={{ fontWeight: "bold", mt: 0.5 }}
fontWeight: "bold",
fontSize: { xs: "1rem", sm: "1.25rem" },
}}
> >
{stats.totalAmount.toLocaleString("de-DE", { {stats.rejectedAmount.toLocaleString("de-DE", {
style: "currency", style: "currency",
currency: "EUR", currency: "EUR",
maximumFractionDigits: 0, maximumFractionDigits: 0,
})} })}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary">
Gesamtsumme
</Typography>
</CardContent> </CardContent>
</Card> </Card>
</Grid> </Grid>
</Grid> </Grid>
{/* Filters and Search */} {/* Search and Filters */}
<Paper sx={{ p: 3, mb: 3 }}> <Paper sx={{ p: 3, mb: 3 }}>
<Grid container spacing={2} alignItems="center"> <Grid container spacing={2} alignItems="center">
<Grid item xs={12} md={4}> <Grid item xs={12} md={6}>
<TextField <TextField
fullWidth fullWidth
label="Suchen" label="Schnellsuche"
value={searchText} value={searchText}
onChange={(e) => setSearchText(e.target.value)} onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()} onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="Projektname, Antrags-ID..."
InputProps={{ InputProps={{
endAdornment: ( endAdornment: (
<IconButton onClick={handleSearch}> <IconButton onClick={handleSearch}>
@ -261,67 +569,234 @@ const AdminDashboard: React.FC = () => {
), ),
}} }}
/> />
{(Object.keys(activeFilters).length > 0 ||
statusFilter ||
variantFilter ||
searchText.trim()) && (
<Box sx={{ mt: 1 }}>
<Button
variant="text"
size="small"
onClick={handleClearAllFilters}
sx={{ color: "text.secondary" }}
>
Alle Filter zurücksetzen
</Button>
</Box>
)}
</Grid> </Grid>
<Grid item xs={12} md={3}> <Grid item xs={12} md={6}>
<TextField <Box sx={{ display: "flex", gap: 1, justifyContent: "flex-end" }}>
fullWidth <Button
select variant="outlined"
label="Status" onClick={handleFilterOpen}
value={statusFilter || ""} startIcon={<FilterList />}
onChange={(e) => setStatusFilter(e.target.value || null)} sx={{ minWidth: 140 }}
> >
<MenuItem value="">Alle Status</MenuItem> Filter{" "}
<MenuItem value="new">Neu</MenuItem> {(() => {
<MenuItem value="in-review">In Prüfung</MenuItem> let count = 0;
<MenuItem value="approved">Genehmigt</MenuItem> if (activeFilters.searchQuery) count++;
<MenuItem value="rejected">Abgelehnt</MenuItem> if (activeFilters.statusFilter) count++;
</TextField> if (activeFilters.variantFilter) count++;
</Grid> if (
<Grid item xs={12} md={3}> activeFilters.amountMin > 0 ||
<TextField activeFilters.amountMax < 300000
fullWidth )
select count++;
label="Typ" if (activeFilters.dateFrom || activeFilters.dateTo) count++;
value={variantFilter || ""} if (activeFilters.createdBy) count++;
onChange={(e) => setVariantFilter(e.target.value || null)} if (activeFilters.hasAttachments !== null) count++;
> if (activeFilters.fuzzySearch === false) count++;
<MenuItem value="">Alle Typen</MenuItem> return count > 0 ? `(${count})` : "";
<MenuItem value="VSM">VSM</MenuItem> })()}
<MenuItem value="QSM">QSM</MenuItem> </Button>
</TextField> <Button
</Grid> variant="outlined"
<Grid item xs={12} md={2}> onClick={() => loadApplicationListAdmin()}
<Button startIcon={<Refresh />}
fullWidth >
variant="outlined" Aktualisieren
onClick={() => loadApplicationListAdmin()} </Button>
startIcon={<Refresh />} </Box>
>
Aktualisieren
</Button>
</Grid> </Grid>
</Grid> </Grid>
{/* Active Filters Display */}
{Object.keys(activeFilters).length > 0 && (
<Box sx={{ mt: 2, display: "flex", gap: 1, flexWrap: "wrap" }}>
{activeFilters.searchQuery && (
<Chip
label={`Suche: ${activeFilters.searchQuery}`}
onDelete={() => handleClearSingleFilter("searchQuery")}
size="small"
/>
)}
{activeFilters.statusFilter && (
<Chip
label={`Status: ${activeFilters.statusFilter}`}
onDelete={() => handleClearSingleFilter("statusFilter")}
size="small"
/>
)}
{activeFilters.variantFilter && (
<Chip
label={`Typ: ${activeFilters.variantFilter}`}
onDelete={() => handleClearSingleFilter("variantFilter")}
size="small"
/>
)}
{(activeFilters.amountMin > 0 ||
activeFilters.amountMax < 300000) && (
<Chip
label={`Betrag: ${activeFilters.amountMin?.toLocaleString("de-DE") || 0}€ - ${activeFilters.amountMax?.toLocaleString("de-DE") || 300000}`}
onDelete={() => handleClearSingleFilter("amountRange")}
size="small"
/>
)}
{(activeFilters.dateFrom || activeFilters.dateTo) && (
<Chip
label={`Datum: ${activeFilters.dateFrom?.format("DD.MM.YY") || "∞"} - ${activeFilters.dateTo?.format("DD.MM.YY") || "∞"}`}
onDelete={() => handleClearSingleFilter("dateRange")}
size="small"
/>
)}
{activeFilters.createdBy && (
<Chip
label={`Ersteller: ${activeFilters.createdBy}`}
onDelete={() => handleClearSingleFilter("createdBy")}
size="small"
/>
)}
{activeFilters.hasAttachments !== null && (
<Chip
label={`Anhänge: ${activeFilters.hasAttachments ? "Ja" : "Nein"}`}
onDelete={() => handleClearSingleFilter("hasAttachments")}
size="small"
/>
)}
{activeFilters.fuzzySearch === false && (
<Chip
label="Exakte Suche"
onDelete={() => handleClearSingleFilter("fuzzySearch")}
size="small"
/>
)}
</Box>
)}
</Paper> </Paper>
{/* Filter Popover */}
<FilterPopover
anchorEl={filterAnchorEl}
open={Boolean(filterAnchorEl)}
onClose={handleFilterClose}
onApplyFilters={handleApplyFilters}
onClearFilters={handleClearAllFilters}
initialFilters={{
searchQuery: searchText,
statusFilter: statusFilter || "",
variantFilter: variantFilter || "",
amountMin: activeFilters.amountMin || 0,
amountMax: activeFilters.amountMax || 300000,
dateFrom: activeFilters.dateFrom || null,
dateTo: activeFilters.dateTo || null,
createdBy: activeFilters.createdBy || "",
hasAttachments: activeFilters.hasAttachments ?? null,
fuzzySearch: activeFilters.fuzzySearch !== false,
}}
/>
{/* Applications Table */} {/* Applications Table */}
<TableContainer component={Paper}> <TableContainer component={Paper}>
<Table> <Table>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell>Antrags-ID</TableCell> <TableCell padding="checkbox">
<TableCell>Projektname</TableCell> <Checkbox
<TableCell>Typ</TableCell> checked={
<TableCell>Status</TableCell> selectedApplications.length === applicationList.length &&
<TableCell align="right">Summe</TableCell> applicationList.length > 0
<TableCell>Erstellt</TableCell> }
<TableCell>Geändert</TableCell> indeterminate={
selectedApplications.length > 0 &&
selectedApplications.length < applicationList.length
}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
</TableCell>
<TableCell>
<TableSortLabel
active={sortBy === "pa_id"}
direction={sortBy === "pa_id" ? sortOrder : "asc"}
onClick={() => handleRequestSort("pa_id")}
>
Antrags-ID
</TableSortLabel>
</TableCell>
<TableCell>
<TableSortLabel
active={sortBy === "project_name"}
direction={sortBy === "project_name" ? sortOrder : "asc"}
onClick={() => handleRequestSort("project_name")}
>
Projektname
</TableSortLabel>
</TableCell>
<TableCell>
<TableSortLabel
active={sortBy === "variant"}
direction={sortBy === "variant" ? sortOrder : "asc"}
onClick={() => handleRequestSort("variant")}
>
Typ
</TableSortLabel>
</TableCell>
<TableCell>
<TableSortLabel
active={sortBy === "status"}
direction={sortBy === "status" ? sortOrder : "asc"}
onClick={() => handleRequestSort("status")}
>
Status
</TableSortLabel>
</TableCell>
<TableCell align="right">
<TableSortLabel
active={sortBy === "total_amount"}
direction={sortBy === "total_amount" ? sortOrder : "asc"}
onClick={() => handleRequestSort("total_amount")}
>
Summe
</TableSortLabel>
</TableCell>
<TableCell>
<TableSortLabel
active={sortBy === "created_at"}
direction={sortBy === "created_at" ? sortOrder : "asc"}
onClick={() => handleRequestSort("created_at")}
>
Erstellt
</TableSortLabel>
</TableCell>
<TableCell>
<TableSortLabel
active={sortBy === "updated_at"}
direction={sortBy === "updated_at" ? sortOrder : "asc"}
onClick={() => handleRequestSort("updated_at")}
>
Geändert
</TableSortLabel>
</TableCell>
<TableCell align="right">Aktionen</TableCell> <TableCell align="right">Aktionen</TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{applicationList.length === 0 && !isLoading ? ( {applicationList.length === 0 && !isLoading ? (
<TableRow> <TableRow>
<TableCell colSpan={8} align="center"> <TableCell colSpan={9} align="center">
<Typography color="text.secondary"> <Typography color="text.secondary">
Keine Anträge gefunden Keine Anträge gefunden
</Typography> </Typography>
@ -329,7 +804,22 @@ const AdminDashboard: React.FC = () => {
</TableRow> </TableRow>
) : ( ) : (
applicationList.map((application) => ( applicationList.map((application) => (
<TableRow key={application.pa_id} hover> <TableRow
key={application.pa_id}
hover
selected={selectedApplications.includes(application.pa_id)}
>
<TableCell padding="checkbox">
<Checkbox
checked={selectedApplications.includes(application.pa_id)}
onChange={(e) =>
handleSelectApplication(
application.pa_id,
e.target.checked,
)
}
/>
</TableCell>
<TableCell> <TableCell>
<Typography variant="body2" sx={{ fontWeight: "medium" }}> <Typography variant="body2" sx={{ fontWeight: "medium" }}>
{application.pa_id} {application.pa_id}
@ -362,8 +852,10 @@ const AdminDashboard: React.FC = () => {
translateStatus(value as string), translateStatus(value as string),
}} }}
> >
<MenuItem value="new">Neu</MenuItem> <MenuItem value="new">Beantragt</MenuItem>
<MenuItem value="in-review">In Prüfung</MenuItem> <MenuItem value="in-review">
Bearbeitung Gesperrt
</MenuItem>
<MenuItem value="approved">Genehmigt</MenuItem> <MenuItem value="approved">Genehmigt</MenuItem>
<MenuItem value="rejected">Abgelehnt</MenuItem> <MenuItem value="rejected">Abgelehnt</MenuItem>
</TextField> </TextField>
@ -425,6 +917,157 @@ const AdminDashboard: React.FC = () => {
)} )}
{isLoading && <LoadingSpinner text="Wird geladen..." />} {isLoading && <LoadingSpinner text="Wird geladen..." />}
{/* Floating Action Bar for Bulk Operations */}
<Slide direction="up" in={showBulkActions} mountOnEnter unmountOnExit>
<Box
sx={{
position: "fixed",
bottom: 24,
left: 0,
right: 0,
zIndex: 1100,
display: "flex",
justifyContent: "center",
maxWidth: "calc(100vw - 120px)",
margin: "0 auto",
pointerEvents: "none", // Allow clicks through the container
}}
>
<Paper
elevation={6}
sx={{
borderRadius: 3,
backgroundColor: "primary.main",
color: "primary.contrastText",
overflow: "hidden",
pointerEvents: "auto", // Re-enable clicks on the paper
width: "fit-content",
minWidth: 400,
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
p: 2,
overflowX: "auto",
scrollbarWidth: "none", // Firefox
"&::-webkit-scrollbar": {
display: "none", // Chrome, Safari, Edge
},
}}
>
<Typography
variant="body2"
sx={{
fontWeight: "medium",
flexShrink: 0,
minWidth: "max-content",
}}
>
{selectedApplications.length} ausgewählt
</Typography>
<Box
sx={{
display: "flex",
gap: 1,
flexShrink: 0,
}}
>
<Button
variant="contained"
size="small"
color="inherit"
onClick={handleBulkSetNew}
startIcon={<LockOpenOutlined />}
sx={{
borderRadius: "20px",
textTransform: "none",
backgroundColor: "white",
color: "primary.main",
"&:hover": {
backgroundColor: "grey.100",
},
}}
>
Freigeben
</Button>
<Button
variant="contained"
size="small"
color="warning"
onClick={handleBulkSetInReview}
startIcon={<BlockOutlined />}
sx={{
borderRadius: "20px",
textTransform: "none",
}}
>
Bearbeitung Sperren
</Button>
<Button
variant="contained"
size="small"
color="success"
onClick={handleBulkApprove}
startIcon={<CheckCircleOutline />}
sx={{
borderRadius: "20px",
textTransform: "none",
}}
>
Genehmigen
</Button>
<Button
variant="contained"
size="small"
color="secondary"
onClick={handleBulkReject}
startIcon={<CancelOutlined />}
sx={{
borderRadius: "20px",
textTransform: "none",
}}
>
Ablehnen
</Button>
<Button
variant="contained"
size="small"
color="error"
onClick={handleBulkDelete}
startIcon={<DeleteOutline />}
sx={{
borderRadius: "20px",
textTransform: "none",
}}
>
Löschen
</Button>
<Button
variant="contained"
size="small"
onClick={() => setSelectedApplications([])}
startIcon={<CancelOutlined />}
sx={{
borderRadius: "20px",
textTransform: "none",
backgroundColor: "grey.300",
color: "grey.800",
"&:hover": {
backgroundColor: "grey.400",
},
}}
>
Abbrechen
</Button>
</Box>
</Box>
</Paper>
</Box>
</Slide>
</Container> </Container>
); );
}; };

View File

@ -135,14 +135,29 @@ const EditApplicationPage: React.FC = () => {
); );
} }
// Check if application can be edited (only "new" status) // Check if application can be edited - block in-review, approved, and rejected for everyone
if (currentApplication.status !== "new" && !isAdmin) { if (
currentApplication.status === "in-review" ||
currentApplication.status === "approved" ||
currentApplication.status === "rejected" ||
(currentApplication.status !== "new" && !isAdmin)
) {
const status = currentApplication.status;
let message =
"Dieser Antrag kann nicht mehr bearbeitet werden, da er bereits in Bearbeitung ist.";
if (status === "in-review") {
message =
"Dieser Antrag ist zur Bearbeitung gesperrt und kann nicht bearbeitet werden.";
} else if (status === "approved") {
message = "Genehmigte Anträge können nicht mehr bearbeitet werden.";
} else if (status === "rejected") {
message = "Abgelehnte Anträge können nicht mehr bearbeitet werden.";
}
return ( return (
<Container maxWidth="md" sx={{ mt: 4 }}> <Container maxWidth="md" sx={{ mt: 4 }}>
<Alert severity="warning"> <Alert severity="warning">{message}</Alert>
Dieser Antrag kann nicht mehr bearbeitet werden, da er bereits in
Bearbeitung ist.
</Alert>
<Box sx={{ mt: 2 }}> <Box sx={{ mt: 2 }}>
<Button <Button
variant="outlined" variant="outlined"

View File

@ -255,7 +255,12 @@ const ViewApplicationPage: React.FC = () => {
: `/application/${paId}/edit`, : `/application/${paId}/edit`,
) )
} }
disabled={!isAdmin && currentApplication.status !== "new"} disabled={
currentApplication.status === "in-review" ||
currentApplication.status === "approved" ||
currentApplication.status === "rejected" ||
(!isAdmin && currentApplication.status !== "new")
}
> >
<Edit /> <Edit />
</IconButton> </IconButton>
@ -590,7 +595,12 @@ const ViewApplicationPage: React.FC = () => {
paId={paId!} paId={paId!}
paKey={paKey || undefined} paKey={paKey || undefined}
isAdmin={isAdmin} isAdmin={isAdmin}
readOnly={!isAdmin && currentApplication.status !== "new"} readOnly={
currentApplication.status === "in-review" ||
currentApplication.status === "approved" ||
currentApplication.status === "rejected" ||
(!isAdmin && currentApplication.status !== "new")
}
attachments={attachments} attachments={attachments}
/> />

View File

@ -41,6 +41,10 @@ interface ApplicationState {
statusFilter: string | null; statusFilter: string | null;
variantFilter: string | null; variantFilter: string | null;
searchQuery: string | null; searchQuery: string | null;
// Sorting
sortBy: string;
sortOrder: "asc" | "desc";
} }
interface ApplicationActions { interface ApplicationActions {
@ -68,9 +72,11 @@ interface ApplicationActions {
// Application list management // Application list management
loadApplicationList: (refresh?: boolean) => Promise<boolean>; loadApplicationList: (refresh?: boolean) => Promise<boolean>;
searchApplications: (query: string) => Promise<boolean>; searchApplications: (query: string) => Promise<boolean>;
searchApplicationsAdvanced: (params: any) => Promise<boolean>;
setStatusFilter: (status: string | null) => void; setStatusFilter: (status: string | null) => void;
setVariantFilter: (variant: string | null) => void; setVariantFilter: (variant: string | null) => void;
setPage: (page: number) => void; setPage: (page: number) => void;
setSorting: (sortBy: string, sortOrder: "asc" | "desc") => void;
// Admin operations // Admin operations
loadApplicationAdmin: (paId: string) => Promise<boolean>; loadApplicationAdmin: (paId: string) => Promise<boolean>;
@ -91,6 +97,13 @@ interface ApplicationActions {
paId: string, paId: string,
) => Promise<{ pa_id: string; pa_key: string } | null>; ) => Promise<{ pa_id: string; pa_key: string } | null>;
// Bulk operations
bulkDeleteApplications: (paIds: string[]) => Promise<boolean>;
bulkApproveApplications: (paIds: string[]) => Promise<boolean>;
bulkRejectApplications: (paIds: string[]) => Promise<boolean>;
bulkSetInReviewApplications: (paIds: string[]) => Promise<boolean>;
bulkSetNewApplications: (paIds: string[]) => Promise<boolean>;
// Form management // Form management
updateFormData: (data: Partial<FormData>) => void; updateFormData: (data: Partial<FormData>) => void;
resetFormData: () => void; resetFormData: () => void;
@ -158,6 +171,10 @@ const initialState: ApplicationState = {
statusFilter: null, statusFilter: null,
variantFilter: null, variantFilter: null,
searchQuery: null, searchQuery: null,
// Sorting
sortBy: "created_at",
sortOrder: "desc",
}; };
export const useApplicationStore = create< export const useApplicationStore = create<
@ -582,6 +599,8 @@ export const useApplicationStore = create<
offset: currentPage * itemsPerPage, offset: currentPage * itemsPerPage,
status: statusFilter || undefined, status: statusFilter || undefined,
variant: variantFilter || undefined, variant: variantFilter || undefined,
order_by: get().sortBy,
order: get().sortOrder,
}); });
} else { } else {
response = await apiClient.listApplications(paId!, paKey!, { response = await apiClient.listApplications(paId!, paKey!, {
@ -589,6 +608,8 @@ export const useApplicationStore = create<
offset: currentPage * itemsPerPage, offset: currentPage * itemsPerPage,
status: statusFilter || undefined, status: statusFilter || undefined,
variant: variantFilter || undefined, variant: variantFilter || undefined,
order_by: get().sortBy,
order: get().sortOrder,
}); });
} }
@ -642,6 +663,8 @@ export const useApplicationStore = create<
offset: 0, offset: 0,
status: get().statusFilter || undefined, status: get().statusFilter || undefined,
variant: get().variantFilter || undefined, variant: get().variantFilter || undefined,
order_by: get().sortBy,
order: get().sortOrder,
}); });
if (isSuccessResponse(response)) { if (isSuccessResponse(response)) {
@ -668,6 +691,63 @@ export const useApplicationStore = create<
} }
}, },
searchApplicationsAdvanced: async (params: any) => {
if (!get().isAdmin) {
set({ error: "Suche nur für Administratoren verfügbar" });
return false;
}
if (!get().masterKey) {
set({
error: "Master key not available. Please login again.",
isLoading: false,
});
return false;
}
set({ isLoading: true, error: null });
try {
const response = await apiClient.searchApplications({
q: params.q,
status: params.status,
variant: params.variant,
amount_min: params.amount_min,
amount_max: params.amount_max,
date_from: params.date_from,
date_to: params.date_to,
created_by: params.created_by,
has_attachments: params.has_attachments,
limit: params.limit || get().itemsPerPage,
offset: params.offset || 0,
order_by: get().sortBy,
order: get().sortOrder,
});
if (isSuccessResponse(response)) {
set({
applicationList: response.data,
currentPage: 0,
isLoading: false,
searchQuery: params.q || null,
});
return true;
} else {
set({
error: getErrorMessage(response),
isLoading: false,
});
return false;
}
} catch (error: any) {
set({
error: error.message || "Suche fehlgeschlagen",
isLoading: false,
});
return false;
}
},
setStatusFilter: (status) => { setStatusFilter: (status) => {
set({ statusFilter: status, currentPage: 0 }); set({ statusFilter: status, currentPage: 0 });
}, },
@ -680,6 +760,10 @@ export const useApplicationStore = create<
set({ currentPage: page }); set({ currentPage: page });
}, },
setSorting: (sortBy, sortOrder) => {
set({ sortBy, sortOrder, currentPage: 0 });
},
// Admin operations // Admin operations
loadApplicationAdmin: async (paId: string) => { loadApplicationAdmin: async (paId: string) => {
if (!get().isAdmin) { if (!get().isAdmin) {
@ -1107,6 +1191,202 @@ export const useApplicationStore = create<
const { paId: currentPaId, isAdmin } = get(); const { paId: currentPaId, isAdmin } = get();
return isAdmin || currentPaId === paId; return isAdmin || currentPaId === paId;
}, },
// Bulk operations
bulkDeleteApplications: async (paIds: string[]) => {
if (!get().isAdmin || !get().masterKey) {
set({ error: "Admin access required" });
return false;
}
set({ isLoading: true, error: null });
try {
const response = await apiClient.bulkOperation({
pa_ids: paIds,
operation: "delete",
});
if (isSuccessResponse(response)) {
// Reload application list to reflect changes
await get().loadApplicationListAdmin();
set({
successMessage: `${response.data.success.length} Anträge erfolgreich gelöscht`,
isLoading: false,
});
return true;
} else {
set({
error: getErrorMessage(response),
isLoading: false,
});
return false;
}
} catch (error) {
set({
error:
error instanceof Error ? error.message : "Fehler beim Löschen",
isLoading: false,
});
return false;
}
},
bulkApproveApplications: async (paIds: string[]) => {
if (!get().isAdmin || !get().masterKey) {
set({ error: "Admin access required" });
return false;
}
set({ isLoading: true, error: null });
try {
const response = await apiClient.bulkOperation({
pa_ids: paIds,
operation: "approve",
});
if (isSuccessResponse(response)) {
await get().loadApplicationListAdmin();
set({
successMessage: `${response.data.success.length} Anträge erfolgreich genehmigt`,
isLoading: false,
});
return true;
} else {
set({
error: getErrorMessage(response),
isLoading: false,
});
return false;
}
} catch (error) {
set({
error:
error instanceof Error ? error.message : "Fehler beim Genehmigen",
isLoading: false,
});
return false;
}
},
bulkRejectApplications: async (paIds: string[]) => {
if (!get().isAdmin || !get().masterKey) {
set({ error: "Admin access required" });
return false;
}
set({ isLoading: true, error: null });
try {
const response = await apiClient.bulkOperation({
pa_ids: paIds,
operation: "reject",
});
if (isSuccessResponse(response)) {
await get().loadApplicationListAdmin();
set({
successMessage: `${response.data.success.length} Anträge erfolgreich abgelehnt`,
isLoading: false,
});
return true;
} else {
set({
error: getErrorMessage(response),
isLoading: false,
});
return false;
}
} catch (error) {
set({
error:
error instanceof Error ? error.message : "Fehler beim Ablehnen",
isLoading: false,
});
return false;
}
},
bulkSetInReviewApplications: async (paIds: string[]) => {
if (!get().isAdmin || !get().masterKey) {
set({ error: "Admin access required" });
return false;
}
set({ isLoading: true, error: null });
try {
const response = await apiClient.bulkOperation({
pa_ids: paIds,
operation: "set_in_review",
});
if (isSuccessResponse(response)) {
await get().loadApplicationListAdmin();
set({
successMessage: `${response.data.success.length} Anträge für Bearbeitung gesperrt`,
isLoading: false,
});
return true;
} else {
set({
error: getErrorMessage(response),
isLoading: false,
});
return false;
}
} catch (error) {
set({
error:
error instanceof Error
? error.message
: "Fehler beim Status-Update",
isLoading: false,
});
return false;
}
},
bulkSetNewApplications: async (paIds: string[]) => {
if (!get().isAdmin || !get().masterKey) {
set({ error: "Admin access required" });
return false;
}
set({ isLoading: true, error: null });
try {
const response = await apiClient.bulkOperation({
pa_ids: paIds,
operation: "set_new",
});
if (isSuccessResponse(response)) {
await get().loadApplicationListAdmin();
set({
successMessage: `${response.data.success.length} Anträge auf "Beantragt" gesetzt`,
isLoading: false,
});
return true;
} else {
set({
error: getErrorMessage(response),
isLoading: false,
});
return false;
}
} catch (error) {
set({
error:
error instanceof Error
? error.message
: "Fehler beim Status-Update",
isLoading: false,
});
return false;
}
},
}), }),
{ {
name: "stupa-application-storage", name: "stupa-application-storage",

View File

@ -0,0 +1,242 @@
/**
* Fuzzy search utility for searching through application data
*/
export interface SearchableItem {
pa_id: string;
project_name?: string;
status: string;
variant: string;
total_amount?: number;
created_at: string;
updated_at: string;
[key: string]: any;
}
/**
* Calculate Levenshtein distance between two strings
*/
function levenshteinDistance(str1: string, str2: string): number {
const matrix = [];
// If one string is empty, return the length of the other
if (str1.length === 0) return str2.length;
if (str2.length === 0) return str1.length;
// Create matrix
for (let i = 0; i <= str2.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= str1.length; j++) {
matrix[0][j] = j;
}
// Fill matrix
for (let i = 1; i <= str2.length; i++) {
for (let j = 1; j <= str1.length; j++) {
if (str2.charAt(i - 1) === str1.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1, // substitution
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j] + 1, // deletion
);
}
}
}
return matrix[str2.length][str1.length];
}
/**
* Calculate fuzzy match score between query and text
* Returns a score between 0 and 1 (1 being perfect match)
*/
function fuzzyScore(query: string, text: string): number {
if (!query || !text) return 0;
const queryLower = query.toLowerCase();
const textLower = text.toLowerCase();
// Exact match gets highest score
if (textLower.includes(queryLower)) {
const ratio = queryLower.length / textLower.length;
return 0.9 + ratio * 0.1; // 0.9 to 1.0
}
// Calculate Levenshtein distance
const distance = levenshteinDistance(queryLower, textLower);
const maxLength = Math.max(queryLower.length, textLower.length);
if (maxLength === 0) return 0;
// Convert distance to similarity score
return Math.max(0, 1 - distance / maxLength);
}
/**
* Search through items with fuzzy matching
*/
export function fuzzySearch<T extends SearchableItem>(
items: T[],
query: string,
options: {
threshold?: number; // Minimum score to include in results
limit?: number; // Maximum number of results
fields?: Array<keyof T>; // Fields to search in
} = {},
): Array<T & { _searchScore: number }> {
const {
threshold = 0.3,
limit = 100,
fields = ["project_name", "pa_id"],
} = options;
if (!query.trim()) {
return items.slice(0, limit).map((item) => ({ ...item, _searchScore: 1 }));
}
const results: Array<T & { _searchScore: number }> = [];
for (const item of items) {
let maxScore = 0;
// Search in specified fields
for (const field of fields) {
const fieldValue = item[field];
if (typeof fieldValue === "string") {
const score = fuzzyScore(query, fieldValue);
maxScore = Math.max(maxScore, score);
}
}
// Include item if score meets threshold
if (maxScore >= threshold) {
results.push({ ...item, _searchScore: maxScore });
}
}
// Sort by score (descending) and limit results
return results
.sort((a, b) => b._searchScore - a._searchScore)
.slice(0, limit);
}
/**
* Highlight matching parts of text for display
*/
export function highlightMatches(text: string, query: string): string {
if (!query || !text) return text;
const queryLower = query.toLowerCase();
const textLower = text.toLowerCase();
// Simple highlighting for exact matches
if (textLower.includes(queryLower)) {
const regex = new RegExp(
`(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`,
"gi",
);
return text.replace(regex, "<mark>$1</mark>");
}
return text;
}
/**
* Filter items based on advanced criteria
*/
export function advancedFilter<T extends SearchableItem>(
items: T[],
filters: {
status?: string;
variant?: string;
amountMin?: number;
amountMax?: number;
dateFrom?: Date;
dateTo?: Date;
createdBy?: string;
hasAttachments?: boolean;
},
): T[] {
return items.filter((item) => {
// Status filter
if (filters.status && item.status !== filters.status) {
return false;
}
// Variant filter
if (filters.variant && item.variant !== filters.variant) {
return false;
}
// Amount range filter
if (
filters.amountMin !== undefined &&
(item.total_amount || 0) < filters.amountMin
) {
return false;
}
if (
filters.amountMax !== undefined &&
(item.total_amount || 0) > filters.amountMax
) {
return false;
}
// Date range filter
if (filters.dateFrom) {
const itemDate = new Date(item.created_at);
if (itemDate < filters.dateFrom) {
return false;
}
}
if (filters.dateTo) {
const itemDate = new Date(item.created_at);
if (itemDate > filters.dateTo) {
return false;
}
}
// Created by filter (if available in item data)
if (filters.createdBy && item.created_by) {
const createdBy = String(item.created_by).toLowerCase();
const filterBy = filters.createdBy.toLowerCase();
if (!createdBy.includes(filterBy)) {
return false;
}
}
// Has attachments filter (if available in item data)
if (
filters.hasAttachments !== undefined &&
item.has_attachments !== undefined
) {
if (item.has_attachments !== filters.hasAttachments) {
return false;
}
}
return true;
});
}
/**
* Combined search and filter function
*/
export function searchAndFilter<T extends SearchableItem>(
items: T[],
searchQuery: string,
filters: Parameters<typeof advancedFilter>[1] = {},
searchOptions: Parameters<typeof fuzzySearch>[2] = {},
): Array<T & { _searchScore: number }> {
// First apply filters
const filteredItems = advancedFilter(items, filters);
// Then apply fuzzy search
return fuzzySearch(filteredItems, searchQuery, searchOptions);
}

View File

@ -1,10 +1,10 @@
export type ApplicationStatus = "new" | "in-review" | "approved" | "rejected"; export type ApplicationStatus = "new" | "in-review" | "approved" | "rejected";
export const statusTranslations: Record<ApplicationStatus, string> = { export const statusTranslations: Record<ApplicationStatus, string> = {
"new": "Neu", new: "Beantragt",
"in-review": "In Prüfung", "in-review": "Bearbeitung Gesperrt",
"approved": "Genehmigt", approved: "Genehmigt",
"rejected": "Abgelehnt" rejected: "Abgelehnt",
}; };
export const translateStatus = (status: string): string => { export const translateStatus = (status: string): string => {
@ -12,7 +12,16 @@ export const translateStatus = (status: string): string => {
return statusTranslations[normalizedStatus] || status; return statusTranslations[normalizedStatus] || status;
}; };
export const getStatusColor = (status: string): "default" | "primary" | "secondary" | "error" | "info" | "success" | "warning" => { export const getStatusColor = (
status: string,
):
| "default"
| "primary"
| "secondary"
| "error"
| "info"
| "success"
| "warning" => {
switch (status.toLowerCase()) { switch (status.toLowerCase()) {
case "new": case "new":
return "info"; return "info";

View File

@ -6,8 +6,8 @@ export type StatusColor = "info" | "warning" | "success" | "error" | "default";
* Translation mapping from English status to German * Translation mapping from English status to German
*/ */
const STATUS_TRANSLATIONS: Record<ApplicationStatus, string> = { const STATUS_TRANSLATIONS: Record<ApplicationStatus, string> = {
new: "Neu", new: "Beantragt",
"in-review": "In Prüfung", "in-review": "Bearbeitung Gesperrt",
approved: "Genehmigt", approved: "Genehmigt",
rejected: "Abgelehnt", rejected: "Abgelehnt",
}; };
@ -41,10 +41,13 @@ export function getStatusColor(status: string): StatusColor {
/** /**
* Get all available statuses with translations * Get all available statuses with translations
*/ */
export function getAllStatuses(): Array<{ value: ApplicationStatus; label: string }> { export function getAllStatuses(): Array<{
value: ApplicationStatus;
label: string;
}> {
return [ return [
{ value: "new", label: "Neu" }, { value: "new", label: "Beantragt" },
{ value: "in-review", label: "In Prüfung" }, { value: "in-review", label: "Bearbeitung Gesperrt" },
{ value: "approved", label: "Genehmigt" }, { value: "approved", label: "Genehmigt" },
{ value: "rejected", label: "Abgelehnt" }, { value: "rejected", label: "Abgelehnt" },
]; ];