Refactor opportunities and settings management

- Removed opportunity detail page route from views.py.
- Deleted opportunity_service.py as it is no longer needed.
- Updated router.py to seed new setting for case_type_module_defaults.
- Enhanced settings.html to include standard modules per case type with UI for selection.
- Implemented JavaScript functions to manage case type module defaults.
- Added RelationService for handling case relations with a tree structure.
- Created migration scripts (128 and 129) for new pipeline fields and descriptions.
- Added script to fix relation types in the database.
This commit is contained in:
Christian 2026-02-15 11:12:58 +01:00
parent 0831715d3a
commit 891180f3f0
21 changed files with 2193 additions and 3393 deletions

View File

@ -174,17 +174,37 @@
.type-tag {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: var(--calendar-bg);
border-radius: 999px;
padding: 0.4rem 0.75rem;
gap: 0.5rem;
background: #ffffff;
border: 1px solid var(--calendar-border);
border-radius: 8px;
padding: 0.35rem 0.6rem;
font-size: 0.8rem;
color: var(--calendar-subtle);
color: var(--calendar-ink);
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
.type-tag:hover {
background: var(--calendar-bg);
border-color: rgba(15, 76, 117, 0.2);
}
.type-tag input {
accent-color: var(--calendar-sea);
width: 1rem;
height: 1rem;
cursor: pointer;
margin: 0;
}
.type-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.action-row {
@ -431,28 +451,34 @@
<div class="filter-block">
<label>Event typer</label>
<div class="type-tags">
<label class="type-tag">
<label class="type-tag" title="Vis deadlines">
<input type="checkbox" class="type-filter" value="deadline" checked>
<span class="type-dot" style="background: var(--calendar-ember);"></span>
Deadline
</label>
<label class="type-tag">
<label class="type-tag" title="Vis udsatte sager">
<input type="checkbox" class="type-filter" value="deferred" checked>
<span class="type-dot" style="background: var(--calendar-violet);"></span>
Deferred
</label>
<label class="type-tag">
<label class="type-tag" title="Vis møder">
<input type="checkbox" class="type-filter" value="meeting" checked>
Moede
<span class="type-dot" style="background: var(--calendar-sea);"></span>
Møde
</label>
<label class="type-tag">
<label class="type-tag" title="Vis teknikerbesøg">
<input type="checkbox" class="type-filter" value="technician_visit" checked>
Teknikerbesoeg
<span class="type-dot" style="background: var(--calendar-sun);"></span>
Tekniker
</label>
<label class="type-tag">
<label class="type-tag" title="Vis OBS punkter">
<input type="checkbox" class="type-filter" value="obs" checked>
<span class="type-dot" style="background: #5a60a8;"></span>
OBS
</label>
<label class="type-tag">
<label class="type-tag" title="Vis reminders">
<input type="checkbox" class="type-filter" value="reminder" checked>
<span class="type-dot" style="background: var(--calendar-mint);"></span>
Reminder
</label>
</div>
@ -479,8 +505,8 @@
<div class="calendar-legend">
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-ember);"></span>Deadline</div>
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-violet);"></span>Deferred</div>
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-sea);"></span>Moede</div>
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-sun);"></span>Teknikerbesoeg</div>
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-sea);"></span>Møde</div>
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-sun);"></span>Teknikerbesøg</div>
<div class="legend-item"><span class="legend-dot" style="background: #5a60a8;"></span>OBS</div>
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-mint);"></span>Reminder</div>
</div>
@ -505,8 +531,8 @@
<div class="col-md-6">
<label class="form-label">Type *</label>
<select class="form-select" id="calendarEventType">
<option value="meeting">Moede</option>
<option value="technician_visit">Teknikerbesoeg</option>
<option value="meeting">Møde</option>
<option value="technician_visit">Teknikerbesøg</option>
<option value="obs">OBS</option>
<option value="reminder">Reminder</option>
</select>
@ -517,7 +543,7 @@
</div>
<div class="col-12">
<label class="form-label">Titel *</label>
<input type="text" class="form-control" id="calendarEventTitle" placeholder="Fx Moede om status">
<input type="text" class="form-control" id="calendarEventTitle" placeholder="Fx Møde om status">
</div>
<div class="col-12">
<label class="form-label">Besked</label>

View File

@ -430,6 +430,56 @@ async def hardware_detail(request: Request, hardware_id: int):
"""
tags = execute_query(tag_query, (hardware_id,))
# Get linked contacts
contacts_query = """
SELECT hc.*, c.first_name, c.last_name, c.email, c.phone, c.mobile
FROM hardware_contacts hc
JOIN contacts c ON c.id = hc.contact_id
WHERE hc.hardware_id = %s
ORDER BY hc.role = 'primary' DESC, c.first_name ASC
"""
contacts = execute_query(contacts_query, (hardware_id,))
# Get available contacts for linking (from current owner)
available_contacts = []
if hardware.get('current_owner_customer_id'):
avail_query = """
SELECT c.id, c.first_name, c.last_name, c.email
FROM contacts c
JOIN contact_companies cc ON c.id = cc.contact_id
WHERE cc.customer_id = %s
AND c.is_active = TRUE
AND c.id NOT IN (
SELECT contact_id FROM hardware_contacts WHERE hardware_id = %s
)
ORDER BY cc.is_primary DESC, c.first_name ASC
"""
available_contacts = execute_query(avail_query, (hardware['current_owner_customer_id'], hardware_id))
# Get customers for ownership selector
owner_customers_query = """
SELECT id, name AS navn
FROM customers
ORDER BY name
"""
owner_customers = execute_query(owner_customers_query)
owner_contacts_query = """
SELECT
cc.customer_id,
c.id,
c.first_name,
c.last_name,
c.email,
c.phone,
cc.is_primary
FROM contacts c
JOIN contact_companies cc ON cc.contact_id = c.id
WHERE c.is_active = TRUE
ORDER BY cc.customer_id, cc.is_primary DESC, c.first_name ASC, c.last_name ASC
"""
owner_contacts = execute_query(owner_contacts_query)
# Get all active locations for the tree (including parent_id for structure)
all_locations_query = """
SELECT id, name, location_type, parent_location_id
@ -448,6 +498,10 @@ async def hardware_detail(request: Request, hardware_id: int):
"attachments": attachments or [],
"cases": cases or [],
"tags": tags or [],
"contacts": contacts or [],
"available_contacts": available_contacts or [],
"owner_customers": owner_customers or [],
"owner_contacts": owner_contacts or [],
"location_tree": location_tree or [],
"eset_specs": extract_eset_specs_summary(hardware)
})
@ -518,3 +572,113 @@ async def update_hardware_location(
execute_query(update_asset_query, (location_id, hardware_id))
return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303)
@router.post("/hardware/{hardware_id}/owner")
async def update_hardware_owner(
request: Request,
hardware_id: int,
owner_customer_id: int = Form(...),
owner_contact_id: Optional[int] = Form(None),
notes: Optional[str] = Form(None)
):
"""Update hardware ownership."""
# Verify hardware exists
check_query = "SELECT id FROM hardware_assets WHERE id = %s AND deleted_at IS NULL"
if not execute_query(check_query, (hardware_id,)):
raise HTTPException(status_code=404, detail="Hardware not found")
# Verify customer exists
customer_check = "SELECT id FROM customers WHERE id = %s AND deleted_at IS NULL"
if not execute_query(customer_check, (owner_customer_id,)):
raise HTTPException(status_code=404, detail="Customer not found")
# Verify owner contact belongs to selected customer
if owner_contact_id:
contact_check_query = """
SELECT 1
FROM contact_companies
WHERE customer_id = %s AND contact_id = %s
LIMIT 1
"""
if not execute_query(contact_check_query, (owner_customer_id, owner_contact_id)):
raise HTTPException(status_code=400, detail="Selected contact does not belong to customer")
# Close active ownership history
close_history_query = """
UPDATE hardware_ownership_history
SET end_date = %s
WHERE hardware_id = %s AND end_date IS NULL AND deleted_at IS NULL
"""
execute_query(close_history_query, (date.today(), hardware_id))
# Insert new ownership history
add_history_query = """
INSERT INTO hardware_ownership_history (
hardware_id, owner_type, owner_customer_id, start_date, notes
)
VALUES (%s, %s, %s, %s, %s)
"""
execute_query(add_history_query, (hardware_id, "customer", owner_customer_id, date.today(), notes))
# Update current owner on hardware
update_asset_query = """
UPDATE hardware_assets
SET current_owner_type = %s, current_owner_customer_id = %s, updated_at = NOW()
WHERE id = %s
"""
execute_query(update_asset_query, ("customer", owner_customer_id, hardware_id))
# Optionally set owner contact as primary for this hardware
if owner_contact_id:
demote_query = """
UPDATE hardware_contacts
SET role = 'user'
WHERE hardware_id = %s AND role = 'primary'
"""
execute_query(demote_query, (hardware_id,))
owner_contact_query = """
INSERT INTO hardware_contacts (hardware_id, contact_id, role, source)
VALUES (%s, %s, 'primary', 'manual')
ON CONFLICT (hardware_id, contact_id)
DO UPDATE SET role = EXCLUDED.role, source = EXCLUDED.source
"""
execute_query(owner_contact_query, (hardware_id, owner_contact_id))
return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303)
@router.post("/hardware/{hardware_id}/contacts/add")
async def add_hardware_contact(
request: Request,
hardware_id: int,
contact_id: int = Form(...)
):
"""Link a contact to hardware."""
# Check if exists
exists_query = "SELECT id FROM hardware_contacts WHERE hardware_id = %s AND contact_id = %s"
if execute_query(exists_query, (hardware_id, contact_id)):
# Already exists, just redirect
pass
else:
insert_query = """
INSERT INTO hardware_contacts (hardware_id, contact_id, role, source)
VALUES (%s, %s, 'user', 'manual')
"""
execute_query(insert_query, (hardware_id, contact_id))
return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303)
@router.post("/hardware/{hardware_id}/contacts/{contact_id}/delete")
async def remove_hardware_contact(
request: Request,
hardware_id: int,
contact_id: int
):
"""Unlink a contact from hardware."""
delete_query = "DELETE FROM hardware_contacts WHERE hardware_id = %s AND contact_id = %s"
execute_query(delete_query, (hardware_id, contact_id))
return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303)

View File

@ -157,6 +157,14 @@
<!-- Customer (Current Owner) -->
{% set current_owner = ownership[0] if ownership else None %}
{% set owner_contact_ns = namespace(contact=None) %}
{% if contacts %}
{% for c in contacts %}
{% if c.role == 'primary' and owner_contact_ns.contact is none %}
{% set owner_contact_ns.contact = c %}
{% endif %}
{% endfor %}
{% endif %}
{% if current_owner and not current_owner.end_date %}
<div class="quick-info-item">
<span class="quick-info-label">Ejer:</span>
@ -361,19 +369,26 @@
</div>
<div class="col-md-6">
<div class="card h-100 shadow-sm border-0">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center">
<h6 class="text-success mb-0"><i class="bi bi-person me-2"></i>Ejer</h6>
<button class="btn btn-sm btn-link p-0" data-bs-toggle="modal" data-bs-target="#ownerModal">Ændre</button>
</div>
<div class="card-body">
{% if current_owner and not current_owner.end_date %}
<div class="text-center py-3">
<div class="fs-4 mb-2 text-success"><i class="bi bi-person-badge"></i></div>
<h5 class="fw-bold">{{ current_owner.customer_name or current_owner.owner_type|title }}</h5>
{% if owner_contact_ns.contact %}
<p class="mb-1">
<span class="badge bg-light text-dark border">{{ owner_contact_ns.contact.first_name }} {{ owner_contact_ns.contact.last_name }}</span>
</p>
{% endif %}
<p class="text-muted small mb-0">Siden: {{ current_owner.start_date }}</p>
</div>
{% else %}
<div class="text-center py-4 text-muted">
<p class="mb-0">Ingen aktiv ejer</p>
<button class="btn btn-sm btn-outline-success mt-2" data-bs-toggle="modal" data-bs-target="#ownerModal">Sæt ejer</button>
</div>
{% endif %}
</div>
@ -412,6 +427,46 @@
</div>
</div>
<!-- Contacts -->
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center">
<h6 class="text-secondary mb-0 fw-bold small"><i class="bi bi-person-lines-fill me-2"></i>Kontaktpersoner</h6>
<button type="button" class="btn btn-sm btn-link text-decoration-none p-0" data-bs-toggle="modal" data-bs-target="#addContactModal">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div class="list-group list-group-flush">
{% if contacts %}
{% for contact in contacts %}
<div class="list-group-item border-0 px-3 py-2">
<div class="d-flex w-100 justify-content-between align-items-center">
<div class="text-truncate" style="max-width: 85%;">
<div class="fw-bold text-dark small">
{{ contact.first_name }} {{ contact.last_name }}
{% if contact.role == 'primary' %}<span class="badge bg-info text-dark ms-1" style="font-size: 0.6rem;">Primær</span>{% endif %}
{% if contact.source == 'eset' %}<span class="badge bg-light text-muted border ms-1" style="font-size: 0.6rem;">ESET</span>{% endif %}
</div>
<div class="text-muted small" style="font-size: 0.75rem;">
{% if contact.email %}<a href="mailto:{{ contact.email }}" class="text-muted text-decoration-none"><i class="bi bi-envelope me-1"></i></a>{% endif %}
{% if contact.phone %}<a href="tel:{{ contact.phone }}" class="text-muted text-decoration-none"><i class="bi bi-phone me-1"></i>{{ contact.phone }}</a>{% endif %}
</div>
</div>
<form action="/hardware/{{ hardware.id }}/contacts/{{ contact.contact_id }}/delete" method="POST" onsubmit="return confirm('Er du sikker på at du vil fjerne denne kontakt?');">
<button type="submit" class="btn btn-sm btn-link text-danger p-0" title="Fjern kontakt">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center py-3 text-muted small">
Ingen kontakter tilknyttet
</div>
{% endif %}
</div>
</div>
<!-- Linked Cases -->
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3">
@ -673,6 +728,65 @@
</div>
</div>
<!-- Modal for Owner -->
<div class="modal fade" id="ownerModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Skift Ejer</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form action="/hardware/{{ hardware.id }}/owner" method="post">
<div class="modal-body">
<div class="mb-3">
<label class="form-label fw-bold">Vælg kunde</label>
<input
type="text"
id="ownerCustomerSearch"
class="form-control mb-2"
placeholder="🔍 Søg virksomhed..."
autocomplete="off"
>
<select id="ownerCustomerSelect" name="owner_customer_id" class="form-select" required>
<option value="">-- Vælg kunde --</option>
{% for customer in owner_customers %}
<option value="{{ customer.id }}" {% if hardware.current_owner_customer_id == customer.id %}selected{% endif %}>
{{ customer.navn }}
</option>
{% endfor %}
</select>
<div id="ownerCustomerHelp" class="form-text">Søg og vælg virksomhed.</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Vælg kontaktperson</label>
<select id="ownerContactSelect" name="owner_contact_id" class="form-select" required>
<option value="">-- Vælg kontaktperson --</option>
{% for contact in owner_contacts %}
<option
value="{{ contact.id }}"
data-customer-id="{{ contact.customer_id }}"
{% if owner_contact_ns.contact and owner_contact_ns.contact.contact_id == contact.id %}selected{% endif %}
>
{{ contact.first_name }} {{ contact.last_name }}{% if contact.email %} ({{ contact.email }}){% endif %}
</option>
{% endfor %}
</select>
<div id="ownerContactHelp" class="form-text">Viser kun kontakter for valgt virksomhed.</div>
</div>
<div class="mb-3">
<label class="form-label">Note (valgfri)</label>
<textarea class="form-control" name="notes" rows="3" placeholder="F.eks. Overdraget til ny kunde"></textarea>
</div>
</div>
<div class="modal-footer bg-light">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="submit" class="btn btn-success">Gem Ejer</button>
</div>
</form>
</div>
</div>
</div>
<!-- Modal for Location -->
<div class="modal fade" id="locationModal" tabindex="-1">
<div class="modal-dialog">
@ -744,6 +858,50 @@
</div>
</div>
<!-- Add Contact Modal -->
<div class="modal fade" id="addContactModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Tilføj Kontaktperson</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form method="POST" action="/hardware/{{ hardware.id }}/contacts/add">
<div class="modal-body">
{% if available_contacts %}
<div class="mb-3">
<label class="form-label">Vælg Kontakt</label>
<select name="contact_id" class="form-select" required>
<option value="">-- Vælg --</option>
{% for contact in available_contacts %}
<option value="{{ contact.id }}">
{{ contact.first_name }} {{ contact.last_name }}
{% if contact.email %}({{ contact.email }}){% endif %}
</option>
{% endfor %}
</select>
<div class="form-text">Viser kun personer fra {{ hardware.customer_name if hardware.customer_name else 'kunden' }} som ikke allerede er tilknyttet.</div>
</div>
<input type="hidden" name="role" value="user">
<input type="hidden" name="source" value="manual">
{% else %}
<div class="alert alert-warning">
Ingen tilgængelige kontakter fundet for denne kunde.
<br><small>Opret først kontakter under kunden.</small>
</div>
{% endif %}
</div>
<div class="modal-footer bg-light">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
{% if available_contacts %}
<button type="submit" class="btn btn-primary">Tilføj</button>
{% endif %}
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
@ -878,6 +1036,101 @@
// Initialize Tags
document.addEventListener('DOMContentLoaded', function() {
const ownerCustomerSearch = document.getElementById('ownerCustomerSearch');
const ownerCustomerSelect = document.getElementById('ownerCustomerSelect');
const ownerContactSelect = document.getElementById('ownerContactSelect');
const ownerCustomerHelp = document.getElementById('ownerCustomerHelp');
const ownerContactHelp = document.getElementById('ownerContactHelp');
function filterOwnerCustomers() {
if (!ownerCustomerSearch || !ownerCustomerSelect) {
return;
}
const filter = ownerCustomerSearch.value.toLowerCase().trim();
const options = Array.from(ownerCustomerSelect.options);
let visibleCount = 0;
options.forEach((option, index) => {
if (index === 0) {
option.hidden = false;
return;
}
const label = (option.textContent || '').toLowerCase();
const isVisible = !filter || label.includes(filter);
option.hidden = !isVisible;
if (isVisible) {
visibleCount += 1;
}
});
const selectedOption = ownerCustomerSelect.selectedOptions[0];
if (selectedOption && selectedOption.hidden) {
ownerCustomerSelect.value = '';
}
if (ownerCustomerHelp) {
if (visibleCount === 0) {
ownerCustomerHelp.textContent = 'Ingen virksomheder matcher søgningen.';
} else {
ownerCustomerHelp.textContent = `Viser ${visibleCount} virksomhed(er).`;
}
}
}
function filterOwnerContacts() {
if (!ownerCustomerSelect || !ownerContactSelect) {
return;
}
const selectedCustomerId = ownerCustomerSelect.value;
const options = Array.from(ownerContactSelect.options);
let visibleCount = 0;
options.forEach((option, index) => {
if (index === 0) {
option.hidden = false;
return;
}
const optionCustomerId = option.getAttribute('data-customer-id');
const isVisible = selectedCustomerId && optionCustomerId === selectedCustomerId;
option.hidden = !isVisible;
if (isVisible) {
visibleCount += 1;
}
});
const selectedOption = ownerContactSelect.selectedOptions[0];
if (!selectedOption || selectedOption.hidden) {
ownerContactSelect.value = '';
}
ownerContactSelect.disabled = !selectedCustomerId || visibleCount === 0;
if (ownerContactHelp) {
if (!selectedCustomerId) {
ownerContactHelp.textContent = 'Vælg først virksomhed.';
} else if (visibleCount === 0) {
ownerContactHelp.textContent = 'Ingen kontaktpersoner fundet for valgt virksomhed.';
} else {
ownerContactHelp.textContent = 'Viser kun kontakter for valgt virksomhed.';
}
}
}
if (ownerCustomerSelect && ownerContactSelect) {
ownerCustomerSelect.addEventListener('change', filterOwnerContacts);
if (ownerCustomerSearch) {
ownerCustomerSearch.addEventListener('input', function() {
filterOwnerCustomers();
filterOwnerContacts();
});
}
filterOwnerCustomers();
filterOwnerContacts();
}
if (window.renderEntityTags) {
window.renderEntityTags('hardware', {{ hardware.id }}, 'hardware-tags');
}

View File

@ -8,6 +8,7 @@ from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from app.core.database import execute_query, execute_query_single
from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate
from app.core.config import settings
@ -397,6 +398,10 @@ async def update_sag(sag_id: int, updates: dict):
if not check:
raise HTTPException(status_code=404, detail="Case not found")
# Backwards compatibility: frontend sends "type", DB stores "template_key"
if "type" in updates and "template_key" not in updates:
updates["template_key"] = updates.get("type")
# Build dynamic update query
allowed_fields = ["titel", "beskrivelse", "template_key", "status", "ansvarlig_bruger_id", "deadline", "deferred_until", "deferred_until_case_id", "deferred_until_status"]
set_clauses = []
@ -411,7 +416,8 @@ async def update_sag(sag_id: int, updates: dict):
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 *"
set_sql = ", ".join(set_clauses)
query = f"UPDATE sag_sager SET {set_sql} WHERE id = %s RETURNING *"
result = execute_query(query, tuple(params))
if result:
@ -424,6 +430,72 @@ async def update_sag(sag_id: int, updates: dict):
logger.error("❌ Error updating case: %s", e)
raise HTTPException(status_code=500, detail="Failed to update case")
class PipelineUpdate(BaseModel):
amount: Optional[float] = None
probability: Optional[int] = Field(default=None, ge=0, le=100)
stage_id: Optional[int] = None
description: Optional[str] = None
@router.patch("/sag/{sag_id}/pipeline")
async def update_sag_pipeline(sag_id: int, pipeline_data: PipelineUpdate):
"""Update pipeline fields for a case."""
try:
exists = execute_query(
"SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(sag_id,)
)
if not exists:
raise HTTPException(status_code=404, detail="Case not found")
provided = pipeline_data.model_dump(exclude_unset=True)
if not provided:
raise HTTPException(status_code=400, detail="No pipeline fields provided")
if "stage_id" in provided and provided["stage_id"] is not None:
stage_exists = execute_query(
"SELECT id FROM pipeline_stages WHERE id = %s",
(provided["stage_id"],)
)
if not stage_exists:
raise HTTPException(status_code=400, detail="Invalid pipeline stage")
set_clauses = []
params = []
if "amount" in provided:
set_clauses.append("pipeline_amount = %s")
params.append(provided["amount"])
if "probability" in provided:
set_clauses.append("pipeline_probability = %s")
params.append(provided["probability"])
if "stage_id" in provided:
set_clauses.append("pipeline_stage_id = %s")
params.append(provided["stage_id"])
if "description" in provided:
set_clauses.append("pipeline_description = %s")
params.append(provided["description"])
params.append(sag_id)
query = f"UPDATE sag_sager SET {', '.join(set_clauses)} WHERE id = %s RETURNING *"
result = execute_query(query, tuple(params))
if not result:
raise HTTPException(status_code=500, detail="Failed to update pipeline")
logger.info("✅ Pipeline updated for case: %s", sag_id)
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error updating pipeline for case %s: %s", sag_id, e)
raise HTTPException(status_code=500, detail="Failed to update pipeline")
@router.delete("/sag/{sag_id}")
async def delete_sag(sag_id: int):
"""Soft-delete a case."""

View File

@ -181,85 +181,11 @@ async def sag_detaljer(request: Request, sag_id: int):
# --- Relation Tree Construction ---
relation_tree = []
try:
# 1. Get all connected case IDs (Recursive CTE)
tree_ids_query = """
WITH RECURSIVE CaseTree AS (
SELECT id FROM sag_sager WHERE id = %s
UNION
SELECT CASE WHEN sr.kilde_sag_id = ct.id THEN sr.målsag_id ELSE sr.kilde_sag_id END
FROM sag_relationer sr
JOIN CaseTree ct ON sr.kilde_sag_id = ct.id OR sr.målsag_id = ct.id
WHERE sr.deleted_at IS NULL
)
SELECT id FROM CaseTree LIMIT 50;
"""
tree_ids_rows = execute_query(tree_ids_query, (sag_id,))
tree_ids = [r['id'] for r in tree_ids_rows]
if tree_ids:
# 2. Fetch details
placeholders = ','.join(['%s'] * len(tree_ids))
tree_cases_query = f"SELECT id, titel, status FROM sag_sager WHERE id IN ({placeholders})"
tree_cases = {c['id']: c for c in execute_query(tree_cases_query, tuple(tree_ids))}
# 3. Fetch edges
tree_edges_query = f"""
SELECT id, kilde_sag_id, målsag_id, relationstype
FROM sag_relationer
WHERE deleted_at IS NULL
AND kilde_sag_id IN ({placeholders})
AND målsag_id IN ({placeholders})
"""
tree_edges = execute_query(tree_edges_query, tuple(tree_ids) * 2)
# 4. Build Graph
children_map = {cid: [] for cid in tree_ids}
parents_map = {cid: [] for cid in tree_ids}
for edge in tree_edges:
k, m, rtype = edge['kilde_sag_id'], edge['målsag_id'], edge['relationstype'].lower()
parent, child = k, m # Default (e.g. Relateret til)
if rtype == 'afledt af': # m is parent of k
parent, child = m, k
elif rtype == 'årsag til': # k is parent of m
parent, child = k, m
if parent in children_map:
children_map[parent].append({
'id': child,
'type': edge['relationstype'],
'rel_id': edge['id']
})
if child in parents_map:
parents_map[child].append(parent)
# 5. Identify Roots and Build
roots = [cid for cid in tree_ids if not parents_map[cid]]
if not roots and tree_ids: roots = [min(tree_ids)] # Fallback
def build_tree_node(cid, visited):
if cid in visited: return None
visited.add(cid)
node_case = tree_cases.get(cid)
if not node_case: return None
children_nodes = []
for child_info in children_map.get(cid, []):
c_node = build_tree_node(child_info['id'], visited.copy())
if c_node:
c_node['relation_type'] = child_info['type']
c_node['relation_id'] = child_info['rel_id']
children_nodes.append(c_node)
return {
'case': node_case,
'children': children_nodes,
'is_current': cid == sag_id
}
relation_tree = [build_tree_node(r, set()) for r in roots]
relation_tree = [n for n in relation_tree if n]
from app.modules.sag.services.relation_service import RelationService
relation_tree = RelationService.get_relation_tree(sag_id)
except Exception as e:
logger.error(f"Error building relation tree: {e}")
relation_tree = []
except Exception as e:
logger.error(f"Error building relation tree: {e}")
relation_tree = []
@ -438,6 +364,16 @@ async def sag_detaljer(request: Request, sag_id: int):
logger.error("❌ Error building related case options: %s", e)
related_case_options = []
pipeline_stages = []
try:
pipeline_stages = execute_query(
"SELECT id, name, color, sort_order FROM pipeline_stages ORDER BY sort_order ASC, id ASC",
(),
)
except Exception as e:
logger.warning("⚠️ Could not load pipeline stages: %s", e)
pipeline_stages = []
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ())
return templates.TemplateResponse("modules/sag/templates/detail.html", {
@ -460,6 +396,7 @@ async def sag_detaljer(request: Request, sag_id: int):
"is_nextcloud": is_nextcloud,
"nextcloud_instance": nextcloud_instance,
"related_case_options": related_case_options,
"pipeline_stages": pipeline_stages,
"status_options": [s["status"] for s in statuses],
})
except HTTPException:

View File

@ -0,0 +1,197 @@
from typing import List, Dict, Optional, Set
from app.core.database import execute_query
class RelationService:
"""Service for handling case relations (Sager)"""
@staticmethod
def get_relation_tree(root_id: int) -> List[Dict]:
"""
Builds a hierarchical tree of relations for a specific case.
Handles cycles and deduplication.
"""
# 1. Fetch all connected cases (Recursive CTE)
# We fetch a network around the case, but limit depth/count to avoid explosion
tree_ids_query = """
WITH RECURSIVE CaseTree AS (
SELECT id, 0 as depth FROM sag_sager WHERE id = %s
UNION
SELECT
CASE WHEN sr.kilde_sag_id = ct.id THEN sr.målsag_id ELSE sr.kilde_sag_id END,
ct.depth + 1
FROM sag_relationer sr
JOIN CaseTree ct ON sr.kilde_sag_id = ct.id OR sr.målsag_id = ct.id
WHERE sr.deleted_at IS NULL AND ct.depth < 5
)
SELECT DISTINCT id FROM CaseTree LIMIT 100;
"""
tree_ids_rows = execute_query(tree_ids_query, (root_id,))
tree_ids = [r['id'] for r in tree_ids_rows]
if not tree_ids:
return []
# 2. Fetch details for these cases
placeholders = ','.join(['%s'] * len(tree_ids))
tree_cases_query = f"SELECT id, titel, status FROM sag_sager WHERE id IN ({placeholders})"
tree_cases = {c['id']: c for c in execute_query(tree_cases_query, tuple(tree_ids))}
# 3. Fetch all edges between these cases
tree_edges_query = f"""
SELECT id, kilde_sag_id, målsag_id, relationstype
FROM sag_relationer
WHERE deleted_at IS NULL
AND kilde_sag_id IN ({placeholders})
AND målsag_id IN ({placeholders})
"""
tree_edges = execute_query(tree_edges_query, tuple(tree_ids) * 2)
# 4. Build Graph (Adjacency List)
children_map: Dict[int, List[Dict]] = {cid: [] for cid in tree_ids}
parents_map: Dict[int, List[int]] = {cid: [] for cid in tree_ids}
# Helper to normalize relation types
# Now that we cleaned DB, we expect standard Danish terms, but good to be safe
def get_direction(k, m, rtype):
rtype_lower = rtype.lower()
if rtype_lower in ['afledt af', 'derived from']:
return m, k # m is parent of k
if rtype_lower in ['årsag til', 'cause of']:
return k, m # k is parent of m
# Default: k is "related" to m, treat as child for visualization if k is current root context
# But here we build a directed graph.
# If relation is symmetric (Relateret til), we must be careful not to create cycle A->B->A
return k, m
processed_edges = set()
for edge in tree_edges:
k, m, rtype = edge['kilde_sag_id'], edge['målsag_id'], edge['relationstype']
# Dedup edges (bi-directional check)
edge_key = tuple(sorted((k,m))) + (rtype,)
# Actually, standardizing direction is key.
# If we have (k, m, 'Relateret til'), we add it once.
parent, child = get_direction(k, m, rtype)
# Avoid self-loops
if parent == child:
continue
# Add to maps
children_map[parent].append({
'id': child,
'type': rtype,
'rel_id': edge['id']
})
parents_map[child].append(parent)
# 5. Build Tree
# We want the `root_id` to be the visual root if possible.
# But if `root_id` is a child of something else in this graph, we might want to show that parent?
# The current design shows the requested case as root, OR finds the "true" root?
# The original code acted a bit vaguely on "roots".
# Let's try to center the view on `root_id`.
# Global visited set to prevent any node from appearing more than once in the entire tree
# This prevents the "duplicate entries" issue where a shared child appears under multiple parents
# However, it makes it hard to see shared dependencies.
# Standard approach: Show duplicate but mark it as "reference" or stop expansion.
global_visited = set()
def build_node(cid: int, path_visited: Set[int], current_rel_type: str = None, current_rel_id: int = None):
if cid not in tree_cases:
return None
# Cycle detection in current path
if cid in path_visited:
return {
'case': tree_cases[cid],
'relation_type': current_rel_type,
'relation_id': current_rel_id,
'is_current': cid == root_id,
'is_cycle': True,
'children': []
}
path_visited.add(cid)
# Sort children for consistent display
children_data = sorted(children_map.get(cid, []), key=lambda x: x['type'])
children_nodes = []
for child_info in children_data:
child_id = child_info['id']
# Check if we've seen this node globally to prevent tree duplication explosion
# If we want a strict tree where every node appears once:
# if child_id in global_visited: continue
# But users usually want to see the context.
# Let's check if the user wanted "Duplicate entries" removed.
# Yes. So let's use global_visited, OR just show a "Link" node.
# Using path_visited.copy() allows Multi-Parent display (A->C, B->C)
# creating visual duplicates.
# If we use global_visited, C only appears under A (if A processed first).
# Compromise: We only expand children if NOT globally visited.
# If globally visited, we show the node but no children (Leaf ref).
is_repeated = child_id in global_visited
global_visited.add(child_id)
child_node = build_node(child_id, path_visited.copy(), child_info['type'], child_info['rel_id'])
if child_node:
if is_repeated:
child_node['children'] = []
child_node['is_repeated'] = True
children_nodes.append(child_node)
return {
'case': tree_cases[cid],
'relation_type': current_rel_type,
'relation_id': current_rel_id,
'is_current': cid == root_id,
'children': children_nodes
}
# Determine Roots:
# If we just want to show the tree FROM the current case downwards (and upwards?),
# the original view mixed everything.
# Let's try to find the "top-most" parents of the current case, to show the full context.
# Traverse up from root_id to find a root
curr = root_id
while parents_map[curr]:
# Pick first parent (naive) - creates a single primary ancestry path
curr = parents_map[curr][0]
if curr == root_id: break # Cycle
effective_root = curr
# Build tree starting from effective root
global_visited.add(effective_root)
full_tree = build_node(effective_root, set())
if not full_tree:
return []
return [full_tree]
@staticmethod
def add_relation(source_id: int, target_id: int, type: str):
"""Creates a relation between two cases."""
query = """
INSERT INTO sag_relationer (kilde_sag_id, målsag_id, relationstype)
VALUES (%s, %s, %s)
RETURNING id
"""
return execute_query(query, (source_id, target_id, type))
@staticmethod
def remove_relation(relation_id: int):
"""Soft deletes a relation."""
query = "UPDATE sag_relationer SET deleted_at = NOW() WHERE id = %s"
execute_query(query, (relation_id,))

View File

@ -148,25 +148,6 @@
</div>
<form id="createForm" novalidate>
<!-- Section: Basic Info -->
<div class="row g-4 mb-4">
<div class="col-md-12">
<label for="titel" class="form-label">Titel <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0"><i class="bi bi-type-h1"></i></span>
<input type="text" class="form-control border-start-0 ps-0" id="titel" placeholder="Kort og præcis titel (f.eks. 'Netværksproblemer hos X')" required>
</div>
</div>
<div class="col-md-12">
<label for="beskrivelse" class="form-label">Beskrivelse</label>
<textarea class="form-control" id="beskrivelse" rows="5" placeholder="Beskriv problemstillingen detaljeret..."></textarea>
<div class="form-text text-end" id="charCount">0 tegn</div>
</div>
</div>
<hr class="my-4 opacity-25">
<!-- Section: Relations -->
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Relationer</h5>
<div class="row g-4 mb-4">
@ -183,13 +164,13 @@
<div id="selectedContacts" class="mt-2 text-wrap"></div>
</div>
<!-- Customer Search -->
<!-- Company Search -->
<div class="col-md-6">
<label class="form-label">Kunde</label>
<label class="form-label">Firma</label>
<div class="search-position-relative">
<div class="input-group">
<span class="input-group-text bg-light border-end-0"><i class="bi bi-building"></i></span>
<input type="text" id="customerSearch" class="form-control border-start-0 ps-0" placeholder="Søg kunde (min. 2 tegn)...">
<input type="text" id="customerSearch" class="form-control border-start-0 ps-0" placeholder="Søg firma (min. 2 tegn)...">
</div>
<div id="customerResults" class="search-results shadow-sm d-none"></div>
</div>
@ -200,6 +181,25 @@
<hr class="my-4 opacity-25">
<!-- Section: Basic Info -->
<div class="row g-4 mb-4">
<div class="col-md-12">
<label for="titel" class="form-label">Titel <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0"><i class="bi bi-type-h1"></i></span>
<input type="text" class="form-control border-start-0 ps-0" id="titel" placeholder="Kort og præcis titel (f.eks. 'Netværksproblemer hos X')" required>
</div>
</div>
<div class="col-md-12">
<label for="beskrivelse" class="form-label">Beskrivelse</label>
<textarea class="form-control" id="beskrivelse" rows="5" placeholder="Beskriv problemstillingen detaljeret..."></textarea>
<div class="form-text text-end" id="charCount">0 tegn</div>
</div>
</div>
<hr class="my-4 opacity-25">
<!-- Section: Hardware & AnyDesk -->
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Hardware (AnyDesk)</h5>
<div class="row g-4 mb-4">

File diff suppressed because it is too large Load Diff

View File

@ -192,11 +192,12 @@
<div class="form-group">
<label for="type">Type *</label>
<select class="form-select" id="type" required>
<option value="ticket" {% if case.type == 'ticket' %}selected{% endif %}>🎫 Ticket</option>
<option value="opgave" {% if case.type == 'opgave' %}selected{% endif %}>🧩 Opgave</option>
<option value="ordre" {% if case.type == 'ordre' %}selected{% endif %}>🧾 Ordre</option>
<option value="projekt" {% if case.type == 'projekt' %}selected{% endif %}>📁 Projekt</option>
<option value="service" {% if case.type == 'service' %}selected{% endif %}>🛠️ Service</option>
<option value="ticket" {% if (case.template_key or case.type) == 'ticket' %}selected{% endif %}>🎫 Ticket</option>
<option value="pipeline" {% if (case.template_key or case.type) == 'pipeline' %}selected{% endif %}>📈 Pipeline</option>
<option value="opgave" {% if (case.template_key or case.type) == 'opgave' %}selected{% endif %}>🧩 Opgave</option>
<option value="ordre" {% if (case.template_key or case.type) == 'ordre' %}selected{% endif %}>🧾 Ordre</option>
<option value="projekt" {% if (case.template_key or case.type) == 'projekt' %}selected{% endif %}>📁 Projekt</option>
<option value="service" {% if (case.template_key or case.type) == 'service' %}selected{% endif %}>🛠️ Service</option>
</select>
</div>
@ -231,7 +232,7 @@
<script>
const caseId = {{ case.id }};
const currentType = "{{ case.type or 'ticket' }}";
const currentType = "{{ case.template_key or case.type or 'ticket' }}";
async function loadCaseTypesSelect() {
const select = document.getElementById('type');
@ -243,7 +244,12 @@
const types = JSON.parse(setting.value || '[]');
if (!Array.isArray(types) || types.length === 0) return;
select.innerHTML = types
const typeSet = new Set(types);
if (currentType) {
typeSet.add(currentType);
}
select.innerHTML = Array.from(typeSet)
.map((type) => `<option value="${type}" ${type === currentType ? 'selected' : ''}>${type}</option>`)
.join('');
} catch (err) {

File diff suppressed because it is too large Load Diff

View File

@ -4,92 +4,112 @@
{% block extra_css %}
<style>
.stage-pill {
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
padding: 0.25rem 0.6rem;
border-radius: 999px;
font-size: 0.8rem;
font-size: 0.75rem;
font-weight: 600;
background: rgba(15, 76, 117, 0.1);
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status-open {
background-color: #e3f2fd;
color: #0d47a1;
border: 1px solid rgba(13, 71, 161, 0.1);
}
.status-closed {
background-color: #f5f5f5;
color: #616161;
border: 1px solid rgba(0,0,0,0.05);
}
.stage-dot {
width: 8px;
height: 8px;
border-radius: 50%;
.opportunity-row:hover {
background-color: rgba(0,0,0,0.02);
}
.opportunity-row td {
vertical-align: middle;
padding-top: 0.75rem;
padding-bottom: 0.75rem;
}
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="fw-bold mb-1">Muligheder</h2>
<p class="text-muted mb-0">Hublokal salgspipeline</p>
<div class="container-fluid px-4 py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="fw-bold mb-1">Muligheder</h2>
<p class="text-muted mb-0">Sager markeret som pipeline</p>
</div>
<div>
<button class="btn btn-primary" onclick="openCreateOpportunityModal()">
<i class="bi bi-plus-lg me-2"></i>Opret mulighed
</button>
</div>
</div>
<div class="d-flex gap-2">
<a class="btn btn-outline-primary" href="/pipeline">
<i class="bi bi-kanban me-2"></i>Sales Board
</a>
<button class="btn btn-primary" onclick="openCreateOpportunityModal()">
<i class="bi bi-plus-lg me-2"></i>Opret mulighed
</button>
</div>
</div>
<div class="card p-3 mb-4">
<div class="row g-2 align-items-center">
<div class="col-md-4">
<input type="text" class="form-control" id="searchInput" placeholder="Søg titel eller kunde..." oninput="renderOpportunities()">
</div>
<div class="col-md-3">
<select class="form-select" id="stageFilter" onchange="renderOpportunities()"></select>
</div>
<div class="col-md-3">
<select class="form-select" id="statusFilter" onchange="renderOpportunities()">
<option value="all">Alle status</option>
<option value="open">Åbne</option>
<option value="won">Vundet</option>
<option value="lost">Tabt</option>
</select>
</div>
<div class="col-md-2 text-end">
<span class="text-muted small" id="countLabel">0 muligheder</span>
<div class="card p-3 mb-4 shadow-sm border-0">
<div class="row g-3 align-items-center">
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search text-muted"></i></span>
<input type="text" class="form-control border-start-0 ps-0" id="searchInput" placeholder="Søg titel eller kunde..." oninput="filterOpportunities()">
</div>
</div>
<div class="col-md-3">
<select class="form-select" id="stageFilter" onchange="filterOpportunities()">
<option value="all">Alle stages</option>
</select>
</div>
<div class="col-md-2">
<select class="form-select" id="statusFilter" onchange="filterOpportunities()">
<option value="all">Alle status</option>
<option value="open" selected>Åbne</option>
<option value="closed">Lukkede</option>
</select>
</div>
<div class="col-md-3 text-end">
<span class="badge bg-light text-dark border" id="countLabel">Henter data...</span>
</div>
</div>
</div>
</div>
<div class="card">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Titel</th>
<th>Kunde</th>
<th>Beløb</th>
<th>Lukningsdato</th>
<th>Stage</th>
<th>Sandsynlighed</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody id="opportunitiesTable">
<tr>
<td colspan="7" class="text-center py-5">
<div class="spinner-border text-primary"></div>
</td>
</tr>
</tbody>
</table>
<div class="card shadow-sm border-0 overflow-hidden">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-light">
<tr>
<th class="ps-4">Titel</th>
<th>Kunde</th>
<th>Stage</th>
<th class="text-end">Sandsynlighed</th>
<th class="text-end">Beløb</th>
<th>Status</th>
<th>Deadline</th>
<th>Ansvarlig</th>
<th>Beskrivelse</th>
<th class="text-end pe-4">Handling</th>
</tr>
</thead>
<tbody id="opportunitiesTable">
<tr>
<td colspan="10" class="text-center py-5">
<div class="spinner-border text-primary spinner-border-sm me-2"></div>
<span class="text-muted">Indlæser muligheder...</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Create Opportunity Modal -->
<div class="modal fade" id="opportunityModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Opret mulighed</h5>
@ -97,207 +117,268 @@
</div>
<div class="modal-body">
<form id="opportunityForm">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Kunde *</label>
<select class="form-select" id="customerId" required></select>
</div>
<div class="col-md-6">
<label class="form-label">Titel *</label>
<input type="text" class="form-control" id="title" required>
</div>
<div class="col-md-6">
<label class="form-label">Beløb</label>
<input type="number" step="0.01" class="form-control" id="amount">
</div>
<div class="col-md-6">
<label class="form-label">Valuta</label>
<select class="form-select" id="currency">
<option value="DKK">DKK</option>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Stage</label>
<select class="form-select" id="stageId"></select>
</div>
<div class="col-md-6">
<label class="form-label">Forventet lukning</label>
<input type="date" class="form-control" id="expectedCloseDate">
</div>
<div class="col-12">
<label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="description" rows="3"></textarea>
</div>
<div class="mb-3">
<label class="form-label small text-uppercase text-muted fw-bold">Titel *</label>
<input type="text" class="form-control" id="title" required placeholder="Fx Opgradering af serverpark">
</div>
<div class="mb-3">
<label class="form-label small text-uppercase text-muted fw-bold">Kunde *</label>
<select class="form-select" id="customerId" required>
<option value="">Vælg kunde...</option>
</select>
</div>
<div class="mb-3">
<label class="form-label small text-uppercase text-muted fw-bold">Forventet lukning (Deadline)</label>
<input type="date" class="form-control" id="expectedCloseDate">
</div>
<div class="mb-3">
<label class="form-label small text-uppercase text-muted fw-bold">Beskrivelse</label>
<textarea class="form-control" id="description" rows="4" placeholder="Beskriv muligheden..."></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="createOpportunity()">Gem</button>
<div class="modal-footer bg-light">
<button type="button" class="btn btn-link text-decoration-none text-muted" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary px-4" onclick="createOpportunity()">Opret</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let opportunities = [];
let stages = [];
let allOpportunities = [];
let customers = [];
document.addEventListener('DOMContentLoaded', async () => {
try {
await loadStages();
} catch (error) {
console.error('Error loading stages:', error);
// Load data in parallel
const [custRes, oppRes] = await Promise.allSettled([
fetch('/api/v1/customers?limit=1000'),
fetch('/api/v1/opportunities')
]);
// Handle customers
if (custRes.status === 'fulfilled') {
const data = await custRes.value.json();
customers = Array.isArray(data) ? data : (data.customers || []);
renderCustomerSelect();
}
try {
await loadCustomers();
} catch (error) {
console.error('Error loading customers:', error);
// Handle opportunities
if (oppRes.status === 'fulfilled') {
allOpportunities = await oppRes.value.json();
renderStageFilter();
filterOpportunities();
} else {
document.getElementById('opportunitiesTable').innerHTML =
'<tr><td colspan="10" class="text-center text-danger py-5">Kunne ikke hente data</td></tr>';
}
try {
await loadOpportunities();
} catch (error) {
console.error('Error loading opportunities:', error);
const tbody = document.getElementById('opportunitiesTable');
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-danger py-5">Fejl ved indlæsning af muligheder</td></tr>';
document.getElementById('countLabel').textContent = '0 muligheder';
}
// Setup search listener
document.getElementById('searchInput').addEventListener('input', debounce(filterOpportunities, 300));
});
async function loadStages() {
const response = await fetch('/api/v1/pipeline/stages');
stages = await response.json();
const stageFilter = document.getElementById('stageFilter');
stageFilter.innerHTML = '<option value="all">Alle stages</option>' +
stages.map(s => `<option value="${s.id}">${s.name}</option>`).join('');
const stageSelect = document.getElementById('stageId');
stageSelect.innerHTML = stages.map(s => `<option value="${s.id}">${s.name}</option>`).join('');
}
async function loadCustomers() {
const response = await fetch('/api/v1/customers?limit=1000');
const data = await response.json();
customers = Array.isArray(data) ? data : (data.customers || []);
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
};
function renderCustomerSelect() {
const select = document.getElementById('customerId');
// Sort customers by name
customers.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
select.innerHTML = '<option value="">Vælg kunde...</option>' +
customers.map(c => `<option value="${c.id}">${escapeHtml(c.name)}</option>`).join('');
}
async function loadOpportunities() {
const response = await fetch('/api/v1/opportunities');
opportunities = await response.json();
renderOpportunities();
function renderStageFilter() {
const select = document.getElementById('stageFilter');
if (!select) return;
const current = select.value || 'all';
const stages = [...new Set(
allOpportunities
.map(o => (o.pipeline_stage || '').trim())
.filter(Boolean)
)].sort((a, b) => a.localeCompare(b, 'da'));
select.innerHTML = '<option value="all">Alle stages</option>' +
stages.map(stage => `<option value="${escapeHtml(stage)}">${escapeHtml(stage)}</option>`).join('');
if (current !== 'all' && stages.includes(current)) {
select.value = current;
}
}
function renderOpportunities() {
function filterOpportunities() {
const search = document.getElementById('searchInput').value.toLowerCase();
const stageFilter = document.getElementById('stageFilter').value;
const statusFilter = document.getElementById('statusFilter').value;
const filtered = opportunities.filter(o => {
const text = `${o.title} ${o.customer_name}`.toLowerCase();
if (search && !text.includes(search)) return false;
if (stageFilter !== 'all' && parseInt(stageFilter) !== o.stage_id) return false;
if (statusFilter === 'won' && !o.is_won) return false;
if (statusFilter === 'lost' && !o.is_lost) return false;
if (statusFilter === 'open' && (o.is_won || o.is_lost)) return false;
return true;
const filtered = allOpportunities.filter(o => {
// Search filter
const matchSearch = !search ||
(o.titel && o.titel.toLowerCase().includes(search)) ||
(o.customer_name && o.customer_name.toLowerCase().includes(search));
// Status filter
let matchStatus = true;
if (statusFilter === 'open') matchStatus = o.status === 'åben';
if (statusFilter === 'closed') matchStatus = o.status !== 'åben';
let matchStage = true;
if (stageFilter !== 'all') {
matchStage = (o.pipeline_stage || '') === stageFilter;
}
return matchSearch && matchStatus && matchStage;
});
renderTable(filtered);
}
function renderTable(data) {
const tbody = document.getElementById('opportunitiesTable');
if (filtered.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-5">Ingen muligheder fundet</td></tr>';
document.getElementById('countLabel').textContent = '0 muligheder';
document.getElementById('countLabel').textContent = `${data.length} fundet`;
if (data.length === 0) {
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-muted py-5">Ingen muligheder fundet</td></tr>';
return;
}
tbody.innerHTML = filtered.map(o => `
<tr class="opportunity-row" style="cursor:pointer" onclick="goToDetail(${o.id})">
<td class="fw-semibold">${escapeHtml(o.title)}</td>
<td>${escapeHtml(o.customer_name || '-')}</td>
<td>${formatCurrency(o.amount, o.currency)}</td>
<td>${o.expected_close_date ? formatDate(o.expected_close_date) : '<span class=\"text-muted\">-</span>'}</td>
tbody.innerHTML = data.map(o => {
const statusClass = (o.status === 'åben') ? 'status-open' : 'status-closed';
const dateStr = o.deadline ? formatDate(o.deadline) : '<span class="text-muted">-</span>';
const stage = o.pipeline_stage ? escapeHtml(o.pipeline_stage) : '<span class="text-muted">-</span>';
const probability = Number.isFinite(Number(o.pipeline_probability)) ? `${Number(o.pipeline_probability)}%` : '<span class="text-muted">-</span>';
const amount = (o.pipeline_amount === null || o.pipeline_amount === undefined)
? '<span class="text-muted">-</span>'
: formatCurrency(o.pipeline_amount);
const descPreview = o.beskrivelse ?
(o.beskrivelse.length > 50 ? escapeHtml(o.beskrivelse.substring(0, 50)) + '...' : escapeHtml(o.beskrivelse))
: '<span class="text-muted fst-italic">Ingen beskrivelse</span>';
return `
<tr class="opportunity-row" style="cursor:pointer" onclick="window.location.href='/sag/${o.id}'">
<td class="fw-semibold ps-4">${escapeHtml(o.titel)}</td>
<td>${escapeHtml(o.customer_name)}</td>
<td>${stage}</td>
<td class="text-end">${probability}</td>
<td class="text-end">${amount}</td>
<td>
<span class="stage-pill">
<span class="stage-dot" style="background:${o.stage_color || '#0f4c75'}"></span>
${escapeHtml(o.stage_name || '-')}
<span class="status-badge ${statusClass}">
${escapeHtml(o.status)}
</span>
</td>
<td>${o.probability || 0}%</td>
<td class="text-end">
<i class="bi bi-arrow-right"></i>
<td>${dateStr}</td>
<td>${escapeHtml(o.ansvarlig_navn)}</td>
<td class="small text-muted">${descPreview}</td>
<td class="text-end pe-4 text-muted">
<i class="bi bi-chevron-right"></i>
</td>
</tr>
`).join('');
document.getElementById('countLabel').textContent = `${filtered.length} muligheder`;
`;
}).join('');
}
function openCreateOpportunityModal() {
document.getElementById('opportunityForm').reset();
const modal = new bootstrap.Modal(document.getElementById('opportunityModal'));
modal.show();
new bootstrap.Modal(document.getElementById('opportunityModal')).show();
}
async function createOpportunity() {
const title = document.getElementById('title').value;
const customerId = document.getElementById('customerId').value;
const desc = document.getElementById('description').value;
const closeDate = document.getElementById('expectedCloseDate').value;
if (!title || !customerId) {
alert('Titel og Kunde skal udfyldes');
return;
}
const payload = {
customer_id: parseInt(document.getElementById('customerId').value),
title: document.getElementById('title').value,
description: document.getElementById('description').value || null,
amount: parseFloat(document.getElementById('amount').value || 0),
currency: document.getElementById('currency').value,
stage_id: parseInt(document.getElementById('stageId').value || 0) || null,
expected_close_date: document.getElementById('expectedCloseDate').value || null
title: title,
customer_id: parseInt(customerId),
description: desc,
expected_close_date: closeDate || null
};
if (!payload.customer_id || !payload.title) {
alert('Kunde og titel er påkrævet');
return;
try {
const btn = document.querySelector('#opportunityModal .btn-primary');
const originalText = btn.textContent;
btn.textContent = 'Gemmer...';
btn.disabled = true;
const res = await fetch('/api/v1/opportunities', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Kunne ikke oprette');
}
const newCase = await res.json();
// Success
bootstrap.Modal.getInstance(document.getElementById('opportunityModal')).hide();
// Reload list
const oppRes = await fetch('/api/v1/opportunities');
allOpportunities = await oppRes.json();
filterOpportunities();
// Reset btn
btn.textContent = originalText;
btn.disabled = false;
} catch (e) {
alert('Fejl: ' + e.message);
const btn = document.querySelector('#opportunityModal .btn-primary');
btn.textContent = 'Opret';
btn.disabled = false;
}
const response = await fetch('/api/v1/opportunities', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
alert('Kunne ikke oprette mulighed');
return;
}
bootstrap.Modal.getInstance(document.getElementById('opportunityModal')).hide();
await loadOpportunities();
}
function goToDetail(id) {
window.location.href = `/opportunities/${id}`;
function formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
return d.toLocaleDateString('da-DK', {day: 'numeric', month: 'short', year: 'numeric'});
}
function formatCurrency(value, currency) {
const num = parseFloat(value || 0);
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(num);
}
function formatDate(value) {
return new Date(value).toLocaleDateString('da-DK');
function formatCurrency(value) {
return new Intl.NumberFormat('da-DK', {
style: 'currency',
currency: 'DKK',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(Number(value || 0));
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
if (text === null || text === undefined) return '';
return String(text)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@ -9,11 +9,3 @@ 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
})

View File

@ -1,22 +0,0 @@
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")
)

View File

@ -72,23 +72,39 @@ async def get_setting(key: str):
query = "SELECT * FROM settings WHERE key = %s"
result = execute_query(query, (key,))
if not result and key == "case_types":
if not result and key in {"case_types", "case_type_module_defaults"}:
seed_query = """
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (key) DO NOTHING
"""
execute_query(
seed_query,
(
"case_types",
'["ticket", "opgave", "ordre", "projekt", "service"]',
"system",
"Sags-typer",
"json",
True,
if key == "case_types":
execute_query(
seed_query,
(
"case_types",
'["ticket", "opgave", "ordre", "projekt", "service"]',
"system",
"Sags-typer",
"json",
True,
)
)
)
if key == "case_type_module_defaults":
execute_query(
seed_query,
(
"case_type_module_defaults",
'{"ticket": ["relations", "call-history", "files", "emails", "hardware", "locations", "contacts", "customers", "wiki", "todo-steps", "time", "solution", "sales", "subscription", "reminders", "calendar"], "opgave": ["relations", "call-history", "files", "emails", "hardware", "locations", "contacts", "customers", "wiki", "todo-steps", "time", "solution", "sales", "subscription", "reminders", "calendar"], "ordre": ["relations", "call-history", "files", "emails", "hardware", "locations", "contacts", "customers", "wiki", "todo-steps", "time", "solution", "sales", "subscription", "reminders", "calendar"], "projekt": ["relations", "call-history", "files", "emails", "hardware", "locations", "contacts", "customers", "wiki", "todo-steps", "time", "solution", "sales", "subscription", "reminders", "calendar"], "service": ["relations", "call-history", "files", "emails", "hardware", "locations", "contacts", "customers", "wiki", "todo-steps", "time", "solution", "sales", "subscription", "reminders", "calendar"]}',
"system",
"Standard moduler pr. sagstype",
"json",
True,
)
)
result = execute_query(query, (key,))
if not result:

View File

@ -1043,6 +1043,34 @@ async def scan_document(file_path: str):
<div class="text-muted">Indlæser...</div>
</div>
</div>
<div class="card p-4 mt-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h5 class="mb-1 fw-bold">Standardmoduler pr. sagstype</h5>
<p class="text-muted mb-0">Vælg hvilke moduler der vises som standard for hver sagstype. Moduler med indhold vises altid.</p>
</div>
</div>
<div class="row g-3 align-items-end mb-3">
<div class="col-md-4">
<label class="form-label">Sagstype</label>
<select id="caseTypeModulesTypeSelect" class="form-select" onchange="renderCaseTypeModuleChecklist()">
<option value="">Vælg sagstype...</option>
</select>
</div>
<div class="col-md-8 text-md-end">
<button class="btn btn-outline-secondary me-2" onclick="resetCaseTypeModuleDefaults()">
<i class="bi bi-arrow-counterclockwise me-1"></i>Nulstil til standard
</button>
<button class="btn btn-primary" onclick="saveCaseTypeModuleDefaults()">
<i class="bi bi-save me-1"></i>Gem standardmoduler
</button>
</div>
</div>
<div id="caseTypeModuleChecklist" class="row g-2">
<div class="text-muted">Indlæser...</div>
</div>
</div>
</div>
</div>
</div>
@ -1878,20 +1906,123 @@ function getCaseTypesSetting() {
return allSettings.find(setting => setting.key === 'case_types');
}
const CASE_MODULE_OPTIONS = [
'relations', 'call-history', 'files', 'emails', 'hardware', 'locations',
'contacts', 'customers', 'wiki', 'todo-steps', 'time', 'solution',
'sales', 'subscription', 'reminders', 'calendar'
];
const CASE_MODULE_LABELS = {
'relations': 'Relationer',
'call-history': 'Opkaldshistorik',
'files': 'Filer',
'emails': 'E-mails',
'hardware': 'Hardware',
'locations': 'Lokationer',
'contacts': 'Kontakter',
'customers': 'Kunder',
'wiki': 'Wiki',
'todo-steps': 'Todo-opgaver',
'time': 'Tid',
'solution': 'Løsning',
'sales': 'Varekøb & salg',
'subscription': 'Abonnement',
'reminders': 'Påmindelser',
'calendar': 'Kalender'
};
let caseTypeModuleDefaultsCache = {};
function normalizeCaseTypeModuleDefaults(raw, caseTypes) {
const normalized = {};
const rawObj = raw && typeof raw === 'object' ? raw : {};
const validTypes = Array.isArray(caseTypes) ? caseTypes : [];
validTypes.forEach(type => {
const existing = rawObj[type];
const asList = Array.isArray(existing) ? existing : CASE_MODULE_OPTIONS;
normalized[type] = asList.filter(m => CASE_MODULE_OPTIONS.includes(m));
});
return normalized;
}
async function loadCaseTypeModuleDefaultsSetting(caseTypes) {
try {
const response = await fetch('/api/v1/settings/case_type_module_defaults');
if (!response.ok) {
caseTypeModuleDefaultsCache = normalizeCaseTypeModuleDefaults({}, caseTypes);
} else {
const setting = await response.json();
const parsed = JSON.parse(setting.value || '{}');
caseTypeModuleDefaultsCache = normalizeCaseTypeModuleDefaults(parsed, caseTypes);
}
} catch (error) {
console.error('Error loading case type module defaults:', error);
caseTypeModuleDefaultsCache = normalizeCaseTypeModuleDefaults({}, caseTypes);
}
renderCaseTypeModuleTypeOptions(caseTypes);
renderCaseTypeModuleChecklist();
}
function renderCaseTypeModuleTypeOptions(caseTypes) {
const select = document.getElementById('caseTypeModulesTypeSelect');
if (!select) return;
const previous = select.value;
select.innerHTML = '<option value="">Vælg sagstype...</option>' +
(caseTypes || []).map(type => `<option value="${type}">${type}</option>`).join('');
if (previous && (caseTypes || []).includes(previous)) {
select.value = previous;
} else if ((caseTypes || []).length > 0) {
select.value = caseTypes[0];
}
}
function renderCaseTypeModuleChecklist() {
const container = document.getElementById('caseTypeModuleChecklist');
const select = document.getElementById('caseTypeModulesTypeSelect');
if (!container || !select) return;
const type = select.value;
if (!type) {
container.innerHTML = '<div class="text-muted">Vælg en sagstype for at redigere standardmoduler.</div>';
return;
}
const enabledModules = new Set(caseTypeModuleDefaultsCache[type] || CASE_MODULE_OPTIONS);
container.innerHTML = CASE_MODULE_OPTIONS.map(moduleKey => `
<div class="col-md-4 col-sm-6">
<div class="form-check border rounded p-2">
<input class="form-check-input" type="checkbox" id="ctmod_${moduleKey}" ${enabledModules.has(moduleKey) ? 'checked' : ''}>
<label class="form-check-label" for="ctmod_${moduleKey}">
${CASE_MODULE_LABELS[moduleKey] || moduleKey}
</label>
</div>
</div>
`).join('');
}
async function loadCaseTypesSetting() {
try {
const response = await fetch('/api/v1/settings/case_types');
if (!response.ok) {
renderCaseTypes([]);
await loadCaseTypeModuleDefaultsSetting([]);
return;
}
const setting = await response.json();
const rawValue = setting.value || '[]';
const parsed = JSON.parse(rawValue);
renderCaseTypes(Array.isArray(parsed) ? parsed : []);
const types = Array.isArray(parsed) ? parsed : [];
renderCaseTypes(types);
await loadCaseTypeModuleDefaultsSetting(types);
} catch (error) {
console.error('Error loading case types:', error);
renderCaseTypes([]);
await loadCaseTypeModuleDefaultsSetting([]);
}
}
@ -1916,12 +2047,17 @@ function renderCaseTypes(types) {
async function saveCaseTypes(types) {
await updateSetting('case_types', JSON.stringify(types));
renderCaseTypes(types);
caseTypeModuleDefaultsCache = normalizeCaseTypeModuleDefaults(caseTypeModuleDefaultsCache, types);
renderCaseTypeModuleTypeOptions(types);
renderCaseTypeModuleChecklist();
await updateSetting('case_type_module_defaults', JSON.stringify(caseTypeModuleDefaultsCache));
}
async function addCaseType() {
const input = document.getElementById('caseTypeInput');
if (!input) return;
const value = input.value.trim();
const value = input.value.trim().toLowerCase();
if (!value) return;
const response = await fetch('/api/v1/settings/case_types');
@ -1947,6 +2083,48 @@ async function removeCaseType(type) {
await saveCaseTypes(filtered);
}
async function saveCaseTypeModuleDefaults() {
const select = document.getElementById('caseTypeModulesTypeSelect');
const type = select ? select.value : '';
if (!type) {
alert('Vælg en sagstype først');
return;
}
const enabled = CASE_MODULE_OPTIONS.filter(moduleKey => {
const checkbox = document.getElementById(`ctmod_${moduleKey}`);
return checkbox ? checkbox.checked : false;
});
caseTypeModuleDefaultsCache[type] = enabled;
await updateSetting('case_type_module_defaults', JSON.stringify(caseTypeModuleDefaultsCache));
if (typeof showNotification === 'function') {
showNotification('Standardmoduler gemt', 'success');
}
}
async function resetCaseTypeModuleDefaults() {
const select = document.getElementById('caseTypeModulesTypeSelect');
const type = select ? select.value : '';
if (!type) {
alert('Vælg en sagstype først');
return;
}
caseTypeModuleDefaultsCache[type] = [...CASE_MODULE_OPTIONS];
renderCaseTypeModuleChecklist();
try {
await updateSetting('case_type_module_defaults', JSON.stringify(caseTypeModuleDefaultsCache));
if (typeof showNotification === 'function') {
showNotification('Standardmoduler nulstillet', 'success');
}
} catch (error) {
if (typeof showNotification === 'function') {
showNotification('Kunne ikke nulstille standardmoduler', 'error');
}
}
}
let usersCache = [];
let groupsCache = [];
let permissionsCache = [];

41
apply_migration_128.py Normal file
View File

@ -0,0 +1,41 @@
import logging
import os
import sys
sys.path.append(os.getcwd())
from app.core.database import execute_query, init_db
from app.core.config import settings
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def run_migration():
migration_file = "migrations/128_sag_pipeline_fields.sql"
logger.info("Applying migration: %s", migration_file)
try:
if "@postgres" in settings.DATABASE_URL:
logger.info("Patching DATABASE_URL for local run")
settings.DATABASE_URL = settings.DATABASE_URL.replace("@postgres", "@localhost").replace(":5432", ":5433")
init_db()
with open(migration_file, "r", encoding="utf-8") as migration:
sql = migration.read()
commands = [cmd.strip() for cmd in sql.split(";") if cmd.strip()]
for command in commands:
logger.info("Executing migration statement...")
execute_query(command, ())
logger.info("✅ Migration 128 applied successfully")
except Exception as exc:
logger.error("❌ Migration 128 failed: %s", exc)
sys.exit(1)
if __name__ == "__main__":
run_migration()

41
apply_migration_129.py Normal file
View File

@ -0,0 +1,41 @@
import logging
import os
import sys
sys.path.append(os.getcwd())
from app.core.database import execute_query, init_db
from app.core.config import settings
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def run_migration():
migration_file = "migrations/129_sag_pipeline_description.sql"
logger.info("Applying migration: %s", migration_file)
try:
if "@postgres" in settings.DATABASE_URL:
logger.info("Patching DATABASE_URL for local run")
settings.DATABASE_URL = settings.DATABASE_URL.replace("@postgres", "@localhost").replace(":5432", ":5433")
init_db()
with open(migration_file, "r", encoding="utf-8") as migration:
sql = migration.read()
commands = [cmd.strip() for cmd in sql.split(";") if cmd.strip()]
for command in commands:
logger.info("Executing migration statement...")
execute_query(command, ())
logger.info("✅ Migration 129 applied successfully")
except Exception as exc:
logger.error("❌ Migration 129 failed: %s", exc)
sys.exit(1)
if __name__ == "__main__":
run_migration()

View File

@ -0,0 +1,38 @@
CREATE TABLE IF NOT EXISTS pipeline_stages (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE,
color VARCHAR(20) DEFAULT '#0f4c75',
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO pipeline_stages (name, color, sort_order)
SELECT 'Lead', '#6c757d', 10
WHERE NOT EXISTS (SELECT 1 FROM pipeline_stages WHERE LOWER(name) = 'lead');
INSERT INTO pipeline_stages (name, color, sort_order)
SELECT 'Kontakt', '#17a2b8', 20
WHERE NOT EXISTS (SELECT 1 FROM pipeline_stages WHERE LOWER(name) = 'kontakt');
INSERT INTO pipeline_stages (name, color, sort_order)
SELECT 'Tilbud', '#ffc107', 30
WHERE NOT EXISTS (SELECT 1 FROM pipeline_stages WHERE LOWER(name) = 'tilbud');
INSERT INTO pipeline_stages (name, color, sort_order)
SELECT 'Forhandling', '#fd7e14', 40
WHERE NOT EXISTS (SELECT 1 FROM pipeline_stages WHERE LOWER(name) = 'forhandling');
INSERT INTO pipeline_stages (name, color, sort_order)
SELECT 'Vundet', '#28a745', 50
WHERE NOT EXISTS (SELECT 1 FROM pipeline_stages WHERE LOWER(name) = 'vundet');
INSERT INTO pipeline_stages (name, color, sort_order)
SELECT 'Tabt', '#dc3545', 60
WHERE NOT EXISTS (SELECT 1 FROM pipeline_stages WHERE LOWER(name) = 'tabt');
ALTER TABLE sag_sager
ADD COLUMN IF NOT EXISTS pipeline_amount DECIMAL(15,2),
ADD COLUMN IF NOT EXISTS pipeline_probability INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS pipeline_stage_id INTEGER REFERENCES pipeline_stages(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_sag_sager_pipeline_stage_id ON sag_sager(pipeline_stage_id);

View File

@ -0,0 +1,2 @@
ALTER TABLE sag_sager
ADD COLUMN IF NOT EXISTS pipeline_description TEXT;

53
scripts/fix_relations.py Normal file
View File

@ -0,0 +1,53 @@
import sys
import os
# Add project root to python path to allow importing app modules
sys.path.append(os.getcwd())
from app.core.database import execute_query, init_db
from app.core.config import settings
def fix_relations():
# Patch database URL for local execution
if "@postgres" in settings.DATABASE_URL:
print(f"🔧 Patcher DATABASE_URL fra '{settings.DATABASE_URL}'...")
settings.DATABASE_URL = settings.DATABASE_URL.replace("@postgres", "@localhost").replace(":5432", ":5433")
print(f" ...til '{settings.DATABASE_URL}'")
# Initialize database connection
init_db()
print("🚀 Standardiserer relationstyper i databasen...")
# Mapping: Højre side (liste) konverteres til Venstre side (key)
mappings = {
"Afledt af": ["DERIVED", "derived", "AFLEDT AF", "afledt af"],
"Blokkerer": ["BLOCKS", "blocks", "BLOKKERER", "blokkerer"],
"Relateret til": ["RELATED", "related", "RELATERET TIL", "relateret til", "relateret"]
}
total_updated = 0
for new_val, old_vals in mappings.items():
for old_val in old_vals:
if old_val == new_val:
continue
# Hent antal der skal opdateres
try:
check_query = "SELECT COUNT(*) as count FROM sag_relationer WHERE relationstype = %s AND deleted_at IS NULL"
result = execute_query(check_query, (old_val,))
count = result[0]['count'] if result else 0
if count > 0:
print(f" 🔄 Konverterer {count} rækker fra '{old_val}' -> '{new_val}'")
update_query = "UPDATE sag_relationer SET relationstype = %s WHERE relationstype = %s"
execute_query(update_query, (new_val, old_val))
total_updated += count
except Exception as e:
print(f"Fejl ved behandling af '{old_val}': {e}")
print(f"✅ Færdig! Opdaterede i alt {total_updated} relationer.")
if __name__ == "__main__":
fix_relations()