2026-01-28 07:48:10 +01:00
|
|
|
"""
|
|
|
|
|
Opportunities (Pipeline) Router
|
2026-02-15 11:12:58 +01:00
|
|
|
Re-implemented to use the standard 'Sag' (Case) model.
|
|
|
|
|
Each 'Opportunity' is now a Case with template_key='pipeline'.
|
2026-01-28 07:48:10 +01:00
|
|
|
"""
|
|
|
|
|
|
2026-02-15 11:12:58 +01:00
|
|
|
from fastapi import APIRouter, HTTPException, Request, Body, Query
|
|
|
|
|
from app.core.database import execute_query
|
2026-01-28 07:48:10 +01:00
|
|
|
import logging
|
2026-02-15 11:12:58 +01:00
|
|
|
from typing import Optional, List, Dict, Any
|
2026-01-28 07:48:10 +01:00
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
2026-02-15 11:12:58 +01:00
|
|
|
@router.get("/opportunities", tags=["Opportunities"])
|
|
|
|
|
async def list_opportunities(
|
|
|
|
|
q: Optional[str] = None,
|
|
|
|
|
stage: Optional[str] = None,
|
|
|
|
|
status: Optional[str] = None
|
|
|
|
|
):
|
|
|
|
|
"""
|
|
|
|
|
List all 'pipeline' cases.
|
|
|
|
|
Criteria:
|
|
|
|
|
- template_key = 'pipeline'
|
|
|
|
|
- OR has tag 'pipeline'
|
|
|
|
|
"""
|
|
|
|
|
query = """
|
|
|
|
|
SELECT
|
|
|
|
|
s.id,
|
|
|
|
|
s.titel,
|
|
|
|
|
s.status,
|
|
|
|
|
s.pipeline_amount,
|
|
|
|
|
s.pipeline_probability,
|
|
|
|
|
s.pipeline_stage_id,
|
|
|
|
|
ps.name AS pipeline_stage,
|
|
|
|
|
s.created_at,
|
|
|
|
|
s.deadline,
|
|
|
|
|
s.beskrivelse,
|
|
|
|
|
s.customer_id,
|
|
|
|
|
COALESCE(c.name, 'Ukendt kunde') as customer_name,
|
|
|
|
|
s.ansvarlig_bruger_id,
|
2026-02-17 08:29:05 +01:00
|
|
|
COALESCE(u.full_name, u.username, 'Ingen') as ansvarlig_navn
|
2026-02-15 11:12:58 +01:00
|
|
|
FROM sag_sager s
|
|
|
|
|
LEFT JOIN customers c ON s.customer_id = c.id
|
2026-02-17 08:29:05 +01:00
|
|
|
LEFT JOIN users u ON s.ansvarlig_bruger_id = u.user_id
|
2026-02-15 11:12:58 +01:00
|
|
|
LEFT JOIN pipeline_stages ps ON ps.id = s.pipeline_stage_id
|
|
|
|
|
WHERE s.deleted_at IS NULL
|
|
|
|
|
AND (
|
|
|
|
|
s.template_key = 'pipeline'
|
|
|
|
|
OR EXISTS (
|
2026-02-17 08:29:05 +01:00
|
|
|
SELECT 1
|
|
|
|
|
FROM entity_tags et
|
|
|
|
|
JOIN tags t ON t.id = et.tag_id
|
|
|
|
|
WHERE et.entity_type = 'case'
|
|
|
|
|
AND et.entity_id = s.id
|
|
|
|
|
AND LOWER(t.name) = 'pipeline'
|
|
|
|
|
)
|
|
|
|
|
OR EXISTS (
|
|
|
|
|
SELECT 1
|
|
|
|
|
FROM sag_tags st
|
|
|
|
|
WHERE st.sag_id = s.id
|
|
|
|
|
AND st.deleted_at IS NULL
|
|
|
|
|
AND LOWER(st.tag_navn) = 'pipeline'
|
2026-01-29 00:36:32 +01:00
|
|
|
)
|
2026-02-15 11:12:58 +01:00
|
|
|
)
|
|
|
|
|
"""
|
2026-01-29 00:36:32 +01:00
|
|
|
|
2026-02-15 11:12:58 +01:00
|
|
|
params = []
|
2026-01-29 00:36:32 +01:00
|
|
|
|
2026-02-15 11:12:58 +01:00
|
|
|
if q:
|
|
|
|
|
query += " AND (s.titel ILIKE %s OR c.name ILIKE %s)"
|
|
|
|
|
params.extend([f"%{q}%", f"%{q}%"])
|
2026-01-29 00:36:32 +01:00
|
|
|
|
2026-02-15 11:12:58 +01:00
|
|
|
if status and status != 'all':
|
|
|
|
|
if status == 'open':
|
|
|
|
|
query += " AND s.status = 'åben'"
|
|
|
|
|
elif status in ('won', 'lost', 'lukket'):
|
|
|
|
|
query += " AND s.status = 'lukket'"
|
|
|
|
|
|
|
|
|
|
if stage and stage != 'all':
|
|
|
|
|
query += " AND LOWER(COALESCE(ps.name, '')) = LOWER(%s)"
|
|
|
|
|
params.append(stage)
|
|
|
|
|
|
|
|
|
|
query += " ORDER BY s.created_at DESC"
|
2026-01-29 00:36:32 +01:00
|
|
|
|
|
|
|
|
try:
|
2026-02-15 11:12:58 +01:00
|
|
|
results = execute_query(query, tuple(params))
|
2026-01-29 00:36:32 +01:00
|
|
|
|
2026-02-15 11:12:58 +01:00
|
|
|
# Transform to match frontend expectations somewhat, or just return as cases
|
|
|
|
|
# We'll return as cases but add 'amount' and 'probability' as nulls if frontend expects them
|
|
|
|
|
# Actually better to update frontend to not expect them.
|
|
|
|
|
return results
|
|
|
|
|
|
2026-01-29 00:36:32 +01:00
|
|
|
except Exception as e:
|
2026-02-15 11:12:58 +01:00
|
|
|
logger.error(f"Failed to list opportunities: {e}")
|
|
|
|
|
# Return empty list on error instead of 500 to keep UI responsive
|
|
|
|
|
return []
|
2026-01-28 07:48:10 +01:00
|
|
|
|
2026-02-15 11:12:58 +01:00
|
|
|
@router.post("/opportunities", tags=["Opportunities"])
|
|
|
|
|
async def create_opportunity(
|
|
|
|
|
payload: Dict[str, Any] = Body(...)
|
|
|
|
|
):
|
2026-01-29 00:36:32 +01:00
|
|
|
"""
|
2026-02-15 11:12:58 +01:00
|
|
|
Create a new 'pipeline' case.
|
2026-01-29 00:36:32 +01:00
|
|
|
"""
|
2026-01-29 23:07:33 +01:00
|
|
|
try:
|
2026-02-15 11:12:58 +01:00
|
|
|
title = payload.get("title")
|
|
|
|
|
customer_id = payload.get("customer_id")
|
|
|
|
|
description = payload.get("description", "")
|
|
|
|
|
# Map frontend 'expected_close_date' to 'deadline'
|
|
|
|
|
deadline = payload.get("expected_close_date") or None
|
|
|
|
|
if deadline == "":
|
|
|
|
|
deadline = None
|
|
|
|
|
|
|
|
|
|
if not title:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Mangler titel")
|
|
|
|
|
if not customer_id:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Mangler kunde")
|
|
|
|
|
|
|
|
|
|
# Insert as a Case with template_key='pipeline'
|
|
|
|
|
query = """
|
|
|
|
|
INSERT INTO sag_sager (
|
|
|
|
|
titel,
|
|
|
|
|
beskrivelse,
|
|
|
|
|
customer_id,
|
|
|
|
|
status,
|
|
|
|
|
template_key,
|
|
|
|
|
deadline,
|
|
|
|
|
created_by_user_id
|
|
|
|
|
) VALUES (
|
|
|
|
|
%s, %s, %s, 'åben', 'pipeline', %s, 1
|
|
|
|
|
) RETURNING id, titel
|
2026-01-28 07:48:10 +01:00
|
|
|
"""
|
2026-02-15 11:12:58 +01:00
|
|
|
|
|
|
|
|
result = execute_query(query, (title, description, customer_id, deadline))
|
|
|
|
|
|
|
|
|
|
if result:
|
|
|
|
|
new_case = result[0]
|
|
|
|
|
return new_case
|
2026-01-29 00:36:32 +01:00
|
|
|
else:
|
2026-02-15 11:12:58 +01:00
|
|
|
raise HTTPException(status_code=500, detail="Kunne ikke oprette pipeline sag")
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Failed to create opportunity case: {e}")
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
2026-01-29 00:36:32 +01:00
|
|
|
|
2026-02-15 11:12:58 +01:00
|
|
|
@router.get("/pipeline/stages", tags=["Opportunities"])
|
|
|
|
|
async def list_pipeline_stages():
|
2026-02-17 08:29:05 +01:00
|
|
|
"""List available pipeline stages from DB with a safe static fallback."""
|
|
|
|
|
try:
|
|
|
|
|
stages = execute_query(
|
|
|
|
|
"""
|
|
|
|
|
SELECT id, name, color, sort_order
|
|
|
|
|
FROM pipeline_stages
|
|
|
|
|
WHERE COALESCE(is_active, TRUE) = TRUE
|
|
|
|
|
ORDER BY sort_order ASC, id ASC
|
|
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
if stages:
|
|
|
|
|
return stages
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning("Could not load pipeline stages from DB: %s", e)
|
|
|
|
|
|
2026-01-29 00:36:32 +01:00
|
|
|
return [
|
2026-02-17 08:29:05 +01:00
|
|
|
{"id": 1, "name": "Lead", "color": "#6c757d", "sort_order": 10},
|
|
|
|
|
{"id": 2, "name": "Kontakt", "color": "#17a2b8", "sort_order": 20},
|
|
|
|
|
{"id": 3, "name": "Tilbud", "color": "#ffc107", "sort_order": 30},
|
|
|
|
|
{"id": 4, "name": "Forhandling", "color": "#fd7e14", "sort_order": 40},
|
|
|
|
|
{"id": 5, "name": "Vundet", "color": "#28a745", "sort_order": 50},
|
|
|
|
|
{"id": 6, "name": "Tabt", "color": "#dc3545", "sort_order": 60},
|
2026-01-29 00:36:32 +01:00
|
|
|
]
|