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,17 +4,22 @@ 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),
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" query = "SELECT * FROM sag_sager WHERE deleted_at IS NULL"
params = [] params = []
@ -24,572 +29,293 @@ async def list_cases(status: Optional[str] = None, customer_id: Optional[int] =
if customer_id: if customer_id:
query += " AND customer_id = %s" query += " AND customer_id = %s"
params.append(customer_id) 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" query += " ORDER BY created_at DESC"
return execute_query(query, tuple(params))
@router.post("/cases", response_model=dict) cases = execute_query(query, tuple(params))
async def create_case(data: dict):
# 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")
@router.post("/sag")
async def create_sag(data: dict):
"""Create a new case.""" """Create a new case."""
try:
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")
query = """ query = """
INSERT INTO sag_sager (titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, created_by_user_id, deadline) INSERT INTO sag_sager
VALUES (%s, %s, %s, %s, %s, %s, %s, %s) (titel, beskrivelse, type, status, customer_id, ansvarlig_bruger_id, deadline)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING * RETURNING *
""" """
params = ( params = (
data.get("titel"), data.get('titel'),
data.get("beskrivelse"), data.get('beskrivelse', ''),
data.get("template_key"), data.get('type', 'ticket'),
data.get("status"), data.get('status', 'åben'),
data.get("customer_id"), data.get('customer_id'),
data.get("ansvarlig_bruger_id"), data.get('ansvarlig_bruger_id'),
data.get("created_by_user_id"), data.get('deadline'),
data.get("deadline"),
) )
result = execute_query(query, params) result = execute_query(query, params)
if not result: if result:
raise HTTPException(status_code=500, detail="Failed to create case.") logger.info("✅ Case created: %s", result[0]['id'])
return result[0] 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("/cases/{id}", response_model=dict) @router.get("/sag/{sag_id}")
async def get_case(id: int): async def get_sag(sag_id: int):
"""Retrieve a specific case by ID.""" """Get a specific case."""
try:
query = "SELECT * FROM sag_sager WHERE id = %s AND deleted_at IS NULL" query = "SELECT * FROM sag_sager WHERE id = %s AND deleted_at IS NULL"
result = execute_query(query, (id,)) result = execute_query(query, (sag_id,))
if not result: if not result:
raise HTTPException(status_code=404, detail="Case not found.") raise HTTPException(status_code=404, detail="Case not found")
return result[0] return result[0]
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error getting case: %s", e)
raise HTTPException(status_code=500, detail="Failed to get case")
@router.patch("/sag/{sag_id}")
async def update_sag(sag_id: int, updates: dict):
"""Update a case."""
try:
# 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")
# Build dynamic update query
allowed_fields = ['titel', 'beskrivelse', 'type', 'status', 'ansvarlig_bruger_id', 'deadline']
set_clauses = []
params = []
for field in allowed_fields:
if field in updates:
set_clauses.append(f"{field} = %s")
params.append(updates[field])
if not set_clauses:
raise HTTPException(status_code=400, detail="No valid fields to update")
params.append(sag_id)
query = f"UPDATE sag_sager SET {', '.join(set_clauses)} WHERE id = %s RETURNING *"
@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)) result = execute_query(query, tuple(params))
if not result: if result:
raise HTTPException(status_code=404, detail="Case not found or not updated.") logger.info("✅ Case updated: %s", sag_id)
return result[0] return result[0]
raise HTTPException(status_code=500, detail="Failed to update case")
@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 case_ids:
raise HTTPException(status_code=400, detail="case_ids list is required")
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: except HTTPException:
raise raise
except Exception as e: except Exception as e:
raise e logger.error("❌ Error updating case: %s", e)
raise HTTPException(status_code=500, detail="Failed to update case")
@router.delete("/sag/{sag_id}")
async def delete_sag(sag_id: int):
"""Soft-delete 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 = "UPDATE sag_sager SET deleted_at = NOW() WHERE id = %s RETURNING id"
result = execute_query(query, (sag_id,))
if result:
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: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error("❌ Error in bulk operations: %s", e) logger.error("❌ Error deleting case: %s", e)
raise HTTPException(status_code=500, detail=f"Bulk operation failed: {str(e)}") raise HTTPException(status_code=500, detail="Failed to delete case")
# ============================================================================ # ============================================================================
# CRUD Endpoints for Relations # RELATIONER - Case Relations
# ============================================================================ # ============================================================================
@router.get("/cases/{id}/relations", response_model=List[dict]) @router.get("/sag/{sag_id}/relationer")
async def list_relations(id: int): async def get_relationer(sag_id: int):
"""List all relations for a specific case.""" """Get all relations for a case."""
try:
# 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 = """ query = """
SELECT sr.*, ss_kilde.titel AS kilde_titel, ss_mål.titel AS mål_titel SELECT sr.*,
ss_kilde.titel as kilde_titel,
ss_mål.titel as mål_titel
FROM sag_relationer sr FROM sag_relationer sr
JOIN sag_sager ss_kilde ON sr.kilde_sag_id = ss_kilde.id 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 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 WHERE (sr.kilde_sag_id = %s OR sr.målsag_id = %s)
AND sr.deleted_at IS NULL
ORDER BY sr.created_at DESC ORDER BY sr.created_at DESC
""" """
return execute_query(query, (id, id)) result = execute_query(query, (sag_id, sag_id))
return result
except HTTPException:
raise
except Exception as e:
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")
@router.post("/cases/{id}/relations", response_model=dict)
async def create_relation(id: int, data: dict):
"""Create a new relation for a case."""
query = """ query = """
INSERT INTO sag_relationer (kilde_sag_id, målsag_id, relationstype) INSERT INTO sag_relationer (kilde_sag_id, målsag_id, relationstype)
VALUES (%s, %s, %s) VALUES (%s, %s, %s)
RETURNING * RETURNING *
""" """
params = ( result = execute_query(query, (sag_id, målsag_id, relationstype))
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:
state = data.get("state")
# 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'")
# 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")
# 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 *
"""
result = execute_query(query, (state, tag_id))
if result: if result:
logger.info("Tag %s state changed to '%s' for case %s", tag_id, state, id) logger.info("✅ Relation created: %s -> %s (%s)", sag_id, målsag_id, relationstype)
return result[0] return result[0]
raise HTTPException(status_code=500, detail="Failed to create relation")
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 creating relation: %s", e)
raise HTTPException(status_code=500, detail="Failed to update tag state") raise HTTPException(status_code=500, detail="Failed to create relation")
# ============================================================================
# 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}/relationer/{relation_id}")
async def delete_relation(sag_id: int, relation_id: int):
"""Soft-delete a relation."""
try: try:
query = """
INSERT INTO sag_kontakter (sag_id, contact_id, role)
VALUES (%s, %s, %s)
RETURNING *
"""
result = execute_query(query, (id, contact_id, role))
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")
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")
# ============================================================================
# SEARCH
# ============================================================================
@router.get("/search/cases")
async def search_cases(q: str):
"""Search for cases by title or description."""
try:
if not q or len(q) < 2:
return []
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
"""
search_term = f"%{q}%"
result = execute_query(query, (search_term, search_term))
return result
except Exception as e:
logger.error("❌ Error searching cases: %s", e)
raise HTTPException(status_code=500, detail="Failed to search cases")
# ============================================================================
# Hardware & Location Relations
# ============================================================================
@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.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( check = execute_query(
"SELECT id FROM hardware_case_relations WHERE case_id = %s AND hardware_id = %s AND deleted_at IS NULL", "SELECT id FROM sag_relationer WHERE id = %s AND deleted_at IS NULL AND (kilde_sag_id = %s OR målsag_id = %s)",
(id, hardware_id) (relation_id, sag_id, sag_id)
) )
if check: if not check:
# Already linked raise HTTPException(status_code=404, detail="Relation not found")
return {"message": "Already linked", "id": check[0]['id']}
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")
# ============================================================================
# TAGS - Case Tags
# ============================================================================
@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("/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 = """ query = """
INSERT INTO hardware_case_relations (case_id, hardware_id, relation_type) INSERT INTO sag_tags (sag_id, tag_navn)
VALUES (%s, %s, 'related') VALUES (%s, %s)
RETURNING * RETURNING *
""" """
result = execute_query(query, (id, hardware_id)) result = execute_query(query, (sag_id, data.get('tag_navn')))
return result[0] if result else {}
@router.delete("/cases/{id}/hardware/{hardware_id}", response_model=dict) if result:
async def remove_hardware_from_case(id: int, hardware_id: int): logger.info("✅ Tag added: %s -> %s", sag_id, data.get('tag_navn'))
"""Unlink hardware from case.""" return result[0]
query = """ raise HTTPException(status_code=500, detail="Failed to add tag")
UPDATE hardware_case_relations except HTTPException:
SET deleted_at = NOW() raise
WHERE case_id = %s AND hardware_id = %s AND deleted_at IS NULL except Exception as e:
RETURNING * logger.error("❌ Error adding tag: %s", e)
""" raise HTTPException(status_code=500, detail="Failed to add tag")
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]) @router.delete("/sag/{sag_id}/tags/{tag_id}")
async def get_case_locations(id: int): async def delete_tag(sag_id: int, tag_id: int):
"""Get locations related to this case.""" """Soft-delete a tag."""
query = """ try:
SELECT l.*, clr.relation_type as link_type, clr.created_at as link_created_at check = execute_query(
FROM locations_locations l "SELECT id FROM sag_tags WHERE id = %s AND sag_id = %s AND deleted_at IS NULL",
JOIN case_location_relations clr ON clr.location_id = l.id (tag_id, sag_id)
WHERE clr.case_id = %s AND clr.deleted_at IS NULL AND l.deleted_at IS NULL )
ORDER BY clr.created_at DESC if not check:
""" raise HTTPException(status_code=404, detail="Tag not found")
return execute_query(query, (id,)) or []
@router.post("/cases/{id}/locations", response_model=dict) query = "UPDATE sag_tags SET deleted_at = NOW() WHERE id = %s RETURNING id"
async def add_location_to_case(id: int, data: dict): result = execute_query(query, (tag_id,))
"""Link location to case."""
location_id = data.get("location_id")
if not location_id:
raise HTTPException(status_code=400, detail="location_id required")
query = """ if result:
INSERT INTO case_location_relations (case_id, location_id, relation_type) logger.info("✅ Tag soft-deleted: %s", tag_id)
VALUES (%s, %s, 'related') return {"status": "deleted", "id": tag_id}
ON CONFLICT (case_id, location_id) DO UPDATE SET deleted_at = NULL raise HTTPException(status_code=500, detail="Failed to delete tag")
RETURNING * except HTTPException:
""" raise
result = execute_query(query, (id, location_id)) except Exception as e:
return result[0] if result else {} logger.error("❌ Error deleting tag: %s", e)
raise HTTPException(status_code=500, detail="Failed to delete tag")
@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,12 +8,19 @@ 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."""
try:
query = "SELECT * FROM sag_sager WHERE deleted_at IS NULL" query = "SELECT * FROM sag_sager WHERE deleted_at IS NULL"
params = [] params = []
@ -25,62 +32,54 @@ async def case_list(request: Request, status: str = Query(None), tag: str = Quer
params.append(customer_id) params.append(customer_id)
query += " ORDER BY created_at DESC" query += " ORDER BY created_at DESC"
cases = execute_query(query, tuple(params)) sager = 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 # Filter by tag if provided
if tag and cases: if tag and sager:
case_ids = [case['id'] for case in cases] 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" tag_query = "SELECT sag_id FROM sag_tags WHERE tag_navn = %s AND deleted_at IS NULL"
tagged = execute_query(tag_query, (tag,)) tagged = execute_query(tag_query, (tag,))
tagged_ids = set(t['sag_id'] for t in tagged) tagged_ids = set(t['sag_id'] for t in tagged)
cases = [case for case in cases if case['id'] in tagged_ids] sager = [s for s in sager if s['id'] in tagged_ids]
return templates.TemplateResponse("modules/sag/templates/index.html", { # 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, "request": request,
"sager": cases, "sager": sager,
"statuses": statuses, "statuses": [s['status'] for s in statuses],
"all_tags": all_tags, "all_tags": [t['tag_navn'] for t in all_tags],
"current_status": status, "current_status": status,
"current_tag": tag "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")
@router.get("/cases/new", response_class=HTMLResponse) @router.get("/sag/{sag_id}", response_class=HTMLResponse)
async def create_case_form_cases(request: Request): async def sag_detaljer(request, sag_id: int):
"""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"
sag_result = execute_query(sag_query, (sag_id,))
if not case_result: if not sag_result:
return HTMLResponse(content="<h1>Case not found</h1>", status_code=404) raise HTTPException(status_code=404, detail="Case not found")
case = case_result[0] sag = sag_result[0]
# Fetch tags # Fetch tags
tags_query = "SELECT * FROM sag_tags WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC" 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,)) tags = execute_query(tags_query, (sag_id,))
# Fetch relations # Fetch relations
relations_query = """ relationer_query = """
SELECT sr.*, SELECT sr.*,
ss_kilde.titel AS kilde_titel, ss_kilde.titel as kilde_titel,
ss_mål.titel AS mål_titel ss_mål.titel as mål_titel
FROM sag_relationer sr FROM sag_relationer sr
JOIN sag_sager ss_kilde ON sr.kilde_sag_id = ss_kilde.id 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 JOIN sag_sager ss_mål ON sr.målsag_id = ss_mål.id
@ -88,49 +87,25 @@ async def case_details(request: Request, case_id: int):
AND sr.deleted_at IS NULL AND sr.deleted_at IS NULL
ORDER BY sr.created_at DESC ORDER BY sr.created_at DESC
""" """
relations = execute_query(relations_query, (case_id, case_id)) relationer = execute_query(relationer_query, (sag_id, sag_id))
# Fetch linked contacts # Fetch customer info if customer_id exists
contacts_query = """ customer = None
SELECT sk.*, CONCAT(c.first_name, ' ', c.last_name) as contact_name, c.email as contact_email if sag.get('customer_id'):
FROM sag_kontakter sk customer_query = "SELECT * FROM customers WHERE id = %s"
LEFT JOIN contacts c ON sk.contact_id = c.id customer_result = execute_query(customer_query, (sag['customer_id'],))
WHERE sk.sag_id = %s AND sk.deleted_at IS NULL if customer_result:
ORDER BY sk.created_at DESC customer = customer_result[0]
"""
contacts = execute_query(contacts_query, (case_id,))
# Fetch linked customers return templates.TemplateResponse("detail.html", {
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, "request": request,
"case": case, "sag": sag,
"customer": customer,
"tags": tags, "tags": tags,
"relations": relations, "relationer": relationer,
"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
}) })
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")

View File

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