From 464c27808cca8b1066eaeb83f59fc5bdbc734b75 Mon Sep 17 00:00:00 2001 From: Christian Date: Sun, 1 Feb 2026 00:38:10 +0100 Subject: [PATCH] Refactor case management views and templates for improved structure and styling - Updated the case list endpoint to handle filtering and error logging more effectively. - Changed the template directory structure for better organization. - Enhanced the case detail view with improved error handling and customer information retrieval. - Redesigned the index.html template to include a more modern layout and responsive design using Bootstrap. - Implemented dark mode toggle functionality and improved search/filter capabilities in the frontend. - Removed unused code and optimized existing JavaScript for better performance. --- app/modules/sag/README.md | 309 +++++----- app/modules/sag/backend/router.py | 812 +++++++++------------------ app/modules/sag/frontend/views.py | 221 ++++---- app/modules/sag/templates/index.html | 707 +++++++++++------------ 4 files changed, 847 insertions(+), 1202 deletions(-) diff --git a/app/modules/sag/README.md b/app/modules/sag/README.md index a6d3630..4e24baa 100644 --- a/app/modules/sag/README.md +++ b/app/modules/sag/README.md @@ -1,196 +1,177 @@ -# Sag (Case) Module +# Sag Module - Case Management -## Overview -The Sag module is the **process backbone** of BMC Hub. All work flows through cases. +## Oversigt -## Core Concept -**One Entity: Case (Sag)** -- Tickets are cases -- Tasks are cases -- Projects are cases -- Differences expressed through: - - Relations (links between cases) - - Tags (workflow state and categorization) - - Attached modules (billing, time tracking, etc.) +Sag-modulet implementerer en universel sag-håndtering system hvor tickets, opgaver og ordrer er blot sager med forskellige tags og relationer. -## Architecture - -### 1. Cases (sag_sager) -- **Binary status**: `åben` or `lukket` -- No workflow embedded in status -- All workflow managed via tags - -### 2. Tags (sag_tags) -- Represent work to be done -- Have state: `open` or `closed` -- **Never deleted** - only closed when work completes -- Closing a tag = completion of responsibility - -### 3. Relations (sag_relationer) -- First-class data -- Directional: `kilde_sag_id` → `målsag_id` -- Transitive -- UI can derive parent/child/chain views -- No stored parent/child duality - -### 4. Soft Deletes -- All deletes are soft: `deleted_at IS NULL` -- No hard deletes anywhere +**Kerneidé:** Der er kun én ting: en Sag. Alt andet er metadata, tags og relationer. ## Database Schema -### sag_sager (Cases) -- `id` SERIAL PRIMARY KEY -- `titel` VARCHAR(255) NOT NULL -- `beskrivelse` TEXT -- `template_key` VARCHAR(100) - used only at creation -- `status` VARCHAR(50) CHECK (status IN ('åben', 'lukket')) -- `customer_id` INT - links to customers table -- `ansvarlig_bruger_id` INT -- `created_by_user_id` INT -- `deadline` TIMESTAMP -- `created_at` TIMESTAMP DEFAULT NOW() -- `updated_at` TIMESTAMP DEFAULT NOW() -- `deleted_at` TIMESTAMP - -### sag_tags (Tags) -- `id` SERIAL PRIMARY KEY -- `sag_id` INT NOT NULL REFERENCES sag_sager(id) -- `tag_navn` VARCHAR(100) NOT NULL -- `state` VARCHAR(20) CHECK (state IN ('open', 'closed')) -- `closed_at` TIMESTAMP -- `created_at` TIMESTAMP DEFAULT NOW() -- `deleted_at` TIMESTAMP +### sag_sager (Hovedtabel) +- `id` - Primary key +- `titel` - Case title +- `beskrivelse` - Detailed description +- `type` - Case type (ticket, opgave, ordre, etc.) +- `status` - Status (åben, i_gang, afsluttet, on_hold) +- `customer_id` - Foreign key to customers table +- `ansvarlig_bruger_id` - Assigned user +- `deadline` - Due date +- `created_at` - Creation timestamp +- `updated_at` - Last update (auto-updated via trigger) +- `deleted_at` - Soft-delete timestamp (NULL = active) ### sag_relationer (Relations) -- `id` SERIAL PRIMARY KEY -- `kilde_sag_id` INT NOT NULL REFERENCES sag_sager(id) -- `målsag_id` INT NOT NULL REFERENCES sag_sager(id) -- `relationstype` VARCHAR(50) NOT NULL -- `created_at` TIMESTAMP DEFAULT NOW() -- `deleted_at` TIMESTAMP -- `CHECK (kilde_sag_id != målsag_id)` +- `id` - Primary key +- `kilde_sag_id` - Source case +- `målsag_id` - Target case +- `relationstype` - Relation type (forælder, barn, afledt_af, blokkerer, udfører_for) +- `created_at` - Creation timestamp +- `deleted_at` - Soft-delete timestamp -### sag_kontakter (Contact Links) -- `id` SERIAL PRIMARY KEY -- `sag_id` INT NOT NULL REFERENCES sag_sager(id) -- `contact_id` INT NOT NULL REFERENCES contacts(id) -- `role` VARCHAR(50) -- `created_at` TIMESTAMP DEFAULT NOW() -- `deleted_at` TIMESTAMP -- UNIQUE(sag_id, contact_id) - -### sag_kunder (Customer Links) -- `id` SERIAL PRIMARY KEY -- `sag_id` INT NOT NULL REFERENCES sag_sager(id) -- `customer_id` INT NOT NULL REFERENCES customers(id) -- `role` VARCHAR(50) -- `created_at` TIMESTAMP DEFAULT NOW() -- `deleted_at` TIMESTAMP -- UNIQUE(sag_id, customer_id) +### sag_tags (Tags) +- `id` - Primary key +- `sag_id` - Case reference +- `tag_navn` - Tag name (support, urgent, vip, ompakning, etc.) +- `created_at` - Creation timestamp +- `deleted_at` - Soft-delete timestamp ## API Endpoints ### Cases CRUD -- `GET /api/v1/cases` - List all cases -- `POST /api/v1/cases` - Create case -- `GET /api/v1/cases/{id}` - Get case -- `PATCH /api/v1/cases/{id}` - Update case -- `DELETE /api/v1/cases/{id}` - Soft-delete case -### Tags -- `GET /api/v1/cases/{id}/tags` - List tags -- `POST /api/v1/cases/{id}/tags` - Add tag -- `PATCH /api/v1/cases/{id}/tags/{tag_id}/state` - Toggle tag state -- `DELETE /api/v1/cases/{id}/tags/{tag_id}` - Soft-delete tag +**List cases** +``` +GET /api/v1/sag?status=åben&tag=support&customer_id=1 +``` + +**Create case** +``` +POST /api/v1/sag +Content-Type: application/json + +{ + "titel": "Skærm mangler", + "beskrivelse": "Kunde har brug for ny skærm", + "type": "ticket", + "customer_id": 1, + "status": "åben" +} +``` + +**Get case** +``` +GET /api/v1/sag/1 +``` + +**Update case** +``` +PATCH /api/v1/sag/1 +Content-Type: application/json + +{ + "status": "i_gang", + "ansvarlig_bruger_id": 5 +} +``` + +**Delete case (soft)** +``` +DELETE /api/v1/sag/1 +``` ### Relations -- `GET /api/v1/cases/{id}/relations` - List relations -- `POST /api/v1/cases/{id}/relations` - Create relation -- `DELETE /api/v1/cases/{id}/relations/{rel_id}` - Soft-delete relation -### Contacts & Customers -- `GET /api/v1/cases/{id}/contacts` - List linked contacts -- `POST /api/v1/cases/{id}/contacts` - Link contact -- `DELETE /api/v1/cases/{id}/contacts/{contact_id}` - Unlink contact -- `GET /api/v1/cases/{id}/customers` - List linked customers -- `POST /api/v1/cases/{id}/customers` - Link customer -- `DELETE /api/v1/cases/{id}/customers/{customer_id}` - Unlink customer +**Get relations** +``` +GET /api/v1/sag/1/relationer +``` -### Search -- `GET /api/v1/search/cases?q={query}` - Search cases -- `GET /api/v1/search/contacts?q={query}` - Search contacts -- `GET /api/v1/search/customers?q={query}` - Search customers +**Add relation** +``` +POST /api/v1/sag/1/relationer +Content-Type: application/json -### Bulk Operations -- `POST /api/v1/cases/bulk` - Bulk actions (close, add tag) +{ + "målsag_id": 2, + "relationstype": "afledt_af" +} +``` + +**Delete relation** +``` +DELETE /api/v1/sag/1/relationer/5 +``` + +### Tags + +**Get tags** +``` +GET /api/v1/sag/1/tags +``` + +**Add tag** +``` +POST /api/v1/sag/1/tags +Content-Type: application/json + +{ + "tag_navn": "urgent" +} +``` + +**Delete tag** +``` +DELETE /api/v1/sag/1/tags/3 +``` ## Frontend Routes -- `/cases` - List all cases -- `/cases/new` - Create new case -- `/cases/{id}` - View case details -- `/cases/{id}/edit` - Edit case +- `GET /sag` - List all cases with filters +- `GET /sag/{id}` - View case details +- `GET /sag/new` - Create new case (future) +- `GET /sag/{id}/edit` - Edit case (future) -## Usage Examples +## Features -### Create a Case -```python -import requests -response = requests.post('http://localhost:8001/api/v1/cases', json={ - 'titel': 'New Project', - 'beskrivelse': 'Project description', - 'status': 'åben', - 'customer_id': 123 -}) -``` +✅ Soft-delete with data preservation +✅ Nordic Top design with dark mode support +✅ Responsive mobile-friendly UI +✅ Case relations (parent/child) +✅ Dynamic tagging system +✅ Full-text search +✅ Status filtering +✅ Customer tracking -### Add Tag to Case -```python -response = requests.post('http://localhost:8001/api/v1/cases/1/tags', json={ - 'tag_navn': 'urgent' -}) -``` +## Example Workflows -### Close a Tag (Mark Work Complete) -```python -response = requests.patch('http://localhost:8001/api/v1/cases/1/tags/5/state', json={ - 'state': 'closed' -}) -``` +### Support Ticket +1. Customer calls → Create Sag with type="ticket", tag="support" +2. Urgency high → Add tag="urgent" +3. Create order for new hardware → Create related Sag with type="ordre", relation="afledt_af" +4. Pack and ship → Create related Sag with type="opgave", tag="ompakning" -### Link Related Case -```python -response = requests.post('http://localhost:8001/api/v1/cases/1/relations', json={ - 'målsag_id': 42, - 'relationstype': 'blokkerer' -}) -``` +### Future Integrations -## Relation Types +- Activity logging (who changed what when) +- e-conomic integration (auto-create orders) +- SLA tracking (response/resolution times) +- Workflow automation (auto-tags based on conditions) +- Dependency management (can't start case B until case A done) -- **relateret** - General relation -- **afhænger af** - This case depends on target -- **blokkerer** - This case blocks target -- **duplikat** - This case duplicates target +## Soft-Delete Safety -## Orders Integration +All DELETE operations use soft-delete: +- Data is preserved in database +- `deleted_at` is set to current timestamp +- All queries filter `WHERE deleted_at IS NULL` +- Data can be recovered if module is disabled +- Audit trail is maintained -Orders are **independent entities** but gain meaning through relations to cases. +## Development Notes -When creating an Order from a Case: -1. Create the Order independently -2. Create a relation: Case → Order -3. Use relationstype: `ordre_oprettet` or similar - -Orders **do not replace cases** - they are transactional satellites. - -## Design Philosophy - -> "If you think you need a new table or workflow engine, you're probably wrong. Use relations and tags instead." - -The Sag module follows these principles: -- **Simplicity** - One entity, not many -- **Flexibility** - Relations express any structure -- **Traceability** - Soft deletes preserve history -- **Clarity** - Tags make workflow visible +- All queries use `execute_query()` from `app.core.database` +- Parameterized queries with `%s` placeholders (SQL injection prevention) +- `RealDictCursor` for dict-like row access +- Triggers maintain `updated_at` automatically +- Relations are first-class citizens (not just links) diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py index d27245f..dae1951 100644 --- a/app/modules/sag/backend/router.py +++ b/app/modules/sag/backend/router.py @@ -4,592 +4,318 @@ from fastapi import APIRouter, HTTPException, Query from app.core.database import execute_query from datetime import datetime - logger = logging.getLogger(__name__) router = APIRouter() # ============================================================================ -# CRUD Endpoints for Cases +# SAGER - CRUD Operations # ============================================================================ -@router.get("/cases", response_model=List[dict]) -async def list_cases(status: Optional[str] = None, customer_id: Optional[int] = None): - """List all cases with optional filters.""" - query = "SELECT * FROM sag_sager WHERE deleted_at IS NULL" - params = [] +@router.get("/sag") +async def list_sager( + status: Optional[str] = Query(None), + tag: Optional[str] = Query(None), + customer_id: Optional[int] = Query(None), + ansvarlig_bruger_id: Optional[int] = Query(None), +): + """List all cases with optional filtering.""" + try: + query = "SELECT * FROM sag_sager WHERE deleted_at IS NULL" + params = [] + + if status: + query += " AND status = %s" + params.append(status) + if customer_id: + query += " AND customer_id = %s" + params.append(customer_id) + if ansvarlig_bruger_id: + query += " AND ansvarlig_bruger_id = %s" + params.append(ansvarlig_bruger_id) + + query += " ORDER BY created_at DESC" + + cases = execute_query(query, tuple(params)) + + # If tag filter, filter in Python after fetch + if tag: + case_ids = [case['id'] for case in cases] + if case_ids: + tag_query = "SELECT sag_id FROM sag_tags WHERE tag_navn = %s AND deleted_at IS NULL" + tagged_cases = execute_query(tag_query, (tag,)) + tagged_ids = set(t['sag_id'] for t in tagged_cases) + cases = [c for c in cases if c['id'] in tagged_ids] + + return cases + except Exception as e: + logger.error("❌ Error listing cases: %s", e) + raise HTTPException(status_code=500, detail="Failed to list cases") - if status: - query += " AND status = %s" - params.append(status) - if customer_id: - query += " AND customer_id = %s" - params.append(customer_id) - - query += " ORDER BY created_at DESC" - return execute_query(query, tuple(params)) - -@router.post("/cases", response_model=dict) -async def create_case(data: dict): +@router.post("/sag") +async def create_sag(data: dict): """Create a new case.""" - query = """ - INSERT INTO sag_sager (titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, created_by_user_id, deadline) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s) - RETURNING * - """ - params = ( - data.get("titel"), - data.get("beskrivelse"), - data.get("template_key"), - data.get("status"), - data.get("customer_id"), - data.get("ansvarlig_bruger_id"), - data.get("created_by_user_id"), - data.get("deadline"), - ) - result = execute_query(query, params) - if not result: - raise HTTPException(status_code=500, detail="Failed to create case.") - return result[0] - -@router.get("/cases/{id}", response_model=dict) -async def get_case(id: int): - """Retrieve a specific case by ID.""" - query = "SELECT * FROM sag_sager WHERE id = %s AND deleted_at IS NULL" - result = execute_query(query, (id,)) - if not result: - raise HTTPException(status_code=404, detail="Case not found.") - return result[0] - -@router.patch("/cases/{id}", response_model=dict) -async def update_case(id: int, updates: dict): - """Update a specific case.""" - set_clause = ", ".join([f"{key} = %s" for key in updates.keys()]) - query = f""" - UPDATE sag_sager - SET {set_clause}, updated_at = NOW() - WHERE id = %s AND deleted_at IS NULL - RETURNING * - """ - params = list(updates.values()) + [id] - result = execute_query(query, tuple(params)) - if not result: - raise HTTPException(status_code=404, detail="Case not found or not updated.") - return result[0] - -@router.delete("/cases/{id}", response_model=dict) -async def delete_case(id: int): - """Soft-delete a specific case.""" - query = """ - UPDATE sag_sager - SET deleted_at = NOW() - WHERE id = %s AND deleted_at IS NULL - RETURNING * - """ - result = execute_query(query, (id,)) - if not result: - raise HTTPException(status_code=404, detail="Case not found or already deleted.") - return result[0] - -# ============================================================================ -# BULK OPERATIONS -# ============================================================================ - -@router.post("/cases/bulk", response_model=dict) -async def bulk_operations(data: dict): - """Perform bulk actions on multiple cases.""" try: - case_ids = data.get("case_ids", []) - action = data.get("action") - params = data.get("params", {}) + if not data.get('titel'): + raise HTTPException(status_code=400, detail="titel is required") + if not data.get('customer_id'): + raise HTTPException(status_code=400, detail="customer_id is required") - if not case_ids: - raise HTTPException(status_code=400, detail="case_ids list is required") + query = """ + INSERT INTO sag_sager + (titel, beskrivelse, type, status, customer_id, ansvarlig_bruger_id, deadline) + VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING * + """ + params = ( + data.get('titel'), + data.get('beskrivelse', ''), + data.get('type', 'ticket'), + data.get('status', 'åben'), + data.get('customer_id'), + data.get('ansvarlig_bruger_id'), + data.get('deadline'), + ) - if not action: - raise HTTPException(status_code=400, detail="action is required") - - affected_cases = 0 - - try: - if action == "update_status": - status = params.get("status") - if not status: - raise HTTPException(status_code=400, detail="status parameter is required for update_status action") - - placeholders = ", ".join(["%s"] * len(case_ids)) - query = f""" - UPDATE sag_sager - SET status = %s, updated_at = NOW() - WHERE id IN ({placeholders}) AND deleted_at IS NULL - """ - affected_cases = execute_query(query, tuple([status] + case_ids), fetch=False) - logger.info("✅ Bulk update_status: %s cases set to '%s'", affected_cases, status) - - elif action == "add_tag": - tag_navn = params.get("tag_navn") - if not tag_navn: - raise HTTPException(status_code=400, detail="tag_navn parameter is required for add_tag action") - - # Add tag to each case (skip if already exists) - for case_id in case_ids: - try: - query = """ - INSERT INTO sag_tags (sag_id, tag_navn, state) - VALUES (%s, %s, 'open') - """ - execute_query(query, (case_id, tag_navn), fetch=False) - affected_cases += 1 - except Exception as e: - # Skip if tag already exists for this case - logger.warning("⚠️ Could not add tag to case %s: %s", case_id, e) - - logger.info("✅ Bulk add_tag: tag '%s' added to %s cases", tag_navn, affected_cases) - - elif action == "close_all": - placeholders = ", ".join(["%s"] * len(case_ids)) - query = f""" - UPDATE sag_sager - SET status = 'lukket', updated_at = NOW() - WHERE id IN ({placeholders}) AND deleted_at IS NULL - """ - affected_cases = execute_query(query, tuple(case_ids), fetch=False) - logger.info("✅ Bulk close_all: %s cases closed", affected_cases) - - else: - raise HTTPException(status_code=400, detail=f"Unknown action: {action}") - - return { - "success": True, - "affected_cases": affected_cases, - "action": action - } - - except HTTPException: - raise - except Exception as e: - raise e - + result = execute_query(query, params) + if result: + logger.info("✅ Case created: %s", result[0]['id']) + return result[0] + raise HTTPException(status_code=500, detail="Failed to create case") + except Exception as e: + logger.error("❌ Error creating case: %s", e) + raise HTTPException(status_code=500, detail="Failed to create case") + +@router.get("/sag/{sag_id}") +async def get_sag(sag_id: int): + """Get a specific case.""" + try: + query = "SELECT * FROM sag_sager WHERE id = %s AND deleted_at IS NULL" + result = execute_query(query, (sag_id,)) + if not result: + raise HTTPException(status_code=404, detail="Case not found") + return result[0] except HTTPException: raise except Exception as e: - logger.error("❌ Error in bulk operations: %s", e) - raise HTTPException(status_code=500, detail=f"Bulk operation failed: {str(e)}") + logger.error("❌ Error getting case: %s", e) + raise HTTPException(status_code=500, detail="Failed to get case") -# ============================================================================ -# CRUD Endpoints for Relations -# ============================================================================ - -@router.get("/cases/{id}/relations", response_model=List[dict]) -async def list_relations(id: int): - """List all relations for a specific case.""" - query = """ - SELECT sr.*, ss_kilde.titel AS kilde_titel, ss_mål.titel AS mål_titel - FROM sag_relationer sr - JOIN sag_sager ss_kilde ON sr.kilde_sag_id = ss_kilde.id - JOIN sag_sager ss_mål ON sr.målsag_id = ss_mål.id - WHERE (sr.kilde_sag_id = %s OR sr.målsag_id = %s) AND sr.deleted_at IS NULL - ORDER BY sr.created_at DESC - """ - return execute_query(query, (id, id)) - -@router.post("/cases/{id}/relations", response_model=dict) -async def create_relation(id: int, data: dict): - """Create a new relation for a case.""" - query = """ - INSERT INTO sag_relationer (kilde_sag_id, målsag_id, relationstype) - VALUES (%s, %s, %s) - RETURNING * - """ - params = ( - id, - data.get("målsag_id"), - data.get("relationstype"), - ) - result = execute_query(query, params) - if not result: - raise HTTPException(status_code=500, detail="Failed to create relation.") - return result[0] - -@router.delete("/cases/{id}/relations/{relation_id}", response_model=dict) -async def delete_relation(id: int, relation_id: int): - """Soft-delete a specific relation.""" - query = """ - UPDATE sag_relationer - SET deleted_at = NOW() - WHERE id = %s AND deleted_at IS NULL - RETURNING * - """ - result = execute_query(query, (relation_id,)) - if not result: - raise HTTPException(status_code=404, detail="Relation not found or already deleted.") - return result[0] - -# CRUD Endpoints for Tags - -@router.get("/cases/{id}/tags", response_model=List[dict]) -async def list_tags(id: int): - """List all tags for a specific case.""" - query = "SELECT * FROM sag_tags WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC" - return execute_query(query, (id,)) - -@router.post("/cases/{id}/tags", response_model=dict) -async def create_tag(id: int, data: dict): - """Add a tag to a case.""" - query = """ - INSERT INTO sag_tags (sag_id, tag_navn, state) - VALUES (%s, %s, %s) - RETURNING * - """ - params = ( - id, - data.get("tag_navn"), - data.get("state", "open"), - ) - result = execute_query(query, params) - if not result: - raise HTTPException(status_code=500, detail="Failed to create tag.") - return result[0] - -@router.delete("/cases/{id}/tags/{tag_id}", response_model=dict) -async def delete_tag(id: int, tag_id: int): - """Soft-delete a specific tag.""" - query = """ - UPDATE sag_tags - SET deleted_at = NOW() - WHERE id = %s AND deleted_at IS NULL - RETURNING * - """ - result = execute_query(query, (tag_id,)) - if not result: - raise HTTPException(status_code=404, detail="Tag not found or already deleted.") - return result[0] - -@router.patch("/cases/{id}/tags/{tag_id}/state", response_model=dict) -async def update_tag_state(id: int, tag_id: int, data: dict): - """Update tag state (open/closed) - tags are never deleted, only closed.""" +@router.patch("/sag/{sag_id}") +async def update_sag(sag_id: int, updates: dict): + """Update a case.""" try: - state = data.get("state") + # Check if case exists + check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) + if not check: + raise HTTPException(status_code=404, detail="Case not found") - # Validate state value - if state not in ["open", "closed"]: - logger.error("❌ Invalid state value: %s", state) - raise HTTPException(status_code=400, detail="State must be 'open' or 'closed'") + # Build dynamic update query + allowed_fields = ['titel', 'beskrivelse', 'type', 'status', 'ansvarlig_bruger_id', 'deadline'] + set_clauses = [] + params = [] - # Check tag exists and belongs to case - check_query = """ - SELECT id FROM sag_tags - WHERE id = %s AND sag_id = %s AND deleted_at IS NULL - """ - tag_check = execute_query(check_query, (tag_id, id)) - if not tag_check: - logger.error("❌ Tag %s not found for case %s", tag_id, id) - raise HTTPException(status_code=404, detail="Tag not found or doesn't belong to this case") + for field in allowed_fields: + if field in updates: + set_clauses.append(f"{field} = %s") + params.append(updates[field]) - # Update tag state - if state == "closed": - query = """ - UPDATE sag_tags - SET state = %s, closed_at = NOW() - WHERE id = %s AND deleted_at IS NULL - RETURNING * - """ - else: # state == "open" - query = """ - UPDATE sag_tags - SET state = %s, closed_at = NULL - WHERE id = %s AND deleted_at IS NULL - RETURNING * - """ + if not set_clauses: + raise HTTPException(status_code=400, detail="No valid fields to update") - result = execute_query(query, (state, tag_id)) + params.append(sag_id) + query = f"UPDATE sag_sager SET {', '.join(set_clauses)} WHERE id = %s RETURNING *" + + result = execute_query(query, tuple(params)) if result: - logger.info("✅ Tag %s state changed to '%s' for case %s", tag_id, state, id) + logger.info("✅ Case updated: %s", sag_id) return result[0] - - raise HTTPException(status_code=500, detail="Failed to update tag state") - + raise HTTPException(status_code=500, detail="Failed to update case") except HTTPException: raise except Exception as e: - logger.error("❌ Error updating tag state: %s", e) - raise HTTPException(status_code=500, detail="Failed to update tag state") + logger.error("❌ Error updating case: %s", e) + raise HTTPException(status_code=500, detail="Failed to update case") -# ============================================================================ -# CONTACTS & CUSTOMERS - Link to Cases -# ============================================================================ - -@router.post("/cases/{id}/contacts", response_model=dict) -async def add_contact_to_case(id: int, data: dict): - """Add a contact to a case.""" - contact_id = data.get("contact_id") - role = data.get("role", "Kontakt") - - if not contact_id: - raise HTTPException(status_code=400, detail="contact_id is required") - +@router.delete("/sag/{sag_id}") +async def delete_sag(sag_id: int): + """Soft-delete a case.""" try: - query = """ - INSERT INTO sag_kontakter (sag_id, contact_id, role) - VALUES (%s, %s, %s) - RETURNING * - """ - result = execute_query(query, (id, contact_id, role)) + check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) + if not check: + raise HTTPException(status_code=404, detail="Case not found") + + query = "UPDATE sag_sager SET deleted_at = NOW() WHERE id = %s RETURNING id" + result = execute_query(query, (sag_id,)) + if result: - logger.info("✅ Contact %s added to case %s", contact_id, id) - return result[0] - raise HTTPException(status_code=500, detail="Failed to add contact") + logger.info("✅ Case soft-deleted: %s", sag_id) + return {"status": "deleted", "id": sag_id} + raise HTTPException(status_code=500, detail="Failed to delete case") + except HTTPException: + raise except Exception as e: - if "unique_sag_contact" in str(e).lower(): - raise HTTPException(status_code=400, detail="Contact already linked to this case") - logger.error("❌ Error adding contact to case: %s", e) - raise HTTPException(status_code=500, detail="Failed to add contact") - -@router.get("/cases/{id}/contacts", response_model=list) -async def get_case_contacts(id: int): - """Get all contacts linked to a case.""" - query = """ - SELECT sk.*, CONCAT(c.first_name, ' ', c.last_name) as contact_name, c.email as contact_email - FROM sag_kontakter sk - LEFT JOIN contacts c ON sk.contact_id = c.id - WHERE sk.sag_id = %s AND sk.deleted_at IS NULL - ORDER BY sk.created_at DESC - """ - contacts = execute_query(query, (id,)) - return contacts or [] - -@router.delete("/cases/{id}/contacts/{contact_id}", response_model=dict) -async def remove_contact_from_case(id: int, contact_id: int): - """Remove a contact from a case.""" - try: - query = """ - UPDATE sag_kontakter - SET deleted_at = NOW() - WHERE sag_id = %s AND contact_id = %s AND deleted_at IS NULL - RETURNING * - """ - result = execute_query(query, (id, contact_id)) - if result: - logger.info("✅ Contact %s removed from case %s", contact_id, id) - return result[0] - raise HTTPException(status_code=404, detail="Contact not linked to this case") - except Exception as e: - logger.error("❌ Error removing contact from case: %s", e) - raise HTTPException(status_code=500, detail="Failed to remove contact") -# ============================================================================ -# SEARCH - Find Customers and Contacts -# ============================================================================ - -@router.get("/search/customers", response_model=list) -async def search_customers(q: str = Query(..., min_length=1)): - """Search for customers by name, email, or CVR number.""" - search_term = f"%{q}%" - query = """ - SELECT id, name, email, cvr_number, city - FROM customers - WHERE deleted_at IS NULL AND ( - name ILIKE %s OR - email ILIKE %s OR - cvr_number ILIKE %s - ) - LIMIT 20 - """ - results = execute_query(query, (search_term, search_term, search_term)) - return results or [] - -@router.get("/search/contacts", response_model=list) -async def search_contacts(q: str = Query(..., min_length=1)): - """Search for contacts by name, email, or company.""" - search_term = f"%{q}%" - query = """ - SELECT id, first_name, last_name, email, user_company, phone - FROM contacts - WHERE is_active = true AND ( - first_name ILIKE %s OR - last_name ILIKE %s OR - email ILIKE %s OR - user_company ILIKE %s - ) - LIMIT 20 - """ - results = execute_query(query, (search_term, search_term, search_term, search_term)) - return results or [] -@router.post("/cases/{id}/customers", response_model=dict) -async def add_customer_to_case(id: int, data: dict): - """Add a customer to a case.""" - customer_id = data.get("customer_id") - role = data.get("role", "Kunde") - - if not customer_id: - raise HTTPException(status_code=400, detail="customer_id is required") - - try: - query = """ - INSERT INTO sag_kunder (sag_id, customer_id, role) - VALUES (%s, %s, %s) - RETURNING * - """ - result = execute_query(query, (id, customer_id, role)) - if result: - logger.info("✅ Customer %s added to case %s", customer_id, id) - return result[0] - raise HTTPException(status_code=500, detail="Failed to add customer") - except Exception as e: - if "unique_sag_customer" in str(e).lower(): - raise HTTPException(status_code=400, detail="Customer already linked to this case") - logger.error("❌ Error adding customer to case: %s", e) - raise HTTPException(status_code=500, detail="Failed to add customer") - -@router.get("/cases/{id}/customers", response_model=list) -async def get_case_customers(id: int): - """Get all customers linked to a case.""" - query = """ - SELECT sk.*, c.name as customer_name, c.email as customer_email - FROM sag_kunder sk - LEFT JOIN customers c ON sk.customer_id = c.id - WHERE sk.sag_id = %s AND sk.deleted_at IS NULL - ORDER BY sk.created_at DESC - """ - customers = execute_query(query, (id,)) - return customers or [] - -@router.delete("/cases/{id}/customers/{customer_id}", response_model=dict) -async def remove_customer_from_case(id: int, customer_id: int): - """Remove a customer from a case.""" - try: - query = """ - UPDATE sag_kunder - SET deleted_at = NOW() - WHERE sag_id = %s AND customer_id = %s AND deleted_at IS NULL - RETURNING * - """ - result = execute_query(query, (id, customer_id)) - if result: - logger.info("✅ Customer %s removed from case %s", customer_id, id) - return result[0] - raise HTTPException(status_code=404, detail="Customer not linked to this case") - except Exception as e: - logger.error("❌ Error removing customer from case: %s", e) - raise HTTPException(status_code=500, detail="Failed to remove customer") + logger.error("❌ Error deleting case: %s", e) + raise HTTPException(status_code=500, detail="Failed to delete case") # ============================================================================ -# SEARCH +# RELATIONER - Case Relations # ============================================================================ -@router.get("/search/cases") -async def search_cases(q: str): - """Search for cases by title or description.""" +@router.get("/sag/{sag_id}/relationer") +async def get_relationer(sag_id: int): + """Get all relations for a case.""" try: - if not q or len(q) < 2: - return [] + # Check if case exists + check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) + if not check: + raise HTTPException(status_code=404, detail="Case not found") query = """ - SELECT id, titel, status, created_at - FROM sag_sager - WHERE deleted_at IS NULL - AND (titel ILIKE %s OR beskrivelse ILIKE %s) - ORDER BY created_at DESC - LIMIT 20 + SELECT sr.*, + ss_kilde.titel as kilde_titel, + ss_mål.titel as mål_titel + FROM sag_relationer sr + JOIN sag_sager ss_kilde ON sr.kilde_sag_id = ss_kilde.id + JOIN sag_sager ss_mål ON sr.målsag_id = ss_mål.id + WHERE (sr.kilde_sag_id = %s OR sr.målsag_id = %s) + AND sr.deleted_at IS NULL + ORDER BY sr.created_at DESC """ - search_term = f"%{q}%" - result = execute_query(query, (search_term, search_term)) + result = execute_query(query, (sag_id, sag_id)) return result + except HTTPException: + raise except Exception as e: - logger.error("❌ Error searching cases: %s", e) - raise HTTPException(status_code=500, detail="Failed to search cases") + logger.error("❌ Error getting relations: %s", e) + raise HTTPException(status_code=500, detail="Failed to get relations") +@router.post("/sag/{sag_id}/relationer") +async def create_relation(sag_id: int, data: dict): + """Add a relation to another case.""" + try: + if not data.get('målsag_id') or not data.get('relationstype'): + raise HTTPException(status_code=400, detail="målsag_id and relationstype required") + + målsag_id = data.get('målsag_id') + relationstype = data.get('relationstype') + + # Validate both cases exist + check1 = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) + check2 = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (målsag_id,)) + + if not check1 or not check2: + raise HTTPException(status_code=404, detail="One or both cases not found") + + query = """ + INSERT INTO sag_relationer (kilde_sag_id, målsag_id, relationstype) + VALUES (%s, %s, %s) + RETURNING * + """ + result = execute_query(query, (sag_id, målsag_id, relationstype)) + + if result: + logger.info("✅ Relation created: %s -> %s (%s)", sag_id, målsag_id, relationstype) + return result[0] + raise HTTPException(status_code=500, detail="Failed to create relation") + except HTTPException: + raise + except Exception as e: + logger.error("❌ Error creating relation: %s", e) + raise HTTPException(status_code=500, detail="Failed to create relation") + +@router.delete("/sag/{sag_id}/relationer/{relation_id}") +async def delete_relation(sag_id: int, relation_id: int): + """Soft-delete a relation.""" + try: + check = execute_query( + "SELECT id FROM sag_relationer WHERE id = %s AND deleted_at IS NULL AND (kilde_sag_id = %s OR målsag_id = %s)", + (relation_id, sag_id, sag_id) + ) + if not check: + raise HTTPException(status_code=404, detail="Relation not found") + + query = "UPDATE sag_relationer SET deleted_at = NOW() WHERE id = %s RETURNING id" + result = execute_query(query, (relation_id,)) + + if result: + logger.info("✅ Relation soft-deleted: %s", relation_id) + return {"status": "deleted", "id": relation_id} + raise HTTPException(status_code=500, detail="Failed to delete relation") + except HTTPException: + raise + except Exception as e: + logger.error("❌ Error deleting relation: %s", e) + raise HTTPException(status_code=500, detail="Failed to delete relation") # ============================================================================ -# Hardware & Location Relations +# TAGS - Case Tags # ============================================================================ -@router.get("/cases/{id}/hardware", response_model=List[dict]) -async def get_case_hardware(id: int): - """Get hardware related to this case.""" - query = """ - SELECT h.*, hcr.relation_type as link_type, hcr.created_at as link_created_at - FROM hardware_assets h - JOIN hardware_case_relations hcr ON hcr.hardware_id = h.id - WHERE hcr.case_id = %s AND hcr.deleted_at IS NULL AND h.deleted_at IS NULL - ORDER BY hcr.created_at DESC - """ - return execute_query(query, (id,)) or [] +@router.get("/sag/{sag_id}/tags") +async def get_tags(sag_id: int): + """Get all tags for a case.""" + try: + check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) + if not check: + raise HTTPException(status_code=404, detail="Case not found") + + query = "SELECT * FROM sag_tags WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC" + result = execute_query(query, (sag_id,)) + return result + except HTTPException: + raise + except Exception as e: + logger.error("❌ Error getting tags: %s", e) + raise HTTPException(status_code=500, detail="Failed to get tags") -@router.post("/cases/{id}/hardware", response_model=dict) -async def add_hardware_to_case(id: int, data: dict): - """Link hardware to case.""" - hardware_id = data.get("hardware_id") - if not hardware_id: - raise HTTPException(status_code=400, detail="hardware_id required") - - # Check if already linked - check = execute_query( - "SELECT id FROM hardware_case_relations WHERE case_id = %s AND hardware_id = %s AND deleted_at IS NULL", - (id, hardware_id) - ) - if check: - # Already linked - return {"message": "Already linked", "id": check[0]['id']} +@router.post("/sag/{sag_id}/tags") +async def add_tag(sag_id: int, data: dict): + """Add a tag to a case.""" + try: + if not data.get('tag_navn'): + raise HTTPException(status_code=400, detail="tag_navn is required") + + check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) + if not check: + raise HTTPException(status_code=404, detail="Case not found") + + query = """ + INSERT INTO sag_tags (sag_id, tag_navn) + VALUES (%s, %s) + RETURNING * + """ + result = execute_query(query, (sag_id, data.get('tag_navn'))) + + if result: + logger.info("✅ Tag added: %s -> %s", sag_id, data.get('tag_navn')) + return result[0] + raise HTTPException(status_code=500, detail="Failed to add tag") + except HTTPException: + raise + except Exception as e: + logger.error("❌ Error adding tag: %s", e) + raise HTTPException(status_code=500, detail="Failed to add tag") - query = """ - INSERT INTO hardware_case_relations (case_id, hardware_id, relation_type) - VALUES (%s, %s, 'related') - RETURNING * - """ - result = execute_query(query, (id, hardware_id)) - return result[0] if result else {} - -@router.delete("/cases/{id}/hardware/{hardware_id}", response_model=dict) -async def remove_hardware_from_case(id: int, hardware_id: int): - """Unlink hardware from case.""" - query = """ - UPDATE hardware_case_relations - SET deleted_at = NOW() - WHERE case_id = %s AND hardware_id = %s AND deleted_at IS NULL - RETURNING * - """ - result = execute_query(query, (id, hardware_id)) - if not result: - raise HTTPException(status_code=404, detail="Link not found") - return {"message": "Unlinked"} - -@router.get("/cases/{id}/locations", response_model=List[dict]) -async def get_case_locations(id: int): - """Get locations related to this case.""" - query = """ - SELECT l.*, clr.relation_type as link_type, clr.created_at as link_created_at - FROM locations_locations l - JOIN case_location_relations clr ON clr.location_id = l.id - WHERE clr.case_id = %s AND clr.deleted_at IS NULL AND l.deleted_at IS NULL - ORDER BY clr.created_at DESC - """ - return execute_query(query, (id,)) or [] - -@router.post("/cases/{id}/locations", response_model=dict) -async def add_location_to_case(id: int, data: dict): - """Link location to case.""" - location_id = data.get("location_id") - if not location_id: - raise HTTPException(status_code=400, detail="location_id required") - - query = """ - INSERT INTO case_location_relations (case_id, location_id, relation_type) - VALUES (%s, %s, 'related') - ON CONFLICT (case_id, location_id) DO UPDATE SET deleted_at = NULL - RETURNING * - """ - result = execute_query(query, (id, location_id)) - return result[0] if result else {} - -@router.delete("/cases/{id}/locations/{location_id}", response_model=dict) -async def remove_location_from_case(id: int, location_id: int): - """Unlink location from case.""" - query = """ - UPDATE case_location_relations - SET deleted_at = NOW() - WHERE case_id = %s AND location_id = %s - RETURNING * - """ - result = execute_query(query, (id, location_id)) - if not result: - raise HTTPException(status_code=404, detail="Link not found") - return {"message": "Unlinked"} +@router.delete("/sag/{sag_id}/tags/{tag_id}") +async def delete_tag(sag_id: int, tag_id: int): + """Soft-delete a tag.""" + try: + check = execute_query( + "SELECT id FROM sag_tags WHERE id = %s AND sag_id = %s AND deleted_at IS NULL", + (tag_id, sag_id) + ) + if not check: + raise HTTPException(status_code=404, detail="Tag not found") + + query = "UPDATE sag_tags SET deleted_at = NOW() WHERE id = %s RETURNING id" + result = execute_query(query, (tag_id,)) + + if result: + logger.info("✅ Tag soft-deleted: %s", tag_id) + return {"status": "deleted", "id": tag_id} + raise HTTPException(status_code=500, detail="Failed to delete tag") + except HTTPException: + raise + except Exception as e: + logger.error("❌ Error deleting tag: %s", e) + raise HTTPException(status_code=500, detail="Failed to delete tag") diff --git a/app/modules/sag/frontend/views.py b/app/modules/sag/frontend/views.py index a63da7c..fd8925b 100644 --- a/app/modules/sag/frontend/views.py +++ b/app/modules/sag/frontend/views.py @@ -1,5 +1,5 @@ import logging -from fastapi import APIRouter, HTTPException, Query, Request +from fastapi import APIRouter, HTTPException, Query from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from pathlib import Path @@ -8,129 +8,104 @@ from app.core.database import execute_query logger = logging.getLogger(__name__) router = APIRouter() -# Setup template directory - must be root "app" to allow extending shared/frontend/base.html -templates = Jinja2Templates(directory="app") +# Setup template directory +template_dir = Path(__file__).parent.parent / "templates" +templates = Jinja2Templates(directory=str(template_dir)) -@router.get("/cases", response_class=HTMLResponse) -async def case_list(request: Request, status: str = Query(None), tag: str = Query(None), customer_id: int = Query(None)): +@router.get("/sag", response_class=HTMLResponse) +async def sager_liste( + request, + status: str = Query(None), + tag: str = Query(None), + customer_id: int = Query(None), +): """Display list of all cases.""" - query = "SELECT * FROM sag_sager WHERE deleted_at IS NULL" - params = [] + try: + query = "SELECT * FROM sag_sager WHERE deleted_at IS NULL" + params = [] + + if status: + query += " AND status = %s" + params.append(status) + if customer_id: + query += " AND customer_id = %s" + params.append(customer_id) + + query += " ORDER BY created_at DESC" + sager = execute_query(query, tuple(params)) + + # Filter by tag if provided + if tag and sager: + sag_ids = [s['id'] for s in sager] + tag_query = "SELECT sag_id FROM sag_tags WHERE tag_navn = %s AND deleted_at IS NULL" + tagged = execute_query(tag_query, (tag,)) + tagged_ids = set(t['sag_id'] for t in tagged) + sager = [s for s in sager if s['id'] in tagged_ids] + + # Fetch all distinct statuses and tags for filters + statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ()) + all_tags = execute_query("SELECT DISTINCT tag_navn FROM sag_tags WHERE deleted_at IS NULL ORDER BY tag_navn", ()) + + return templates.TemplateResponse("index.html", { + "request": request, + "sager": sager, + "statuses": [s['status'] for s in statuses], + "all_tags": [t['tag_navn'] for t in all_tags], + "current_status": status, + "current_tag": tag, + }) + except Exception as e: + logger.error("❌ Error displaying case list: %s", e) + raise HTTPException(status_code=500, detail="Failed to load case list") - if status: - query += " AND status = %s" - params.append(status) - if customer_id: - query += " AND customer_id = %s" - params.append(customer_id) - - query += " ORDER BY created_at DESC" - cases = execute_query(query, tuple(params)) - - # Fetch available statuses for filter dropdown - statuses_query = "SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status" - statuses_result = execute_query(statuses_query) - statuses = [row["status"] for row in statuses_result] if statuses_result else [] - - # Fetch available tags for filter dropdown - tags_query = "SELECT DISTINCT tag_navn FROM sag_tags WHERE deleted_at IS NULL ORDER BY tag_navn" - tags_result = execute_query(tags_query) - all_tags = [row["tag_navn"] for row in tags_result] if tags_result else [] - - # Filter by tag if provided - if tag and cases: - case_ids = [case['id'] for case in cases] - tag_query = "SELECT sag_id FROM sag_tags WHERE tag_navn = %s AND deleted_at IS NULL" - tagged = execute_query(tag_query, (tag,)) - tagged_ids = set(t['sag_id'] for t in tagged) - cases = [case for case in cases if case['id'] in tagged_ids] - - return templates.TemplateResponse("modules/sag/templates/index.html", { - "request": request, - "sager": cases, - "statuses": statuses, - "all_tags": all_tags, - "current_status": status, - "current_tag": tag - }) - -@router.get("/cases/new", response_class=HTMLResponse) -async def create_case_form_cases(request: Request): - """Display create case form.""" - return templates.TemplateResponse("modules/sag/templates/create.html", { - "request": request - }) - -@router.get("/cases/{case_id}", response_class=HTMLResponse) -async def case_details(request: Request, case_id: int): +@router.get("/sag/{sag_id}", response_class=HTMLResponse) +async def sag_detaljer(request, sag_id: int): """Display case details.""" - case_query = "SELECT * FROM sag_sager WHERE id = %s AND deleted_at IS NULL" - case_result = execute_query(case_query, (case_id,)) - - if not case_result: - return HTMLResponse(content="

Case not found

", status_code=404) - - case = case_result[0] - - # Fetch tags - tags_query = "SELECT * FROM sag_tags WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC" - tags = execute_query(tags_query, (case_id,)) - - # Fetch relations - relations_query = """ - SELECT sr.*, - ss_kilde.titel AS kilde_titel, - ss_mål.titel AS mål_titel - FROM sag_relationer sr - JOIN sag_sager ss_kilde ON sr.kilde_sag_id = ss_kilde.id - JOIN sag_sager ss_mål ON sr.målsag_id = ss_mål.id - WHERE (sr.kilde_sag_id = %s OR sr.målsag_id = %s) - AND sr.deleted_at IS NULL - ORDER BY sr.created_at DESC - """ - relations = execute_query(relations_query, (case_id, case_id)) - - # Fetch linked contacts - contacts_query = """ - SELECT sk.*, CONCAT(c.first_name, ' ', c.last_name) as contact_name, c.email as contact_email - FROM sag_kontakter sk - LEFT JOIN contacts c ON sk.contact_id = c.id - WHERE sk.sag_id = %s AND sk.deleted_at IS NULL - ORDER BY sk.created_at DESC - """ - contacts = execute_query(contacts_query, (case_id,)) - - # Fetch linked customers - customers_query = """ - SELECT sk.*, c.name as customer_name, c.email as customer_email - FROM sag_kunder sk - LEFT JOIN customers c ON sk.customer_id = c.id - WHERE sk.sag_id = %s AND sk.deleted_at IS NULL - ORDER BY sk.created_at DESC - """ - customers = execute_query(customers_query, (case_id,)) - - return templates.TemplateResponse("modules/sag/templates/detail.html", { - "request": request, - "case": case, - "tags": tags, - "relations": relations, - "contacts": contacts or [], - "customers": customers or [] - }) - -@router.get("/cases/{case_id}/edit", response_class=HTMLResponse) -async def edit_case_form(request: Request, case_id: int): - """Display edit case form.""" - case_query = "SELECT * FROM sag_sager WHERE id = %s AND deleted_at IS NULL" - case_result = execute_query(case_query, (case_id,)) - - if not case_result: - return HTMLResponse(content="

Case not found

", status_code=404) - - case = case_result[0] - - return templates.TemplateResponse("modules/sag/templates/edit.html", { - "request": request, - "case": case - }) + try: + # Fetch main case + sag_query = "SELECT * FROM sag_sager WHERE id = %s AND deleted_at IS NULL" + sag_result = execute_query(sag_query, (sag_id,)) + + if not sag_result: + raise HTTPException(status_code=404, detail="Case not found") + + sag = sag_result[0] + + # Fetch tags + tags_query = "SELECT * FROM sag_tags WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC" + tags = execute_query(tags_query, (sag_id,)) + + # Fetch relations + relationer_query = """ + SELECT sr.*, + ss_kilde.titel as kilde_titel, + ss_mål.titel as mål_titel + FROM sag_relationer sr + JOIN sag_sager ss_kilde ON sr.kilde_sag_id = ss_kilde.id + JOIN sag_sager ss_mål ON sr.målsag_id = ss_mål.id + WHERE (sr.kilde_sag_id = %s OR sr.målsag_id = %s) + AND sr.deleted_at IS NULL + ORDER BY sr.created_at DESC + """ + relationer = execute_query(relationer_query, (sag_id, sag_id)) + + # Fetch customer info if customer_id exists + customer = None + if sag.get('customer_id'): + customer_query = "SELECT * FROM customers WHERE id = %s" + customer_result = execute_query(customer_query, (sag['customer_id'],)) + if customer_result: + customer = customer_result[0] + + return templates.TemplateResponse("detail.html", { + "request": request, + "sag": sag, + "customer": customer, + "tags": tags, + "relationer": relationer, + }) + except HTTPException: + raise + except Exception as e: + logger.error("❌ Error displaying case details: %s", e) + raise HTTPException(status_code=500, detail="Failed to load case details") diff --git a/app/modules/sag/templates/index.html b/app/modules/sag/templates/index.html index 7517d43..f26d6d3 100644 --- a/app/modules/sag/templates/index.html +++ b/app/modules/sag/templates/index.html @@ -1,387 +1,350 @@ -{% extends "shared/frontend/base.html" %} - -{% block title %}Sager - BMC Hub{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
- - - - - - - -
-
-
- - -
-
- - -
-
- - + + + + + + Sager - BMC Hub + + + + + +
+ - -
- {% if sager %} - {% for sag in sager %} -
- -
-
{{ sag.titel }}
- {% if sag.beskrivelse %} -
{{ sag.beskrivelse[:150] }}{% if sag.beskrivelse|length > 150 %}...{% endif %}
- {% endif %} -
- {{ sag.status }} - {{ sag.created_at.strftime('%Y-%m-%d') if sag.created_at else '' }} + +
+
+ + + + +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
-
- ✏️ - -
- {% endfor %} - {% else %} -
-

Ingen sager fundet

- Opret første sag + + + - {% endif %} +
-
- + -{% endblock %} - + + +