diff --git a/backend/src/service_api.py b/backend/src/service_api.py index dd2cb4ad..c1e24fed 100644 --- a/backend/src/service_api.py +++ b/backend/src/service_api.py @@ -43,7 +43,7 @@ from sqlalchemy import ( from sqlalchemy.dialects.mysql import LONGTEXT from sqlalchemy.orm import declarative_base, sessionmaker, Session from sqlalchemy.exc import IntegrityError -from sqlalchemy import text as sql_text +from sqlalchemy import text as sql_text, text import PyPDF2 from PyPDF2.errors import PdfReadError @@ -617,6 +617,15 @@ def update_application( if not app_row: raise HTTPException(status_code=404, detail="Application not found") + # Check if application is in final states (in-review, approved, rejected) and user is not admin + if app_row.status in ["in-review", "approved", "rejected"] and auth["scope"] != "master": + status_messages = { + "in-review": "Cannot update application while in review", + "approved": "Cannot update approved application", + "rejected": "Cannot update rejected application" + } + raise HTTPException(status_code=403, detail=status_messages[app_row.status]) + # Payload beschaffen payload: Dict[str, Any] raw_form: Optional[Dict[str, Any]] = None @@ -671,6 +680,175 @@ def update_application( return StreamingResponse(io.BytesIO(pdf_bytes), media_type="application/pdf", headers=headers) +@app.get("/applications/search") +def search_applications( + q: Optional[str] = Query(None, description="Volltext über payload_json (einfach)"), + status: Optional[str] = Query(None), + variant: Optional[str] = Query(None), + amount_min: Optional[float] = Query(None, description="Mindestbetrag"), + amount_max: Optional[float] = Query(None, description="Höchstbetrag"), + date_from: Optional[str] = Query(None, description="Erstellungsdatum ab (ISO format)"), + date_to: Optional[str] = Query(None, description="Erstellungsdatum bis (ISO format)"), + created_by: Optional[str] = Query(None, description="Ersteller (E-Mail)"), + has_attachments: Optional[bool] = Query(None, description="Mit/ohne Anhänge"), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + order_by: Optional[str] = Query("created_at", description="Sort by: pa_id, project_name, variant, status, total_amount, created_at, updated_at"), + order: Optional[str] = Query("desc", description="Sort order: asc, desc"), + x_master_key: Optional[str] = Header(None, alias="X-MASTER-KEY"), + x_forwarded_for: Optional[str] = Header(None), + db: Session = Depends(get_db), +): + rate_limit_ip(x_forwarded_for or "") + _ = _auth_from_request(db, None, None, None, x_master_key, x_forwarded_for) + + # sehr einfache Suche (MySQL JSON_EXTRACT/LIKE); für produktion auf FTS migrieren + base_sql = """ + SELECT a.pa_id, a.variant, a.status, a.created_at, a.updated_at, + JSON_UNQUOTE(JSON_EXTRACT(a.payload_json, '$.pa.project.name')) as project_name, + COALESCE(att_count.attachment_count, 0) as attachment_count + FROM applications a + LEFT JOIN ( + SELECT aa.application_id, COUNT(*) as attachment_count + FROM application_attachments aa + GROUP BY aa.application_id + ) att_count ON a.id = att_count.application_id + WHERE 1=1""" + params = {} + if status: + base_sql += " AND a.status=:status" + params["status"] = status + if variant: + base_sql += " AND a.variant=:variant" + params["variant"] = variant.upper() + if q: + # naive Suche im JSON + base_sql += " AND JSON_SEARCH(JSON_EXTRACT(a.payload_json, '$'), 'all', :q) IS NOT NULL" + params["q"] = f"%{q}%" + + # Date range filters + if date_from: + try: + from datetime import datetime + # Handle YYYY-MM-DD format from frontend + if len(date_from) == 10 and '-' in date_from: + start_date = datetime.strptime(date_from, "%Y-%m-%d") + base_sql += " AND a.created_at >= :date_from" + params["date_from"] = start_date.strftime("%Y-%m-%d 00:00:00") + else: + base_sql += " AND a.created_at >= :date_from" + params["date_from"] = date_from + except: + base_sql += " AND a.created_at >= :date_from" + params["date_from"] = date_from + + if date_to: + try: + from datetime import datetime + # Handle YYYY-MM-DD format from frontend + if len(date_to) == 10 and '-' in date_to: + end_date = datetime.strptime(date_to, "%Y-%m-%d") + # Set to end of day (23:59:59) + base_sql += " AND a.created_at <= :date_to" + params["date_to"] = end_date.strftime("%Y-%m-%d 23:59:59") + else: + # Try ISO format with time adjustment + end_date = datetime.fromisoformat(date_to.replace('Z', '+00:00')) + end_date = end_date.replace(hour=23, minute=59, second=59, microsecond=999999) + base_sql += " AND a.created_at <= :date_to" + params["date_to"] = end_date.isoformat() + except: + # Fallback to original behavior if date parsing fails + base_sql += " AND a.created_at <= :date_to" + params["date_to"] = date_to + + # Created by filter (search in applicant email) - fuzzy search + if created_by: + base_sql += " AND LOWER(JSON_UNQUOTE(JSON_EXTRACT(a.payload_json, '$.pa.applicant.contact.email'))) LIKE LOWER(:created_by)" + params["created_by"] = f"%{created_by}%" + + # Has attachments filter + if has_attachments is not None: + if has_attachments: + base_sql += " AND att_count.attachment_count > 0" + else: + base_sql += " AND (att_count.attachment_count IS NULL OR att_count.attachment_count = 0)" + + # Add sorting + valid_db_sort_fields = { + "pa_id": "pa_id", + "variant": "variant", + "status": "status", + "created_at": "created_at", + "updated_at": "updated_at", + "project_name": "project_name" + } + + db_sort_field = valid_db_sort_fields.get(order_by, "created_at") + sort_order = order.upper() if order and order.upper() in ['ASC', 'DESC'] else 'DESC' + + base_sql += f" ORDER BY {db_sort_field} {sort_order} LIMIT :limit OFFSET :offset" + params["limit"] = limit + params["offset"] = offset + + rows = db.execute(sql_text(base_sql), params).all() + + # Calculate total_amount and apply post-processing filters + result = [] + for r in rows: + # Extract basic info + pa_id = r[0] + variant = r[1] + status = r[2] + created_at = r[3] + updated_at = r[4] + project_name = r[5] if r[5] else None + has_attachments_count = r[6] or 0 + + # Calculate total_amount + total_amount = 0.0 + + try: + # Get the full application to calculate total and check attachments + app_row = db.execute(select(Application).where(Application.pa_id == pa_id)).scalar_one_or_none() + if app_row and app_row.payload_json: + payload = json.loads(app_row.payload_json) if isinstance(app_row.payload_json, str) else app_row.payload_json + project = payload.get("pa", {}).get("project", {}) + # Calculate total from costs + costs = project.get("costs", []) + for cost in costs: + if isinstance(cost, dict) and "amountEur" in cost: + amount = cost.get("amountEur") + if amount is not None and isinstance(amount, (int, float)): + total_amount += float(amount) + except: + pass + + # Apply amount filters + if amount_min is not None and total_amount < amount_min: + continue + if amount_max is not None and total_amount > amount_max: + continue + + # Add to results if all filters pass + result.append({ + "pa_id": pa_id, + "variant": variant, + "status": status, + "created_at": created_at.isoformat(), + "updated_at": updated_at.isoformat(), + "project_name": project_name, + "total_amount": total_amount + }) + + # Handle sorting for total_amount which requires post-processing + if order_by == "total_amount": + reverse_order = (order.lower() == 'desc') if order else True + result.sort(key=lambda x: x["total_amount"] or 0, reverse=reverse_order) + + return result + + @app.get("/applications/{pa_id}") def get_application( pa_id: str, @@ -725,6 +903,8 @@ def list_applications( offset: int = Query(0, ge=0), status: Optional[str] = Query(None), variant: Optional[str] = Query(None), + order_by: Optional[str] = Query("created_at", description="Sort by: pa_id, project_name, variant, status, total_amount, created_at, updated_at"), + order: Optional[str] = Query("desc", description="Sort order: asc, desc"), x_master_key: Optional[str] = Header(None, alias="X-MASTER-KEY"), x_pa_key: Optional[str] = Header(None, alias="X-PA-KEY"), pa_id: Optional[str] = Query(None, description="Mit Key: nur diesen Antrag anzeigen"), @@ -737,7 +917,24 @@ def list_applications( # Mit Master-Key: alle listen/filtern if x_master_key: _ = _auth_from_request(db, None, None, None, x_master_key) - q = select(Application).order_by(Application.created_at.desc()) + + # Validate and map sort parameters + valid_sort_fields = { + "pa_id": Application.pa_id, + "variant": Application.variant, + "status": Application.status, + "created_at": Application.created_at, + "updated_at": Application.updated_at + } + + sort_field = valid_sort_fields.get(order_by, Application.created_at) + sort_order = order.lower() if order and order.lower() in ['asc', 'desc'] else 'desc' + + if sort_order == 'desc': + q = select(Application).order_by(sort_field.desc()) + else: + q = select(Application).order_by(sort_field.asc()) + if status: q = q.where(Application.status == status) if variant: @@ -773,6 +970,15 @@ def list_applications( "created_at": r.created_at.isoformat(), "updated_at": r.updated_at.isoformat() }) + + # Handle sorting for fields that require post-processing (project_name, total_amount) + if order_by in ["project_name", "total_amount"]: + reverse_order = (order.lower() == 'desc') if order else True + if order_by == "project_name": + result.sort(key=lambda x: (x["project_name"] or "").lower(), reverse=reverse_order) + elif order_by == "total_amount": + result.sort(key=lambda x: x["total_amount"] or 0, reverse=reverse_order) + return result # Ohne Master: nur eigenen Antrag (pa_id + key erforderlich) @@ -800,6 +1006,8 @@ def list_applications( except: pass + # Note: Sorting is not really applicable for single application return + # but we keep the parameters for API consistency return [{ "pa_id": app_row.pa_id, "variant": "VSM" if app_row.variant == "COMMON" else app_row.variant, @@ -914,46 +1122,66 @@ def reset_credentials( } -@app.get("/applications/search") -def search_applications( - q: Optional[str] = Query(None, description="Volltext über payload_json (einfach)"), - status: Optional[str] = Query(None), - variant: Optional[str] = Query(None), - limit: int = Query(50, ge=1, le=200), - offset: int = Query(0, ge=0), + + + +# ------------------------------------------------------------- +# Bulk Operations Endpoints +# ------------------------------------------------------------- + +class BulkOperationRequest(BaseModel): + pa_ids: List[str] + operation: str # "delete", "approve", "reject", "set_in_review", "set_new" + +@app.post("/admin/applications/bulk") +def bulk_operation( + request: BulkOperationRequest, x_master_key: Optional[str] = Header(None, alias="X-MASTER-KEY"), x_forwarded_for: Optional[str] = Header(None), db: Session = Depends(get_db), ): + """Perform bulk operations on applications (Admin only)""" rate_limit_ip(x_forwarded_for or "") - _ = _auth_from_request(db, None, None, None, x_master_key) + _ = _auth_from_request(db, None, None, None, x_master_key, x_forwarded_for) - # sehr einfache Suche (MySQL JSON_EXTRACT/LIKE); für produktion auf FTS migrieren - base_sql = "SELECT pa_id, variant, status, created_at, updated_at FROM applications WHERE 1=1" - params = {} - if status: - base_sql += " AND status=:status" - params["status"] = status - if variant: - base_sql += " AND variant=:variant" - params["variant"] = variant.upper() - if q: - # naive Suche im JSON - base_sql += " AND JSON_SEARCH(JSON_EXTRACT(payload_json, '$'), 'all', :q) IS NOT NULL" - params["q"] = f"%{q}%" - base_sql += " ORDER BY created_at DESC LIMIT :limit OFFSET :offset" - params["limit"] = limit - params["offset"] = offset + if not request.pa_ids: + raise HTTPException(status_code=400, detail="No application IDs provided") - rows = db.execute(sql_text(base_sql), params).all() - return [ - {"pa_id": r[0], "variant": r[1], "status": r[2], - "created_at": r[3].isoformat(), "updated_at": r[4].isoformat()} - for r in rows - ] + if request.operation not in ["delete", "approve", "reject", "set_in_review", "set_new"]: + raise HTTPException(status_code=400, detail="Invalid operation") + results = {"success": [], "failed": []} + + for pa_id in request.pa_ids: + try: + app = db.query(Application).filter(Application.pa_id == pa_id).first() + if not app: + results["failed"].append({"pa_id": pa_id, "error": "Application not found"}) + continue + + if request.operation == "delete": + # Delete related data first + db.execute(text("DELETE FROM application_attachments WHERE application_id = :app_id"), {"app_id": app.id}) + db.execute(text("DELETE FROM comparison_offers WHERE application_id = :app_id"), {"app_id": app.id}) + db.execute(text("DELETE FROM cost_position_justifications WHERE application_id = :app_id"), {"app_id": app.id}) + db.delete(app) + elif request.operation == "approve": + app.status = "approved" + elif request.operation == "reject": + app.status = "rejected" + elif request.operation == "set_in_review": + app.status = "in-review" + elif request.operation == "set_new": + app.status = "new" + + results["success"].append(pa_id) + + except Exception as e: + results["failed"].append({"pa_id": pa_id, "error": str(e)}) + + db.commit() + return results -# ------------------------------------------------------------- # Attachment Endpoints # ------------------------------------------------------------- @@ -978,6 +1206,15 @@ async def upload_attachment( if not app: raise HTTPException(status_code=404, detail="Application not found") + # Check if application is in final states and user is not admin + if app.status in ["in-review", "approved", "rejected"] and auth["scope"] != "master": + status_messages = { + "in-review": "Cannot upload attachments while application is in review", + "approved": "Cannot upload attachments to approved application", + "rejected": "Cannot upload attachments to rejected application" + } + raise HTTPException(status_code=403, detail=status_messages[app.status]) + # Check attachment count limit (30 attachments max) attachment_count = db.query(ApplicationAttachment).filter( ApplicationAttachment.application_id == app.id @@ -1123,6 +1360,15 @@ def delete_attachment( if not app: raise HTTPException(status_code=404, detail="Application not found") + # Check if application is in final states and user is not admin + if app.status in ["in-review", "approved", "rejected"] and auth["scope"] != "master": + status_messages = { + "in-review": "Cannot delete attachments while application is in review", + "approved": "Cannot delete attachments from approved application", + "rejected": "Cannot delete attachments from rejected application" + } + raise HTTPException(status_code=403, detail=status_messages[app.status]) + # Check if attachment belongs to this application app_attachment = db.query(ApplicationAttachment).filter( ApplicationAttachment.application_id == app.id, @@ -1165,6 +1411,15 @@ async def create_comparison_offer( if not app: raise HTTPException(status_code=404, detail="Application not found") + # Check if application is in final states and user is not admin + if app.status in ["in-review", "approved", "rejected"] and auth["scope"] != "master": + status_messages = { + "in-review": "Cannot create comparison offers while application is in review", + "approved": "Cannot create comparison offers for approved application", + "rejected": "Cannot create comparison offers for rejected application" + } + raise HTTPException(status_code=403, detail=status_messages[app.status]) + # Validate cost position index payload = app.payload_json costs = payload.get("pa", {}).get("project", {}).get("costs", []) @@ -1305,6 +1560,15 @@ def delete_comparison_offer( if not app: raise HTTPException(status_code=404, detail="Application not found") + # Check if application is in final states and user is not admin + if app.status in ["in-review", "approved", "rejected"] and auth["scope"] != "master": + status_messages = { + "in-review": "Cannot delete comparison offers while application is in review", + "approved": "Cannot delete comparison offers from approved application", + "rejected": "Cannot delete comparison offers from rejected application" + } + raise HTTPException(status_code=403, detail=status_messages[app.status]) + # Get and delete offer offer = db.query(ComparisonOffer).filter( ComparisonOffer.id == offer_id, @@ -1340,6 +1604,15 @@ def update_cost_position_justification( if not app: raise HTTPException(status_code=404, detail="Application not found") + # Check if application is in final states and user is not admin + if app.status in ["in-review", "approved", "rejected"] and auth["scope"] != "master": + status_messages = { + "in-review": "Cannot update cost position justification while application is in review", + "approved": "Cannot update cost position justification for approved application", + "rejected": "Cannot update cost position justification for rejected application" + } + raise HTTPException(status_code=403, detail=status_messages[app.status]) + # Validate cost position index payload = app.payload_json costs = payload.get("pa", {}).get("project", {}).get("costs", []) @@ -1393,6 +1666,15 @@ def set_preferred_offer( if not app: raise HTTPException(status_code=404, detail="Application not found") + # Check if application is in final states and user is not admin + if app.status in ["in-review", "approved", "rejected"] and auth["scope"] != "master": + status_messages = { + "in-review": "Cannot set preferred offer while application is in review", + "approved": "Cannot set preferred offer for approved application", + "rejected": "Cannot set preferred offer for rejected application" + } + raise HTTPException(status_code=403, detail=status_messages[app.status]) + # Validate cost position index payload = app.payload_json costs = payload.get("pa", {}).get("project", {}).get("costs", []) diff --git a/docs/features/ADMIN_TABLE_SORTING.md b/docs/features/ADMIN_TABLE_SORTING.md new file mode 100644 index 00000000..a2d98a2e --- /dev/null +++ b/docs/features/ADMIN_TABLE_SORTING.md @@ -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([]); +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(() => ({ + ...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 \ No newline at end of file diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e8546b1e..81600062 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -272,6 +272,8 @@ export class ApiClient { offset?: number; status?: string; variant?: string; + order_by?: string; + order?: string; }, ): Promise> { const config = { @@ -297,6 +299,8 @@ export class ApiClient { offset?: number; status?: string; variant?: string; + order_by?: string; + order?: string; }): Promise> { if (!this.masterKey) { throw new Error("Master key required for admin operations"); @@ -454,8 +458,16 @@ export class ApiClient { q?: string; status?: string; variant?: string; + amount_min?: number; + amount_max?: number; + date_from?: string; + date_to?: string; + created_by?: string; + has_attachments?: boolean; limit?: number; offset?: number; + order_by?: string; + order?: string; }): Promise> { if (!this.masterKey) { throw new Error("Master key required for admin operations"); @@ -469,6 +481,32 @@ export class ApiClient { this.client.get("/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 diff --git a/frontend/src/components/FilterPopover/FilterPopover.tsx b/frontend/src/components/FilterPopover/FilterPopover.tsx new file mode 100644 index 00000000..08ff1aa8 --- /dev/null +++ b/frontend/src/components/FilterPopover/FilterPopover.tsx @@ -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; + 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 = ({ + onApplyFilters, + onClearFilters, + initialFilters = {}, + anchorEl, + open, + onClose, +}) => { + const [filters, setFilters] = useState(() => ({ + ...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 = ( + 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 ( + + + + {/* Header */} + + + + Erweiterte Filter + {activeFiltersCount > 0 && ( + + )} + + + + + + + + {/* Search */} + + + handleFilterChange("searchQuery", e.target.value) + } + onKeyDown={handleKeyDown} + placeholder="Projektname, Beschreibung, Antrags-ID..." + InputProps={{ + startAdornment: , + }} + helperText="Durchsucht alle Inhalte der Anträge" + /> + + handleFilterChange("fuzzySearch", e.target.checked) + } + /> + } + label="Unscharfe Suche (Fuzzy)" + sx={{ mt: 1 }} + /> + + + + + {/* Status & Type */} + + + Status & Typ + + + + handleFilterChange("statusFilter", e.target.value) + } + > + Alle Status + Beantragt + Bearbeitung Gesperrt + Genehmigt + Abgelehnt + + + handleFilterChange("variantFilter", e.target.value) + } + > + Alle Typen + VSM + QSM + + + + + + + {/* Amount Range */} + + + Betragsspanne + + + { + 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))) + } + /> + + + + + + {/* Date Range */} + + + Erstellungsdatum + + + { + console.log("DateFrom changed:", date); + handleFilterChange("dateFrom", date); + }} + format="DD.MM.YYYY" + slotProps={{ + textField: { + fullWidth: true, + size: "small", + variant: "outlined", + }, + }} + /> + { + console.log("DateTo changed:", date); + handleFilterChange("dateTo", date); + }} + format="DD.MM.YYYY" + slotProps={{ + textField: { + fullWidth: true, + size: "small", + variant: "outlined", + }, + }} + /> + + + + + + {/* Additional Filters */} + + + Weitere Filter + + + + handleFilterChange("createdBy", e.target.value) + } + placeholder="E-Mail oder Name des Erstellers" + /> + { + const value = e.target.value; + handleFilterChange( + "hasAttachments", + value === "all" ? null : value === "true", + ); + }} + > + Alle + Mit Anhängen + Ohne Anhänge + + + + + {/* Action Buttons */} + + + + + + + + + ); +}; + +export default FilterPopover; diff --git a/frontend/src/components/FilterPopover/index.ts b/frontend/src/components/FilterPopover/index.ts new file mode 100644 index 00000000..00178a94 --- /dev/null +++ b/frontend/src/components/FilterPopover/index.ts @@ -0,0 +1 @@ +export { default } from "./FilterPopover"; diff --git a/frontend/src/pages/AdminApplicationView.tsx b/frontend/src/pages/AdminApplicationView.tsx index 90d79641..bdd154c8 100644 --- a/frontend/src/pages/AdminApplicationView.tsx +++ b/frontend/src/pages/AdminApplicationView.tsx @@ -267,8 +267,8 @@ const AdminApplicationView: React.FC = () => { onChange={(e) => setNewStatus(e.target.value)} sx={{ minWidth: 100 }} > - Neu - In Prüfung + Beantragt + Bearbeitung Gesperrt Genehmigt Abgelehnt @@ -689,8 +689,10 @@ const AdminApplicationView: React.FC = () => { onChange={(e) => setNewStatus(e.target.value)} sx={{ minWidth: "100px" }} > - Neu - In Prüfung + Beantragt + + Bearbeitung Gesperrt + Genehmigt Abgelehnt diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx index cbc4b285..bd2d1a39 100644 --- a/frontend/src/pages/AdminDashboard.tsx +++ b/frontend/src/pages/AdminDashboard.tsx @@ -20,19 +20,37 @@ import { MenuItem, Alert, Pagination, + TableSortLabel, + Checkbox, + Slide, } from "@mui/material"; -import { Visibility, Delete, Search, Refresh } from "@mui/icons-material"; +import { + Visibility, + Delete, + Search, + Refresh, + FilterList, + DeleteOutline, + CheckCircleOutline, + BlockOutlined, + LockOpenOutlined, + CancelOutlined, +} from "@mui/icons-material"; import { useNavigate } from "react-router-dom"; import dayjs from "dayjs"; // Store import { useApplicationStore } from "../store/applicationStore"; +// Types +import { ApplicationListItem } from "../types/api"; + // Utils import { translateStatus } from "../utils/statusTranslations"; // Components import LoadingSpinner from "../components/LoadingSpinner/LoadingSpinner"; +import FilterPopover from "../components/FilterPopover/FilterPopover"; const AdminDashboard: React.FC = () => { const navigate = useNavigate(); @@ -49,19 +67,37 @@ const AdminDashboard: React.FC = () => { statusFilter, variantFilter, searchQuery, + sortBy, + sortOrder, loadApplicationListAdmin, - searchApplications, + searchApplicationsAdvanced, setStatusFilter, setVariantFilter, setPage, updateApplicationStatusAdmin, deleteApplicationAdmin, + setSorting, + bulkDeleteApplications, + bulkApproveApplications, + bulkRejectApplications, + bulkSetInReviewApplications, + bulkSetNewApplications, isAdmin, masterKey, } = useApplicationStore(); // Local state const [searchText, setSearchText] = useState(searchQuery || ""); + const [filterAnchorEl, setFilterAnchorEl] = useState( + null, + ); + const [activeFilters, setActiveFilters] = useState({}); + + // Bulk actions state + const [selectedApplications, setSelectedApplications] = useState( + [], + ); + const [showBulkActions, setShowBulkActions] = useState(false); // Redirect if not admin useEffect(() => { @@ -83,17 +119,51 @@ const AdminDashboard: React.FC = () => { currentPage, statusFilter, variantFilter, + sortBy, + sortOrder, ]); - // Handle search + // Handle search with fuzzy matching const handleSearch = () => { if (searchText.trim()) { - searchApplications(searchText.trim()); + // Build search parameters including active filters + const searchParams = { + q: searchText.trim(), + status: statusFilter || undefined, + variant: variantFilter || undefined, + // Include any additional active filters + amount_min: + activeFilters.amountMin > 0 ? activeFilters.amountMin : undefined, + amount_max: + activeFilters.amountMax < 300000 + ? activeFilters.amountMax + : undefined, + date_from: activeFilters.dateFrom?.format("YYYY-MM-DD") || undefined, + date_to: activeFilters.dateTo?.format("YYYY-MM-DD") || undefined, + created_by: activeFilters.createdBy || undefined, + has_attachments: + activeFilters.hasAttachments !== null + ? activeFilters.hasAttachments + : undefined, + limit: 50, + offset: 0, + }; + + // Use advanced search to include all filters + searchApplicationsAdvanced(searchParams); } else { loadApplicationListAdmin(); } }; + // Handle sorting + const handleRequestSort = (property: keyof ApplicationListItem) => { + const currentSortBy = sortBy as keyof ApplicationListItem; + const isAsc = currentSortBy === property && sortOrder === "asc"; + const newOrder = isAsc ? "desc" : "asc"; + setSorting(property, newOrder); + }; + // Handle status change const handleStatusChange = async (paId: string, newStatus: string) => { await updateApplicationStatusAdmin(paId, newStatus); @@ -110,6 +180,94 @@ const AdminDashboard: React.FC = () => { } }; + // Handle bulk selection + const handleSelectApplication = (paId: string, checked: boolean) => { + if (checked) { + setSelectedApplications((prev) => [...prev, paId]); + } else { + setSelectedApplications((prev) => prev.filter((id) => id !== paId)); + } + }; + + const handleSelectAll = (checked: boolean) => { + if (checked) { + setSelectedApplications(applicationList.map((app) => app.pa_id)); + } else { + setSelectedApplications([]); + } + }; + + // Bulk actions handlers + const handleBulkDelete = async () => { + if ( + window.confirm( + `Sind Sie sicher, dass Sie ${selectedApplications.length} Anträge löschen möchten?`, + ) + ) { + const success = await bulkDeleteApplications(selectedApplications); + if (success) { + setSelectedApplications([]); + } + } + }; + + const handleBulkReject = async () => { + if ( + window.confirm( + `Sind Sie sicher, dass Sie ${selectedApplications.length} Anträge ablehnen möchten?`, + ) + ) { + const success = await bulkRejectApplications(selectedApplications); + if (success) { + setSelectedApplications([]); + } + } + }; + + const handleBulkSetInReview = async () => { + if ( + window.confirm( + `Sind Sie sicher, dass Sie ${selectedApplications.length} Anträge für Bearbeitung sperren möchten?`, + ) + ) { + const success = await bulkSetInReviewApplications(selectedApplications); + if (success) { + setSelectedApplications([]); + } + } + }; + + const handleBulkSetNew = async () => { + if ( + window.confirm( + `Sind Sie sicher, dass Sie ${selectedApplications.length} Anträge auf "Beantragt" zurücksetzen möchten?`, + ) + ) { + const success = await bulkSetNewApplications(selectedApplications); + if (success) { + setSelectedApplications([]); + } + } + }; + + const handleBulkApprove = async () => { + if ( + window.confirm( + `Sind Sie sicher, dass Sie ${selectedApplications.length} Anträge genehmigen möchten?`, + ) + ) { + const success = await bulkApproveApplications(selectedApplications); + if (success) { + setSelectedApplications([]); + } + } + }; + + // Show/hide bulk actions based on selection + React.useEffect(() => { + setShowBulkActions(selectedApplications.length > 0); + }, [selectedApplications]); + if (!isAdmin) { return null; // Will redirect } @@ -130,6 +288,123 @@ const AdminDashboard: React.FC = () => { (sum, app) => sum + (app.total_amount || 0), 0, ), + // Calculate amounts per status + newAmount: applicationList + .filter((app) => app.status === "new") + .reduce((sum, app) => sum + (app.total_amount || 0), 0), + inReviewAmount: applicationList + .filter((app) => app.status === "in-review") + .reduce((sum, app) => sum + (app.total_amount || 0), 0), + approvedAmount: applicationList + .filter((app) => app.status === "approved") + .reduce((sum, app) => sum + (app.total_amount || 0), 0), + rejectedAmount: applicationList + .filter((app) => app.status === "rejected") + .reduce((sum, app) => sum + (app.total_amount || 0), 0), + }; + + // Handle advanced filters + const handleFilterOpen = (event: React.MouseEvent) => { + setFilterAnchorEl(event.currentTarget); + }; + + const handleFilterClose = () => { + setFilterAnchorEl(null); + }; + + const handleApplyFilters = (filters: any) => { + setActiveFilters(filters); + + // Prepare API parameters with extended filters + const searchParams = { + q: filters.searchQuery || undefined, + status: filters.statusFilter || undefined, + variant: filters.variantFilter || undefined, + amount_min: filters.amountMin > 0 ? filters.amountMin : undefined, + amount_max: filters.amountMax < 300000 ? filters.amountMax : undefined, + date_from: filters.dateFrom?.format("YYYY-MM-DD") || undefined, + date_to: filters.dateTo?.format("YYYY-MM-DD") || undefined, + created_by: filters.createdBy || undefined, + has_attachments: + filters.hasAttachments !== null ? filters.hasAttachments : undefined, + limit: 50, + offset: 0, + }; + + // Remove undefined values + const cleanParams = Object.fromEntries( + Object.entries(searchParams).filter(([_, value]) => value !== undefined), + ); + + // Use advanced search API with all filters + if (Object.keys(cleanParams).length > 2) { + // More than just limit and offset - use advanced search + searchApplicationsAdvanced(cleanParams); + } else { + // Update store filters for basic filtering + if (filters.statusFilter) { + setStatusFilter(filters.statusFilter); + } + if (filters.variantFilter) { + setVariantFilter(filters.variantFilter); + } + loadApplicationListAdmin(); + } + }; + + const handleClearAllFilters = () => { + setActiveFilters({}); + setStatusFilter(null); + setVariantFilter(null); + setSearchText(""); + loadApplicationListAdmin(); + }; + + const handleClearSingleFilter = (filterKey: string) => { + const newFilters = { ...activeFilters }; + switch (filterKey) { + case "searchQuery": + newFilters.searchQuery = ""; + setSearchText(""); + break; + case "statusFilter": + newFilters.statusFilter = ""; + setStatusFilter(null); + break; + case "variantFilter": + newFilters.variantFilter = ""; + setVariantFilter(null); + break; + case "amountRange": + newFilters.amountMin = 0; + newFilters.amountMax = 300000; + break; + case "dateRange": + newFilters.dateFrom = null; + newFilters.dateTo = null; + break; + case "createdBy": + newFilters.createdBy = ""; + break; + case "hasAttachments": + newFilters.hasAttachments = null; + break; + case "fuzzySearch": + newFilters.fuzzySearch = true; + break; + } + setActiveFilters(newFilters); + + // Apply the updated filters + if ( + Object.values(newFilters).some( + (v) => v && v !== "" && v !== 0 && v !== 300000, + ) + ) { + handleApplyFilters(newFilters); + } else { + loadApplicationListAdmin(); + } }; return ( @@ -158,7 +433,7 @@ const AdminDashboard: React.FC = () => { {/* Statistics Cards */} - + @@ -167,34 +442,67 @@ const AdminDashboard: React.FC = () => { Gesamt + + {stats.totalAmount.toLocaleString("de-DE", { + style: "currency", + currency: "EUR", + maximumFractionDigits: 0, + })} + - + {stats.new} - Neu + Beantragt + + + {stats.newAmount.toLocaleString("de-DE", { + style: "currency", + currency: "EUR", + maximumFractionDigits: 0, + })} - + {stats.inReview} - In Prüfung + Bearbeitung Gesperrt + + + {stats.inReviewAmount.toLocaleString("de-DE", { + style: "currency", + currency: "EUR", + maximumFractionDigits: 0, + })} - + @@ -203,10 +511,21 @@ const AdminDashboard: React.FC = () => { Genehmigt + + {stats.approvedAmount.toLocaleString("de-DE", { + style: "currency", + currency: "EUR", + maximumFractionDigits: 0, + })} + - + @@ -215,44 +534,33 @@ const AdminDashboard: React.FC = () => { Abgelehnt - - - - - - - {stats.totalAmount.toLocaleString("de-DE", { + {stats.rejectedAmount.toLocaleString("de-DE", { style: "currency", currency: "EUR", maximumFractionDigits: 0, })} - - Gesamtsumme - - {/* Filters and Search */} + {/* Search and Filters */} - + setSearchText(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleSearch()} + placeholder="Projektname, Antrags-ID..." InputProps={{ endAdornment: ( @@ -261,67 +569,234 @@ const AdminDashboard: React.FC = () => { ), }} /> + {(Object.keys(activeFilters).length > 0 || + statusFilter || + variantFilter || + searchText.trim()) && ( + + + + )} - - setStatusFilter(e.target.value || null)} - > - Alle Status - Neu - In Prüfung - Genehmigt - Abgelehnt - - - - setVariantFilter(e.target.value || null)} - > - Alle Typen - VSM - QSM - - - - + + + + + + + {/* Active Filters Display */} + {Object.keys(activeFilters).length > 0 && ( + + {activeFilters.searchQuery && ( + handleClearSingleFilter("searchQuery")} + size="small" + /> + )} + {activeFilters.statusFilter && ( + handleClearSingleFilter("statusFilter")} + size="small" + /> + )} + {activeFilters.variantFilter && ( + handleClearSingleFilter("variantFilter")} + size="small" + /> + )} + {(activeFilters.amountMin > 0 || + activeFilters.amountMax < 300000) && ( + handleClearSingleFilter("amountRange")} + size="small" + /> + )} + + {(activeFilters.dateFrom || activeFilters.dateTo) && ( + handleClearSingleFilter("dateRange")} + size="small" + /> + )} + + {activeFilters.createdBy && ( + handleClearSingleFilter("createdBy")} + size="small" + /> + )} + {activeFilters.hasAttachments !== null && ( + handleClearSingleFilter("hasAttachments")} + size="small" + /> + )} + {activeFilters.fuzzySearch === false && ( + handleClearSingleFilter("fuzzySearch")} + size="small" + /> + )} + + )} + {/* Filter Popover */} + + {/* Applications Table */} - Antrags-ID - Projektname - Typ - Status - Summe - Erstellt - Geändert + + 0 + } + indeterminate={ + selectedApplications.length > 0 && + selectedApplications.length < applicationList.length + } + onChange={(e) => handleSelectAll(e.target.checked)} + /> + + + handleRequestSort("pa_id")} + > + Antrags-ID + + + + handleRequestSort("project_name")} + > + Projektname + + + + handleRequestSort("variant")} + > + Typ + + + + handleRequestSort("status")} + > + Status + + + + handleRequestSort("total_amount")} + > + Summe + + + + handleRequestSort("created_at")} + > + Erstellt + + + + handleRequestSort("updated_at")} + > + Geändert + + Aktionen {applicationList.length === 0 && !isLoading ? ( - + Keine Anträge gefunden @@ -329,7 +804,22 @@ const AdminDashboard: React.FC = () => { ) : ( applicationList.map((application) => ( - + + + + handleSelectApplication( + application.pa_id, + e.target.checked, + ) + } + /> + {application.pa_id} @@ -362,8 +852,10 @@ const AdminDashboard: React.FC = () => { translateStatus(value as string), }} > - Neu - In Prüfung + Beantragt + + Bearbeitung Gesperrt + Genehmigt Abgelehnt @@ -425,6 +917,157 @@ const AdminDashboard: React.FC = () => { )} {isLoading && } + + {/* Floating Action Bar for Bulk Operations */} + + + + + + {selectedApplications.length} ausgewählt + + + + + + + + + + + + + ); }; diff --git a/frontend/src/pages/EditApplicationPage.tsx b/frontend/src/pages/EditApplicationPage.tsx index 81586235..fce9f002 100644 --- a/frontend/src/pages/EditApplicationPage.tsx +++ b/frontend/src/pages/EditApplicationPage.tsx @@ -135,14 +135,29 @@ const EditApplicationPage: React.FC = () => { ); } - // Check if application can be edited (only "new" status) - if (currentApplication.status !== "new" && !isAdmin) { + // Check if application can be edited - block in-review, approved, and rejected for everyone + if ( + currentApplication.status === "in-review" || + currentApplication.status === "approved" || + currentApplication.status === "rejected" || + (currentApplication.status !== "new" && !isAdmin) + ) { + const status = currentApplication.status; + let message = + "Dieser Antrag kann nicht mehr bearbeitet werden, da er bereits in Bearbeitung ist."; + + if (status === "in-review") { + message = + "Dieser Antrag ist zur Bearbeitung gesperrt und kann nicht bearbeitet werden."; + } else if (status === "approved") { + message = "Genehmigte Anträge können nicht mehr bearbeitet werden."; + } else if (status === "rejected") { + message = "Abgelehnte Anträge können nicht mehr bearbeitet werden."; + } + return ( - - Dieser Antrag kann nicht mehr bearbeitet werden, da er bereits in - Bearbeitung ist. - + {message}