Filters and Bulk Actions
This commit is contained in:
parent
c004449212
commit
4ca18da706
@ -43,7 +43,7 @@ from sqlalchemy import (
|
||||
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker, Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy import text as sql_text
|
||||
from sqlalchemy import text as sql_text, text
|
||||
|
||||
import PyPDF2
|
||||
from PyPDF2.errors import PdfReadError
|
||||
@ -617,6 +617,15 @@ def update_application(
|
||||
if not app_row:
|
||||
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: Dict[str, Any]
|
||||
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)
|
||||
|
||||
|
||||
@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}")
|
||||
def get_application(
|
||||
pa_id: str,
|
||||
@ -725,6 +903,8 @@ def list_applications(
|
||||
offset: int = Query(0, ge=0),
|
||||
status: 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_pa_key: Optional[str] = Header(None, alias="X-PA-KEY"),
|
||||
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
|
||||
if 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:
|
||||
q = q.where(Application.status == status)
|
||||
if variant:
|
||||
@ -773,6 +970,15 @@ def list_applications(
|
||||
"created_at": r.created_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
|
||||
|
||||
# Ohne Master: nur eigenen Antrag (pa_id + key erforderlich)
|
||||
@ -800,6 +1006,8 @@ def list_applications(
|
||||
except:
|
||||
pass
|
||||
|
||||
# Note: Sorting is not really applicable for single application return
|
||||
# but we keep the parameters for API consistency
|
||||
return [{
|
||||
"pa_id": app_row.pa_id,
|
||||
"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),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
|
||||
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# Bulk Operations Endpoints
|
||||
# -------------------------------------------------------------
|
||||
|
||||
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_forwarded_for: Optional[str] = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Perform bulk operations on applications (Admin only)"""
|
||||
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
|
||||
base_sql = "SELECT pa_id, variant, status, created_at, updated_at FROM applications WHERE 1=1"
|
||||
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
|
||||
if not request.pa_ids:
|
||||
raise HTTPException(status_code=400, detail="No application IDs provided")
|
||||
|
||||
rows = db.execute(sql_text(base_sql), params).all()
|
||||
return [
|
||||
{"pa_id": r[0], "variant": r[1], "status": r[2],
|
||||
"created_at": r[3].isoformat(), "updated_at": r[4].isoformat()}
|
||||
for r in rows
|
||||
]
|
||||
if request.operation not in ["delete", "approve", "reject", "set_in_review", "set_new"]:
|
||||
raise HTTPException(status_code=400, detail="Invalid operation")
|
||||
|
||||
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
|
||||
# -------------------------------------------------------------
|
||||
|
||||
@ -978,6 +1206,15 @@ async def upload_attachment(
|
||||
if not app:
|
||||
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)
|
||||
attachment_count = db.query(ApplicationAttachment).filter(
|
||||
ApplicationAttachment.application_id == app.id
|
||||
@ -1123,6 +1360,15 @@ def delete_attachment(
|
||||
if not app:
|
||||
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
|
||||
app_attachment = db.query(ApplicationAttachment).filter(
|
||||
ApplicationAttachment.application_id == app.id,
|
||||
@ -1165,6 +1411,15 @@ async def create_comparison_offer(
|
||||
if not app:
|
||||
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
|
||||
payload = app.payload_json
|
||||
costs = payload.get("pa", {}).get("project", {}).get("costs", [])
|
||||
@ -1305,6 +1560,15 @@ def delete_comparison_offer(
|
||||
if not app:
|
||||
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
|
||||
offer = db.query(ComparisonOffer).filter(
|
||||
ComparisonOffer.id == offer_id,
|
||||
@ -1340,6 +1604,15 @@ def update_cost_position_justification(
|
||||
if not app:
|
||||
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
|
||||
payload = app.payload_json
|
||||
costs = payload.get("pa", {}).get("project", {}).get("costs", [])
|
||||
@ -1393,6 +1666,15 @@ def set_preferred_offer(
|
||||
if not app:
|
||||
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
|
||||
payload = app.payload_json
|
||||
costs = payload.get("pa", {}).get("project", {}).get("costs", [])
|
||||
|
||||
489
docs/features/ADMIN_TABLE_SORTING.md
Normal file
489
docs/features/ADMIN_TABLE_SORTING.md
Normal 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
|
||||
@ -272,6 +272,8 @@ export class ApiClient {
|
||||
offset?: number;
|
||||
status?: string;
|
||||
variant?: string;
|
||||
order_by?: string;
|
||||
order?: string;
|
||||
},
|
||||
): Promise<ApiResponse<ApplicationListItem[]>> {
|
||||
const config = {
|
||||
@ -297,6 +299,8 @@ export class ApiClient {
|
||||
offset?: number;
|
||||
status?: string;
|
||||
variant?: string;
|
||||
order_by?: string;
|
||||
order?: string;
|
||||
}): Promise<ApiResponse<ApplicationListItem[]>> {
|
||||
if (!this.masterKey) {
|
||||
throw new Error("Master key required for admin operations");
|
||||
@ -454,8 +458,16 @@ export class ApiClient {
|
||||
q?: string;
|
||||
status?: string;
|
||||
variant?: string;
|
||||
amount_min?: number;
|
||||
amount_max?: number;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
created_by?: string;
|
||||
has_attachments?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
order_by?: string;
|
||||
order?: string;
|
||||
}): Promise<ApiResponse<ApplicationListItem[]>> {
|
||||
if (!this.masterKey) {
|
||||
throw new Error("Master key required for admin operations");
|
||||
@ -469,6 +481,32 @@ export class ApiClient {
|
||||
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
|
||||
|
||||
426
frontend/src/components/FilterPopover/FilterPopover.tsx
Normal file
426
frontend/src/components/FilterPopover/FilterPopover.tsx
Normal 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;
|
||||
1
frontend/src/components/FilterPopover/index.ts
Normal file
1
frontend/src/components/FilterPopover/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from "./FilterPopover";
|
||||
@ -267,8 +267,8 @@ const AdminApplicationView: React.FC = () => {
|
||||
onChange={(e) => setNewStatus(e.target.value)}
|
||||
sx={{ minWidth: 100 }}
|
||||
>
|
||||
<MenuItem value="new">Neu</MenuItem>
|
||||
<MenuItem value="in-review">In Prüfung</MenuItem>
|
||||
<MenuItem value="new">Beantragt</MenuItem>
|
||||
<MenuItem value="in-review">Bearbeitung Gesperrt</MenuItem>
|
||||
<MenuItem value="approved">Genehmigt</MenuItem>
|
||||
<MenuItem value="rejected">Abgelehnt</MenuItem>
|
||||
</TextField>
|
||||
@ -689,8 +689,10 @@ const AdminApplicationView: React.FC = () => {
|
||||
onChange={(e) => setNewStatus(e.target.value)}
|
||||
sx={{ minWidth: "100px" }}
|
||||
>
|
||||
<MenuItem value="new">Neu</MenuItem>
|
||||
<MenuItem value="in-review">In Prüfung</MenuItem>
|
||||
<MenuItem value="new">Beantragt</MenuItem>
|
||||
<MenuItem value="in-review">
|
||||
Bearbeitung Gesperrt
|
||||
</MenuItem>
|
||||
<MenuItem value="approved">Genehmigt</MenuItem>
|
||||
<MenuItem value="rejected">Abgelehnt</MenuItem>
|
||||
</TextField>
|
||||
|
||||
@ -20,19 +20,37 @@ import {
|
||||
MenuItem,
|
||||
Alert,
|
||||
Pagination,
|
||||
TableSortLabel,
|
||||
Checkbox,
|
||||
Slide,
|
||||
} 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 dayjs from "dayjs";
|
||||
|
||||
// Store
|
||||
import { useApplicationStore } from "../store/applicationStore";
|
||||
|
||||
// Types
|
||||
import { ApplicationListItem } from "../types/api";
|
||||
|
||||
// Utils
|
||||
import { translateStatus } from "../utils/statusTranslations";
|
||||
|
||||
// Components
|
||||
import LoadingSpinner from "../components/LoadingSpinner/LoadingSpinner";
|
||||
import FilterPopover from "../components/FilterPopover/FilterPopover";
|
||||
|
||||
const AdminDashboard: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
@ -49,19 +67,37 @@ const AdminDashboard: React.FC = () => {
|
||||
statusFilter,
|
||||
variantFilter,
|
||||
searchQuery,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
loadApplicationListAdmin,
|
||||
searchApplications,
|
||||
searchApplicationsAdvanced,
|
||||
setStatusFilter,
|
||||
setVariantFilter,
|
||||
setPage,
|
||||
updateApplicationStatusAdmin,
|
||||
deleteApplicationAdmin,
|
||||
setSorting,
|
||||
bulkDeleteApplications,
|
||||
bulkApproveApplications,
|
||||
bulkRejectApplications,
|
||||
bulkSetInReviewApplications,
|
||||
bulkSetNewApplications,
|
||||
isAdmin,
|
||||
masterKey,
|
||||
} = useApplicationStore();
|
||||
|
||||
// Local state
|
||||
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
|
||||
useEffect(() => {
|
||||
@ -83,17 +119,51 @@ const AdminDashboard: React.FC = () => {
|
||||
currentPage,
|
||||
statusFilter,
|
||||
variantFilter,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]);
|
||||
|
||||
// Handle search
|
||||
// Handle search with fuzzy matching
|
||||
const handleSearch = () => {
|
||||
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 {
|
||||
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
|
||||
const handleStatusChange = async (paId: string, newStatus: string) => {
|
||||
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) {
|
||||
return null; // Will redirect
|
||||
}
|
||||
@ -130,6 +288,123 @@ const AdminDashboard: React.FC = () => {
|
||||
(sum, app) => sum + (app.total_amount || 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 (
|
||||
@ -158,7 +433,7 @@ const AdminDashboard: React.FC = () => {
|
||||
|
||||
{/* Statistics Cards */}
|
||||
<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>
|
||||
<CardContent sx={{ textAlign: "center" }}>
|
||||
<Typography variant="h4" color="primary">
|
||||
@ -167,34 +442,67 @@ const AdminDashboard: React.FC = () => {
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Gesamt
|
||||
</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>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={2}>
|
||||
<Grid item xs={12} sm={6} md={2.4}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: "center" }}>
|
||||
<Typography variant="h4" color="info.main">
|
||||
{stats.new}
|
||||
</Typography>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={2}>
|
||||
<Grid item xs={12} sm={6} md={2.4}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: "center" }}>
|
||||
<Typography variant="h4" color="warning.main">
|
||||
{stats.inReview}
|
||||
</Typography>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={2}>
|
||||
<Grid item xs={12} sm={6} md={2.4}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: "center" }}>
|
||||
<Typography variant="h4" color="success.main">
|
||||
@ -203,10 +511,21 @@ const AdminDashboard: React.FC = () => {
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Genehmigt
|
||||
</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>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={2}>
|
||||
<Grid item xs={12} sm={6} md={2.4}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: "center" }}>
|
||||
<Typography variant="h4" color="error.main">
|
||||
@ -215,44 +534,33 @@ const AdminDashboard: React.FC = () => {
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Abgelehnt
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={2}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: "center" }}>
|
||||
<Typography
|
||||
variant="h5"
|
||||
color="text.primary"
|
||||
sx={{
|
||||
fontWeight: "bold",
|
||||
fontSize: { xs: "1rem", sm: "1.25rem" },
|
||||
}}
|
||||
variant="body2"
|
||||
color="error.main"
|
||||
sx={{ fontWeight: "bold", mt: 0.5 }}
|
||||
>
|
||||
{stats.totalAmount.toLocaleString("de-DE", {
|
||||
{stats.rejectedAmount.toLocaleString("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
maximumFractionDigits: 0,
|
||||
})}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Gesamtsumme
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Filters and Search */}
|
||||
{/* Search and Filters */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid item xs={12} md={4}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Suchen"
|
||||
label="Schnellsuche"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
placeholder="Projektname, Antrags-ID..."
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<IconButton onClick={handleSearch}>
|
||||
@ -261,67 +569,234 @@ const AdminDashboard: React.FC = () => {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<TextField
|
||||
fullWidth
|
||||
select
|
||||
label="Status"
|
||||
value={statusFilter || ""}
|
||||
onChange={(e) => setStatusFilter(e.target.value || null)}
|
||||
>
|
||||
<MenuItem value="">Alle Status</MenuItem>
|
||||
<MenuItem value="new">Neu</MenuItem>
|
||||
<MenuItem value="in-review">In Prüfung</MenuItem>
|
||||
<MenuItem value="approved">Genehmigt</MenuItem>
|
||||
<MenuItem value="rejected">Abgelehnt</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<TextField
|
||||
fullWidth
|
||||
select
|
||||
label="Typ"
|
||||
value={variantFilter || ""}
|
||||
onChange={(e) => setVariantFilter(e.target.value || null)}
|
||||
>
|
||||
<MenuItem value="">Alle Typen</MenuItem>
|
||||
<MenuItem value="VSM">VSM</MenuItem>
|
||||
<MenuItem value="QSM">QSM</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={2}>
|
||||
{(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 item xs={12} md={6}>
|
||||
<Box sx={{ display: "flex", gap: 1, justifyContent: "flex-end" }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleFilterOpen}
|
||||
startIcon={<FilterList />}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
Filter{" "}
|
||||
{(() => {
|
||||
let count = 0;
|
||||
if (activeFilters.searchQuery) count++;
|
||||
if (activeFilters.statusFilter) count++;
|
||||
if (activeFilters.variantFilter) count++;
|
||||
if (
|
||||
activeFilters.amountMin > 0 ||
|
||||
activeFilters.amountMax < 300000
|
||||
)
|
||||
count++;
|
||||
if (activeFilters.dateFrom || activeFilters.dateTo) count++;
|
||||
if (activeFilters.createdBy) count++;
|
||||
if (activeFilters.hasAttachments !== null) count++;
|
||||
if (activeFilters.fuzzySearch === false) count++;
|
||||
return count > 0 ? `(${count})` : "";
|
||||
})()}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
onClick={() => loadApplicationListAdmin()}
|
||||
startIcon={<Refresh />}
|
||||
>
|
||||
Aktualisieren
|
||||
</Button>
|
||||
</Box>
|
||||
</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>
|
||||
|
||||
{/* 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 */}
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Antrags-ID</TableCell>
|
||||
<TableCell>Projektname</TableCell>
|
||||
<TableCell>Typ</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="right">Summe</TableCell>
|
||||
<TableCell>Erstellt</TableCell>
|
||||
<TableCell>Geändert</TableCell>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedApplications.length === applicationList.length &&
|
||||
applicationList.length > 0
|
||||
}
|
||||
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>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{applicationList.length === 0 && !isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} align="center">
|
||||
<TableCell colSpan={9} align="center">
|
||||
<Typography color="text.secondary">
|
||||
Keine Anträge gefunden
|
||||
</Typography>
|
||||
@ -329,7 +804,22 @@ const AdminDashboard: React.FC = () => {
|
||||
</TableRow>
|
||||
) : (
|
||||
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>
|
||||
<Typography variant="body2" sx={{ fontWeight: "medium" }}>
|
||||
{application.pa_id}
|
||||
@ -362,8 +852,10 @@ const AdminDashboard: React.FC = () => {
|
||||
translateStatus(value as string),
|
||||
}}
|
||||
>
|
||||
<MenuItem value="new">Neu</MenuItem>
|
||||
<MenuItem value="in-review">In Prüfung</MenuItem>
|
||||
<MenuItem value="new">Beantragt</MenuItem>
|
||||
<MenuItem value="in-review">
|
||||
Bearbeitung Gesperrt
|
||||
</MenuItem>
|
||||
<MenuItem value="approved">Genehmigt</MenuItem>
|
||||
<MenuItem value="rejected">Abgelehnt</MenuItem>
|
||||
</TextField>
|
||||
@ -425,6 +917,157 @@ const AdminDashboard: React.FC = () => {
|
||||
)}
|
||||
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -135,14 +135,29 @@ const EditApplicationPage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Check if application can be edited (only "new" status)
|
||||
if (currentApplication.status !== "new" && !isAdmin) {
|
||||
// Check if application can be edited - block in-review, approved, and rejected for everyone
|
||||
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 (
|
||||
<Container maxWidth="md" sx={{ mt: 4 }}>
|
||||
<Alert severity="warning">
|
||||
Dieser Antrag kann nicht mehr bearbeitet werden, da er bereits in
|
||||
Bearbeitung ist.
|
||||
</Alert>
|
||||
<Alert severity="warning">{message}</Alert>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
|
||||
@ -255,7 +255,12 @@ const ViewApplicationPage: React.FC = () => {
|
||||
: `/application/${paId}/edit`,
|
||||
)
|
||||
}
|
||||
disabled={!isAdmin && currentApplication.status !== "new"}
|
||||
disabled={
|
||||
currentApplication.status === "in-review" ||
|
||||
currentApplication.status === "approved" ||
|
||||
currentApplication.status === "rejected" ||
|
||||
(!isAdmin && currentApplication.status !== "new")
|
||||
}
|
||||
>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
@ -590,7 +595,12 @@ const ViewApplicationPage: React.FC = () => {
|
||||
paId={paId!}
|
||||
paKey={paKey || undefined}
|
||||
isAdmin={isAdmin}
|
||||
readOnly={!isAdmin && currentApplication.status !== "new"}
|
||||
readOnly={
|
||||
currentApplication.status === "in-review" ||
|
||||
currentApplication.status === "approved" ||
|
||||
currentApplication.status === "rejected" ||
|
||||
(!isAdmin && currentApplication.status !== "new")
|
||||
}
|
||||
attachments={attachments}
|
||||
/>
|
||||
|
||||
|
||||
@ -41,6 +41,10 @@ interface ApplicationState {
|
||||
statusFilter: string | null;
|
||||
variantFilter: string | null;
|
||||
searchQuery: string | null;
|
||||
|
||||
// Sorting
|
||||
sortBy: string;
|
||||
sortOrder: "asc" | "desc";
|
||||
}
|
||||
|
||||
interface ApplicationActions {
|
||||
@ -68,9 +72,11 @@ interface ApplicationActions {
|
||||
// Application list management
|
||||
loadApplicationList: (refresh?: boolean) => Promise<boolean>;
|
||||
searchApplications: (query: string) => Promise<boolean>;
|
||||
searchApplicationsAdvanced: (params: any) => Promise<boolean>;
|
||||
setStatusFilter: (status: string | null) => void;
|
||||
setVariantFilter: (variant: string | null) => void;
|
||||
setPage: (page: number) => void;
|
||||
setSorting: (sortBy: string, sortOrder: "asc" | "desc") => void;
|
||||
|
||||
// Admin operations
|
||||
loadApplicationAdmin: (paId: string) => Promise<boolean>;
|
||||
@ -91,6 +97,13 @@ interface ApplicationActions {
|
||||
paId: string,
|
||||
) => 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
|
||||
updateFormData: (data: Partial<FormData>) => void;
|
||||
resetFormData: () => void;
|
||||
@ -158,6 +171,10 @@ const initialState: ApplicationState = {
|
||||
statusFilter: null,
|
||||
variantFilter: null,
|
||||
searchQuery: null,
|
||||
|
||||
// Sorting
|
||||
sortBy: "created_at",
|
||||
sortOrder: "desc",
|
||||
};
|
||||
|
||||
export const useApplicationStore = create<
|
||||
@ -582,6 +599,8 @@ export const useApplicationStore = create<
|
||||
offset: currentPage * itemsPerPage,
|
||||
status: statusFilter || undefined,
|
||||
variant: variantFilter || undefined,
|
||||
order_by: get().sortBy,
|
||||
order: get().sortOrder,
|
||||
});
|
||||
} else {
|
||||
response = await apiClient.listApplications(paId!, paKey!, {
|
||||
@ -589,6 +608,8 @@ export const useApplicationStore = create<
|
||||
offset: currentPage * itemsPerPage,
|
||||
status: statusFilter || undefined,
|
||||
variant: variantFilter || undefined,
|
||||
order_by: get().sortBy,
|
||||
order: get().sortOrder,
|
||||
});
|
||||
}
|
||||
|
||||
@ -642,6 +663,8 @@ export const useApplicationStore = create<
|
||||
offset: 0,
|
||||
status: get().statusFilter || undefined,
|
||||
variant: get().variantFilter || undefined,
|
||||
order_by: get().sortBy,
|
||||
order: get().sortOrder,
|
||||
});
|
||||
|
||||
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) => {
|
||||
set({ statusFilter: status, currentPage: 0 });
|
||||
},
|
||||
@ -680,6 +760,10 @@ export const useApplicationStore = create<
|
||||
set({ currentPage: page });
|
||||
},
|
||||
|
||||
setSorting: (sortBy, sortOrder) => {
|
||||
set({ sortBy, sortOrder, currentPage: 0 });
|
||||
},
|
||||
|
||||
// Admin operations
|
||||
loadApplicationAdmin: async (paId: string) => {
|
||||
if (!get().isAdmin) {
|
||||
@ -1107,6 +1191,202 @@ export const useApplicationStore = create<
|
||||
const { paId: currentPaId, isAdmin } = get();
|
||||
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",
|
||||
|
||||
242
frontend/src/utils/fuzzySearch.ts
Normal file
242
frontend/src/utils/fuzzySearch.ts
Normal 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);
|
||||
}
|
||||
@ -1,10 +1,10 @@
|
||||
export type ApplicationStatus = "new" | "in-review" | "approved" | "rejected";
|
||||
|
||||
export const statusTranslations: Record<ApplicationStatus, string> = {
|
||||
"new": "Neu",
|
||||
"in-review": "In Prüfung",
|
||||
"approved": "Genehmigt",
|
||||
"rejected": "Abgelehnt"
|
||||
new: "Beantragt",
|
||||
"in-review": "Bearbeitung Gesperrt",
|
||||
approved: "Genehmigt",
|
||||
rejected: "Abgelehnt",
|
||||
};
|
||||
|
||||
export const translateStatus = (status: string): string => {
|
||||
@ -12,7 +12,16 @@ export const translateStatus = (status: string): string => {
|
||||
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()) {
|
||||
case "new":
|
||||
return "info";
|
||||
|
||||
@ -6,8 +6,8 @@ export type StatusColor = "info" | "warning" | "success" | "error" | "default";
|
||||
* Translation mapping from English status to German
|
||||
*/
|
||||
const STATUS_TRANSLATIONS: Record<ApplicationStatus, string> = {
|
||||
new: "Neu",
|
||||
"in-review": "In Prüfung",
|
||||
new: "Beantragt",
|
||||
"in-review": "Bearbeitung Gesperrt",
|
||||
approved: "Genehmigt",
|
||||
rejected: "Abgelehnt",
|
||||
};
|
||||
@ -41,10 +41,13 @@ export function getStatusColor(status: string): StatusColor {
|
||||
/**
|
||||
* Get all available statuses with translations
|
||||
*/
|
||||
export function getAllStatuses(): Array<{ value: ApplicationStatus; label: string }> {
|
||||
export function getAllStatuses(): Array<{
|
||||
value: ApplicationStatus;
|
||||
label: string;
|
||||
}> {
|
||||
return [
|
||||
{ value: "new", label: "Neu" },
|
||||
{ value: "in-review", label: "In Prüfung" },
|
||||
{ value: "new", label: "Beantragt" },
|
||||
{ value: "in-review", label: "Bearbeitung Gesperrt" },
|
||||
{ value: "approved", label: "Genehmigt" },
|
||||
{ value: "rejected", label: "Abgelehnt" },
|
||||
];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user