From 25168108d6b583b55563c097c809788fbdc9da44 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 29 Jan 2026 23:07:33 +0100 Subject: [PATCH] feat(sag): Initialize case management module with CRUD operations, relations, and tags - Added backend API routes for case management including listing, creating, updating, and deleting cases. - Implemented relations and tags functionality for cases. - Created frontend views for displaying case lists and details with filtering options. - Added database migration scripts to set up necessary tables and indexes. - Included HTML templates for case listing and detail views with responsive design. - Configured module metadata in module.json for integration. --- SAG_MODULE_PLAN.md | 492 ++++++++++++++++++ app/modules/sag/README.md | 177 +++++++ app/modules/sag/backend/__init__.py | 1 + app/modules/sag/backend/router.py | 321 ++++++++++++ app/modules/sag/frontend/__init__.py | 1 + app/modules/sag/frontend/views.py | 111 ++++ app/modules/sag/migrations/001_init.sql | 62 +++ app/modules/sag/module.json | 17 + app/modules/sag/templates/detail.html | 236 +++++++++ app/modules/sag/templates/index.html | 350 +++++++++++++ app/opportunities/backend/router.py | 64 ++- .../frontend/opportunity_detail.html | 122 ++++- docker-compose.prod.yml | 16 +- updateto.sh | 65 ++- 14 files changed, 1993 insertions(+), 42 deletions(-) create mode 100644 SAG_MODULE_PLAN.md create mode 100644 app/modules/sag/README.md create mode 100644 app/modules/sag/backend/__init__.py create mode 100644 app/modules/sag/backend/router.py create mode 100644 app/modules/sag/frontend/__init__.py create mode 100644 app/modules/sag/frontend/views.py create mode 100644 app/modules/sag/migrations/001_init.sql create mode 100644 app/modules/sag/module.json create mode 100644 app/modules/sag/templates/detail.html create mode 100644 app/modules/sag/templates/index.html diff --git a/SAG_MODULE_PLAN.md b/SAG_MODULE_PLAN.md new file mode 100644 index 0000000..87be3de --- /dev/null +++ b/SAG_MODULE_PLAN.md @@ -0,0 +1,492 @@ +# Implementeringsplan: Sag-modulet (Case Module) + +## Oversigt - Hvad er "Sag"? + +**Sag-modulet** er hjertet i BMC Hub's nye relation- og proces-styringssystem. I stedet for at have separate systemer for "tickets", "opgaver" og "ordrer", har vi én universel entitet: **en Sag**. + +### Kerneidéen (meget vigtig!) + +> **Der er kun én ting: en Sag. Tickets, opgaver og ordrer er blot sager med forskellige relationer, tags og moduler.** + +**Eksempler:** + +1. **Kunde ringer og skal have ny skærm** + - Dette er en *Sag* (ticket-type med tag: `support`) + - Den får tag: `urgent` fordi det er ekspres + +2. **Indkøb af skærm hos leverandør** + - Dette er også en *Sag* (ordre-type med tag: `indkøb`) + - Den er *relateret til* den første sag som "afledt_af" + - Ansvarlig: Indkøbschef + +3. **Ompakning og afsendelse af skærm** + - Dette er en *Sag* (opgave-type med tag: `ompakning`) + - Den er relateret til indkøbssagen som "udførelse_for" + - Ansvarlig: Lagermedarbejder + - Deadline: I dag + +**Alle tre er samme datatype i databasen.** Forskellen er: +- Hvilke *tags* de har +- Hvilken *kunde/kontakt* de er knyttet til +- Hvilke *relationer* de har til andre sager +- Hvem der er *ansvarlig* + +### Hvad betyder det for systemet? + +**Uden Sag-modulet:** +- Du skal have en "Ticket-sektion" for support +- Du skal have en "Task-sektion" for opgaver +- Du skal have en "Order-sektion" for ordrer +- De snakker ikke sammen naturligt +- Data-duplikering +- Kompleks logik + +**Med Sag-modulet:** +- Ét API endpoint: `/api/v1/sag` +- Ét UI-område: "Sager" med intelligente filtre +- Relationer er førsteklasses borgere (se hvad der hænger sammen) +- Tags styr flowet (f.eks. "support" + "urgent" = prioriteret) +- Sager kan "vokse": Start som ticket → bliv til ordre → bliv til installation +- Alt er søgbart og filterabelt på tværs af domæner + +--- + +## Teknisk arkitektur + +### Databasestruktur + +Sag-modulet bruger tre hovedtabeller (med `sag_` prefix): + +#### **sag_sager** (Hovedtabel for sager) +``` +id (primary key) +titel (VARCHAR) - kort navn på sagen +beskrivelse (TEXT) - detaljeret beskrivelse +template_key (VARCHAR) - struktur-template (f.eks. "ticket", "opgave", "ordre") - default NULL +status (VARCHAR) - "åben" eller "lukket" +customer_id (foreign key) - hvilken kunde sagen handler om - NULLABLE +ansvarlig_bruger_id (foreign key) - hvem skal håndtere den +created_by_user_id (foreign key) - hvem oprettede sagen +deadline (TIMESTAMP) - hvornår skal det være færdigt +created_at (TIMESTAMP) +updated_at (TIMESTAMP) +deleted_at (TIMESTAMP) - soft-delete: sættes når sagen "slettes" +``` + +**Soft-delete:** Når du sletter en sag, bliver `deleted_at` sat til nu. Sagen bliver ikke fjernet fra DB. Det betyder: +- Du kan gendanne data hvis modulet deaktiveres +- Historien bevares (audit trail) +- Relations er intakte hvis du genopretter + +#### **sag_relationer** (Hvordan sager hænger sammen) +``` +id (primary key) +kilde_sag_id (foreign key) - hvilken sag relationen STARTER fra (retning: fra denne) +målsag_id (foreign key) - hvilken sag relationen PEGER PÅ (retning: til denne) +relationstype (VARCHAR) - f.eks. "parent_of", "child_of", "derived_from", "blocks", "executes_for" +created_at (TIMESTAMP) +deleted_at (TIMESTAMP) - soft-delete +``` + +**Eksempel (retningsbestemt):** +- Sag 1 (kundesamtale) → Sag 5 (indkøb af skærm) +- kilde_sag_id: 1, målsag_id: 5 +- relationstype: "derives" eller "parent_of" +- Betyder: "Sag 1 er forælder/genererer Sag 5" + +**Note:** Relationer er enrettet. For bidirektionale links oprettes to relations (1→5 og 5→1). + +#### **sag_tags** (Hvordan vi kategoriserer sager) +``` +id (primary key) +sag_id (foreign key) - hvilken sag tagget tilhører +tag_navn (VARCHAR) - f.eks. "support", "urgent", "vip", "ompakning" +state (VARCHAR) - "aktiv" eller "inaktiv" - default "aktiv" +closed_at (TIMESTAMP) - hvornår tagget blev lukket/inaktiveret - NULLABLE +created_at (TIMESTAMP) +deleted_at (TIMESTAMP) - soft-delete +``` + +**Tags bruges til:** +- Filtrering: "Vis alle sager med tag = support" +- Workflow: "Sager med tag = urgent skal løses i dag" +- Kategorisering: "Alle sager med tag = ompakning" + +### API-endpoints + +**Sager CRUD:** +- `GET /api/v1/cases` - Liste alle sager (filter efter tags, status, ansvarlig) +- `POST /api/v1/cases` - Opret ny sag +- `GET /api/v1/cases/{id}` - Vis detaljer om en sag +- `PATCH /api/v1/cases/{id}` - Opdater en sag +- `DELETE /api/v1/cases/{id}` - Slet en sag (soft-delete, sætter deleted_at) + +**Relationer:** +- `GET /api/v1/cases/{id}/relations` - Vis alle relaterede sager +- `POST /api/v1/cases/{id}/relations` - Tilføj relation til anden sag +- `DELETE /api/v1/cases/{id}/relations/{relation_id}` - Fjern relation + +**Tags:** +- `GET /api/v1/cases/{id}/tags` - Vis alle tags på sagen +- `POST /api/v1/cases/{id}/tags` - Tilføj tag +- `DELETE /api/v1/cases/{id}/tags/{tag_id}` - Fjern tag + +### UI-koncept + +**Sag-listen** (`/sag`): +- Alle dine sager på ét sted +- Filter: "Mine sager", "Åbne sager", "Sager med tag=support", "Sager med tag=urgent" +- Søgebar +- Sortering efter deadline, oprettelsestid, status + +**Sag-listen** (`/cases`): +**Sag-detaljer** (`/cases/{id}`): +- Hovedinfo: titel, beskrivelse, status, deadline +- **Relaterede sager**: Sektioner som: + - "Forælder-sag" (hvis denne sag er en del af noget større) + - "Barn-sager" (sager der er afledt af denne) + - "Blokeret af" (sager der holder denne op) + - "Udfører for" (hvis denne er udførelsessag for noget) +- **Tags**: Viste tags, mulighed for at tilføje flere +- **Ansvarlig**: Hvem skal håndtere det +- **Historie**: Hvis modulet får aktivitetslog senere + +**Designet:** +- Nordic Top minimalistisk design +- Dark mode support +- Responsive (mobil-venligt) +- Intuitivt navigation mellem relaterede sager + +--- + +## Implementeringsplan - Trin for trin + +### Fase 1: Modul-struktur (forberedelse) + +#### Trin 1.1: Opret modul-mappen +``` +app/modules/sag/ +├── module.json # Modulets metadata +├── README.md # Dokumentation +├── backend/ +│ ├── __init__.py +│ └── router.py # FastAPI endpoints +├── frontend/ +│ ├── __init__.py +│ └── views.py # HTML views +├── templates/ +│ ├── index.html # Sag-liste +│ └── detail.html # Sag-detaljer +└── migrations/ + └── 001_init.sql # Database schema +``` + +#### Trin 1.2: Opret module.json +```json +{ + "name": "sag", + "version": "1.0.0", + "description": "Universel sag-håndtering - tickets, opgaver og ordrer som sager med relationer", + "author": "BMC Networks", + "enabled": true, + "dependencies": [], + "table_prefix": "sag_", + "api_prefix": "/api/v1/cases", + "tags": ["Sag", "Case Management"], + "config": { + "safety_switches": { + "read_only": false, + "dry_run": false + } + } +} +``` + +### Fase 2: Database-setup + +#### Trin 2.1: Opret migrations/001_init.sql + +SQL-migrations definerer tabeller for sager, relationer og tags. Se `migrations/001_init.sql` for detaljer. + +**Vigtige points:** +- Alle tabelnavne starter med `sag_` +- Soft-delete: `deleted_at` kolonne hvor man checker `WHERE deleted_at IS NULL` +- Foreign keys til `customers` for at linke til kundedata +- Indexes for performance +- Triggers til auto-update af `updated_at` + +**Eksempel-query (queries filtrerer soft-deleted):** +```sql +SELECT * FROM sag_sager +WHERE customer_id = %s + AND deleted_at IS NULL +ORDER BY created_at DESC; +``` + +### Fase 3: Backend-API + +#### Trin 3.1: Opret backend/router.py + +Implementer alle 9 API-endpoints med disse mønstre: + +**GET /cases (list):** +```python +@router.get("/cases") +async def list_sager( + status: str = None, + tag: str = None, + customer_id: int = None, + ansvarlig_bruger_id: int = None +): + # Build query med WHERE deleted_at IS NULL + # Filter efter parameters + # Return liste +``` + +**POST /cases (create):** +```python +@router.post("/cases") +async def create_sag(sag_data: dict): + # Validér input + # INSERT INTO sag_sager + # RETURNING * + # Return ny sag +``` + +**GET /cases/{id}:** +```python +@router.get("/cases/{id}") +async def get_sag(id: int): + # SELECT * FROM sag_sager WHERE id = %s AND deleted_at IS NULL + # Hvis ikke found: HTTPException(404) + # Return sag detaljer +``` + +**PATCH /cases/{id} (update):** +```python +@router.patch("/cases/{id}") +async def update_sag(id: int, updates: dict): + # UPDATE sag_sager SET ... WHERE id = %s + # Automatisk updated_at via trigger + # Return opdateret sag +``` + +**DELETE /cases/{id} (soft-delete):** +```python +@router.delete("/cases/{id}") +async def delete_sag(id: int): + # UPDATE sag_sager SET deleted_at = NOW() WHERE id = %s + # Return success +``` + +**Relationer endpoints:** Lignende pattern for `/cases/{id}/relations` + +**Tags endpoints:** Lignende pattern for `/cases/{id}/tags` + +**Vigtige mønstre:** +- Altid bruge `execute_query()` fra `app.core.database` +- Parameteriserede queries (`%s` placeholders) +- `RealDictCursor` for dict-like row access +- Filtrer `WHERE deleted_at IS NULL` på alle SELECT queries +- Eksportér router som `router` (module loader leder efter denne) + +### Fase 4: Frontend-views + +#### Trin 4.1: Opret frontend/views.py + +```python +from fastapi import APIRouter +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +router = APIRouter() +templates = Jinja2Templates(directory="app/modules/sag/templates") + +@router.get("/cases", response_class=HTMLResponse) +async def cases_liste(request): + # Hent sager fra API + return templates.TemplateResponse("index.html", {"request": request, "cases": ...}) + +@router.get("/cases/{id}", response_class=HTMLResponse) +async def sag_detaljer(request, id: int): + # Hent sag + relationer + tags + return templates.TemplateResponse("detail.html", {"request": request, "sag": ..., "relationer": ...}) +``` + +### Fase 5: Frontend-templates + +#### Trin 5.1: Opret templates/index.html + +Sag-listen med: +- Search-bar +- Filter-knapper (status, tags, ansvarlig) +- Tabel/kort-view med alle sager +- Klikkable sager der går til `/sag/{id}` +- Nordic Top design med dark mode + +#### Trin 5.2: Opret templates/detail.html + +Sag-detaljer med: +- Hovedinfo: titel, beskrivelse, status, deadline, ansvarlig +- Sektioner: "Relaterede sager", "Tags", "Aktivitet" (hvis implementeret senere) +- Knap til at redigere sagen +- Knap til at tilføje relation +- Knap til at tilføje tag +- Mulighed for at se og slette relationer/tags + +### Fase 6: Test og aktivering + +#### Trin 6.1: Test databasen +```bash +docker compose exec db psql -U bmc_admin -d bmc_hub -c "SELECT * FROM sag_sager;" +``` + +#### Trin 6.2: Test API-endpoints +```bash +# Opret sag +curl -X POST http://localhost:8001/api/v1/cases \ + -H "Content-Type: application/json" \ + -d '{"titel": "Test sag", "customer_id": 1}' + +# Hent sag +curl http://localhost:8001/api/v1/cases/1 + +# Hent sag-liste +curl http://localhost:8001/api/v1/cases +``` + +#### Trin 6.3: Test frontend +- Besøg http://localhost:8001/cases +- Se sag-liste +- Klik på sag → se detaljer +- Tilføj tag, relation + +#### Trin 6.4: Test soft-delete +- Slet sag via `DELETE /cases/{id}` +- Check databasen: `deleted_at` skal være sat +- Verify den ikke vises i list-endpoints mere + +#### Trin 6.5: Test modul-deaktivering +- Rediger `module.json`: sæt `"enabled": false` +- Restart Docker: `docker compose restart api` +- Besøg http://localhost:8001/cases → 404 +- Besøg http://localhost:8001/api/v1/cases → 404 +- Revert: `"enabled": true`, restart, verifiér det virker igen + +### Fase 7: Dokumentation + +#### Trin 7.1: Opret README.md i modulet +Dokumenter: +- Hvad modulet gør +- API-endpoints med eksempler +- Database-schema +- Hvordan man bruger relationer og tags +- Eksempel-workflows + +--- + +## Vigtige principper under implementeringen + +### 1. **Soft-delete først** +Alle `DELETE` operationer sætter `deleted_at` til `NOW()` i stedet for at slette fysisk. Det betyder: +- Data bevares hvis modulet deaktiveres +- Audit trail bevares +- Relationer forbliver intakte + +### 2. **Always filter deleted_at** +Alle SELECT queries skal have: +```sql +WHERE deleted_at IS NULL +``` + +Undtagelse: Admin-sider der skal se "deleted history" (implementeres senere). + +### 3. **Foreign keys til customers** +Alle sager skal være knyttet til en `customer_id`. Det gør det muligt at: +- Lave customer-specifikke views senere +- Sikre data-isolation +- Tracke hvem sagerne handler om + +### 4. **Relationer er data** +Relationer er ikke blot links - de er egne database-records med type og soft-delete. Det betyder: +- Du kan se hele historien af relationer +- Du kan "gendanne" relationer hvis de slettes +- Relationstyper er konfigurerbare + +### 5. **Tags driver visibility** +Tags bruges til: +- UI-filtre: "Vis kun sager med tag=urgent" +- Workflow: "Sager med tag=support skal have SLA" +- Kategorisering: "Alt med tag=ompakning" + +--- + +## Hvad efter? + +Når Sag-modulet er live, kan du: + +1. **Konvertere tickets til sager** - Migrationsscript der tager gamle tickets og laver dem til sager +2. **Konvertere opgaver til sager** - Samme pattern +3. **Tilføje aktivitetslog** - "Hvem ændrede hvad hvornår" på hver sag +4. **Integrere med e-conomic** - Når en sag får tag=faktura, oprettes den som ordre i e-conomic +5. **Tilføje workflowkonfiguration** - "Hvis status=i_gang og tag=urgent, send reminder hver dag" +6. **Tilføje dependencies** - "Sag B kan ikke starte før Sag A er done" +7. **Tilføje SLA-tracking** - "Support-sager skal løses inden 24 timer" + +Men først: **Få grundlaget på plads med denne modul-implementering.** + +--- + +## Kommandoer til at komme i gang + +```bash +# Gå til workspace +cd /Users/christianthomas/DEV/bmc_hub_dev + +# Se hvor vi er +docker compose ps -a + +# Start dev-miljø hvis det ikke kører +docker compose up -d + +# Se logs +docker compose logs -f api + +# Efter at have lavet koden: restart API +docker compose restart api + +# Test at modulet loadet +docker compose logs api | grep -i "sag" + +# Manuelt test af database-migration +docker compose exec db psql -U bmc_admin -d bmc_hub -c "\dt sag_*" +``` + +--- + +## Tidsestimation + +- **Fase 1-2 (modul + database)**: 30 min +- **Fase 3 (backend API)**: 1-2 timer +- **Fase 4-5 (frontend)**: 1-2 timer +- **Fase 6 (test)**: 30 min +- **Fase 7 (dokumentation)**: 30 min + +**Total: 4-6 timer** + +--- + +## TL;DR - for implementer + +1. Opret `app/modules/sag/` med standard-struktur +2. Opret `module.json` med `"enabled": true` +3. Opret `migrations/001_init.sql` med 3 tabeller (`sag_sager`, `sag_relationer`, `sag_tags`) +4. Implementer 9 API-endpoints i `backend/router.py` (alle queries filtrerer `deleted_at IS NULL`) +5. Implementer 2 HTML-views i `frontend/views.py` (liste + detaljer) +6. Opret 2 templates i `templates/` (index.html + detail.html) +7. Test endpoints og UI +8. Verifiér soft-delete virker +9. Verifiér modulet kan deaktiveres og data bevares +10. Skrive README.md + +**Modulet bliver automatisk loadet af system - ingen manual registration nødvendig.** diff --git a/app/modules/sag/README.md b/app/modules/sag/README.md new file mode 100644 index 0000000..4e24baa --- /dev/null +++ b/app/modules/sag/README.md @@ -0,0 +1,177 @@ +# Sag Module - Case Management + +## Oversigt + +Sag-modulet implementerer en universel sag-håndtering system hvor tickets, opgaver og ordrer er blot sager med forskellige tags og relationer. + +**Kerneidé:** Der er kun én ting: en Sag. Alt andet er metadata, tags og relationer. + +## Database Schema + +### sag_sager (Hovedtabel) +- `id` - Primary key +- `titel` - Case title +- `beskrivelse` - Detailed description +- `type` - Case type (ticket, opgave, ordre, etc.) +- `status` - Status (åben, i_gang, afsluttet, on_hold) +- `customer_id` - Foreign key to customers table +- `ansvarlig_bruger_id` - Assigned user +- `deadline` - Due date +- `created_at` - Creation timestamp +- `updated_at` - Last update (auto-updated via trigger) +- `deleted_at` - Soft-delete timestamp (NULL = active) + +### sag_relationer (Relations) +- `id` - Primary key +- `kilde_sag_id` - Source case +- `målsag_id` - Target case +- `relationstype` - Relation type (forælder, barn, afledt_af, blokkerer, udfører_for) +- `created_at` - Creation timestamp +- `deleted_at` - Soft-delete timestamp + +### sag_tags (Tags) +- `id` - Primary key +- `sag_id` - Case reference +- `tag_navn` - Tag name (support, urgent, vip, ompakning, etc.) +- `created_at` - Creation timestamp +- `deleted_at` - Soft-delete timestamp + +## API Endpoints + +### Cases CRUD + +**List cases** +``` +GET /api/v1/sag?status=åben&tag=support&customer_id=1 +``` + +**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 + +**Get relations** +``` +GET /api/v1/sag/1/relationer +``` + +**Add relation** +``` +POST /api/v1/sag/1/relationer +Content-Type: application/json + +{ + "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 + +- `GET /sag` - List all cases with filters +- `GET /sag/{id}` - View case details +- `GET /sag/new` - Create new case (future) +- `GET /sag/{id}/edit` - Edit case (future) + +## Features + +✅ Soft-delete with data preservation +✅ Nordic Top design with dark mode support +✅ Responsive mobile-friendly UI +✅ Case relations (parent/child) +✅ Dynamic tagging system +✅ Full-text search +✅ Status filtering +✅ Customer tracking + +## Example Workflows + +### Support Ticket +1. Customer calls → Create Sag with type="ticket", tag="support" +2. Urgency high → Add tag="urgent" +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" + +### Future Integrations + +- 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) + +## Soft-Delete Safety + +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 + +## Development Notes + +- All queries use `execute_query()` from `app.core.database` +- Parameterized queries with `%s` placeholders (SQL injection prevention) +- `RealDictCursor` for dict-like row access +- Triggers maintain `updated_at` automatically +- Relations are first-class citizens (not just links) diff --git a/app/modules/sag/backend/__init__.py b/app/modules/sag/backend/__init__.py new file mode 100644 index 0000000..9a692cb --- /dev/null +++ b/app/modules/sag/backend/__init__.py @@ -0,0 +1 @@ +"""Sag module backend.""" diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py new file mode 100644 index 0000000..dae1951 --- /dev/null +++ b/app/modules/sag/backend/router.py @@ -0,0 +1,321 @@ +import logging +from typing import List, Optional +from fastapi import APIRouter, HTTPException, Query +from app.core.database import execute_query +from datetime import datetime + +logger = logging.getLogger(__name__) +router = APIRouter() + +# ============================================================================ +# SAGER - CRUD Operations +# ============================================================================ + +@router.get("/sag") +async def list_sager( + status: Optional[str] = Query(None), + tag: Optional[str] = Query(None), + customer_id: Optional[int] = Query(None), + ansvarlig_bruger_id: Optional[int] = Query(None), +): + """List all cases with optional filtering.""" + try: + query = "SELECT * FROM sag_sager WHERE deleted_at IS NULL" + 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") + +@router.post("/sag") +async def create_sag(data: dict): + """Create a new case.""" + try: + if not data.get('titel'): + raise HTTPException(status_code=400, detail="titel is required") + if not data.get('customer_id'): + raise HTTPException(status_code=400, detail="customer_id is required") + + query = """ + 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'), + ) + + result = execute_query(query, params) + if result: + logger.info("✅ Case created: %s", result[0]['id']) + return result[0] + raise HTTPException(status_code=500, detail="Failed to create case") + except Exception as e: + logger.error("❌ Error creating case: %s", e) + raise HTTPException(status_code=500, detail="Failed to create case") + +@router.get("/sag/{sag_id}") +async def get_sag(sag_id: int): + """Get a specific case.""" + try: + query = "SELECT * FROM sag_sager WHERE id = %s AND deleted_at IS NULL" + result = execute_query(query, (sag_id,)) + if not result: + raise HTTPException(status_code=404, detail="Case not found") + return result[0] + except HTTPException: + raise + except Exception as e: + logger.error("❌ Error getting case: %s", e) + raise HTTPException(status_code=500, detail="Failed to get case") + +@router.patch("/sag/{sag_id}") +async def update_sag(sag_id: int, updates: dict): + """Update a case.""" + try: + # Check if case exists + check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) + if not check: + raise HTTPException(status_code=404, detail="Case not found") + + # Build dynamic update query + allowed_fields = ['titel', 'beskrivelse', 'type', 'status', 'ansvarlig_bruger_id', 'deadline'] + set_clauses = [] + params = [] + + for field in allowed_fields: + if field in updates: + set_clauses.append(f"{field} = %s") + params.append(updates[field]) + + if not set_clauses: + raise HTTPException(status_code=400, detail="No valid fields to update") + + params.append(sag_id) + query = f"UPDATE sag_sager SET {', '.join(set_clauses)} WHERE id = %s RETURNING *" + + result = execute_query(query, tuple(params)) + if result: + logger.info("✅ Case updated: %s", sag_id) + return result[0] + raise HTTPException(status_code=500, detail="Failed to update case") + except HTTPException: + raise + except Exception as e: + logger.error("❌ Error updating case: %s", e) + raise HTTPException(status_code=500, detail="Failed to update case") + +@router.delete("/sag/{sag_id}") +async def delete_sag(sag_id: int): + """Soft-delete a case.""" + try: + check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) + if not check: + raise HTTPException(status_code=404, detail="Case not found") + + query = "UPDATE sag_sager SET deleted_at = NOW() WHERE id = %s RETURNING id" + result = execute_query(query, (sag_id,)) + + if result: + logger.info("✅ Case soft-deleted: %s", sag_id) + return {"status": "deleted", "id": sag_id} + raise HTTPException(status_code=500, detail="Failed to delete case") + except HTTPException: + raise + except Exception as e: + logger.error("❌ Error deleting case: %s", e) + raise HTTPException(status_code=500, detail="Failed to delete case") + +# ============================================================================ +# RELATIONER - Case Relations +# ============================================================================ + +@router.get("/sag/{sag_id}/relationer") +async def get_relationer(sag_id: int): + """Get all relations for a case.""" + try: + # Check if case exists + check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) + if not check: + raise HTTPException(status_code=404, detail="Case not found") + + query = """ + 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 + """ + result = execute_query(query, (sag_id, sag_id)) + return result + except HTTPException: + raise + except Exception as e: + logger.error("❌ Error getting relations: %s", e) + raise HTTPException(status_code=500, detail="Failed to get relations") + +@router.post("/sag/{sag_id}/relationer") +async def create_relation(sag_id: int, data: dict): + """Add a relation to another case.""" + try: + if not data.get('målsag_id') or not data.get('relationstype'): + raise HTTPException(status_code=400, detail="målsag_id and relationstype required") + + målsag_id = data.get('målsag_id') + relationstype = data.get('relationstype') + + # Validate both cases exist + check1 = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) + check2 = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (målsag_id,)) + + if not check1 or not check2: + raise HTTPException(status_code=404, detail="One or both cases not found") + + 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") + +# ============================================================================ +# TAGS - Case Tags +# ============================================================================ + +@router.get("/sag/{sag_id}/tags") +async def get_tags(sag_id: int): + """Get all tags for a case.""" + try: + check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) + if not check: + raise HTTPException(status_code=404, detail="Case not found") + + query = "SELECT * FROM sag_tags WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC" + result = execute_query(query, (sag_id,)) + return result + except HTTPException: + raise + except Exception as e: + logger.error("❌ Error getting tags: %s", e) + raise HTTPException(status_code=500, detail="Failed to get tags") + +@router.post("/sag/{sag_id}/tags") +async def add_tag(sag_id: int, data: dict): + """Add a tag to a case.""" + try: + if not data.get('tag_navn'): + raise HTTPException(status_code=400, detail="tag_navn is required") + + check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) + if not check: + raise HTTPException(status_code=404, detail="Case not found") + + query = """ + INSERT INTO sag_tags (sag_id, tag_navn) + VALUES (%s, %s) + 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") + +@router.delete("/sag/{sag_id}/tags/{tag_id}") +async def delete_tag(sag_id: int, tag_id: int): + """Soft-delete a tag.""" + try: + check = execute_query( + "SELECT id FROM sag_tags WHERE id = %s AND sag_id = %s AND deleted_at IS NULL", + (tag_id, sag_id) + ) + if not check: + raise HTTPException(status_code=404, detail="Tag not found") + + query = "UPDATE sag_tags SET deleted_at = NOW() WHERE id = %s RETURNING id" + result = execute_query(query, (tag_id,)) + + if result: + logger.info("✅ Tag soft-deleted: %s", tag_id) + return {"status": "deleted", "id": tag_id} + raise HTTPException(status_code=500, detail="Failed to delete tag") + except HTTPException: + raise + except Exception as e: + logger.error("❌ Error deleting tag: %s", e) + raise HTTPException(status_code=500, detail="Failed to delete tag") diff --git a/app/modules/sag/frontend/__init__.py b/app/modules/sag/frontend/__init__.py new file mode 100644 index 0000000..b53c67e --- /dev/null +++ b/app/modules/sag/frontend/__init__.py @@ -0,0 +1 @@ +"""Sag module frontend.""" diff --git a/app/modules/sag/frontend/views.py b/app/modules/sag/frontend/views.py new file mode 100644 index 0000000..fd8925b --- /dev/null +++ b/app/modules/sag/frontend/views.py @@ -0,0 +1,111 @@ +import logging +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from pathlib import Path +from app.core.database import execute_query + +logger = logging.getLogger(__name__) +router = APIRouter() + +# Setup template directory +template_dir = Path(__file__).parent.parent / "templates" +templates = Jinja2Templates(directory=str(template_dir)) + +@router.get("/sag", response_class=HTMLResponse) +async def sager_liste( + request, + status: str = Query(None), + tag: str = Query(None), + customer_id: int = Query(None), +): + """Display list of all cases.""" + 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) + + 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") + +@router.get("/sag/{sag_id}", response_class=HTMLResponse) +async def sag_detaljer(request, sag_id: int): + """Display case details.""" + try: + # Fetch main case + sag_query = "SELECT * FROM sag_sager WHERE id = %s AND deleted_at IS NULL" + sag_result = execute_query(sag_query, (sag_id,)) + + if not sag_result: + raise HTTPException(status_code=404, detail="Case not found") + + sag = sag_result[0] + + # Fetch tags + 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, (sag_id,)) + + # Fetch relations + relationer_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 + """ + relationer = execute_query(relationer_query, (sag_id, sag_id)) + + # Fetch customer info if customer_id exists + customer = None + if sag.get('customer_id'): + customer_query = "SELECT * FROM customers WHERE id = %s" + customer_result = execute_query(customer_query, (sag['customer_id'],)) + if customer_result: + customer = customer_result[0] + + return templates.TemplateResponse("detail.html", { + "request": request, + "sag": sag, + "customer": customer, + "tags": tags, + "relationer": relationer, + }) + except HTTPException: + raise + except Exception as e: + logger.error("❌ Error displaying case details: %s", e) + raise HTTPException(status_code=500, detail="Failed to load case details") diff --git a/app/modules/sag/migrations/001_init.sql b/app/modules/sag/migrations/001_init.sql new file mode 100644 index 0000000..610bfad --- /dev/null +++ b/app/modules/sag/migrations/001_init.sql @@ -0,0 +1,62 @@ +-- Sag Module: Initialize case management tables +-- Supports universal case handling with relations and tags + +-- Main cases table +CREATE TABLE IF NOT EXISTS sag_sager ( + id SERIAL PRIMARY KEY, + titel VARCHAR(255) NOT NULL, + beskrivelse TEXT, + type VARCHAR(50) NOT NULL DEFAULT 'ticket', -- ticket, opgave, ordre, etc. + status VARCHAR(50) NOT NULL DEFAULT 'åben', -- åben, i_gang, afsluttet, on_hold + customer_id INTEGER NOT NULL REFERENCES customers(id), + ansvarlig_bruger_id INTEGER, + deadline TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP, + CONSTRAINT valid_status CHECK (status IN ('åben', 'i_gang', 'afsluttet', 'on_hold')) +); + +-- Relations between cases +CREATE TABLE IF NOT EXISTS sag_relationer ( + id SERIAL PRIMARY KEY, + kilde_sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE, + målsag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE, + relationstype VARCHAR(50) NOT NULL, -- forælder, barn, afledt_af, blokkerer, udfører_for + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP, + CONSTRAINT different_cases CHECK (kilde_sag_id != målsag_id) +); + +-- Tags for categorization +CREATE TABLE IF NOT EXISTS sag_tags ( + id SERIAL PRIMARY KEY, + sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE, + tag_navn VARCHAR(100) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP +); + +-- Indexes for performance +CREATE INDEX IF NOT EXISTS idx_sag_sager_customer_id ON sag_sager(customer_id) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_sag_sager_status ON sag_sager(status) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_sag_sager_ansvarlig ON sag_sager(ansvarlig_bruger_id) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_sag_relationer_kilde ON sag_relationer(kilde_sag_id) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_sag_relationer_mål ON sag_relationer(målsag_id) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_sag_tags_sag_id ON sag_tags(sag_id) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_sag_tags_tag_navn ON sag_tags(tag_navn) WHERE deleted_at IS NULL; + +-- Trigger to auto-update updated_at +CREATE OR REPLACE FUNCTION update_sag_sager_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trigger_sag_sager_updated_at ON sag_sager; +CREATE TRIGGER trigger_sag_sager_updated_at +BEFORE UPDATE ON sag_sager +FOR EACH ROW +EXECUTE FUNCTION update_sag_sager_updated_at(); diff --git a/app/modules/sag/module.json b/app/modules/sag/module.json new file mode 100644 index 0000000..513623d --- /dev/null +++ b/app/modules/sag/module.json @@ -0,0 +1,17 @@ +{ + "name": "sag", + "version": "1.0.0", + "description": "Universel sag-håndtering - tickets, opgaver og ordrer som sager med relationer", + "author": "BMC Networks", + "enabled": true, + "dependencies": [], + "table_prefix": "sag_", + "api_prefix": "/api/v1/sag", + "tags": ["Sag", "Case Management"], + "config": { + "safety_switches": { + "read_only": false, + "dry_run": false + } + } +} diff --git a/app/modules/sag/templates/detail.html b/app/modules/sag/templates/detail.html new file mode 100644 index 0000000..1d4bbca --- /dev/null +++ b/app/modules/sag/templates/detail.html @@ -0,0 +1,236 @@ + + + + + + {{ sag.titel }} - BMC Hub + + + + + + + + +
+
+ ← Tilbage til sager + + +
+
+

{{ sag.titel }}

+
+
+
+
+ +

{{ sag.beskrivelse or 'Ingen beskrivelse' }}

+
+
+ +

{{ sag.status }}

+
+
+ +
+
+ +

{{ sag.type }}

+
+
+ +

{{ sag.deadline[:10] if sag.deadline else 'Ikke sat' }}

+
+
+ + {% if customer %} +
+
+ +

{{ customer.name }}

+
+
+ {% endif %} + +
+
+ +

{{ sag.created_at }}

+
+
+
+
+ + + {% if tags %} +
+
+
📌 Tags
+
+
+ {% for tag in tags %} + {{ tag.tag_navn }} + {% endfor %} +
+
+ {% endif %} + + + {% if relationer %} +
+
+
🔗 Relaterede sager
+
+
+ {% for rel in relationer %} +
+
{{ rel.relationstype }}
+ {% if rel.kilde_sag_id == sag.id %} + → {{ rel.mål_titel }} + {% else %} + ← {{ rel.kilde_titel }} + {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + + +
+ ✏️ Rediger + +
+
+
+ + + + diff --git a/app/modules/sag/templates/index.html b/app/modules/sag/templates/index.html new file mode 100644 index 0000000..f26d6d3 --- /dev/null +++ b/app/modules/sag/templates/index.html @@ -0,0 +1,350 @@ + + + + + + Sager - BMC Hub + + + + + + + + +
+
+ + + + +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ + +
+
+
+ + + +
+
+ + + + + diff --git a/app/opportunities/backend/router.py b/app/opportunities/backend/router.py index 52fb651..6b09051 100644 --- a/app/opportunities/backend/router.py +++ b/app/opportunities/backend/router.py @@ -17,6 +17,8 @@ import logging import os import shutil +import psycopg2 + from app.core.config import settings from app.core.database import execute_query, execute_query_single, execute_update from app.services.opportunity_service import handle_stage_change @@ -31,6 +33,19 @@ except ImportError: logger = logging.getLogger(__name__) router = APIRouter() + +def _is_undefined_table_error(exc: Exception) -> bool: + # Postgres undefined_table SQLSTATE + if getattr(exc, "pgcode", None) == "42P01": + return True + undefined_table = getattr(psycopg2, "errors", None) + if undefined_table is not None: + try: + return isinstance(exc, psycopg2.errors.UndefinedTable) + except Exception: + return False + return False + @router.post("/opportunities/{opportunity_id}/email-links", tags=["Opportunities"]) async def add_opportunity_email_link(opportunity_id: int, payload: dict): """Add a linked email to an opportunity""" @@ -49,6 +64,11 @@ async def add_opportunity_email_link(opportunity_id: int, payload: dict): (opportunity_id, email_id) ) except Exception as e: + if _is_undefined_table_error(e): + raise HTTPException( + status_code=409, + detail="Database migration required: run 032_opportunity_emails_m2m.sql", + ) logger.error(f"Failed to add email link: {e}") raise HTTPException(status_code=500, detail="Kunne ikke tilføje email-link") @@ -63,6 +83,11 @@ async def remove_opportunity_email_link(opportunity_id: int, email_id: int): (opportunity_id, email_id) ) except Exception as e: + if _is_undefined_table_error(e): + raise HTTPException( + status_code=409, + detail="Database migration required: run 032_opportunity_emails_m2m.sql", + ) logger.error(f"Failed to remove email link: {e}") raise HTTPException(status_code=500, detail="Kunne ikke fjerne email-link") @@ -318,8 +343,23 @@ def _store_upload_file(upload_file: UploadFile, subdir: str) -> Tuple[str, int]: destination = _resolve_attachment_path(stored_name) destination.parent.mkdir(parents=True, exist_ok=True) upload_file.file.seek(0) - with destination.open("wb") as buffer: - shutil.copyfileobj(upload_file.file, buffer) + try: + with destination.open("wb") as buffer: + shutil.copyfileobj(upload_file.file, buffer) + except PermissionError as e: + logger.error( + "❌ Upload permission denied: %s (base=%s, subdir=%s)", + str(destination), + str(UPLOAD_BASE_PATH), + subdir, + ) + raise HTTPException( + status_code=500, + detail=( + "Upload directory is not writable. Fix permissions for the host-mounted 'uploads' folder " + "(e.g. /srv/podman/bmc_hub_v1.0/uploads) and restart the API container." + ), + ) from e return stored_name, destination.stat().st_size @@ -497,6 +537,16 @@ def _get_opportunity(opportunity_id: int): ORDER BY e.received_date DESC """ linked_emails = execute_query(email_query, (opportunity_id,)) + try: + linked_emails = execute_query(email_query, (opportunity_id,)) + except Exception as e: + if _is_undefined_table_error(e): + logger.warning( + "⚠️ Missing table pipeline_opportunity_emails; linked_emails disabled until migration 032_opportunity_emails_m2m.sql is applied" + ) + linked_emails = [] + else: + raise opportunity["linked_emails"] = linked_emails or [] # Fetch linked contacts @@ -508,6 +558,16 @@ def _get_opportunity(opportunity_id: int): ORDER BY c.first_name, c.last_name """ linked_contacts = execute_query(contacts_query, (opportunity_id,)) + try: + linked_contacts = execute_query(contacts_query, (opportunity_id,)) + except Exception as e: + if _is_undefined_table_error(e): + logger.warning( + "⚠️ Missing table pipeline_opportunity_contacts; linked_contacts disabled until migration 033_opportunity_contacts.sql is applied" + ) + linked_contacts = [] + else: + raise opportunity["linked_contacts"] = linked_contacts or [] return opportunity diff --git a/app/opportunities/frontend/opportunity_detail.html b/app/opportunities/frontend/opportunity_detail.html index 2fd43ba..9f02052 100644 --- a/app/opportunities/frontend/opportunity_detail.html +++ b/app/opportunities/frontend/opportunity_detail.html @@ -229,6 +229,44 @@ + + +
Grundoplysninger
@@ -721,6 +759,9 @@ document.addEventListener('DOMContentLoaded', async () => { // --- Contact Persons Logic --- let contactSearchTimeout = null; const contactInput = document.getElementById('contactSearchInput'); + let contactRoleModal = null; + let pendingContactId = null; + const contactSuggestionMap = new Map(); contactInput?.addEventListener('input', (e) => { const term = e.target.value.trim(); @@ -747,6 +788,11 @@ document.addEventListener('DOMContentLoaded', async () => { function renderContactSuggestions(contacts) { const container = document.getElementById('contactSearchResults'); if(!container) return; + + contactSuggestionMap.clear(); + for (const c of (contacts || [])) { + if (c && typeof c.id === 'number') contactSuggestionMap.set(c.id, c); + } if(contacts.length === 0) { container.innerHTML = '
  • Ingen fundet
  • '; @@ -768,27 +814,66 @@ document.addEventListener('DOMContentLoaded', async () => { // Expose to global scope for onclick in string literal window.dataSelectContact = async function(contactId) { - const role = prompt("Rolle (f.eks. Beslutningstager, Influencer)?", ""); - - try { - const resp = await fetch(`${API_BASE}/api/v1/opportunities/${opportunityId}/contacts`, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({contact_id: contactId, role: role}) + pendingContactId = contactId; + const contact = contactSuggestionMap.get(contactId); + + if (!contactRoleModal) { + contactRoleModal = new bootstrap.Modal(document.getElementById('contactRoleModal')); + document.getElementById('contactRoleSaveBtn')?.addEventListener('click', submitContactRole); + document.getElementById('contactRoleInput')?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + submitContactRole(); + } }); - if(resp.ok) { - opportunity = await resp.json(); - if(opportunity.linked_contacts) renderLinkedContacts(opportunity.linked_contacts); - document.getElementById('contactSearchInput').value = ''; - } else { - alert('Fejl ved tilføjelse'); - } - } catch(e) { - console.error(e); - alert('Fejl ved tilføjelse'); } + + const nameEl = document.getElementById('contactRoleModalContactName'); + if (nameEl && contact) { + nameEl.textContent = `${contact.first_name || ''} ${contact.last_name || ''}`.trim() || `#${contactId}`; + } else if (nameEl) { + nameEl.textContent = `#${contactId}`; + } + + const roleInput = document.getElementById('contactRoleInput'); + if (roleInput) { + roleInput.value = ''; + setTimeout(() => roleInput.focus(), 150); + } + + contactRoleModal.show(); }; + async function submitContactRole() { + if (!pendingContactId) return; + const roleInput = document.getElementById('contactRoleInput'); + const roleValue = roleInput?.value?.trim() || null; + try { + const resp = await fetch(`${API_BASE}/api/v1/opportunities/${opportunityId}/contacts`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({contact_id: pendingContactId, role: roleValue}) + }); + if (resp.ok) { + opportunity = await resp.json(); + if(opportunity.linked_contacts) renderLinkedContacts(opportunity.linked_contacts); + document.getElementById('contactSearchInput').value = ''; + pendingContactId = null; + contactRoleModal?.hide(); + } else { + try { + const errJson = await resp.json(); + alert(errJson.detail || 'Fejl ved tilføjelse'); + } catch(e) { + alert('Fejl ved tilføjelse'); + } + } + } catch(e) { + console.error(e); + alert('Fejl ved tilføjelse'); + } + } + window.renderLinkedContacts = function(contacts) { const container = document.getElementById('linkedContactsList'); if(!container) return; @@ -804,7 +889,8 @@ document.addEventListener('DOMContentLoaded', async () => {
    ${escapeHtml(c.first_name)} ${escapeHtml(c.last_name)}
    ${escapeHtml(c.role || 'Ingen rolle')}
    ${c.email ? `` : ''} - ${c.mobile_phone ? `` : ''} + ${c.phone ? `` : ''} + ${c.mobile ? `` : ''}