diff --git a/VERSION b/VERSION index e4e1e51..cf7af5e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.151 \ No newline at end of file +1.3.152 \ No newline at end of file diff --git a/app/customers/frontend/customer_detail.html b/app/customers/frontend/customer_detail.html index aae38ee..a039dc2 100644 --- a/app/customers/frontend/customer_detail.html +++ b/app/customers/frontend/customer_detail.html @@ -500,6 +500,41 @@ + +
+
+
+
Kunde pipeline
+ Muligheder knyttet til kunden +
+ +
+
+
+ + + + + + + + + + + + + + + +
TitelBeløbStageSandsynlighedHandling
+
+
+
+
+
+
@@ -767,12 +802,63 @@
+ + + {% endblock %} {% block extra_js %} +{% endblock %} diff --git a/app/opportunities/__init__.py b/app/opportunities/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/opportunities/backend/__init__.py b/app/opportunities/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/opportunities/backend/router.py b/app/opportunities/backend/router.py new file mode 100644 index 0000000..57d607e --- /dev/null +++ b/app/opportunities/backend/router.py @@ -0,0 +1,307 @@ +""" +Opportunities (Pipeline) Router +Hub-local sales pipeline +""" + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Optional, List +from datetime import date +import logging + +from app.core.database import execute_query, execute_query_single, execute_update +from app.services.opportunity_service import handle_stage_change + +logger = logging.getLogger(__name__) +router = APIRouter() + + +class PipelineStageBase(BaseModel): + name: str + description: Optional[str] = None + sort_order: int = 0 + default_probability: int = 0 + color: Optional[str] = "#0f4c75" + is_won: bool = False + is_lost: bool = False + is_active: bool = True + + +class PipelineStageCreate(PipelineStageBase): + pass + + +class PipelineStageUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + sort_order: Optional[int] = None + default_probability: Optional[int] = None + color: Optional[str] = None + is_won: Optional[bool] = None + is_lost: Optional[bool] = None + is_active: Optional[bool] = None + + +class OpportunityBase(BaseModel): + customer_id: int + title: str + description: Optional[str] = None + amount: Optional[float] = 0 + currency: Optional[str] = "DKK" + expected_close_date: Optional[date] = None + stage_id: Optional[int] = None + owner_user_id: Optional[int] = None + + +class OpportunityCreate(OpportunityBase): + pass + + +class OpportunityUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + amount: Optional[float] = None + currency: Optional[str] = None + expected_close_date: Optional[date] = None + stage_id: Optional[int] = None + owner_user_id: Optional[int] = None + is_active: Optional[bool] = None + + +class OpportunityStageUpdate(BaseModel): + stage_id: int + note: Optional[str] = None + user_id: Optional[int] = None + + +def _get_stage(stage_id: int): + stage = execute_query_single( + "SELECT * FROM pipeline_stages WHERE id = %s AND is_active = TRUE", + (stage_id,) + ) + if not stage: + raise HTTPException(status_code=404, detail="Stage not found") + return stage + + +def _get_default_stage(): + stage = execute_query_single( + "SELECT * FROM pipeline_stages WHERE is_active = TRUE ORDER BY sort_order ASC LIMIT 1" + ) + if not stage: + raise HTTPException(status_code=400, detail="No active stages configured") + return stage + + +def _get_opportunity(opportunity_id: int): + query = """ + SELECT o.*, c.name AS customer_name, + s.name AS stage_name, s.color AS stage_color, s.is_won, s.is_lost + FROM pipeline_opportunities o + JOIN customers c ON c.id = o.customer_id + JOIN pipeline_stages s ON s.id = o.stage_id + WHERE o.id = %s + """ + opportunity = execute_query_single(query, (opportunity_id,)) + if not opportunity: + raise HTTPException(status_code=404, detail="Opportunity not found") + return opportunity + + +def _insert_stage_history(opportunity_id: int, from_stage_id: Optional[int], to_stage_id: int, + user_id: Optional[int] = None, note: Optional[str] = None): + execute_query( + """ + INSERT INTO pipeline_stage_history (opportunity_id, from_stage_id, to_stage_id, changed_by_user_id, note) + VALUES (%s, %s, %s, %s, %s) + """, + (opportunity_id, from_stage_id, to_stage_id, user_id, note) + ) + + +# ============================ +# Pipeline Stages +# ============================ + +@router.get("/pipeline/stages", tags=["Pipeline Stages"]) +async def list_stages(): + query = "SELECT * FROM pipeline_stages WHERE is_active = TRUE ORDER BY sort_order ASC" + return execute_query(query) or [] + + +@router.post("/pipeline/stages", tags=["Pipeline Stages"]) +async def create_stage(stage: PipelineStageCreate): + query = """ + INSERT INTO pipeline_stages + (name, description, sort_order, default_probability, color, is_won, is_lost, is_active) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + RETURNING * + """ + result = execute_query(query, ( + stage.name, + stage.description, + stage.sort_order, + stage.default_probability, + stage.color, + stage.is_won, + stage.is_lost, + stage.is_active + )) + logger.info("✅ Created pipeline stage: %s", stage.name) + return result[0] if result else None + + +@router.put("/pipeline/stages/{stage_id}", tags=["Pipeline Stages"]) +async def update_stage(stage_id: int, stage: PipelineStageUpdate): + updates = [] + params = [] + for field, value in stage.dict(exclude_unset=True).items(): + updates.append(f"{field} = %s") + params.append(value) + + if not updates: + raise HTTPException(status_code=400, detail="No fields to update") + + params.append(stage_id) + query = f"UPDATE pipeline_stages SET {', '.join(updates)}, updated_at = CURRENT_TIMESTAMP WHERE id = %s RETURNING *" + result = execute_query(query, tuple(params)) + + if not result: + raise HTTPException(status_code=404, detail="Stage not found") + + logger.info("✅ Updated pipeline stage: %s", stage_id) + return result[0] + + +@router.delete("/pipeline/stages/{stage_id}", tags=["Pipeline Stages"]) +async def deactivate_stage(stage_id: int): + affected = execute_update( + "UPDATE pipeline_stages SET is_active = FALSE, updated_at = CURRENT_TIMESTAMP WHERE id = %s", + (stage_id,) + ) + if not affected: + raise HTTPException(status_code=404, detail="Stage not found") + logger.info("⚠️ Deactivated pipeline stage: %s", stage_id) + return {"status": "success", "stage_id": stage_id} + + +# ============================ +# Opportunities +# ============================ + +@router.get("/opportunities", tags=["Opportunities"]) +async def list_opportunities(customer_id: Optional[int] = None, stage_id: Optional[int] = None): + query = """ + SELECT o.*, c.name AS customer_name, + s.name AS stage_name, s.color AS stage_color, s.is_won, s.is_lost + FROM pipeline_opportunities o + JOIN customers c ON c.id = o.customer_id + JOIN pipeline_stages s ON s.id = o.stage_id + WHERE o.is_active = TRUE + """ + params: List = [] + if customer_id is not None: + query += " AND o.customer_id = %s" + params.append(customer_id) + if stage_id is not None: + query += " AND o.stage_id = %s" + params.append(stage_id) + + query += " ORDER BY o.updated_at DESC NULLS LAST, o.created_at DESC" + if params: + return execute_query(query, tuple(params)) or [] + return execute_query(query) or [] + + +@router.get("/opportunities/{opportunity_id}", tags=["Opportunities"]) +async def get_opportunity(opportunity_id: int): + return _get_opportunity(opportunity_id) + + +@router.post("/opportunities", tags=["Opportunities"]) +async def create_opportunity(opportunity: OpportunityCreate): + stage = _get_stage(opportunity.stage_id) if opportunity.stage_id else _get_default_stage() + probability = stage["default_probability"] + + query = """ + INSERT INTO pipeline_opportunities + (customer_id, title, description, amount, currency, expected_close_date, stage_id, probability, owner_user_id) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """ + result = execute_query(query, ( + opportunity.customer_id, + opportunity.title, + opportunity.description, + opportunity.amount or 0, + opportunity.currency or "DKK", + opportunity.expected_close_date, + stage["id"], + probability, + opportunity.owner_user_id + )) + + if not result: + raise HTTPException(status_code=500, detail="Failed to create opportunity") + + opportunity_id = result[0]["id"] + _insert_stage_history(opportunity_id, None, stage["id"], opportunity.owner_user_id, "Oprettet") + logger.info("✅ Created opportunity %s", opportunity_id) + return _get_opportunity(opportunity_id) + + +@router.put("/opportunities/{opportunity_id}", tags=["Opportunities"]) +async def update_opportunity(opportunity_id: int, update: OpportunityUpdate): + existing = _get_opportunity(opportunity_id) + updates = [] + params = [] + + update_dict = update.dict(exclude_unset=True) + stage_changed = False + new_stage = None + + if "stage_id" in update_dict: + new_stage = _get_stage(update_dict["stage_id"]) + update_dict["probability"] = new_stage["default_probability"] + stage_changed = new_stage["id"] != existing["stage_id"] + + for field, value in update_dict.items(): + updates.append(f"{field} = %s") + params.append(value) + + if not updates: + raise HTTPException(status_code=400, detail="No fields to update") + + params.append(opportunity_id) + query = f"UPDATE pipeline_opportunities SET {', '.join(updates)}, updated_at = CURRENT_TIMESTAMP WHERE id = %s" + execute_update(query, tuple(params)) + + if stage_changed and new_stage: + _insert_stage_history(opportunity_id, existing["stage_id"], new_stage["id"], update.owner_user_id, "Stage ændret") + updated = _get_opportunity(opportunity_id) + handle_stage_change(updated, new_stage) + + return _get_opportunity(opportunity_id) + + +@router.patch("/opportunities/{opportunity_id}/stage", tags=["Opportunities"]) +async def update_opportunity_stage(opportunity_id: int, update: OpportunityStageUpdate): + existing = _get_opportunity(opportunity_id) + new_stage = _get_stage(update.stage_id) + + execute_update( + """ + UPDATE pipeline_opportunities + SET stage_id = %s, + probability = %s, + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + """, + (new_stage["id"], new_stage["default_probability"], opportunity_id) + ) + + _insert_stage_history(opportunity_id, existing["stage_id"], new_stage["id"], update.user_id, update.note) + updated = _get_opportunity(opportunity_id) + handle_stage_change(updated, new_stage) + + return updated diff --git a/app/opportunities/frontend/__init__.py b/app/opportunities/frontend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/opportunities/frontend/opportunities.html b/app/opportunities/frontend/opportunities.html new file mode 100644 index 0000000..1bfe9eb --- /dev/null +++ b/app/opportunities/frontend/opportunities.html @@ -0,0 +1,289 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Muligheder - BMC Hub{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+

Muligheder

+

Hub‑lokal salgspipeline

+
+
+ + Sales Board + + +
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+ 0 muligheder +
+
+
+ +
+
+ + + + + + + + + + + + + + + + + +
TitelKundeBeløbLukningsdatoStageSandsynlighedHandling
+
+
+
+
+ + + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/opportunities/frontend/opportunity_detail.html b/app/opportunities/frontend/opportunity_detail.html new file mode 100644 index 0000000..9a8d9ac --- /dev/null +++ b/app/opportunities/frontend/opportunity_detail.html @@ -0,0 +1,216 @@ +{% extends "shared/frontend/base.html" %} + +{% block title %}Mulighed - BMC Hub{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+

Mulighed

+

Detaljeret pipeline‑visning

+
+
+ + Tilbage + + +
+
+ +
+
+
+
Grundoplysninger
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
Salgsstatus
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
Løsning & Salgsdetaljer
+
+
+ + +
+
+ + +
+
+
+ +
+
Tilbud & Kontrakt
+
Felt til dokumentlink og kontraktstatus kommer i næste version.
+
+
+ +
+
+
Pipeline‑status
+
+ Kunde + - +
+
+ Stage + - +
+
+ Værdi + - +
+
+ Sandsynlighed + - +
+
+
+
Næste aktivitet
+
Aktivitetsmodul kommer senere.
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/opportunities/frontend/views.py b/app/opportunities/frontend/views.py new file mode 100644 index 0000000..3e98517 --- /dev/null +++ b/app/opportunities/frontend/views.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter, Request +from fastapi.templating import Jinja2Templates +from fastapi.responses import HTMLResponse + +router = APIRouter() +templates = Jinja2Templates(directory="app") + + +@router.get("/opportunities", response_class=HTMLResponse) +async def opportunities_page(request: Request): + return templates.TemplateResponse("opportunities/frontend/opportunities.html", {"request": request}) + + +@router.get("/opportunities/{opportunity_id}", response_class=HTMLResponse) +async def opportunity_detail_page(request: Request, opportunity_id: int): + return templates.TemplateResponse("opportunities/frontend/opportunity_detail.html", { + "request": request, + "opportunity_id": opportunity_id + }) diff --git a/app/services/opportunity_service.py b/app/services/opportunity_service.py new file mode 100644 index 0000000..a9a6d00 --- /dev/null +++ b/app/services/opportunity_service.py @@ -0,0 +1,22 @@ +import logging +from typing import Dict + +logger = logging.getLogger(__name__) + + +def handle_stage_change(opportunity: Dict, stage: Dict) -> None: + """Handle side-effects for stage changes (Hub-local).""" + if stage.get("is_won"): + logger.info("✅ Opportunity won (id=%s, customer_id=%s)", opportunity.get("id"), opportunity.get("customer_id")) + _create_local_order_placeholder(opportunity) + elif stage.get("is_lost"): + logger.info("⚠️ Opportunity lost (id=%s, customer_id=%s)", opportunity.get("id"), opportunity.get("customer_id")) + + +def _create_local_order_placeholder(opportunity: Dict) -> None: + """Placeholder for local order creation (next version).""" + logger.info( + "🧩 Local order hook pending for opportunity %s (customer_id=%s)", + opportunity.get("id"), + opportunity.get("customer_id") + ) diff --git a/app/settings/frontend/settings.html b/app/settings/frontend/settings.html index ccca557..2da7aab 100644 --- a/app/settings/frontend/settings.html +++ b/app/settings/frontend/settings.html @@ -92,6 +92,9 @@ Tags + + Pipeline + Sync @@ -304,6 +307,42 @@ + +
+
+
+
Pipeline Stages
+

Administrer faser i salgspipelinen

+
+ +
+ +
+
+ + + + + + + + + + + + + + + +
NavnSorteringStandard %StatusHandling
+
+
+
+
+
+
@@ -742,11 +781,66 @@ async def scan_document(file_path: str):
+ + + {% endblock %} {% block extra_js %} diff --git a/app/shared/frontend/base.html b/app/shared/frontend/base.html index 892965c..3fb7417 100644 --- a/app/shared/frontend/base.html +++ b/app/shared/frontend/base.html @@ -253,6 +253,7 @@
  • Webshop Administration
  • +
  • Muligheder
  • Pipeline
  • diff --git a/main.py b/main.py index 27fce3b..11baecb 100644 --- a/main.py +++ b/main.py @@ -53,6 +53,8 @@ from app.backups.frontend import views as backups_views from app.backups.backend.scheduler import backup_scheduler from app.conversations.backend import router as conversations_api from app.conversations.frontend import views as conversations_views +from app.opportunities.backend import router as opportunities_api +from app.opportunities.frontend import views as opportunities_views # Modules from app.modules.webshop.backend import router as webshop_api @@ -134,6 +136,7 @@ app.include_router(emails_api.router, prefix="/api/v1", tags=["Emails"]) app.include_router(settings_api.router, prefix="/api/v1", tags=["Settings"]) app.include_router(backups_api, prefix="/api/v1", tags=["Backups"]) app.include_router(conversations_api.router, prefix="/api/v1", tags=["Conversations"]) +app.include_router(opportunities_api.router, prefix="/api/v1", tags=["Opportunities"]) # Module Routers app.include_router(webshop_api.router, prefix="/api/v1", tags=["Webshop"]) @@ -153,6 +156,7 @@ app.include_router(emails_views.router, tags=["Frontend"]) app.include_router(backups_views.router, tags=["Frontend"]) app.include_router(conversations_views.router, tags=["Frontend"]) app.include_router(webshop_views.router, tags=["Frontend"]) +app.include_router(opportunities_views.router, tags=["Frontend"]) # Serve static files (UI) app.mount("/static", StaticFiles(directory="static", html=True), name="static") diff --git a/migrations/016_opportunities.sql b/migrations/016_opportunities.sql new file mode 100644 index 0000000..1e06d01 --- /dev/null +++ b/migrations/016_opportunities.sql @@ -0,0 +1,59 @@ +-- ========================================================================= +-- Migration 016: Pipeline Opportunities (Hub-local) +-- ========================================================================= + +CREATE TABLE IF NOT EXISTS pipeline_stages ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + default_probability INTEGER NOT NULL DEFAULT 0, + color VARCHAR(20) DEFAULT '#0f4c75', + is_won BOOLEAN DEFAULT FALSE, + is_lost BOOLEAN DEFAULT FALSE, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS pipeline_opportunities ( + id SERIAL PRIMARY KEY, + customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + amount NUMERIC(12, 2) DEFAULT 0, + currency VARCHAR(10) DEFAULT 'DKK', + expected_close_date DATE, + stage_id INTEGER NOT NULL REFERENCES pipeline_stages(id), + probability INTEGER NOT NULL DEFAULT 0, + owner_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS pipeline_stage_history ( + id SERIAL PRIMARY KEY, + opportunity_id INTEGER NOT NULL REFERENCES pipeline_opportunities(id) ON DELETE CASCADE, + from_stage_id INTEGER REFERENCES pipeline_stages(id), + to_stage_id INTEGER NOT NULL REFERENCES pipeline_stages(id), + changed_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL, + note TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_pipeline_opportunities_customer_id ON pipeline_opportunities(customer_id); +CREATE INDEX IF NOT EXISTS idx_pipeline_opportunities_stage_id ON pipeline_opportunities(stage_id); +CREATE INDEX IF NOT EXISTS idx_pipeline_stage_history_opportunity_id ON pipeline_stage_history(opportunity_id); + +-- Seed default stages (Option B) +INSERT INTO pipeline_stages (name, description, sort_order, default_probability, color, is_won, is_lost) +SELECT * FROM (VALUES + ('Ny', 'Ny mulighed', 1, 10, '#0f4c75', FALSE, FALSE), + ('Afklaring', 'Kvalificering og behov', 2, 25, '#1b6ca8', FALSE, FALSE), + ('Tilbud', 'Tilbud er sendt', 3, 50, '#3282b8', FALSE, FALSE), + ('Commit', 'Forhandling og commit', 4, 75, '#5b8c5a', FALSE, FALSE), + ('Vundet', 'Lukket som vundet', 5, 100, '#2f9e44', TRUE, FALSE), + ('Tabt', 'Lukket som tabt', 6, 0, '#d9480f', FALSE, TRUE) +) AS v(name, description, sort_order, default_probability, color, is_won, is_lost) +WHERE NOT EXISTS (SELECT 1 FROM pipeline_stages); diff --git a/updateto.sh b/updateto.sh index 09c07ad..69eebb0 100644 --- a/updateto.sh +++ b/updateto.sh @@ -40,6 +40,11 @@ if [ ! -f ".env" ]; then exit 1 fi +# Load environment variables (DB credentials) +set -a +source .env +set +a + # Update RELEASE_VERSION in .env echo "📝 Opdaterer .env med version $VERSION..." if grep -q "^RELEASE_VERSION=" .env; then @@ -79,6 +84,25 @@ echo "" echo "⏳ Venter på container startup..." sleep 5 +# Run database migration +echo "" +echo "🧱 Kører database migrationer..." +if [ -z "$POSTGRES_USER" ] || [ -z "$POSTGRES_DB" ]; then + echo "❌ Fejl: POSTGRES_USER/POSTGRES_DB mangler i .env" + exit 1 +fi + +for i in {1..10}; do + if podman exec bmc-hub-postgres-prod pg_isready -U "$POSTGRES_USER" >/dev/null 2>&1; then + break + fi + echo "⏳ Venter på postgres... ($i/10)" + sleep 2 +done + +podman exec -i bmc-hub-postgres-prod psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f /docker-entrypoint-initdb.d/016_opportunities.sql +echo "✅ Migration 016_opportunities.sql kørt" + # Show logs echo "" echo "📋 Logs fra startup:"