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:
parent
fe2110891f
commit
464c27808c
@ -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
|
|
||||||
|
|||||||
@ -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"}
|
|
||||||
|
|||||||
@ -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
|
|
||||||
})
|
|
||||||
|
|||||||
@ -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 %}
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user