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.dialects.mysql import LONGTEXT
|
||||||
from sqlalchemy.orm import declarative_base, sessionmaker, Session
|
from sqlalchemy.orm import declarative_base, sessionmaker, Session
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy import text as sql_text
|
from sqlalchemy import text as sql_text, text
|
||||||
|
|
||||||
import PyPDF2
|
import PyPDF2
|
||||||
from PyPDF2.errors import PdfReadError
|
from PyPDF2.errors import PdfReadError
|
||||||
@ -617,6 +617,15 @@ def update_application(
|
|||||||
if not app_row:
|
if not app_row:
|
||||||
raise HTTPException(status_code=404, detail="Application not found")
|
raise HTTPException(status_code=404, detail="Application not found")
|
||||||
|
|
||||||
|
# Check if application is in final states (in-review, approved, rejected) and user is not admin
|
||||||
|
if app_row.status in ["in-review", "approved", "rejected"] and auth["scope"] != "master":
|
||||||
|
status_messages = {
|
||||||
|
"in-review": "Cannot update application while in review",
|
||||||
|
"approved": "Cannot update approved application",
|
||||||
|
"rejected": "Cannot update rejected application"
|
||||||
|
}
|
||||||
|
raise HTTPException(status_code=403, detail=status_messages[app_row.status])
|
||||||
|
|
||||||
# Payload beschaffen
|
# Payload beschaffen
|
||||||
payload: Dict[str, Any]
|
payload: Dict[str, Any]
|
||||||
raw_form: Optional[Dict[str, Any]] = None
|
raw_form: Optional[Dict[str, Any]] = None
|
||||||
@ -671,6 +680,175 @@ def update_application(
|
|||||||
return StreamingResponse(io.BytesIO(pdf_bytes), media_type="application/pdf", headers=headers)
|
return StreamingResponse(io.BytesIO(pdf_bytes), media_type="application/pdf", headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/applications/search")
|
||||||
|
def search_applications(
|
||||||
|
q: Optional[str] = Query(None, description="Volltext über payload_json (einfach)"),
|
||||||
|
status: Optional[str] = Query(None),
|
||||||
|
variant: Optional[str] = Query(None),
|
||||||
|
amount_min: Optional[float] = Query(None, description="Mindestbetrag"),
|
||||||
|
amount_max: Optional[float] = Query(None, description="Höchstbetrag"),
|
||||||
|
date_from: Optional[str] = Query(None, description="Erstellungsdatum ab (ISO format)"),
|
||||||
|
date_to: Optional[str] = Query(None, description="Erstellungsdatum bis (ISO format)"),
|
||||||
|
created_by: Optional[str] = Query(None, description="Ersteller (E-Mail)"),
|
||||||
|
has_attachments: Optional[bool] = Query(None, description="Mit/ohne Anhänge"),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
order_by: Optional[str] = Query("created_at", description="Sort by: pa_id, project_name, variant, status, total_amount, created_at, updated_at"),
|
||||||
|
order: Optional[str] = Query("desc", description="Sort order: asc, desc"),
|
||||||
|
x_master_key: Optional[str] = Header(None, alias="X-MASTER-KEY"),
|
||||||
|
x_forwarded_for: Optional[str] = Header(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
rate_limit_ip(x_forwarded_for or "")
|
||||||
|
_ = _auth_from_request(db, None, None, None, x_master_key, x_forwarded_for)
|
||||||
|
|
||||||
|
# sehr einfache Suche (MySQL JSON_EXTRACT/LIKE); für produktion auf FTS migrieren
|
||||||
|
base_sql = """
|
||||||
|
SELECT a.pa_id, a.variant, a.status, a.created_at, a.updated_at,
|
||||||
|
JSON_UNQUOTE(JSON_EXTRACT(a.payload_json, '$.pa.project.name')) as project_name,
|
||||||
|
COALESCE(att_count.attachment_count, 0) as attachment_count
|
||||||
|
FROM applications a
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT aa.application_id, COUNT(*) as attachment_count
|
||||||
|
FROM application_attachments aa
|
||||||
|
GROUP BY aa.application_id
|
||||||
|
) att_count ON a.id = att_count.application_id
|
||||||
|
WHERE 1=1"""
|
||||||
|
params = {}
|
||||||
|
if status:
|
||||||
|
base_sql += " AND a.status=:status"
|
||||||
|
params["status"] = status
|
||||||
|
if variant:
|
||||||
|
base_sql += " AND a.variant=:variant"
|
||||||
|
params["variant"] = variant.upper()
|
||||||
|
if q:
|
||||||
|
# naive Suche im JSON
|
||||||
|
base_sql += " AND JSON_SEARCH(JSON_EXTRACT(a.payload_json, '$'), 'all', :q) IS NOT NULL"
|
||||||
|
params["q"] = f"%{q}%"
|
||||||
|
|
||||||
|
# Date range filters
|
||||||
|
if date_from:
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
# Handle YYYY-MM-DD format from frontend
|
||||||
|
if len(date_from) == 10 and '-' in date_from:
|
||||||
|
start_date = datetime.strptime(date_from, "%Y-%m-%d")
|
||||||
|
base_sql += " AND a.created_at >= :date_from"
|
||||||
|
params["date_from"] = start_date.strftime("%Y-%m-%d 00:00:00")
|
||||||
|
else:
|
||||||
|
base_sql += " AND a.created_at >= :date_from"
|
||||||
|
params["date_from"] = date_from
|
||||||
|
except:
|
||||||
|
base_sql += " AND a.created_at >= :date_from"
|
||||||
|
params["date_from"] = date_from
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
# Handle YYYY-MM-DD format from frontend
|
||||||
|
if len(date_to) == 10 and '-' in date_to:
|
||||||
|
end_date = datetime.strptime(date_to, "%Y-%m-%d")
|
||||||
|
# Set to end of day (23:59:59)
|
||||||
|
base_sql += " AND a.created_at <= :date_to"
|
||||||
|
params["date_to"] = end_date.strftime("%Y-%m-%d 23:59:59")
|
||||||
|
else:
|
||||||
|
# Try ISO format with time adjustment
|
||||||
|
end_date = datetime.fromisoformat(date_to.replace('Z', '+00:00'))
|
||||||
|
end_date = end_date.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||||
|
base_sql += " AND a.created_at <= :date_to"
|
||||||
|
params["date_to"] = end_date.isoformat()
|
||||||
|
except:
|
||||||
|
# Fallback to original behavior if date parsing fails
|
||||||
|
base_sql += " AND a.created_at <= :date_to"
|
||||||
|
params["date_to"] = date_to
|
||||||
|
|
||||||
|
# Created by filter (search in applicant email) - fuzzy search
|
||||||
|
if created_by:
|
||||||
|
base_sql += " AND LOWER(JSON_UNQUOTE(JSON_EXTRACT(a.payload_json, '$.pa.applicant.contact.email'))) LIKE LOWER(:created_by)"
|
||||||
|
params["created_by"] = f"%{created_by}%"
|
||||||
|
|
||||||
|
# Has attachments filter
|
||||||
|
if has_attachments is not None:
|
||||||
|
if has_attachments:
|
||||||
|
base_sql += " AND att_count.attachment_count > 0"
|
||||||
|
else:
|
||||||
|
base_sql += " AND (att_count.attachment_count IS NULL OR att_count.attachment_count = 0)"
|
||||||
|
|
||||||
|
# Add sorting
|
||||||
|
valid_db_sort_fields = {
|
||||||
|
"pa_id": "pa_id",
|
||||||
|
"variant": "variant",
|
||||||
|
"status": "status",
|
||||||
|
"created_at": "created_at",
|
||||||
|
"updated_at": "updated_at",
|
||||||
|
"project_name": "project_name"
|
||||||
|
}
|
||||||
|
|
||||||
|
db_sort_field = valid_db_sort_fields.get(order_by, "created_at")
|
||||||
|
sort_order = order.upper() if order and order.upper() in ['ASC', 'DESC'] else 'DESC'
|
||||||
|
|
||||||
|
base_sql += f" ORDER BY {db_sort_field} {sort_order} LIMIT :limit OFFSET :offset"
|
||||||
|
params["limit"] = limit
|
||||||
|
params["offset"] = offset
|
||||||
|
|
||||||
|
rows = db.execute(sql_text(base_sql), params).all()
|
||||||
|
|
||||||
|
# Calculate total_amount and apply post-processing filters
|
||||||
|
result = []
|
||||||
|
for r in rows:
|
||||||
|
# Extract basic info
|
||||||
|
pa_id = r[0]
|
||||||
|
variant = r[1]
|
||||||
|
status = r[2]
|
||||||
|
created_at = r[3]
|
||||||
|
updated_at = r[4]
|
||||||
|
project_name = r[5] if r[5] else None
|
||||||
|
has_attachments_count = r[6] or 0
|
||||||
|
|
||||||
|
# Calculate total_amount
|
||||||
|
total_amount = 0.0
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get the full application to calculate total and check attachments
|
||||||
|
app_row = db.execute(select(Application).where(Application.pa_id == pa_id)).scalar_one_or_none()
|
||||||
|
if app_row and app_row.payload_json:
|
||||||
|
payload = json.loads(app_row.payload_json) if isinstance(app_row.payload_json, str) else app_row.payload_json
|
||||||
|
project = payload.get("pa", {}).get("project", {})
|
||||||
|
# Calculate total from costs
|
||||||
|
costs = project.get("costs", [])
|
||||||
|
for cost in costs:
|
||||||
|
if isinstance(cost, dict) and "amountEur" in cost:
|
||||||
|
amount = cost.get("amountEur")
|
||||||
|
if amount is not None and isinstance(amount, (int, float)):
|
||||||
|
total_amount += float(amount)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Apply amount filters
|
||||||
|
if amount_min is not None and total_amount < amount_min:
|
||||||
|
continue
|
||||||
|
if amount_max is not None and total_amount > amount_max:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Add to results if all filters pass
|
||||||
|
result.append({
|
||||||
|
"pa_id": pa_id,
|
||||||
|
"variant": variant,
|
||||||
|
"status": status,
|
||||||
|
"created_at": created_at.isoformat(),
|
||||||
|
"updated_at": updated_at.isoformat(),
|
||||||
|
"project_name": project_name,
|
||||||
|
"total_amount": total_amount
|
||||||
|
})
|
||||||
|
|
||||||
|
# Handle sorting for total_amount which requires post-processing
|
||||||
|
if order_by == "total_amount":
|
||||||
|
reverse_order = (order.lower() == 'desc') if order else True
|
||||||
|
result.sort(key=lambda x: x["total_amount"] or 0, reverse=reverse_order)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@app.get("/applications/{pa_id}")
|
@app.get("/applications/{pa_id}")
|
||||||
def get_application(
|
def get_application(
|
||||||
pa_id: str,
|
pa_id: str,
|
||||||
@ -725,6 +903,8 @@ def list_applications(
|
|||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
status: Optional[str] = Query(None),
|
status: Optional[str] = Query(None),
|
||||||
variant: Optional[str] = Query(None),
|
variant: Optional[str] = Query(None),
|
||||||
|
order_by: Optional[str] = Query("created_at", description="Sort by: pa_id, project_name, variant, status, total_amount, created_at, updated_at"),
|
||||||
|
order: Optional[str] = Query("desc", description="Sort order: asc, desc"),
|
||||||
x_master_key: Optional[str] = Header(None, alias="X-MASTER-KEY"),
|
x_master_key: Optional[str] = Header(None, alias="X-MASTER-KEY"),
|
||||||
x_pa_key: Optional[str] = Header(None, alias="X-PA-KEY"),
|
x_pa_key: Optional[str] = Header(None, alias="X-PA-KEY"),
|
||||||
pa_id: Optional[str] = Query(None, description="Mit Key: nur diesen Antrag anzeigen"),
|
pa_id: Optional[str] = Query(None, description="Mit Key: nur diesen Antrag anzeigen"),
|
||||||
@ -737,7 +917,24 @@ def list_applications(
|
|||||||
# Mit Master-Key: alle listen/filtern
|
# Mit Master-Key: alle listen/filtern
|
||||||
if x_master_key:
|
if x_master_key:
|
||||||
_ = _auth_from_request(db, None, None, None, x_master_key)
|
_ = _auth_from_request(db, None, None, None, x_master_key)
|
||||||
q = select(Application).order_by(Application.created_at.desc())
|
|
||||||
|
# Validate and map sort parameters
|
||||||
|
valid_sort_fields = {
|
||||||
|
"pa_id": Application.pa_id,
|
||||||
|
"variant": Application.variant,
|
||||||
|
"status": Application.status,
|
||||||
|
"created_at": Application.created_at,
|
||||||
|
"updated_at": Application.updated_at
|
||||||
|
}
|
||||||
|
|
||||||
|
sort_field = valid_sort_fields.get(order_by, Application.created_at)
|
||||||
|
sort_order = order.lower() if order and order.lower() in ['asc', 'desc'] else 'desc'
|
||||||
|
|
||||||
|
if sort_order == 'desc':
|
||||||
|
q = select(Application).order_by(sort_field.desc())
|
||||||
|
else:
|
||||||
|
q = select(Application).order_by(sort_field.asc())
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
q = q.where(Application.status == status)
|
q = q.where(Application.status == status)
|
||||||
if variant:
|
if variant:
|
||||||
@ -773,6 +970,15 @@ def list_applications(
|
|||||||
"created_at": r.created_at.isoformat(),
|
"created_at": r.created_at.isoformat(),
|
||||||
"updated_at": r.updated_at.isoformat()
|
"updated_at": r.updated_at.isoformat()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Handle sorting for fields that require post-processing (project_name, total_amount)
|
||||||
|
if order_by in ["project_name", "total_amount"]:
|
||||||
|
reverse_order = (order.lower() == 'desc') if order else True
|
||||||
|
if order_by == "project_name":
|
||||||
|
result.sort(key=lambda x: (x["project_name"] or "").lower(), reverse=reverse_order)
|
||||||
|
elif order_by == "total_amount":
|
||||||
|
result.sort(key=lambda x: x["total_amount"] or 0, reverse=reverse_order)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Ohne Master: nur eigenen Antrag (pa_id + key erforderlich)
|
# Ohne Master: nur eigenen Antrag (pa_id + key erforderlich)
|
||||||
@ -800,6 +1006,8 @@ def list_applications(
|
|||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Note: Sorting is not really applicable for single application return
|
||||||
|
# but we keep the parameters for API consistency
|
||||||
return [{
|
return [{
|
||||||
"pa_id": app_row.pa_id,
|
"pa_id": app_row.pa_id,
|
||||||
"variant": "VSM" if app_row.variant == "COMMON" else app_row.variant,
|
"variant": "VSM" if app_row.variant == "COMMON" else app_row.variant,
|
||||||
@ -914,46 +1122,66 @@ def reset_credentials(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/applications/search")
|
|
||||||
def search_applications(
|
|
||||||
q: Optional[str] = Query(None, description="Volltext über payload_json (einfach)"),
|
|
||||||
status: Optional[str] = Query(None),
|
# -------------------------------------------------------------
|
||||||
variant: Optional[str] = Query(None),
|
# Bulk Operations Endpoints
|
||||||
limit: int = Query(50, ge=1, le=200),
|
# -------------------------------------------------------------
|
||||||
offset: int = Query(0, ge=0),
|
|
||||||
|
class BulkOperationRequest(BaseModel):
|
||||||
|
pa_ids: List[str]
|
||||||
|
operation: str # "delete", "approve", "reject", "set_in_review", "set_new"
|
||||||
|
|
||||||
|
@app.post("/admin/applications/bulk")
|
||||||
|
def bulk_operation(
|
||||||
|
request: BulkOperationRequest,
|
||||||
x_master_key: Optional[str] = Header(None, alias="X-MASTER-KEY"),
|
x_master_key: Optional[str] = Header(None, alias="X-MASTER-KEY"),
|
||||||
x_forwarded_for: Optional[str] = Header(None),
|
x_forwarded_for: Optional[str] = Header(None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
"""Perform bulk operations on applications (Admin only)"""
|
||||||
rate_limit_ip(x_forwarded_for or "")
|
rate_limit_ip(x_forwarded_for or "")
|
||||||
_ = _auth_from_request(db, None, None, None, x_master_key)
|
_ = _auth_from_request(db, None, None, None, x_master_key, x_forwarded_for)
|
||||||
|
|
||||||
# sehr einfache Suche (MySQL JSON_EXTRACT/LIKE); für produktion auf FTS migrieren
|
if not request.pa_ids:
|
||||||
base_sql = "SELECT pa_id, variant, status, created_at, updated_at FROM applications WHERE 1=1"
|
raise HTTPException(status_code=400, detail="No application IDs provided")
|
||||||
params = {}
|
|
||||||
if status:
|
|
||||||
base_sql += " AND status=:status"
|
|
||||||
params["status"] = status
|
|
||||||
if variant:
|
|
||||||
base_sql += " AND variant=:variant"
|
|
||||||
params["variant"] = variant.upper()
|
|
||||||
if q:
|
|
||||||
# naive Suche im JSON
|
|
||||||
base_sql += " AND JSON_SEARCH(JSON_EXTRACT(payload_json, '$'), 'all', :q) IS NOT NULL"
|
|
||||||
params["q"] = f"%{q}%"
|
|
||||||
base_sql += " ORDER BY created_at DESC LIMIT :limit OFFSET :offset"
|
|
||||||
params["limit"] = limit
|
|
||||||
params["offset"] = offset
|
|
||||||
|
|
||||||
rows = db.execute(sql_text(base_sql), params).all()
|
if request.operation not in ["delete", "approve", "reject", "set_in_review", "set_new"]:
|
||||||
return [
|
raise HTTPException(status_code=400, detail="Invalid operation")
|
||||||
{"pa_id": r[0], "variant": r[1], "status": r[2],
|
|
||||||
"created_at": r[3].isoformat(), "updated_at": r[4].isoformat()}
|
|
||||||
for r in rows
|
|
||||||
]
|
|
||||||
|
|
||||||
|
results = {"success": [], "failed": []}
|
||||||
|
|
||||||
|
for pa_id in request.pa_ids:
|
||||||
|
try:
|
||||||
|
app = db.query(Application).filter(Application.pa_id == pa_id).first()
|
||||||
|
if not app:
|
||||||
|
results["failed"].append({"pa_id": pa_id, "error": "Application not found"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if request.operation == "delete":
|
||||||
|
# Delete related data first
|
||||||
|
db.execute(text("DELETE FROM application_attachments WHERE application_id = :app_id"), {"app_id": app.id})
|
||||||
|
db.execute(text("DELETE FROM comparison_offers WHERE application_id = :app_id"), {"app_id": app.id})
|
||||||
|
db.execute(text("DELETE FROM cost_position_justifications WHERE application_id = :app_id"), {"app_id": app.id})
|
||||||
|
db.delete(app)
|
||||||
|
elif request.operation == "approve":
|
||||||
|
app.status = "approved"
|
||||||
|
elif request.operation == "reject":
|
||||||
|
app.status = "rejected"
|
||||||
|
elif request.operation == "set_in_review":
|
||||||
|
app.status = "in-review"
|
||||||
|
elif request.operation == "set_new":
|
||||||
|
app.status = "new"
|
||||||
|
|
||||||
|
results["success"].append(pa_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
results["failed"].append({"pa_id": pa_id, "error": str(e)})
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return results
|
||||||
|
|
||||||
# -------------------------------------------------------------
|
|
||||||
# Attachment Endpoints
|
# Attachment Endpoints
|
||||||
# -------------------------------------------------------------
|
# -------------------------------------------------------------
|
||||||
|
|
||||||
@ -978,6 +1206,15 @@ async def upload_attachment(
|
|||||||
if not app:
|
if not app:
|
||||||
raise HTTPException(status_code=404, detail="Application not found")
|
raise HTTPException(status_code=404, detail="Application not found")
|
||||||
|
|
||||||
|
# Check if application is in final states and user is not admin
|
||||||
|
if app.status in ["in-review", "approved", "rejected"] and auth["scope"] != "master":
|
||||||
|
status_messages = {
|
||||||
|
"in-review": "Cannot upload attachments while application is in review",
|
||||||
|
"approved": "Cannot upload attachments to approved application",
|
||||||
|
"rejected": "Cannot upload attachments to rejected application"
|
||||||
|
}
|
||||||
|
raise HTTPException(status_code=403, detail=status_messages[app.status])
|
||||||
|
|
||||||
# Check attachment count limit (30 attachments max)
|
# Check attachment count limit (30 attachments max)
|
||||||
attachment_count = db.query(ApplicationAttachment).filter(
|
attachment_count = db.query(ApplicationAttachment).filter(
|
||||||
ApplicationAttachment.application_id == app.id
|
ApplicationAttachment.application_id == app.id
|
||||||
@ -1123,6 +1360,15 @@ def delete_attachment(
|
|||||||
if not app:
|
if not app:
|
||||||
raise HTTPException(status_code=404, detail="Application not found")
|
raise HTTPException(status_code=404, detail="Application not found")
|
||||||
|
|
||||||
|
# Check if application is in final states and user is not admin
|
||||||
|
if app.status in ["in-review", "approved", "rejected"] and auth["scope"] != "master":
|
||||||
|
status_messages = {
|
||||||
|
"in-review": "Cannot delete attachments while application is in review",
|
||||||
|
"approved": "Cannot delete attachments from approved application",
|
||||||
|
"rejected": "Cannot delete attachments from rejected application"
|
||||||
|
}
|
||||||
|
raise HTTPException(status_code=403, detail=status_messages[app.status])
|
||||||
|
|
||||||
# Check if attachment belongs to this application
|
# Check if attachment belongs to this application
|
||||||
app_attachment = db.query(ApplicationAttachment).filter(
|
app_attachment = db.query(ApplicationAttachment).filter(
|
||||||
ApplicationAttachment.application_id == app.id,
|
ApplicationAttachment.application_id == app.id,
|
||||||
@ -1165,6 +1411,15 @@ async def create_comparison_offer(
|
|||||||
if not app:
|
if not app:
|
||||||
raise HTTPException(status_code=404, detail="Application not found")
|
raise HTTPException(status_code=404, detail="Application not found")
|
||||||
|
|
||||||
|
# Check if application is in final states and user is not admin
|
||||||
|
if app.status in ["in-review", "approved", "rejected"] and auth["scope"] != "master":
|
||||||
|
status_messages = {
|
||||||
|
"in-review": "Cannot create comparison offers while application is in review",
|
||||||
|
"approved": "Cannot create comparison offers for approved application",
|
||||||
|
"rejected": "Cannot create comparison offers for rejected application"
|
||||||
|
}
|
||||||
|
raise HTTPException(status_code=403, detail=status_messages[app.status])
|
||||||
|
|
||||||
# Validate cost position index
|
# Validate cost position index
|
||||||
payload = app.payload_json
|
payload = app.payload_json
|
||||||
costs = payload.get("pa", {}).get("project", {}).get("costs", [])
|
costs = payload.get("pa", {}).get("project", {}).get("costs", [])
|
||||||
@ -1305,6 +1560,15 @@ def delete_comparison_offer(
|
|||||||
if not app:
|
if not app:
|
||||||
raise HTTPException(status_code=404, detail="Application not found")
|
raise HTTPException(status_code=404, detail="Application not found")
|
||||||
|
|
||||||
|
# Check if application is in final states and user is not admin
|
||||||
|
if app.status in ["in-review", "approved", "rejected"] and auth["scope"] != "master":
|
||||||
|
status_messages = {
|
||||||
|
"in-review": "Cannot delete comparison offers while application is in review",
|
||||||
|
"approved": "Cannot delete comparison offers from approved application",
|
||||||
|
"rejected": "Cannot delete comparison offers from rejected application"
|
||||||
|
}
|
||||||
|
raise HTTPException(status_code=403, detail=status_messages[app.status])
|
||||||
|
|
||||||
# Get and delete offer
|
# Get and delete offer
|
||||||
offer = db.query(ComparisonOffer).filter(
|
offer = db.query(ComparisonOffer).filter(
|
||||||
ComparisonOffer.id == offer_id,
|
ComparisonOffer.id == offer_id,
|
||||||
@ -1340,6 +1604,15 @@ def update_cost_position_justification(
|
|||||||
if not app:
|
if not app:
|
||||||
raise HTTPException(status_code=404, detail="Application not found")
|
raise HTTPException(status_code=404, detail="Application not found")
|
||||||
|
|
||||||
|
# Check if application is in final states and user is not admin
|
||||||
|
if app.status in ["in-review", "approved", "rejected"] and auth["scope"] != "master":
|
||||||
|
status_messages = {
|
||||||
|
"in-review": "Cannot update cost position justification while application is in review",
|
||||||
|
"approved": "Cannot update cost position justification for approved application",
|
||||||
|
"rejected": "Cannot update cost position justification for rejected application"
|
||||||
|
}
|
||||||
|
raise HTTPException(status_code=403, detail=status_messages[app.status])
|
||||||
|
|
||||||
# Validate cost position index
|
# Validate cost position index
|
||||||
payload = app.payload_json
|
payload = app.payload_json
|
||||||
costs = payload.get("pa", {}).get("project", {}).get("costs", [])
|
costs = payload.get("pa", {}).get("project", {}).get("costs", [])
|
||||||
@ -1393,6 +1666,15 @@ def set_preferred_offer(
|
|||||||
if not app:
|
if not app:
|
||||||
raise HTTPException(status_code=404, detail="Application not found")
|
raise HTTPException(status_code=404, detail="Application not found")
|
||||||
|
|
||||||
|
# Check if application is in final states and user is not admin
|
||||||
|
if app.status in ["in-review", "approved", "rejected"] and auth["scope"] != "master":
|
||||||
|
status_messages = {
|
||||||
|
"in-review": "Cannot set preferred offer while application is in review",
|
||||||
|
"approved": "Cannot set preferred offer for approved application",
|
||||||
|
"rejected": "Cannot set preferred offer for rejected application"
|
||||||
|
}
|
||||||
|
raise HTTPException(status_code=403, detail=status_messages[app.status])
|
||||||
|
|
||||||
# Validate cost position index
|
# Validate cost position index
|
||||||
payload = app.payload_json
|
payload = app.payload_json
|
||||||
costs = payload.get("pa", {}).get("project", {}).get("costs", [])
|
costs = payload.get("pa", {}).get("project", {}).get("costs", [])
|
||||||
|
|||||||
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;
|
offset?: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
variant?: string;
|
variant?: string;
|
||||||
|
order_by?: string;
|
||||||
|
order?: string;
|
||||||
},
|
},
|
||||||
): Promise<ApiResponse<ApplicationListItem[]>> {
|
): Promise<ApiResponse<ApplicationListItem[]>> {
|
||||||
const config = {
|
const config = {
|
||||||
@ -297,6 +299,8 @@ export class ApiClient {
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
variant?: string;
|
variant?: string;
|
||||||
|
order_by?: string;
|
||||||
|
order?: string;
|
||||||
}): Promise<ApiResponse<ApplicationListItem[]>> {
|
}): Promise<ApiResponse<ApplicationListItem[]>> {
|
||||||
if (!this.masterKey) {
|
if (!this.masterKey) {
|
||||||
throw new Error("Master key required for admin operations");
|
throw new Error("Master key required for admin operations");
|
||||||
@ -454,8 +458,16 @@ export class ApiClient {
|
|||||||
q?: string;
|
q?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
variant?: string;
|
variant?: string;
|
||||||
|
amount_min?: number;
|
||||||
|
amount_max?: number;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
created_by?: string;
|
||||||
|
has_attachments?: boolean;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
|
order_by?: string;
|
||||||
|
order?: string;
|
||||||
}): Promise<ApiResponse<ApplicationListItem[]>> {
|
}): Promise<ApiResponse<ApplicationListItem[]>> {
|
||||||
if (!this.masterKey) {
|
if (!this.masterKey) {
|
||||||
throw new Error("Master key required for admin operations");
|
throw new Error("Master key required for admin operations");
|
||||||
@ -469,6 +481,32 @@ export class ApiClient {
|
|||||||
this.client.get<ApplicationListItem[]>("/applications/search", config),
|
this.client.get<ApplicationListItem[]>("/applications/search", config),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform bulk operations on applications (admin only)
|
||||||
|
*/
|
||||||
|
async bulkOperation(params: {
|
||||||
|
pa_ids: string[];
|
||||||
|
operation: "delete" | "approve" | "reject" | "set_in_review" | "set_new";
|
||||||
|
}): Promise<
|
||||||
|
ApiResponse<{
|
||||||
|
success: string[];
|
||||||
|
failed: Array<{ pa_id: string; error: string }>;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
if (!this.masterKey) {
|
||||||
|
throw new Error("Master key required for admin operations");
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = {
|
||||||
|
pa_ids: params.pa_ids,
|
||||||
|
operation: params.operation,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.handleResponse(
|
||||||
|
this.client.post("/admin/applications/bulk", request),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default client instance
|
// Default client instance
|
||||||
|
|||||||
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)}
|
onChange={(e) => setNewStatus(e.target.value)}
|
||||||
sx={{ minWidth: 100 }}
|
sx={{ minWidth: 100 }}
|
||||||
>
|
>
|
||||||
<MenuItem value="new">Neu</MenuItem>
|
<MenuItem value="new">Beantragt</MenuItem>
|
||||||
<MenuItem value="in-review">In Prüfung</MenuItem>
|
<MenuItem value="in-review">Bearbeitung Gesperrt</MenuItem>
|
||||||
<MenuItem value="approved">Genehmigt</MenuItem>
|
<MenuItem value="approved">Genehmigt</MenuItem>
|
||||||
<MenuItem value="rejected">Abgelehnt</MenuItem>
|
<MenuItem value="rejected">Abgelehnt</MenuItem>
|
||||||
</TextField>
|
</TextField>
|
||||||
@ -689,8 +689,10 @@ const AdminApplicationView: React.FC = () => {
|
|||||||
onChange={(e) => setNewStatus(e.target.value)}
|
onChange={(e) => setNewStatus(e.target.value)}
|
||||||
sx={{ minWidth: "100px" }}
|
sx={{ minWidth: "100px" }}
|
||||||
>
|
>
|
||||||
<MenuItem value="new">Neu</MenuItem>
|
<MenuItem value="new">Beantragt</MenuItem>
|
||||||
<MenuItem value="in-review">In Prüfung</MenuItem>
|
<MenuItem value="in-review">
|
||||||
|
Bearbeitung Gesperrt
|
||||||
|
</MenuItem>
|
||||||
<MenuItem value="approved">Genehmigt</MenuItem>
|
<MenuItem value="approved">Genehmigt</MenuItem>
|
||||||
<MenuItem value="rejected">Abgelehnt</MenuItem>
|
<MenuItem value="rejected">Abgelehnt</MenuItem>
|
||||||
</TextField>
|
</TextField>
|
||||||
|
|||||||
@ -20,19 +20,37 @@ import {
|
|||||||
MenuItem,
|
MenuItem,
|
||||||
Alert,
|
Alert,
|
||||||
Pagination,
|
Pagination,
|
||||||
|
TableSortLabel,
|
||||||
|
Checkbox,
|
||||||
|
Slide,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { Visibility, Delete, Search, Refresh } from "@mui/icons-material";
|
import {
|
||||||
|
Visibility,
|
||||||
|
Delete,
|
||||||
|
Search,
|
||||||
|
Refresh,
|
||||||
|
FilterList,
|
||||||
|
DeleteOutline,
|
||||||
|
CheckCircleOutline,
|
||||||
|
BlockOutlined,
|
||||||
|
LockOpenOutlined,
|
||||||
|
CancelOutlined,
|
||||||
|
} from "@mui/icons-material";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
// Store
|
// Store
|
||||||
import { useApplicationStore } from "../store/applicationStore";
|
import { useApplicationStore } from "../store/applicationStore";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import { ApplicationListItem } from "../types/api";
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
import { translateStatus } from "../utils/statusTranslations";
|
import { translateStatus } from "../utils/statusTranslations";
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import LoadingSpinner from "../components/LoadingSpinner/LoadingSpinner";
|
import LoadingSpinner from "../components/LoadingSpinner/LoadingSpinner";
|
||||||
|
import FilterPopover from "../components/FilterPopover/FilterPopover";
|
||||||
|
|
||||||
const AdminDashboard: React.FC = () => {
|
const AdminDashboard: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -49,19 +67,37 @@ const AdminDashboard: React.FC = () => {
|
|||||||
statusFilter,
|
statusFilter,
|
||||||
variantFilter,
|
variantFilter,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
loadApplicationListAdmin,
|
loadApplicationListAdmin,
|
||||||
searchApplications,
|
searchApplicationsAdvanced,
|
||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
setVariantFilter,
|
setVariantFilter,
|
||||||
setPage,
|
setPage,
|
||||||
updateApplicationStatusAdmin,
|
updateApplicationStatusAdmin,
|
||||||
deleteApplicationAdmin,
|
deleteApplicationAdmin,
|
||||||
|
setSorting,
|
||||||
|
bulkDeleteApplications,
|
||||||
|
bulkApproveApplications,
|
||||||
|
bulkRejectApplications,
|
||||||
|
bulkSetInReviewApplications,
|
||||||
|
bulkSetNewApplications,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
masterKey,
|
masterKey,
|
||||||
} = useApplicationStore();
|
} = useApplicationStore();
|
||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const [searchText, setSearchText] = useState(searchQuery || "");
|
const [searchText, setSearchText] = useState(searchQuery || "");
|
||||||
|
const [filterAnchorEl, setFilterAnchorEl] = useState<null | HTMLElement>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [activeFilters, setActiveFilters] = useState<any>({});
|
||||||
|
|
||||||
|
// Bulk actions state
|
||||||
|
const [selectedApplications, setSelectedApplications] = useState<string[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [showBulkActions, setShowBulkActions] = useState(false);
|
||||||
|
|
||||||
// Redirect if not admin
|
// Redirect if not admin
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -83,17 +119,51 @@ const AdminDashboard: React.FC = () => {
|
|||||||
currentPage,
|
currentPage,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
variantFilter,
|
variantFilter,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Handle search
|
// Handle search with fuzzy matching
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
if (searchText.trim()) {
|
if (searchText.trim()) {
|
||||||
searchApplications(searchText.trim());
|
// Build search parameters including active filters
|
||||||
|
const searchParams = {
|
||||||
|
q: searchText.trim(),
|
||||||
|
status: statusFilter || undefined,
|
||||||
|
variant: variantFilter || undefined,
|
||||||
|
// Include any additional active filters
|
||||||
|
amount_min:
|
||||||
|
activeFilters.amountMin > 0 ? activeFilters.amountMin : undefined,
|
||||||
|
amount_max:
|
||||||
|
activeFilters.amountMax < 300000
|
||||||
|
? activeFilters.amountMax
|
||||||
|
: undefined,
|
||||||
|
date_from: activeFilters.dateFrom?.format("YYYY-MM-DD") || undefined,
|
||||||
|
date_to: activeFilters.dateTo?.format("YYYY-MM-DD") || undefined,
|
||||||
|
created_by: activeFilters.createdBy || undefined,
|
||||||
|
has_attachments:
|
||||||
|
activeFilters.hasAttachments !== null
|
||||||
|
? activeFilters.hasAttachments
|
||||||
|
: undefined,
|
||||||
|
limit: 50,
|
||||||
|
offset: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use advanced search to include all filters
|
||||||
|
searchApplicationsAdvanced(searchParams);
|
||||||
} else {
|
} else {
|
||||||
loadApplicationListAdmin();
|
loadApplicationListAdmin();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle sorting
|
||||||
|
const handleRequestSort = (property: keyof ApplicationListItem) => {
|
||||||
|
const currentSortBy = sortBy as keyof ApplicationListItem;
|
||||||
|
const isAsc = currentSortBy === property && sortOrder === "asc";
|
||||||
|
const newOrder = isAsc ? "desc" : "asc";
|
||||||
|
setSorting(property, newOrder);
|
||||||
|
};
|
||||||
|
|
||||||
// Handle status change
|
// Handle status change
|
||||||
const handleStatusChange = async (paId: string, newStatus: string) => {
|
const handleStatusChange = async (paId: string, newStatus: string) => {
|
||||||
await updateApplicationStatusAdmin(paId, newStatus);
|
await updateApplicationStatusAdmin(paId, newStatus);
|
||||||
@ -110,6 +180,94 @@ const AdminDashboard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle bulk selection
|
||||||
|
const handleSelectApplication = (paId: string, checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedApplications((prev) => [...prev, paId]);
|
||||||
|
} else {
|
||||||
|
setSelectedApplications((prev) => prev.filter((id) => id !== paId));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedApplications(applicationList.map((app) => app.pa_id));
|
||||||
|
} else {
|
||||||
|
setSelectedApplications([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bulk actions handlers
|
||||||
|
const handleBulkDelete = async () => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
`Sind Sie sicher, dass Sie ${selectedApplications.length} Anträge löschen möchten?`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const success = await bulkDeleteApplications(selectedApplications);
|
||||||
|
if (success) {
|
||||||
|
setSelectedApplications([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkReject = async () => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
`Sind Sie sicher, dass Sie ${selectedApplications.length} Anträge ablehnen möchten?`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const success = await bulkRejectApplications(selectedApplications);
|
||||||
|
if (success) {
|
||||||
|
setSelectedApplications([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkSetInReview = async () => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
`Sind Sie sicher, dass Sie ${selectedApplications.length} Anträge für Bearbeitung sperren möchten?`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const success = await bulkSetInReviewApplications(selectedApplications);
|
||||||
|
if (success) {
|
||||||
|
setSelectedApplications([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkSetNew = async () => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
`Sind Sie sicher, dass Sie ${selectedApplications.length} Anträge auf "Beantragt" zurücksetzen möchten?`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const success = await bulkSetNewApplications(selectedApplications);
|
||||||
|
if (success) {
|
||||||
|
setSelectedApplications([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkApprove = async () => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
`Sind Sie sicher, dass Sie ${selectedApplications.length} Anträge genehmigen möchten?`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const success = await bulkApproveApplications(selectedApplications);
|
||||||
|
if (success) {
|
||||||
|
setSelectedApplications([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show/hide bulk actions based on selection
|
||||||
|
React.useEffect(() => {
|
||||||
|
setShowBulkActions(selectedApplications.length > 0);
|
||||||
|
}, [selectedApplications]);
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return null; // Will redirect
|
return null; // Will redirect
|
||||||
}
|
}
|
||||||
@ -130,6 +288,123 @@ const AdminDashboard: React.FC = () => {
|
|||||||
(sum, app) => sum + (app.total_amount || 0),
|
(sum, app) => sum + (app.total_amount || 0),
|
||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
|
// Calculate amounts per status
|
||||||
|
newAmount: applicationList
|
||||||
|
.filter((app) => app.status === "new")
|
||||||
|
.reduce((sum, app) => sum + (app.total_amount || 0), 0),
|
||||||
|
inReviewAmount: applicationList
|
||||||
|
.filter((app) => app.status === "in-review")
|
||||||
|
.reduce((sum, app) => sum + (app.total_amount || 0), 0),
|
||||||
|
approvedAmount: applicationList
|
||||||
|
.filter((app) => app.status === "approved")
|
||||||
|
.reduce((sum, app) => sum + (app.total_amount || 0), 0),
|
||||||
|
rejectedAmount: applicationList
|
||||||
|
.filter((app) => app.status === "rejected")
|
||||||
|
.reduce((sum, app) => sum + (app.total_amount || 0), 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle advanced filters
|
||||||
|
const handleFilterOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
|
setFilterAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilterClose = () => {
|
||||||
|
setFilterAnchorEl(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplyFilters = (filters: any) => {
|
||||||
|
setActiveFilters(filters);
|
||||||
|
|
||||||
|
// Prepare API parameters with extended filters
|
||||||
|
const searchParams = {
|
||||||
|
q: filters.searchQuery || undefined,
|
||||||
|
status: filters.statusFilter || undefined,
|
||||||
|
variant: filters.variantFilter || undefined,
|
||||||
|
amount_min: filters.amountMin > 0 ? filters.amountMin : undefined,
|
||||||
|
amount_max: filters.amountMax < 300000 ? filters.amountMax : undefined,
|
||||||
|
date_from: filters.dateFrom?.format("YYYY-MM-DD") || undefined,
|
||||||
|
date_to: filters.dateTo?.format("YYYY-MM-DD") || undefined,
|
||||||
|
created_by: filters.createdBy || undefined,
|
||||||
|
has_attachments:
|
||||||
|
filters.hasAttachments !== null ? filters.hasAttachments : undefined,
|
||||||
|
limit: 50,
|
||||||
|
offset: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove undefined values
|
||||||
|
const cleanParams = Object.fromEntries(
|
||||||
|
Object.entries(searchParams).filter(([_, value]) => value !== undefined),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use advanced search API with all filters
|
||||||
|
if (Object.keys(cleanParams).length > 2) {
|
||||||
|
// More than just limit and offset - use advanced search
|
||||||
|
searchApplicationsAdvanced(cleanParams);
|
||||||
|
} else {
|
||||||
|
// Update store filters for basic filtering
|
||||||
|
if (filters.statusFilter) {
|
||||||
|
setStatusFilter(filters.statusFilter);
|
||||||
|
}
|
||||||
|
if (filters.variantFilter) {
|
||||||
|
setVariantFilter(filters.variantFilter);
|
||||||
|
}
|
||||||
|
loadApplicationListAdmin();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearAllFilters = () => {
|
||||||
|
setActiveFilters({});
|
||||||
|
setStatusFilter(null);
|
||||||
|
setVariantFilter(null);
|
||||||
|
setSearchText("");
|
||||||
|
loadApplicationListAdmin();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearSingleFilter = (filterKey: string) => {
|
||||||
|
const newFilters = { ...activeFilters };
|
||||||
|
switch (filterKey) {
|
||||||
|
case "searchQuery":
|
||||||
|
newFilters.searchQuery = "";
|
||||||
|
setSearchText("");
|
||||||
|
break;
|
||||||
|
case "statusFilter":
|
||||||
|
newFilters.statusFilter = "";
|
||||||
|
setStatusFilter(null);
|
||||||
|
break;
|
||||||
|
case "variantFilter":
|
||||||
|
newFilters.variantFilter = "";
|
||||||
|
setVariantFilter(null);
|
||||||
|
break;
|
||||||
|
case "amountRange":
|
||||||
|
newFilters.amountMin = 0;
|
||||||
|
newFilters.amountMax = 300000;
|
||||||
|
break;
|
||||||
|
case "dateRange":
|
||||||
|
newFilters.dateFrom = null;
|
||||||
|
newFilters.dateTo = null;
|
||||||
|
break;
|
||||||
|
case "createdBy":
|
||||||
|
newFilters.createdBy = "";
|
||||||
|
break;
|
||||||
|
case "hasAttachments":
|
||||||
|
newFilters.hasAttachments = null;
|
||||||
|
break;
|
||||||
|
case "fuzzySearch":
|
||||||
|
newFilters.fuzzySearch = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
setActiveFilters(newFilters);
|
||||||
|
|
||||||
|
// Apply the updated filters
|
||||||
|
if (
|
||||||
|
Object.values(newFilters).some(
|
||||||
|
(v) => v && v !== "" && v !== 0 && v !== 300000,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
handleApplyFilters(newFilters);
|
||||||
|
} else {
|
||||||
|
loadApplicationListAdmin();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -158,7 +433,7 @@ const AdminDashboard: React.FC = () => {
|
|||||||
|
|
||||||
{/* Statistics Cards */}
|
{/* Statistics Cards */}
|
||||||
<Grid container spacing={3} sx={{ mb: 4 }}>
|
<Grid container spacing={3} sx={{ mb: 4 }}>
|
||||||
<Grid item xs={12} sm={6} md={2}>
|
<Grid item xs={12} sm={6} md={2.4}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent sx={{ textAlign: "center" }}>
|
<CardContent sx={{ textAlign: "center" }}>
|
||||||
<Typography variant="h4" color="primary">
|
<Typography variant="h4" color="primary">
|
||||||
@ -167,34 +442,67 @@ const AdminDashboard: React.FC = () => {
|
|||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
Gesamt
|
Gesamt
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="primary"
|
||||||
|
sx={{ fontWeight: "bold", mt: 0.5 }}
|
||||||
|
>
|
||||||
|
{stats.totalAmount.toLocaleString("de-DE", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={6} md={2}>
|
<Grid item xs={12} sm={6} md={2.4}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent sx={{ textAlign: "center" }}>
|
<CardContent sx={{ textAlign: "center" }}>
|
||||||
<Typography variant="h4" color="info.main">
|
<Typography variant="h4" color="info.main">
|
||||||
{stats.new}
|
{stats.new}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
Neu
|
Beantragt
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="info.main"
|
||||||
|
sx={{ fontWeight: "bold", mt: 0.5 }}
|
||||||
|
>
|
||||||
|
{stats.newAmount.toLocaleString("de-DE", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
})}
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={6} md={2}>
|
<Grid item xs={12} sm={6} md={2.4}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent sx={{ textAlign: "center" }}>
|
<CardContent sx={{ textAlign: "center" }}>
|
||||||
<Typography variant="h4" color="warning.main">
|
<Typography variant="h4" color="warning.main">
|
||||||
{stats.inReview}
|
{stats.inReview}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
In Prüfung
|
Bearbeitung Gesperrt
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="warning.main"
|
||||||
|
sx={{ fontWeight: "bold", mt: 0.5 }}
|
||||||
|
>
|
||||||
|
{stats.inReviewAmount.toLocaleString("de-DE", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
})}
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={6} md={2}>
|
<Grid item xs={12} sm={6} md={2.4}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent sx={{ textAlign: "center" }}>
|
<CardContent sx={{ textAlign: "center" }}>
|
||||||
<Typography variant="h4" color="success.main">
|
<Typography variant="h4" color="success.main">
|
||||||
@ -203,10 +511,21 @@ const AdminDashboard: React.FC = () => {
|
|||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
Genehmigt
|
Genehmigt
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="success.main"
|
||||||
|
sx={{ fontWeight: "bold", mt: 0.5 }}
|
||||||
|
>
|
||||||
|
{stats.approvedAmount.toLocaleString("de-DE", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={6} md={2}>
|
<Grid item xs={12} sm={6} md={2.4}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent sx={{ textAlign: "center" }}>
|
<CardContent sx={{ textAlign: "center" }}>
|
||||||
<Typography variant="h4" color="error.main">
|
<Typography variant="h4" color="error.main">
|
||||||
@ -215,44 +534,33 @@ const AdminDashboard: React.FC = () => {
|
|||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
Abgelehnt
|
Abgelehnt
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12} sm={6} md={2}>
|
|
||||||
<Card>
|
|
||||||
<CardContent sx={{ textAlign: "center" }}>
|
|
||||||
<Typography
|
<Typography
|
||||||
variant="h5"
|
variant="body2"
|
||||||
color="text.primary"
|
color="error.main"
|
||||||
sx={{
|
sx={{ fontWeight: "bold", mt: 0.5 }}
|
||||||
fontWeight: "bold",
|
|
||||||
fontSize: { xs: "1rem", sm: "1.25rem" },
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{stats.totalAmount.toLocaleString("de-DE", {
|
{stats.rejectedAmount.toLocaleString("de-DE", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "EUR",
|
currency: "EUR",
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
})}
|
})}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Gesamtsumme
|
|
||||||
</Typography>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Filters and Search */}
|
{/* Search and Filters */}
|
||||||
<Paper sx={{ p: 3, mb: 3 }}>
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
<Grid container spacing={2} alignItems="center">
|
<Grid container spacing={2} alignItems="center">
|
||||||
<Grid item xs={12} md={4}>
|
<Grid item xs={12} md={6}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Suchen"
|
label="Schnellsuche"
|
||||||
value={searchText}
|
value={searchText}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||||
|
placeholder="Projektname, Antrags-ID..."
|
||||||
InputProps={{
|
InputProps={{
|
||||||
endAdornment: (
|
endAdornment: (
|
||||||
<IconButton onClick={handleSearch}>
|
<IconButton onClick={handleSearch}>
|
||||||
@ -261,67 +569,234 @@ const AdminDashboard: React.FC = () => {
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{(Object.keys(activeFilters).length > 0 ||
|
||||||
|
statusFilter ||
|
||||||
|
variantFilter ||
|
||||||
|
searchText.trim()) && (
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
onClick={handleClearAllFilters}
|
||||||
|
sx={{ color: "text.secondary" }}
|
||||||
|
>
|
||||||
|
Alle Filter zurücksetzen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} md={3}>
|
<Grid item xs={12} md={6}>
|
||||||
<TextField
|
<Box sx={{ display: "flex", gap: 1, justifyContent: "flex-end" }}>
|
||||||
fullWidth
|
<Button
|
||||||
select
|
variant="outlined"
|
||||||
label="Status"
|
onClick={handleFilterOpen}
|
||||||
value={statusFilter || ""}
|
startIcon={<FilterList />}
|
||||||
onChange={(e) => setStatusFilter(e.target.value || null)}
|
sx={{ minWidth: 140 }}
|
||||||
>
|
>
|
||||||
<MenuItem value="">Alle Status</MenuItem>
|
Filter{" "}
|
||||||
<MenuItem value="new">Neu</MenuItem>
|
{(() => {
|
||||||
<MenuItem value="in-review">In Prüfung</MenuItem>
|
let count = 0;
|
||||||
<MenuItem value="approved">Genehmigt</MenuItem>
|
if (activeFilters.searchQuery) count++;
|
||||||
<MenuItem value="rejected">Abgelehnt</MenuItem>
|
if (activeFilters.statusFilter) count++;
|
||||||
</TextField>
|
if (activeFilters.variantFilter) count++;
|
||||||
</Grid>
|
if (
|
||||||
<Grid item xs={12} md={3}>
|
activeFilters.amountMin > 0 ||
|
||||||
<TextField
|
activeFilters.amountMax < 300000
|
||||||
fullWidth
|
)
|
||||||
select
|
count++;
|
||||||
label="Typ"
|
if (activeFilters.dateFrom || activeFilters.dateTo) count++;
|
||||||
value={variantFilter || ""}
|
if (activeFilters.createdBy) count++;
|
||||||
onChange={(e) => setVariantFilter(e.target.value || null)}
|
if (activeFilters.hasAttachments !== null) count++;
|
||||||
>
|
if (activeFilters.fuzzySearch === false) count++;
|
||||||
<MenuItem value="">Alle Typen</MenuItem>
|
return count > 0 ? `(${count})` : "";
|
||||||
<MenuItem value="VSM">VSM</MenuItem>
|
})()}
|
||||||
<MenuItem value="QSM">QSM</MenuItem>
|
</Button>
|
||||||
</TextField>
|
<Button
|
||||||
</Grid>
|
variant="outlined"
|
||||||
<Grid item xs={12} md={2}>
|
onClick={() => loadApplicationListAdmin()}
|
||||||
<Button
|
startIcon={<Refresh />}
|
||||||
fullWidth
|
>
|
||||||
variant="outlined"
|
Aktualisieren
|
||||||
onClick={() => loadApplicationListAdmin()}
|
</Button>
|
||||||
startIcon={<Refresh />}
|
</Box>
|
||||||
>
|
|
||||||
Aktualisieren
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{/* Active Filters Display */}
|
||||||
|
{Object.keys(activeFilters).length > 0 && (
|
||||||
|
<Box sx={{ mt: 2, display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||||
|
{activeFilters.searchQuery && (
|
||||||
|
<Chip
|
||||||
|
label={`Suche: ${activeFilters.searchQuery}`}
|
||||||
|
onDelete={() => handleClearSingleFilter("searchQuery")}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeFilters.statusFilter && (
|
||||||
|
<Chip
|
||||||
|
label={`Status: ${activeFilters.statusFilter}`}
|
||||||
|
onDelete={() => handleClearSingleFilter("statusFilter")}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeFilters.variantFilter && (
|
||||||
|
<Chip
|
||||||
|
label={`Typ: ${activeFilters.variantFilter}`}
|
||||||
|
onDelete={() => handleClearSingleFilter("variantFilter")}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(activeFilters.amountMin > 0 ||
|
||||||
|
activeFilters.amountMax < 300000) && (
|
||||||
|
<Chip
|
||||||
|
label={`Betrag: ${activeFilters.amountMin?.toLocaleString("de-DE") || 0}€ - ${activeFilters.amountMax?.toLocaleString("de-DE") || 300000}€`}
|
||||||
|
onDelete={() => handleClearSingleFilter("amountRange")}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(activeFilters.dateFrom || activeFilters.dateTo) && (
|
||||||
|
<Chip
|
||||||
|
label={`Datum: ${activeFilters.dateFrom?.format("DD.MM.YY") || "∞"} - ${activeFilters.dateTo?.format("DD.MM.YY") || "∞"}`}
|
||||||
|
onDelete={() => handleClearSingleFilter("dateRange")}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeFilters.createdBy && (
|
||||||
|
<Chip
|
||||||
|
label={`Ersteller: ${activeFilters.createdBy}`}
|
||||||
|
onDelete={() => handleClearSingleFilter("createdBy")}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeFilters.hasAttachments !== null && (
|
||||||
|
<Chip
|
||||||
|
label={`Anhänge: ${activeFilters.hasAttachments ? "Ja" : "Nein"}`}
|
||||||
|
onDelete={() => handleClearSingleFilter("hasAttachments")}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeFilters.fuzzySearch === false && (
|
||||||
|
<Chip
|
||||||
|
label="Exakte Suche"
|
||||||
|
onDelete={() => handleClearSingleFilter("fuzzySearch")}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
{/* Filter Popover */}
|
||||||
|
<FilterPopover
|
||||||
|
anchorEl={filterAnchorEl}
|
||||||
|
open={Boolean(filterAnchorEl)}
|
||||||
|
onClose={handleFilterClose}
|
||||||
|
onApplyFilters={handleApplyFilters}
|
||||||
|
onClearFilters={handleClearAllFilters}
|
||||||
|
initialFilters={{
|
||||||
|
searchQuery: searchText,
|
||||||
|
statusFilter: statusFilter || "",
|
||||||
|
variantFilter: variantFilter || "",
|
||||||
|
amountMin: activeFilters.amountMin || 0,
|
||||||
|
amountMax: activeFilters.amountMax || 300000,
|
||||||
|
dateFrom: activeFilters.dateFrom || null,
|
||||||
|
dateTo: activeFilters.dateTo || null,
|
||||||
|
createdBy: activeFilters.createdBy || "",
|
||||||
|
hasAttachments: activeFilters.hasAttachments ?? null,
|
||||||
|
fuzzySearch: activeFilters.fuzzySearch !== false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Applications Table */}
|
{/* Applications Table */}
|
||||||
<TableContainer component={Paper}>
|
<TableContainer component={Paper}>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>Antrags-ID</TableCell>
|
<TableCell padding="checkbox">
|
||||||
<TableCell>Projektname</TableCell>
|
<Checkbox
|
||||||
<TableCell>Typ</TableCell>
|
checked={
|
||||||
<TableCell>Status</TableCell>
|
selectedApplications.length === applicationList.length &&
|
||||||
<TableCell align="right">Summe</TableCell>
|
applicationList.length > 0
|
||||||
<TableCell>Erstellt</TableCell>
|
}
|
||||||
<TableCell>Geändert</TableCell>
|
indeterminate={
|
||||||
|
selectedApplications.length > 0 &&
|
||||||
|
selectedApplications.length < applicationList.length
|
||||||
|
}
|
||||||
|
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TableSortLabel
|
||||||
|
active={sortBy === "pa_id"}
|
||||||
|
direction={sortBy === "pa_id" ? sortOrder : "asc"}
|
||||||
|
onClick={() => handleRequestSort("pa_id")}
|
||||||
|
>
|
||||||
|
Antrags-ID
|
||||||
|
</TableSortLabel>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TableSortLabel
|
||||||
|
active={sortBy === "project_name"}
|
||||||
|
direction={sortBy === "project_name" ? sortOrder : "asc"}
|
||||||
|
onClick={() => handleRequestSort("project_name")}
|
||||||
|
>
|
||||||
|
Projektname
|
||||||
|
</TableSortLabel>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TableSortLabel
|
||||||
|
active={sortBy === "variant"}
|
||||||
|
direction={sortBy === "variant" ? sortOrder : "asc"}
|
||||||
|
onClick={() => handleRequestSort("variant")}
|
||||||
|
>
|
||||||
|
Typ
|
||||||
|
</TableSortLabel>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TableSortLabel
|
||||||
|
active={sortBy === "status"}
|
||||||
|
direction={sortBy === "status" ? sortOrder : "asc"}
|
||||||
|
onClick={() => handleRequestSort("status")}
|
||||||
|
>
|
||||||
|
Status
|
||||||
|
</TableSortLabel>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<TableSortLabel
|
||||||
|
active={sortBy === "total_amount"}
|
||||||
|
direction={sortBy === "total_amount" ? sortOrder : "asc"}
|
||||||
|
onClick={() => handleRequestSort("total_amount")}
|
||||||
|
>
|
||||||
|
Summe
|
||||||
|
</TableSortLabel>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TableSortLabel
|
||||||
|
active={sortBy === "created_at"}
|
||||||
|
direction={sortBy === "created_at" ? sortOrder : "asc"}
|
||||||
|
onClick={() => handleRequestSort("created_at")}
|
||||||
|
>
|
||||||
|
Erstellt
|
||||||
|
</TableSortLabel>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TableSortLabel
|
||||||
|
active={sortBy === "updated_at"}
|
||||||
|
direction={sortBy === "updated_at" ? sortOrder : "asc"}
|
||||||
|
onClick={() => handleRequestSort("updated_at")}
|
||||||
|
>
|
||||||
|
Geändert
|
||||||
|
</TableSortLabel>
|
||||||
|
</TableCell>
|
||||||
<TableCell align="right">Aktionen</TableCell>
|
<TableCell align="right">Aktionen</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{applicationList.length === 0 && !isLoading ? (
|
{applicationList.length === 0 && !isLoading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8} align="center">
|
<TableCell colSpan={9} align="center">
|
||||||
<Typography color="text.secondary">
|
<Typography color="text.secondary">
|
||||||
Keine Anträge gefunden
|
Keine Anträge gefunden
|
||||||
</Typography>
|
</Typography>
|
||||||
@ -329,7 +804,22 @@ const AdminDashboard: React.FC = () => {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
applicationList.map((application) => (
|
applicationList.map((application) => (
|
||||||
<TableRow key={application.pa_id} hover>
|
<TableRow
|
||||||
|
key={application.pa_id}
|
||||||
|
hover
|
||||||
|
selected={selectedApplications.includes(application.pa_id)}
|
||||||
|
>
|
||||||
|
<TableCell padding="checkbox">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedApplications.includes(application.pa_id)}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleSelectApplication(
|
||||||
|
application.pa_id,
|
||||||
|
e.target.checked,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Typography variant="body2" sx={{ fontWeight: "medium" }}>
|
<Typography variant="body2" sx={{ fontWeight: "medium" }}>
|
||||||
{application.pa_id}
|
{application.pa_id}
|
||||||
@ -362,8 +852,10 @@ const AdminDashboard: React.FC = () => {
|
|||||||
translateStatus(value as string),
|
translateStatus(value as string),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MenuItem value="new">Neu</MenuItem>
|
<MenuItem value="new">Beantragt</MenuItem>
|
||||||
<MenuItem value="in-review">In Prüfung</MenuItem>
|
<MenuItem value="in-review">
|
||||||
|
Bearbeitung Gesperrt
|
||||||
|
</MenuItem>
|
||||||
<MenuItem value="approved">Genehmigt</MenuItem>
|
<MenuItem value="approved">Genehmigt</MenuItem>
|
||||||
<MenuItem value="rejected">Abgelehnt</MenuItem>
|
<MenuItem value="rejected">Abgelehnt</MenuItem>
|
||||||
</TextField>
|
</TextField>
|
||||||
@ -425,6 +917,157 @@ const AdminDashboard: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading && <LoadingSpinner text="Wird geladen..." />}
|
{isLoading && <LoadingSpinner text="Wird geladen..." />}
|
||||||
|
|
||||||
|
{/* Floating Action Bar for Bulk Operations */}
|
||||||
|
<Slide direction="up" in={showBulkActions} mountOnEnter unmountOnExit>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "fixed",
|
||||||
|
bottom: 24,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1100,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
maxWidth: "calc(100vw - 120px)",
|
||||||
|
margin: "0 auto",
|
||||||
|
pointerEvents: "none", // Allow clicks through the container
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={6}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: "primary.main",
|
||||||
|
color: "primary.contrastText",
|
||||||
|
overflow: "hidden",
|
||||||
|
pointerEvents: "auto", // Re-enable clicks on the paper
|
||||||
|
width: "fit-content",
|
||||||
|
minWidth: 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 2,
|
||||||
|
p: 2,
|
||||||
|
overflowX: "auto",
|
||||||
|
scrollbarWidth: "none", // Firefox
|
||||||
|
"&::-webkit-scrollbar": {
|
||||||
|
display: "none", // Chrome, Safari, Edge
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: "medium",
|
||||||
|
flexShrink: 0,
|
||||||
|
minWidth: "max-content",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedApplications.length} ausgewählt
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 1,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
color="inherit"
|
||||||
|
onClick={handleBulkSetNew}
|
||||||
|
startIcon={<LockOpenOutlined />}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "20px",
|
||||||
|
textTransform: "none",
|
||||||
|
backgroundColor: "white",
|
||||||
|
color: "primary.main",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "grey.100",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Freigeben
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
color="warning"
|
||||||
|
onClick={handleBulkSetInReview}
|
||||||
|
startIcon={<BlockOutlined />}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "20px",
|
||||||
|
textTransform: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Bearbeitung Sperren
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
color="success"
|
||||||
|
onClick={handleBulkApprove}
|
||||||
|
startIcon={<CheckCircleOutline />}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "20px",
|
||||||
|
textTransform: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Genehmigen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
color="secondary"
|
||||||
|
onClick={handleBulkReject}
|
||||||
|
startIcon={<CancelOutlined />}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "20px",
|
||||||
|
textTransform: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ablehnen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={handleBulkDelete}
|
||||||
|
startIcon={<DeleteOutline />}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "20px",
|
||||||
|
textTransform: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
onClick={() => setSelectedApplications([])}
|
||||||
|
startIcon={<CancelOutlined />}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "20px",
|
||||||
|
textTransform: "none",
|
||||||
|
backgroundColor: "grey.300",
|
||||||
|
color: "grey.800",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "grey.400",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
</Slide>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -135,14 +135,29 @@ const EditApplicationPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if application can be edited (only "new" status)
|
// Check if application can be edited - block in-review, approved, and rejected for everyone
|
||||||
if (currentApplication.status !== "new" && !isAdmin) {
|
if (
|
||||||
|
currentApplication.status === "in-review" ||
|
||||||
|
currentApplication.status === "approved" ||
|
||||||
|
currentApplication.status === "rejected" ||
|
||||||
|
(currentApplication.status !== "new" && !isAdmin)
|
||||||
|
) {
|
||||||
|
const status = currentApplication.status;
|
||||||
|
let message =
|
||||||
|
"Dieser Antrag kann nicht mehr bearbeitet werden, da er bereits in Bearbeitung ist.";
|
||||||
|
|
||||||
|
if (status === "in-review") {
|
||||||
|
message =
|
||||||
|
"Dieser Antrag ist zur Bearbeitung gesperrt und kann nicht bearbeitet werden.";
|
||||||
|
} else if (status === "approved") {
|
||||||
|
message = "Genehmigte Anträge können nicht mehr bearbeitet werden.";
|
||||||
|
} else if (status === "rejected") {
|
||||||
|
message = "Abgelehnte Anträge können nicht mehr bearbeitet werden.";
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="md" sx={{ mt: 4 }}>
|
<Container maxWidth="md" sx={{ mt: 4 }}>
|
||||||
<Alert severity="warning">
|
<Alert severity="warning">{message}</Alert>
|
||||||
Dieser Antrag kann nicht mehr bearbeitet werden, da er bereits in
|
|
||||||
Bearbeitung ist.
|
|
||||||
</Alert>
|
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box sx={{ mt: 2 }}>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|||||||
@ -255,7 +255,12 @@ const ViewApplicationPage: React.FC = () => {
|
|||||||
: `/application/${paId}/edit`,
|
: `/application/${paId}/edit`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
disabled={!isAdmin && currentApplication.status !== "new"}
|
disabled={
|
||||||
|
currentApplication.status === "in-review" ||
|
||||||
|
currentApplication.status === "approved" ||
|
||||||
|
currentApplication.status === "rejected" ||
|
||||||
|
(!isAdmin && currentApplication.status !== "new")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Edit />
|
<Edit />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@ -590,7 +595,12 @@ const ViewApplicationPage: React.FC = () => {
|
|||||||
paId={paId!}
|
paId={paId!}
|
||||||
paKey={paKey || undefined}
|
paKey={paKey || undefined}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
readOnly={!isAdmin && currentApplication.status !== "new"}
|
readOnly={
|
||||||
|
currentApplication.status === "in-review" ||
|
||||||
|
currentApplication.status === "approved" ||
|
||||||
|
currentApplication.status === "rejected" ||
|
||||||
|
(!isAdmin && currentApplication.status !== "new")
|
||||||
|
}
|
||||||
attachments={attachments}
|
attachments={attachments}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -41,6 +41,10 @@ interface ApplicationState {
|
|||||||
statusFilter: string | null;
|
statusFilter: string | null;
|
||||||
variantFilter: string | null;
|
variantFilter: string | null;
|
||||||
searchQuery: string | null;
|
searchQuery: string | null;
|
||||||
|
|
||||||
|
// Sorting
|
||||||
|
sortBy: string;
|
||||||
|
sortOrder: "asc" | "desc";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApplicationActions {
|
interface ApplicationActions {
|
||||||
@ -68,9 +72,11 @@ interface ApplicationActions {
|
|||||||
// Application list management
|
// Application list management
|
||||||
loadApplicationList: (refresh?: boolean) => Promise<boolean>;
|
loadApplicationList: (refresh?: boolean) => Promise<boolean>;
|
||||||
searchApplications: (query: string) => Promise<boolean>;
|
searchApplications: (query: string) => Promise<boolean>;
|
||||||
|
searchApplicationsAdvanced: (params: any) => Promise<boolean>;
|
||||||
setStatusFilter: (status: string | null) => void;
|
setStatusFilter: (status: string | null) => void;
|
||||||
setVariantFilter: (variant: string | null) => void;
|
setVariantFilter: (variant: string | null) => void;
|
||||||
setPage: (page: number) => void;
|
setPage: (page: number) => void;
|
||||||
|
setSorting: (sortBy: string, sortOrder: "asc" | "desc") => void;
|
||||||
|
|
||||||
// Admin operations
|
// Admin operations
|
||||||
loadApplicationAdmin: (paId: string) => Promise<boolean>;
|
loadApplicationAdmin: (paId: string) => Promise<boolean>;
|
||||||
@ -91,6 +97,13 @@ interface ApplicationActions {
|
|||||||
paId: string,
|
paId: string,
|
||||||
) => Promise<{ pa_id: string; pa_key: string } | null>;
|
) => Promise<{ pa_id: string; pa_key: string } | null>;
|
||||||
|
|
||||||
|
// Bulk operations
|
||||||
|
bulkDeleteApplications: (paIds: string[]) => Promise<boolean>;
|
||||||
|
bulkApproveApplications: (paIds: string[]) => Promise<boolean>;
|
||||||
|
bulkRejectApplications: (paIds: string[]) => Promise<boolean>;
|
||||||
|
bulkSetInReviewApplications: (paIds: string[]) => Promise<boolean>;
|
||||||
|
bulkSetNewApplications: (paIds: string[]) => Promise<boolean>;
|
||||||
|
|
||||||
// Form management
|
// Form management
|
||||||
updateFormData: (data: Partial<FormData>) => void;
|
updateFormData: (data: Partial<FormData>) => void;
|
||||||
resetFormData: () => void;
|
resetFormData: () => void;
|
||||||
@ -158,6 +171,10 @@ const initialState: ApplicationState = {
|
|||||||
statusFilter: null,
|
statusFilter: null,
|
||||||
variantFilter: null,
|
variantFilter: null,
|
||||||
searchQuery: null,
|
searchQuery: null,
|
||||||
|
|
||||||
|
// Sorting
|
||||||
|
sortBy: "created_at",
|
||||||
|
sortOrder: "desc",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useApplicationStore = create<
|
export const useApplicationStore = create<
|
||||||
@ -582,6 +599,8 @@ export const useApplicationStore = create<
|
|||||||
offset: currentPage * itemsPerPage,
|
offset: currentPage * itemsPerPage,
|
||||||
status: statusFilter || undefined,
|
status: statusFilter || undefined,
|
||||||
variant: variantFilter || undefined,
|
variant: variantFilter || undefined,
|
||||||
|
order_by: get().sortBy,
|
||||||
|
order: get().sortOrder,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
response = await apiClient.listApplications(paId!, paKey!, {
|
response = await apiClient.listApplications(paId!, paKey!, {
|
||||||
@ -589,6 +608,8 @@ export const useApplicationStore = create<
|
|||||||
offset: currentPage * itemsPerPage,
|
offset: currentPage * itemsPerPage,
|
||||||
status: statusFilter || undefined,
|
status: statusFilter || undefined,
|
||||||
variant: variantFilter || undefined,
|
variant: variantFilter || undefined,
|
||||||
|
order_by: get().sortBy,
|
||||||
|
order: get().sortOrder,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -642,6 +663,8 @@ export const useApplicationStore = create<
|
|||||||
offset: 0,
|
offset: 0,
|
||||||
status: get().statusFilter || undefined,
|
status: get().statusFilter || undefined,
|
||||||
variant: get().variantFilter || undefined,
|
variant: get().variantFilter || undefined,
|
||||||
|
order_by: get().sortBy,
|
||||||
|
order: get().sortOrder,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isSuccessResponse(response)) {
|
if (isSuccessResponse(response)) {
|
||||||
@ -668,6 +691,63 @@ export const useApplicationStore = create<
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
searchApplicationsAdvanced: async (params: any) => {
|
||||||
|
if (!get().isAdmin) {
|
||||||
|
set({ error: "Suche nur für Administratoren verfügbar" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!get().masterKey) {
|
||||||
|
set({
|
||||||
|
error: "Master key not available. Please login again.",
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.searchApplications({
|
||||||
|
q: params.q,
|
||||||
|
status: params.status,
|
||||||
|
variant: params.variant,
|
||||||
|
amount_min: params.amount_min,
|
||||||
|
amount_max: params.amount_max,
|
||||||
|
date_from: params.date_from,
|
||||||
|
date_to: params.date_to,
|
||||||
|
created_by: params.created_by,
|
||||||
|
has_attachments: params.has_attachments,
|
||||||
|
limit: params.limit || get().itemsPerPage,
|
||||||
|
offset: params.offset || 0,
|
||||||
|
order_by: get().sortBy,
|
||||||
|
order: get().sortOrder,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isSuccessResponse(response)) {
|
||||||
|
set({
|
||||||
|
applicationList: response.data,
|
||||||
|
currentPage: 0,
|
||||||
|
isLoading: false,
|
||||||
|
searchQuery: params.q || null,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
set({
|
||||||
|
error: getErrorMessage(response),
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
set({
|
||||||
|
error: error.message || "Suche fehlgeschlagen",
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
setStatusFilter: (status) => {
|
setStatusFilter: (status) => {
|
||||||
set({ statusFilter: status, currentPage: 0 });
|
set({ statusFilter: status, currentPage: 0 });
|
||||||
},
|
},
|
||||||
@ -680,6 +760,10 @@ export const useApplicationStore = create<
|
|||||||
set({ currentPage: page });
|
set({ currentPage: page });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setSorting: (sortBy, sortOrder) => {
|
||||||
|
set({ sortBy, sortOrder, currentPage: 0 });
|
||||||
|
},
|
||||||
|
|
||||||
// Admin operations
|
// Admin operations
|
||||||
loadApplicationAdmin: async (paId: string) => {
|
loadApplicationAdmin: async (paId: string) => {
|
||||||
if (!get().isAdmin) {
|
if (!get().isAdmin) {
|
||||||
@ -1107,6 +1191,202 @@ export const useApplicationStore = create<
|
|||||||
const { paId: currentPaId, isAdmin } = get();
|
const { paId: currentPaId, isAdmin } = get();
|
||||||
return isAdmin || currentPaId === paId;
|
return isAdmin || currentPaId === paId;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Bulk operations
|
||||||
|
bulkDeleteApplications: async (paIds: string[]) => {
|
||||||
|
if (!get().isAdmin || !get().masterKey) {
|
||||||
|
set({ error: "Admin access required" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.bulkOperation({
|
||||||
|
pa_ids: paIds,
|
||||||
|
operation: "delete",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isSuccessResponse(response)) {
|
||||||
|
// Reload application list to reflect changes
|
||||||
|
await get().loadApplicationListAdmin();
|
||||||
|
set({
|
||||||
|
successMessage: `${response.data.success.length} Anträge erfolgreich gelöscht`,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
set({
|
||||||
|
error: getErrorMessage(response),
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : "Fehler beim Löschen",
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
bulkApproveApplications: async (paIds: string[]) => {
|
||||||
|
if (!get().isAdmin || !get().masterKey) {
|
||||||
|
set({ error: "Admin access required" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.bulkOperation({
|
||||||
|
pa_ids: paIds,
|
||||||
|
operation: "approve",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isSuccessResponse(response)) {
|
||||||
|
await get().loadApplicationListAdmin();
|
||||||
|
set({
|
||||||
|
successMessage: `${response.data.success.length} Anträge erfolgreich genehmigt`,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
set({
|
||||||
|
error: getErrorMessage(response),
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : "Fehler beim Genehmigen",
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
bulkRejectApplications: async (paIds: string[]) => {
|
||||||
|
if (!get().isAdmin || !get().masterKey) {
|
||||||
|
set({ error: "Admin access required" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.bulkOperation({
|
||||||
|
pa_ids: paIds,
|
||||||
|
operation: "reject",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isSuccessResponse(response)) {
|
||||||
|
await get().loadApplicationListAdmin();
|
||||||
|
set({
|
||||||
|
successMessage: `${response.data.success.length} Anträge erfolgreich abgelehnt`,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
set({
|
||||||
|
error: getErrorMessage(response),
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : "Fehler beim Ablehnen",
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
bulkSetInReviewApplications: async (paIds: string[]) => {
|
||||||
|
if (!get().isAdmin || !get().masterKey) {
|
||||||
|
set({ error: "Admin access required" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.bulkOperation({
|
||||||
|
pa_ids: paIds,
|
||||||
|
operation: "set_in_review",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isSuccessResponse(response)) {
|
||||||
|
await get().loadApplicationListAdmin();
|
||||||
|
set({
|
||||||
|
successMessage: `${response.data.success.length} Anträge für Bearbeitung gesperrt`,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
set({
|
||||||
|
error: getErrorMessage(response),
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Fehler beim Status-Update",
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
bulkSetNewApplications: async (paIds: string[]) => {
|
||||||
|
if (!get().isAdmin || !get().masterKey) {
|
||||||
|
set({ error: "Admin access required" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.bulkOperation({
|
||||||
|
pa_ids: paIds,
|
||||||
|
operation: "set_new",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isSuccessResponse(response)) {
|
||||||
|
await get().loadApplicationListAdmin();
|
||||||
|
set({
|
||||||
|
successMessage: `${response.data.success.length} Anträge auf "Beantragt" gesetzt`,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
set({
|
||||||
|
error: getErrorMessage(response),
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Fehler beim Status-Update",
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "stupa-application-storage",
|
name: "stupa-application-storage",
|
||||||
|
|||||||
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 type ApplicationStatus = "new" | "in-review" | "approved" | "rejected";
|
||||||
|
|
||||||
export const statusTranslations: Record<ApplicationStatus, string> = {
|
export const statusTranslations: Record<ApplicationStatus, string> = {
|
||||||
"new": "Neu",
|
new: "Beantragt",
|
||||||
"in-review": "In Prüfung",
|
"in-review": "Bearbeitung Gesperrt",
|
||||||
"approved": "Genehmigt",
|
approved: "Genehmigt",
|
||||||
"rejected": "Abgelehnt"
|
rejected: "Abgelehnt",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const translateStatus = (status: string): string => {
|
export const translateStatus = (status: string): string => {
|
||||||
@ -12,7 +12,16 @@ export const translateStatus = (status: string): string => {
|
|||||||
return statusTranslations[normalizedStatus] || status;
|
return statusTranslations[normalizedStatus] || status;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getStatusColor = (status: string): "default" | "primary" | "secondary" | "error" | "info" | "success" | "warning" => {
|
export const getStatusColor = (
|
||||||
|
status: string,
|
||||||
|
):
|
||||||
|
| "default"
|
||||||
|
| "primary"
|
||||||
|
| "secondary"
|
||||||
|
| "error"
|
||||||
|
| "info"
|
||||||
|
| "success"
|
||||||
|
| "warning" => {
|
||||||
switch (status.toLowerCase()) {
|
switch (status.toLowerCase()) {
|
||||||
case "new":
|
case "new":
|
||||||
return "info";
|
return "info";
|
||||||
|
|||||||
@ -6,8 +6,8 @@ export type StatusColor = "info" | "warning" | "success" | "error" | "default";
|
|||||||
* Translation mapping from English status to German
|
* Translation mapping from English status to German
|
||||||
*/
|
*/
|
||||||
const STATUS_TRANSLATIONS: Record<ApplicationStatus, string> = {
|
const STATUS_TRANSLATIONS: Record<ApplicationStatus, string> = {
|
||||||
new: "Neu",
|
new: "Beantragt",
|
||||||
"in-review": "In Prüfung",
|
"in-review": "Bearbeitung Gesperrt",
|
||||||
approved: "Genehmigt",
|
approved: "Genehmigt",
|
||||||
rejected: "Abgelehnt",
|
rejected: "Abgelehnt",
|
||||||
};
|
};
|
||||||
@ -41,10 +41,13 @@ export function getStatusColor(status: string): StatusColor {
|
|||||||
/**
|
/**
|
||||||
* Get all available statuses with translations
|
* Get all available statuses with translations
|
||||||
*/
|
*/
|
||||||
export function getAllStatuses(): Array<{ value: ApplicationStatus; label: string }> {
|
export function getAllStatuses(): Array<{
|
||||||
|
value: ApplicationStatus;
|
||||||
|
label: string;
|
||||||
|
}> {
|
||||||
return [
|
return [
|
||||||
{ value: "new", label: "Neu" },
|
{ value: "new", label: "Beantragt" },
|
||||||
{ value: "in-review", label: "In Prüfung" },
|
{ value: "in-review", label: "Bearbeitung Gesperrt" },
|
||||||
{ value: "approved", label: "Genehmigt" },
|
{ value: "approved", label: "Genehmigt" },
|
||||||
{ value: "rejected", label: "Abgelehnt" },
|
{ value: "rejected", label: "Abgelehnt" },
|
||||||
];
|
];
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user