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.
This commit is contained in:
Christian 2026-02-01 00:38:10 +01:00
parent fe2110891f
commit 464c27808c
4 changed files with 847 additions and 1202 deletions

View File

@ -1,196 +1,177 @@
# Sag (Case) Module # Sag Module - Case Management
## Overview ## Oversigt
The Sag module is the **process backbone** of BMC Hub. All work flows through cases.
## Core Concept Sag-modulet implementerer en universel sag-håndtering system hvor tickets, opgaver og ordrer er blot sager med forskellige tags og relationer.
**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.)
## Architecture **Kerneidé:** Der er kun én ting: en Sag. Alt andet er metadata, tags og relationer.
### 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
## Database Schema ## Database Schema
### sag_sager (Cases) ### sag_sager (Hovedtabel)
- `id` SERIAL PRIMARY KEY - `id` - Primary key
- `titel` VARCHAR(255) NOT NULL - `titel` - Case title
- `beskrivelse` TEXT - `beskrivelse` - Detailed description
- `template_key` VARCHAR(100) - used only at creation - `type` - Case type (ticket, opgave, ordre, etc.)
- `status` VARCHAR(50) CHECK (status IN ('åben', 'lukket')) - `status` - Status (åben, i_gang, afsluttet, on_hold)
- `customer_id` INT - links to customers table - `customer_id` - Foreign key to customers table
- `ansvarlig_bruger_id` INT - `ansvarlig_bruger_id` - Assigned user
- `created_by_user_id` INT - `deadline` - Due date
- `deadline` TIMESTAMP - `created_at` - Creation timestamp
- `created_at` TIMESTAMP DEFAULT NOW() - `updated_at` - Last update (auto-updated via trigger)
- `updated_at` TIMESTAMP DEFAULT NOW() - `deleted_at` - Soft-delete timestamp (NULL = active)
- `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_relationer (Relations) ### sag_relationer (Relations)
- `id` SERIAL PRIMARY KEY - `id` - Primary key
- `kilde_sag_id` INT NOT NULL REFERENCES sag_sager(id) - `kilde_sag_id` - Source case
- `målsag_id` INT NOT NULL REFERENCES sag_sager(id) - `målsag_id` - Target case
- `relationstype` VARCHAR(50) NOT NULL - `relationstype` - Relation type (forælder, barn, afledt_af, blokkerer, udfører_for)
- `created_at` TIMESTAMP DEFAULT NOW() - `created_at` - Creation timestamp
- `deleted_at` TIMESTAMP - `deleted_at` - Soft-delete timestamp
- `CHECK (kilde_sag_id != målsag_id)`
### sag_kontakter (Contact Links) ### sag_tags (Tags)
- `id` SERIAL PRIMARY KEY - `id` - Primary key
- `sag_id` INT NOT NULL REFERENCES sag_sager(id) - `sag_id` - Case reference
- `contact_id` INT NOT NULL REFERENCES contacts(id) - `tag_navn` - Tag name (support, urgent, vip, ompakning, etc.)
- `role` VARCHAR(50) - `created_at` - Creation timestamp
- `created_at` TIMESTAMP DEFAULT NOW() - `deleted_at` - Soft-delete timestamp
- `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)
## API Endpoints ## API Endpoints
### Cases CRUD ### 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 **List cases**
- `GET /api/v1/cases/{id}/tags` - List tags ```
- `POST /api/v1/cases/{id}/tags` - Add tag GET /api/v1/sag?status=åben&tag=support&customer_id=1
- `PATCH /api/v1/cases/{id}/tags/{tag_id}/state` - Toggle tag state ```
- `DELETE /api/v1/cases/{id}/tags/{tag_id}` - Soft-delete tag
**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 ### 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 relations**
- `GET /api/v1/cases/{id}/contacts` - List linked contacts ```
- `POST /api/v1/cases/{id}/contacts` - Link contact GET /api/v1/sag/1/relationer
- `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
### Search **Add relation**
- `GET /api/v1/search/cases?q={query}` - Search cases ```
- `GET /api/v1/search/contacts?q={query}` - Search contacts POST /api/v1/sag/1/relationer
- `GET /api/v1/search/customers?q={query}` - Search customers 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 ## Frontend Routes
- `/cases` - List all cases - `GET /sag` - List all cases with filters
- `/cases/new` - Create new case - `GET /sag/{id}` - View case details
- `/cases/{id}` - View case details - `GET /sag/new` - Create new case (future)
- `/cases/{id}/edit` - Edit case - `GET /sag/{id}/edit` - Edit case (future)
## Usage Examples ## Features
### Create a Case ✅ Soft-delete with data preservation
```python ✅ Nordic Top design with dark mode support
import requests ✅ Responsive mobile-friendly UI
response = requests.post('http://localhost:8001/api/v1/cases', json={ ✅ Case relations (parent/child)
'titel': 'New Project', ✅ Dynamic tagging system
'beskrivelse': 'Project description', ✅ Full-text search
'status': 'åben', ✅ Status filtering
'customer_id': 123 ✅ Customer tracking
})
```
### Add Tag to Case ## Example Workflows
```python
response = requests.post('http://localhost:8001/api/v1/cases/1/tags', json={
'tag_navn': 'urgent'
})
```
### Close a Tag (Mark Work Complete) ### Support Ticket
```python 1. Customer calls → Create Sag with type="ticket", tag="support"
response = requests.patch('http://localhost:8001/api/v1/cases/1/tags/5/state', json={ 2. Urgency high → Add tag="urgent"
'state': 'closed' 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 ### Future Integrations
```python
response = requests.post('http://localhost:8001/api/v1/cases/1/relations', json={
'målsag_id': 42,
'relationstype': 'blokkerer'
})
```
## 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 ## Soft-Delete Safety
- **afhænger af** - This case depends on target
- **blokkerer** - This case blocks target
- **duplikat** - This case duplicates target
## 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: - All queries use `execute_query()` from `app.core.database`
1. Create the Order independently - Parameterized queries with `%s` placeholders (SQL injection prevention)
2. Create a relation: Case → Order - `RealDictCursor` for dict-like row access
3. Use relationstype: `ordre_oprettet` or similar - Triggers maintain `updated_at` automatically
- Relations are first-class citizens (not just links)
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

View File

@ -4,592 +4,318 @@ from fastapi import APIRouter, HTTPException, Query
from app.core.database import execute_query from app.core.database import execute_query
from datetime import datetime from datetime import datetime
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
# ============================================================================ # ============================================================================
# CRUD Endpoints for Cases # SAGER - CRUD Operations
# ============================================================================ # ============================================================================
@router.get("/cases", response_model=List[dict]) @router.get("/sag")
async def list_cases(status: Optional[str] = None, customer_id: Optional[int] = None): async def list_sager(
"""List all cases with optional filters.""" status: Optional[str] = Query(None),
query = "SELECT * FROM sag_sager WHERE deleted_at IS NULL" tag: Optional[str] = Query(None),
params = [] 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: @router.post("/sag")
query += " AND status = %s" async def create_sag(data: dict):
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):
"""Create a new case.""" """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: try:
case_ids = data.get("case_ids", []) if not data.get('titel'):
action = data.get("action") raise HTTPException(status_code=400, detail="titel is required")
params = data.get("params", {}) if not data.get('customer_id'):
raise HTTPException(status_code=400, detail="customer_id is required")
if not case_ids: query = """
raise HTTPException(status_code=400, detail="case_ids list is required") 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: result = execute_query(query, params)
raise HTTPException(status_code=400, detail="action is required") if result:
logger.info("✅ Case created: %s", result[0]['id'])
affected_cases = 0 return result[0]
raise HTTPException(status_code=500, detail="Failed to create case")
try: except Exception as e:
if action == "update_status": logger.error("❌ Error creating case: %s", e)
status = params.get("status") raise HTTPException(status_code=500, detail="Failed to create case")
if not status:
raise HTTPException(status_code=400, detail="status parameter is required for update_status action") @router.get("/sag/{sag_id}")
async def get_sag(sag_id: int):
placeholders = ", ".join(["%s"] * len(case_ids)) """Get a specific case."""
query = f""" try:
UPDATE sag_sager query = "SELECT * FROM sag_sager WHERE id = %s AND deleted_at IS NULL"
SET status = %s, updated_at = NOW() result = execute_query(query, (sag_id,))
WHERE id IN ({placeholders}) AND deleted_at IS NULL if not result:
""" raise HTTPException(status_code=404, detail="Case not found")
affected_cases = execute_query(query, tuple([status] + case_ids), fetch=False) return result[0]
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
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error("❌ Error in bulk operations: %s", e) logger.error("❌ Error getting case: %s", e)
raise HTTPException(status_code=500, detail=f"Bulk operation failed: {str(e)}") raise HTTPException(status_code=500, detail="Failed to get case")
# ============================================================================ @router.patch("/sag/{sag_id}")
# CRUD Endpoints for Relations async def update_sag(sag_id: int, updates: dict):
# ============================================================================ """Update a case."""
@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."""
try: 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 # Build dynamic update query
if state not in ["open", "closed"]: allowed_fields = ['titel', 'beskrivelse', 'type', 'status', 'ansvarlig_bruger_id', 'deadline']
logger.error("❌ Invalid state value: %s", state) set_clauses = []
raise HTTPException(status_code=400, detail="State must be 'open' or 'closed'") params = []
# Check tag exists and belongs to case for field in allowed_fields:
check_query = """ if field in updates:
SELECT id FROM sag_tags set_clauses.append(f"{field} = %s")
WHERE id = %s AND sag_id = %s AND deleted_at IS NULL params.append(updates[field])
"""
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")
# Update tag state if not set_clauses:
if state == "closed": raise HTTPException(status_code=400, detail="No valid fields to update")
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 *
"""
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: 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] return result[0]
raise HTTPException(status_code=500, detail="Failed to update case")
raise HTTPException(status_code=500, detail="Failed to update tag state")
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error("❌ Error updating tag state: %s", e) logger.error("❌ Error updating case: %s", e)
raise HTTPException(status_code=500, detail="Failed to update tag state") raise HTTPException(status_code=500, detail="Failed to update case")
# ============================================================================ @router.delete("/sag/{sag_id}")
# CONTACTS & CUSTOMERS - Link to Cases async def delete_sag(sag_id: int):
# ============================================================================ """Soft-delete a case."""
@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")
try: try:
query = """ check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
INSERT INTO sag_kontakter (sag_id, contact_id, role) if not check:
VALUES (%s, %s, %s) raise HTTPException(status_code=404, detail="Case not found")
RETURNING *
""" query = "UPDATE sag_sager SET deleted_at = NOW() WHERE id = %s RETURNING id"
result = execute_query(query, (id, contact_id, role)) result = execute_query(query, (sag_id,))
if result: if result:
logger.info("✅ Contact %s added to case %s", contact_id, id) logger.info("✅ Case soft-deleted: %s", sag_id)
return result[0] return {"status": "deleted", "id": sag_id}
raise HTTPException(status_code=500, detail="Failed to add contact") raise HTTPException(status_code=500, detail="Failed to delete case")
except HTTPException:
raise
except Exception as e: except Exception as e:
if "unique_sag_contact" in str(e).lower(): logger.error("❌ Error deleting case: %s", e)
raise HTTPException(status_code=400, detail="Contact already linked to this case") raise HTTPException(status_code=500, detail="Failed to delete 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")
# ============================================================================ # ============================================================================
# SEARCH # RELATIONER - Case Relations
# ============================================================================ # ============================================================================
@router.get("/search/cases") @router.get("/sag/{sag_id}/relationer")
async def search_cases(q: str): async def get_relationer(sag_id: int):
"""Search for cases by title or description.""" """Get all relations for a case."""
try: try:
if not q or len(q) < 2: # Check if case exists
return [] 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 = """ query = """
SELECT id, titel, status, created_at SELECT sr.*,
FROM sag_sager ss_kilde.titel as kilde_titel,
WHERE deleted_at IS NULL ss_mål.titel as mål_titel
AND (titel ILIKE %s OR beskrivelse ILIKE %s) FROM sag_relationer sr
ORDER BY created_at DESC JOIN sag_sager ss_kilde ON sr.kilde_sag_id = ss_kilde.id
LIMIT 20 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, (sag_id, sag_id))
result = execute_query(query, (search_term, search_term))
return result return result
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error("❌ Error searching cases: %s", e) logger.error("❌ Error getting relations: %s", e)
raise HTTPException(status_code=500, detail="Failed to search cases") 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]) @router.get("/sag/{sag_id}/tags")
async def get_case_hardware(id: int): async def get_tags(sag_id: int):
"""Get hardware related to this case.""" """Get all tags for a case."""
query = """ try:
SELECT h.*, hcr.relation_type as link_type, hcr.created_at as link_created_at check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
FROM hardware_assets h if not check:
JOIN hardware_case_relations hcr ON hcr.hardware_id = h.id raise HTTPException(status_code=404, detail="Case not found")
WHERE hcr.case_id = %s AND hcr.deleted_at IS NULL AND h.deleted_at IS NULL
ORDER BY hcr.created_at DESC 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 execute_query(query, (id,)) or [] 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) @router.post("/sag/{sag_id}/tags")
async def add_hardware_to_case(id: int, data: dict): async def add_tag(sag_id: int, data: dict):
"""Link hardware to case.""" """Add a tag to a case."""
hardware_id = data.get("hardware_id") try:
if not hardware_id: if not data.get('tag_navn'):
raise HTTPException(status_code=400, detail="hardware_id required") raise HTTPException(status_code=400, detail="tag_navn is required")
# Check if already linked check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
check = execute_query( if not check:
"SELECT id FROM hardware_case_relations WHERE case_id = %s AND hardware_id = %s AND deleted_at IS NULL", raise HTTPException(status_code=404, detail="Case not found")
(id, hardware_id)
) query = """
if check: INSERT INTO sag_tags (sag_id, tag_navn)
# Already linked VALUES (%s, %s)
return {"message": "Already linked", "id": check[0]['id']} 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 = """ @router.delete("/sag/{sag_id}/tags/{tag_id}")
INSERT INTO hardware_case_relations (case_id, hardware_id, relation_type) async def delete_tag(sag_id: int, tag_id: int):
VALUES (%s, %s, 'related') """Soft-delete a tag."""
RETURNING * try:
""" check = execute_query(
result = execute_query(query, (id, hardware_id)) "SELECT id FROM sag_tags WHERE id = %s AND sag_id = %s AND deleted_at IS NULL",
return result[0] if result else {} (tag_id, sag_id)
)
@router.delete("/cases/{id}/hardware/{hardware_id}", response_model=dict) if not check:
async def remove_hardware_from_case(id: int, hardware_id: int): raise HTTPException(status_code=404, detail="Tag not found")
"""Unlink hardware from case."""
query = """ query = "UPDATE sag_tags SET deleted_at = NOW() WHERE id = %s RETURNING id"
UPDATE hardware_case_relations result = execute_query(query, (tag_id,))
SET deleted_at = NOW()
WHERE case_id = %s AND hardware_id = %s AND deleted_at IS NULL if result:
RETURNING * logger.info("✅ Tag soft-deleted: %s", tag_id)
""" return {"status": "deleted", "id": tag_id}
result = execute_query(query, (id, hardware_id)) raise HTTPException(status_code=500, detail="Failed to delete tag")
if not result: except HTTPException:
raise HTTPException(status_code=404, detail="Link not found") raise
return {"message": "Unlinked"} except Exception as e:
logger.error("❌ Error deleting tag: %s", e)
@router.get("/cases/{id}/locations", response_model=List[dict]) raise HTTPException(status_code=500, detail="Failed to delete tag")
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"}

View File

@ -1,5 +1,5 @@
import logging import logging
from fastapi import APIRouter, HTTPException, Query, Request from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pathlib import Path from pathlib import Path
@ -8,129 +8,104 @@ from app.core.database import execute_query
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
# Setup template directory - must be root "app" to allow extending shared/frontend/base.html # Setup template directory
templates = Jinja2Templates(directory="app") template_dir = Path(__file__).parent.parent / "templates"
templates = Jinja2Templates(directory=str(template_dir))
@router.get("/cases", response_class=HTMLResponse) @router.get("/sag", response_class=HTMLResponse)
async def case_list(request: Request, status: str = Query(None), tag: str = Query(None), customer_id: int = Query(None)): async def sager_liste(
request,
status: str = Query(None),
tag: str = Query(None),
customer_id: int = Query(None),
):
"""Display list of all cases.""" """Display list of all cases."""
query = "SELECT * FROM sag_sager WHERE deleted_at IS NULL" try:
params = [] 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: @router.get("/sag/{sag_id}", response_class=HTMLResponse)
query += " AND status = %s" async def sag_detaljer(request, sag_id: int):
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):
"""Display case details.""" """Display case details."""
case_query = "SELECT * FROM sag_sager WHERE id = %s AND deleted_at IS NULL" try:
case_result = execute_query(case_query, (case_id,)) # Fetch main case
sag_query = "SELECT * FROM sag_sager WHERE id = %s AND deleted_at IS NULL"
if not case_result: sag_result = execute_query(sag_query, (sag_id,))
return HTMLResponse(content="<h1>Case not found</h1>", status_code=404)
if not sag_result:
case = case_result[0] raise HTTPException(status_code=404, detail="Case not found")
# Fetch tags sag = sag_result[0]
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 tags
tags_query = "SELECT * FROM sag_tags WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC"
# Fetch relations tags = execute_query(tags_query, (sag_id,))
relations_query = """
SELECT sr.*, # Fetch relations
ss_kilde.titel AS kilde_titel, relationer_query = """
ss_mål.titel AS mål_titel SELECT sr.*,
FROM sag_relationer sr ss_kilde.titel as kilde_titel,
JOIN sag_sager ss_kilde ON sr.kilde_sag_id = ss_kilde.id ss_mål.titel as mål_titel
JOIN sag_sager ss_mål ON sr.målsag_id = ss_mål.id FROM sag_relationer sr
WHERE (sr.kilde_sag_id = %s OR sr.målsag_id = %s) JOIN sag_sager ss_kilde ON sr.kilde_sag_id = ss_kilde.id
AND sr.deleted_at IS NULL JOIN sag_sager ss_mål ON sr.målsag_id = ss_mål.id
ORDER BY sr.created_at DESC WHERE (sr.kilde_sag_id = %s OR sr.målsag_id = %s)
""" AND sr.deleted_at IS NULL
relations = execute_query(relations_query, (case_id, case_id)) ORDER BY sr.created_at DESC
"""
# Fetch linked contacts relationer = execute_query(relationer_query, (sag_id, sag_id))
contacts_query = """
SELECT sk.*, CONCAT(c.first_name, ' ', c.last_name) as contact_name, c.email as contact_email # Fetch customer info if customer_id exists
FROM sag_kontakter sk customer = None
LEFT JOIN contacts c ON sk.contact_id = c.id if sag.get('customer_id'):
WHERE sk.sag_id = %s AND sk.deleted_at IS NULL customer_query = "SELECT * FROM customers WHERE id = %s"
ORDER BY sk.created_at DESC customer_result = execute_query(customer_query, (sag['customer_id'],))
""" if customer_result:
contacts = execute_query(contacts_query, (case_id,)) customer = customer_result[0]
# Fetch linked customers return templates.TemplateResponse("detail.html", {
customers_query = """ "request": request,
SELECT sk.*, c.name as customer_name, c.email as customer_email "sag": sag,
FROM sag_kunder sk "customer": customer,
LEFT JOIN customers c ON sk.customer_id = c.id "tags": tags,
WHERE sk.sag_id = %s AND sk.deleted_at IS NULL "relationer": relationer,
ORDER BY sk.created_at DESC })
""" except HTTPException:
customers = execute_query(customers_query, (case_id,)) raise
except Exception as e:
return templates.TemplateResponse("modules/sag/templates/detail.html", { logger.error("❌ Error displaying case details: %s", e)
"request": request, raise HTTPException(status_code=500, detail="Failed to load case details")
"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="<h1>Case not found</h1>", status_code=404)
case = case_result[0]
return templates.TemplateResponse("modules/sag/templates/edit.html", {
"request": request,
"case": case
})

View File

@ -1,387 +1,350 @@
{% extends "shared/frontend/base.html" %} <!DOCTYPE html>
<html lang="da">
{% block title %}Sager - BMC Hub{% endblock %} <head>
<meta charset="UTF-8">
{% block extra_css %} <meta name="viewport" content="width=device-width, initial-scale=1.0">
<style> <title>Sager - BMC Hub</title>
.page-header { <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
margin-bottom: 2rem; <style>
display: flex; :root {
justify-content: space-between; --primary-color: #0f4c75;
align-items: center; --secondary-color: #3282b8;
flex-wrap: wrap; --accent-color: #00a8e8;
gap: 1rem; --bg-light: #f7f9fc;
} --bg-dark: #1a1a2e;
--text-light: #333;
.page-header h1 { --text-dark: #f0f0f0;
font-size: 2rem; --border-color: #ddd;
font-weight: 700; }
margin: 0;
} body {
background-color: var(--bg-light);
.btn-new-case { color: var(--text-light);
background-color: var(--accent); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: white; }
border: none;
padding: 0.6rem 1.5rem; body.dark-mode {
border-radius: 8px; background-color: var(--bg-dark);
font-weight: 500; color: var(--text-dark);
transition: all 0.3s ease; }
text-decoration: none;
display: inline-flex; .navbar {
align-items: center; background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
gap: 0.5rem; box-shadow: 0 2px 8px rgba(0,0,0,0.1);
} }
.btn-new-case:hover { .navbar-brand {
background-color: #0056b3; font-weight: 600;
color: white; font-size: 1.4rem;
transform: translateY(-2px); }
box-shadow: 0 4px 12px rgba(15, 76, 117, 0.3);
} .content-wrapper {
padding: 2rem 0;
.filter-section { min-height: 100vh;
background: var(--bg-card); }
padding: 1.5rem;
border-radius: 12px; .page-header {
margin-bottom: 2rem; margin-bottom: 2rem;
border: 1px solid rgba(0,0,0,0.1); display: flex;
} justify-content: space-between;
align-items: center;
.filter-section label { flex-wrap: wrap;
font-weight: 600; gap: 1rem;
color: var(--accent); }
margin-bottom: 0.5rem;
display: block; .page-header h1 {
} font-size: 2rem;
font-weight: 700;
.filter-section input, color: var(--primary-color);
.filter-section select { margin: 0;
border-radius: 8px; }
border: 1px solid rgba(0,0,0,0.1);
} body.dark-mode .page-header h1 {
color: var(--accent-color);
.sag-card { }
display: block;
background: var(--bg-card); .btn-new {
border: 1px solid rgba(0,0,0,0.1); background-color: var(--accent-color);
border-radius: 12px; color: white;
padding: 1.5rem; border: none;
margin-bottom: 1rem; padding: 0.6rem 1.5rem;
text-decoration: none; border-radius: 6px;
color: inherit; font-weight: 500;
transition: all 0.2s; transition: all 0.3s ease;
cursor: pointer; }
}
.btn-new:hover {
.sag-card:hover { background-color: var(--secondary-color);
transform: translateY(-2px); color: white;
box-shadow: 0 4px 12px rgba(0,0,0,0.1); transform: translateY(-2px);
border-color: var(--accent); box-shadow: 0 4px 12px rgba(0,168,232,0.3);
} }
.sag-title { .filter-section {
font-size: 1.1rem; background: white;
font-weight: 600; padding: 1.5rem;
color: var(--accent); border-radius: 8px;
margin-bottom: 0.5rem; margin-bottom: 2rem;
} box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.sag-meta {
display: flex; body.dark-mode .filter-section {
gap: 1rem; background-color: #2a2a3e;
font-size: 0.85rem; box-shadow: 0 2px 4px rgba(0,0,0,0.3);
flex-wrap: wrap; }
margin-top: 1rem;
} .filter-section label {
font-weight: 600;
.status-badge { color: var(--primary-color);
padding: 0.3rem 0.8rem; margin-bottom: 0.5rem;
border-radius: 20px; display: block;
font-weight: 500; font-size: 0.9rem;
font-size: 0.8rem; }
}
body.dark-mode .filter-section label {
.status-åben { color: var(--accent-color);
background-color: #d1ecf1; }
color: #0c5460;
} .filter-section select,
.filter-section input {
.status-lukket { border: 1px solid var(--border-color);
background-color: #f8d7da; border-radius: 4px;
color: #721c24; padding: 0.5rem;
} font-size: 0.95rem;
}
[data-bs-theme="dark"] .status-åben {
background-color: #1a4d5c; body.dark-mode .filter-section select,
color: #66d9e8; body.dark-mode .filter-section input {
} background-color: #3a3a4e;
color: var(--text-dark);
[data-bs-theme="dark"] .status-lukket { border-color: #555;
background-color: #5c2b2f; }
color: #f8a5ac;
} .sag-card {
background: white;
.empty-state { border-radius: 8px;
text-align: center; padding: 1.5rem;
padding: 3rem 1rem; margin-bottom: 1rem;
color: var(--text-secondary); border-left: 4px solid var(--primary-color);
} cursor: pointer;
transition: all 0.3s ease;
.empty-state p { box-shadow: 0 2px 4px rgba(0,0,0,0.05);
font-size: 1.1rem; text-decoration: none;
margin: 0; color: inherit;
} display: block;
}
#bulkActions {
display: flex; body.dark-mode .sag-card {
align-items: center; background-color: #2a2a3e;
justify-content: space-between; box-shadow: 0 2px 4px rgba(0,0,0,0.3);
padding: 1rem; }
background: var(--accent-light);
margin-bottom: 1rem; .sag-card:hover {
border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);
border: 1px solid var(--accent); transform: translateY(-2px);
} border-left-color: var(--accent-color);
}
.case-checkbox {
position: absolute; body.dark-mode .sag-card:hover {
top: 1rem; box-shadow: 0 4px 12px rgba(0,168,232,0.2);
left: 1rem; }
width: 20px;
height: 20px; .sag-title {
cursor: pointer; font-size: 1.1rem;
z-index: 5; font-weight: 600;
} color: var(--primary-color);
margin-bottom: 0.5rem;
.sag-card-with-checkbox { }
padding-left: 3rem;
} body.dark-mode .sag-title {
color: var(--accent-color);
.bulk-action-btn { }
padding: 0.5rem 1rem;
border-radius: 6px; .sag-meta {
border: none; display: flex;
cursor: pointer; justify-content: space-between;
font-weight: 500; align-items: center;
transition: all 0.2s; flex-wrap: wrap;
} gap: 1rem;
font-size: 0.9rem;
.bulk-action-btn:hover { color: #666;
transform: translateY(-1px); margin-top: 1rem;
} }
.btn-bulk-close { body.dark-mode .sag-meta {
background: #28a745; color: #aaa;
color: white; }
}
.status-badge {
.btn-bulk-close:hover { display: inline-block;
background: #218838; padding: 0.3rem 0.7rem;
} border-radius: 20px;
font-size: 0.8rem;
.btn-bulk-tag { font-weight: 500;
background: var(--accent); }
color: white;
} .status-åben {
background-color: #ffeaa7;
.btn-bulk-tag:hover { color: #d63031;
background: #0056b3; }
}
.status-i_gang {
.btn-bulk-clear { background-color: #a29bfe;
background: #6c757d; color: #2d3436;
color: white; }
}
.status-afsluttet {
.btn-bulk-clear:hover { background-color: #55efc4;
background: #5a6268; color: #00b894;
} }
</style>
{% endblock %} .status-on_hold {
background-color: #fab1a0;
{% block content %} color: #e17055;
<div class="container" style="margin-top: 2rem; margin-bottom: 2rem;"> }
<!-- Page Header -->
<div class="page-header"> .tag {
<h1>📋 Sager</h1> display: inline-block;
<a href="/cases/new" class="btn-new-case"> background-color: var(--primary-color);
<i class="bi bi-plus-lg"></i> Ny Sag color: white;
</a> padding: 0.3rem 0.6rem;
</div> border-radius: 4px;
font-size: 0.8rem;
<!-- Bulk Actions Bar (hidden by default) --> margin-right: 0.5rem;
<div id="bulkActions" style="display: none;"> margin-top: 0.5rem;
<span id="selectedCount" style="font-weight: 600; color: var(--accent);">0 sager valgt</span> }
<div style="display: inline-flex; gap: 0.5rem;">
<button onclick="bulkClose()" class="bulk-action-btn btn-bulk-close">Luk alle</button> body.dark-mode .tag {
<button onclick="bulkAddTag()" class="bulk-action-btn btn-bulk-tag">Tilføj tag</button> background-color: var(--secondary-color);
<button onclick="clearSelection()" class="bulk-action-btn btn-bulk-clear">Ryd</button> }
</div>
</div> .dark-mode-toggle {
background: none;
<!-- Filter Section --> border: none;
<div class="filter-section"> color: white;
<div class="row g-3"> font-size: 1.2rem;
<div class="col-md-4"> cursor: pointer;
<label>Søg</label> padding: 0.5rem;
<input type="text" id="searchInput" class="form-control" placeholder="Søg efter sag..."> }
</div>
<div class="col-md-4"> .empty-state {
<label>Status</label> text-align: center;
<select id="statusFilter" class="form-select"> padding: 3rem 1rem;
<option value="">Alle statuser</option> color: #999;
{% for status in statuses %} }
<option value="{{ status }}">{{ status }}</option>
{% endfor %} body.dark-mode .empty-state {
</select> color: #666;
</div> }
<div class="col-md-4"> </style>
<label>Tags</label> </head>
<select id="tagFilter" class="form-select"> <body>
<option value="">Alle tags</option> <!-- Navigation -->
{% for tag in all_tags %} <nav class="navbar navbar-expand-lg navbar-dark">
<option value="{{ tag }}">{{ tag }}</option> <div class="container">
{% endfor %} <a class="navbar-brand" href="/">🛠️ BMC Hub</a>
</select> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link active" href="/sag">Sager</a>
</li>
<li class="nav-item">
<button class="dark-mode-toggle" onclick="toggleDarkMode()">🌙</button>
</li>
</ul>
</div> </div>
</div> </div>
</div> </nav>
<!-- Cases List --> <!-- Main Content -->
<div> <div class="content-wrapper">
{% if sager %} <div class="container">
{% for sag in sager %} <!-- Page Header -->
<div class="sag-card sag-card-with-checkbox" data-status="{{ sag.status }}" style="cursor: default; position: relative;"> <div class="page-header">
<input type="checkbox" class="case-checkbox" data-case-id="{{ sag.id }}"> <h1>📋 Sager</h1>
<div style="cursor: pointer;" onclick="window.location.href='/cases/{{ sag.id }}'"> <a href="/sag/new" class="btn-new">+ Ny sag</a>
<div class="sag-title">{{ sag.titel }}</div> </div>
{% if sag.beskrivelse %}
<div style="color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 0.5rem;">{{ sag.beskrivelse[:150] }}{% if sag.beskrivelse|length > 150 %}...{% endif %}</div> <!-- Filters -->
{% endif %} <div class="filter-section">
<div class="sag-meta"> <div class="row g-3">
<span class="status-badge status-{{ sag.status }}">{{ sag.status }}</span> <div class="col-md-4">
<span style="color: var(--text-secondary);">{{ sag.created_at.strftime('%Y-%m-%d') if sag.created_at else '' }}</span> <label>Status</label>
<form method="get" style="display: flex; gap: 0.5rem;">
<select name="status" onchange="this.form.submit()" style="flex: 1;">
<option value="">Alle statuser</option>
{% for s in statuses %}
<option value="{{ s }}" {% if s == current_status %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</form>
</div>
<div class="col-md-4">
<label>Tag</label>
<form method="get" style="display: flex; gap: 0.5rem;">
<select name="tag" onchange="this.form.submit()" style="flex: 1;">
<option value="">Alle tags</option>
{% for t in all_tags %}
<option value="{{ t }}" {% if t == current_tag %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</form>
</div>
<div class="col-md-4">
<label>Søg</label>
<input type="text" placeholder="Søg efter sager..." class="form-control" id="searchInput">
</div> </div>
</div> </div>
<div style="position: absolute; top: 1rem; right: 1rem; display: flex; gap: 0.5rem;">
<a href="/cases/{{ sag.id }}/edit" class="btn" style="background-color: var(--accent-color); color: white; padding: 0.4rem 0.8rem; border-radius: 6px; text-decoration: none; font-size: 0.85rem; display: flex; align-items: center; gap: 0.3rem;" title="Rediger">✏️</a>
<a href="/cases/{{ sag.id }}" class="btn" style="background-color: #6c757d; color: white; padding: 0.4rem 0.8rem; border-radius: 6px; text-decoration: none; font-size: 0.85rem; display: flex; align-items: center; gap: 0.3rem;" title="Detaljer"></a>
</div>
</div> </div>
{% endfor %}
{% else %} <!-- Cases List -->
<div class="empty-state"> <div id="casesList">
<p><i class="bi bi-inbox" style="font-size: 2rem; display: block; margin-bottom: 1rem;"></i>Ingen sager fundet</p> {% if sager %}
<a href="/cases/new" class="btn-new-case">Opret første sag</a> {% for sag in sager %}
<a href="/sag/{{ sag.id }}" class="sag-card">
<div class="sag-title">{{ sag.titel }}</div>
{% if sag.beskrivelse %}
<div style="color: #666; font-size: 0.9rem; margin-bottom: 0.5rem;">{{ sag.beskrivelse[:100] }}{% if sag.beskrivelse|length > 100 %}...{% endif %}</div>
{% endif %}
<div class="sag-meta">
<span class="status-badge status-{{ sag.status }}">{{ sag.status }}</span>
<span>{{ sag.type }}</span>
<span style="color: #999;">{{ sag.created_at[:10] }}</span>
</div>
</a>
{% endfor %}
{% else %}
<div class="empty-state">
<p>Ingen sager fundet</p>
</div>
{% endif %}
</div> </div>
{% endif %} </div>
</div> </div>
</div>
<script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
// Bulk selection state <script>
let selectedCases = new Set(); function toggleDarkMode() {
document.body.classList.toggle('dark-mode');
// Bulk selection handler localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
document.addEventListener('change', function(e) {
if (e.target.classList.contains('case-checkbox')) {
const caseId = parseInt(e.target.dataset.caseId);
if (e.target.checked) {
selectedCases.add(caseId);
} else {
selectedCases.delete(caseId);
}
updateBulkBar();
} }
});
function updateBulkBar() {
const count = selectedCases.size;
const bar = document.getElementById('bulkActions');
if (count > 0) {
bar.style.display = 'flex';
document.getElementById('selectedCount').textContent = `${count} sager valgt`;
} else {
bar.style.display = 'none';
}
}
async function bulkClose() {
const caseIds = Array.from(selectedCases);
if (!confirm(`Luk ${caseIds.length} sager?`)) return;
try { // Load dark mode preference
const response = await fetch('/api/v1/cases/bulk', { if (localStorage.getItem('darkMode') === 'true') {
method: 'POST', document.body.classList.add('dark-mode');
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
case_ids: caseIds,
action: 'close_all'
})
});
if (response.ok) {
window.location.reload();
} else {
const error = await response.json();
alert(`Fejl: ${error.detail}`);
}
} catch (err) {
alert('Fejl ved bulk lukning: ' + err.message);
} }
}
async function bulkAddTag() {
const tagName = prompt('Indtast tag navn:');
if (!tagName) return;
const caseIds = Array.from(selectedCases); // Search functionality
try { document.getElementById('searchInput').addEventListener('keyup', function(e) {
const response = await fetch('/api/v1/cases/bulk', { const search = e.target.value.toLowerCase();
method: 'POST', document.querySelectorAll('.sag-card').forEach(card => {
headers: {'Content-Type': 'application/json'}, const text = card.textContent.toLowerCase();
body: JSON.stringify({ card.style.display = text.includes(search) ? 'block' : 'none';
case_ids: caseIds,
action: 'add_tag',
params: {tag_navn: tagName}
})
}); });
if (response.ok) {
window.location.reload();
} else {
const error = await response.json();
alert(`Fejl: ${error.detail}`);
}
} catch (err) {
alert('Fejl ved bulk tag tilføjelse: ' + err.message);
}
}
function clearSelection() {
selectedCases.clear();
document.querySelectorAll('.case-checkbox').forEach(cb => cb.checked = false);
updateBulkBar();
}
// Search functionality
document.getElementById('searchInput').addEventListener('keyup', function(e) {
const search = e.target.value.toLowerCase();
document.querySelectorAll('.sag-card').forEach(card => {
const text = card.textContent.toLowerCase();
const display = text.includes(search) ? 'block' : 'none';
card.style.display = display;
}); });
}); </script>
</body>
// Status filter </html>
document.getElementById('statusFilter').addEventListener('change', function(e) {
const status = e.target.value;
document.querySelectorAll('.sag-card').forEach(card => {
const cardStatus = card.dataset.status;
const display = status === '' || cardStatus === status ? 'block' : 'none';
card.style.display = display;
});
});
</script>
{% endblock %}