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 { .type-tag {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.5rem;
background: var(--calendar-bg); background: #ffffff;
border-radius: 999px; border: 1px solid var(--calendar-border);
padding: 0.4rem 0.75rem; border-radius: 8px;
padding: 0.35rem 0.6rem;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--calendar-subtle); color: var(--calendar-ink);
font-weight: 500;
cursor: pointer; 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 { .type-tag input {
accent-color: var(--calendar-sea); 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 { .action-row {
@ -431,28 +451,34 @@
<div class="filter-block"> <div class="filter-block">
<label>Event typer</label> <label>Event typer</label>
<div class="type-tags"> <div class="type-tags">
<label class="type-tag"> <label class="type-tag" title="Vis deadlines">
<input type="checkbox" class="type-filter" value="deadline" checked> <input type="checkbox" class="type-filter" value="deadline" checked>
<span class="type-dot" style="background: var(--calendar-ember);"></span>
Deadline Deadline
</label> </label>
<label class="type-tag"> <label class="type-tag" title="Vis udsatte sager">
<input type="checkbox" class="type-filter" value="deferred" checked> <input type="checkbox" class="type-filter" value="deferred" checked>
<span class="type-dot" style="background: var(--calendar-violet);"></span>
Deferred Deferred
</label> </label>
<label class="type-tag"> <label class="type-tag" title="Vis møder">
<input type="checkbox" class="type-filter" value="meeting" checked> <input type="checkbox" class="type-filter" value="meeting" checked>
Moede <span class="type-dot" style="background: var(--calendar-sea);"></span>
Møde
</label> </label>
<label class="type-tag"> <label class="type-tag" title="Vis teknikerbesøg">
<input type="checkbox" class="type-filter" value="technician_visit" checked> <input type="checkbox" class="type-filter" value="technician_visit" checked>
Teknikerbesoeg <span class="type-dot" style="background: var(--calendar-sun);"></span>
Tekniker
</label> </label>
<label class="type-tag"> <label class="type-tag" title="Vis OBS punkter">
<input type="checkbox" class="type-filter" value="obs" checked> <input type="checkbox" class="type-filter" value="obs" checked>
<span class="type-dot" style="background: #5a60a8;"></span>
OBS OBS
</label> </label>
<label class="type-tag"> <label class="type-tag" title="Vis reminders">
<input type="checkbox" class="type-filter" value="reminder" checked> <input type="checkbox" class="type-filter" value="reminder" checked>
<span class="type-dot" style="background: var(--calendar-mint);"></span>
Reminder Reminder
</label> </label>
</div> </div>
@ -479,8 +505,8 @@
<div class="calendar-legend"> <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-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-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-sea);"></span>Møde</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-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: #5a60a8;"></span>OBS</div>
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-mint);"></span>Reminder</div> <div class="legend-item"><span class="legend-dot" style="background: var(--calendar-mint);"></span>Reminder</div>
</div> </div>
@ -505,8 +531,8 @@
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Type *</label> <label class="form-label">Type *</label>
<select class="form-select" id="calendarEventType"> <select class="form-select" id="calendarEventType">
<option value="meeting">Moede</option> <option value="meeting">Møde</option>
<option value="technician_visit">Teknikerbesoeg</option> <option value="technician_visit">Teknikerbesøg</option>
<option value="obs">OBS</option> <option value="obs">OBS</option>
<option value="reminder">Reminder</option> <option value="reminder">Reminder</option>
</select> </select>
@ -517,7 +543,7 @@
</div> </div>
<div class="col-12"> <div class="col-12">
<label class="form-label">Titel *</label> <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>
<div class="col-12"> <div class="col-12">
<label class="form-label">Besked</label> <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,)) 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) # Get all active locations for the tree (including parent_id for structure)
all_locations_query = """ all_locations_query = """
SELECT id, name, location_type, parent_location_id SELECT id, name, location_type, parent_location_id
@ -448,6 +498,10 @@ async def hardware_detail(request: Request, hardware_id: int):
"attachments": attachments or [], "attachments": attachments or [],
"cases": cases or [], "cases": cases or [],
"tags": tags 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 [], "location_tree": location_tree or [],
"eset_specs": extract_eset_specs_summary(hardware) "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)) execute_query(update_asset_query, (location_id, hardware_id))
return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303) 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) --> <!-- Customer (Current Owner) -->
{% set current_owner = ownership[0] if ownership else None %} {% 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 %} {% if current_owner and not current_owner.end_date %}
<div class="quick-info-item"> <div class="quick-info-item">
<span class="quick-info-label">Ejer:</span> <span class="quick-info-label">Ejer:</span>
@ -361,19 +369,26 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="card h-100 shadow-sm border-0"> <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> <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>
<div class="card-body"> <div class="card-body">
{% if current_owner and not current_owner.end_date %} {% if current_owner and not current_owner.end_date %}
<div class="text-center py-3"> <div class="text-center py-3">
<div class="fs-4 mb-2 text-success"><i class="bi bi-person-badge"></i></div> <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> <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> <p class="text-muted small mb-0">Siden: {{ current_owner.start_date }}</p>
</div> </div>
{% else %} {% else %}
<div class="text-center py-4 text-muted"> <div class="text-center py-4 text-muted">
<p class="mb-0">Ingen aktiv ejer</p> <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> </div>
{% endif %} {% endif %}
</div> </div>
@ -412,6 +427,46 @@
</div> </div>
</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 --> <!-- Linked Cases -->
<div class="card shadow-sm border-0 mb-4"> <div class="card shadow-sm border-0 mb-4">
<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">
@ -673,6 +728,65 @@
</div> </div>
</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 --> <!-- Modal for Location -->
<div class="modal fade" id="locationModal" tabindex="-1"> <div class="modal fade" id="locationModal" tabindex="-1">
<div class="modal-dialog"> <div class="modal-dialog">
@ -744,6 +858,50 @@
</div> </div>
</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 %} {% endblock %}
{% block extra_js %} {% block extra_js %}
@ -878,6 +1036,101 @@
// Initialize Tags // Initialize Tags
document.addEventListener('DOMContentLoaded', function() { 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) { if (window.renderEntityTags) {
window.renderEntityTags('hardware', {{ hardware.id }}, 'hardware-tags'); 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 import APIRouter, HTTPException, Query, UploadFile, File, Request
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from app.core.database import execute_query, execute_query_single from app.core.database import execute_query, execute_query_single
from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate
from app.core.config import settings from app.core.config import settings
@ -397,6 +398,10 @@ async def update_sag(sag_id: int, updates: dict):
if not check: if not check:
raise HTTPException(status_code=404, detail="Case not found") 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 # Build dynamic update query
allowed_fields = ["titel", "beskrivelse", "template_key", "status", "ansvarlig_bruger_id", "deadline", "deferred_until", "deferred_until_case_id", "deferred_until_status"] allowed_fields = ["titel", "beskrivelse", "template_key", "status", "ansvarlig_bruger_id", "deadline", "deferred_until", "deferred_until_case_id", "deferred_until_status"]
set_clauses = [] 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") raise HTTPException(status_code=400, detail="No valid fields to update")
params.append(sag_id) 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)) result = execute_query(query, tuple(params))
if result: if result:
@ -424,6 +430,72 @@ async def update_sag(sag_id: int, updates: dict):
logger.error("❌ Error updating case: %s", e) logger.error("❌ Error updating case: %s", e)
raise HTTPException(status_code=500, detail="Failed to update case") 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}") @router.delete("/sag/{sag_id}")
async def delete_sag(sag_id: int): async def delete_sag(sag_id: int):
"""Soft-delete a case.""" """Soft-delete a case."""

View File

@ -181,85 +181,11 @@ async def sag_detaljer(request: Request, sag_id: int):
# --- Relation Tree Construction --- # --- Relation Tree Construction ---
relation_tree = [] relation_tree = []
try: try:
# 1. Get all connected case IDs (Recursive CTE) from app.modules.sag.services.relation_service import RelationService
tree_ids_query = """ relation_tree = RelationService.get_relation_tree(sag_id)
WITH RECURSIVE CaseTree AS ( except Exception as e:
SELECT id FROM sag_sager WHERE id = %s logger.error(f"Error building relation tree: {e}")
UNION relation_tree = []
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]
except Exception as e: except Exception as e:
logger.error(f"Error building relation tree: {e}") logger.error(f"Error building relation tree: {e}")
relation_tree = [] relation_tree = []
@ -438,6 +364,16 @@ async def sag_detaljer(request: Request, sag_id: int):
logger.error("❌ Error building related case options: %s", e) logger.error("❌ Error building related case options: %s", e)
related_case_options = [] 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", ()) 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", { 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, "is_nextcloud": is_nextcloud,
"nextcloud_instance": nextcloud_instance, "nextcloud_instance": nextcloud_instance,
"related_case_options": related_case_options, "related_case_options": related_case_options,
"pipeline_stages": pipeline_stages,
"status_options": [s["status"] for s in statuses], "status_options": [s["status"] for s in statuses],
}) })
except HTTPException: 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> </div>
<form id="createForm" novalidate> <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 --> <!-- Section: Relations -->
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Relationer</h5> <h5 class="mb-3 text-muted fw-bold small text-uppercase">Relationer</h5>
<div class="row g-4 mb-4"> <div class="row g-4 mb-4">
@ -183,13 +164,13 @@
<div id="selectedContacts" class="mt-2 text-wrap"></div> <div id="selectedContacts" class="mt-2 text-wrap"></div>
</div> </div>
<!-- Customer Search --> <!-- Company Search -->
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Kunde</label> <label class="form-label">Firma</label>
<div class="search-position-relative"> <div class="search-position-relative">
<div class="input-group"> <div class="input-group">
<span class="input-group-text bg-light border-end-0"><i class="bi bi-building"></i></span> <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>
<div id="customerResults" class="search-results shadow-sm d-none"></div> <div id="customerResults" class="search-results shadow-sm d-none"></div>
</div> </div>
@ -200,6 +181,25 @@
<hr class="my-4 opacity-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 --> <!-- Section: Hardware & AnyDesk -->
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Hardware (AnyDesk)</h5> <h5 class="mb-3 text-muted fw-bold small text-uppercase">Hardware (AnyDesk)</h5>
<div class="row g-4 mb-4"> <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"> <div class="form-group">
<label for="type">Type *</label> <label for="type">Type *</label>
<select class="form-select" id="type" required> <select class="form-select" id="type" required>
<option value="ticket" {% if case.type == 'ticket' %}selected{% endif %}>🎫 Ticket</option> <option value="ticket" {% if (case.template_key or case.type) == 'ticket' %}selected{% endif %}>🎫 Ticket</option>
<option value="opgave" {% if case.type == 'opgave' %}selected{% endif %}>🧩 Opgave</option> <option value="pipeline" {% if (case.template_key or case.type) == 'pipeline' %}selected{% endif %}>📈 Pipeline</option>
<option value="ordre" {% if case.type == 'ordre' %}selected{% endif %}>🧾 Ordre</option> <option value="opgave" {% if (case.template_key or case.type) == 'opgave' %}selected{% endif %}>🧩 Opgave</option>
<option value="projekt" {% if case.type == 'projekt' %}selected{% endif %}>📁 Projekt</option> <option value="ordre" {% if (case.template_key or case.type) == 'ordre' %}selected{% endif %}>🧾 Ordre</option>
<option value="service" {% if case.type == 'service' %}selected{% endif %}>🛠️ Service</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> </select>
</div> </div>
@ -231,7 +232,7 @@
<script> <script>
const caseId = {{ case.id }}; const caseId = {{ case.id }};
const currentType = "{{ case.type or 'ticket' }}"; const currentType = "{{ case.template_key or case.type or 'ticket' }}";
async function loadCaseTypesSelect() { async function loadCaseTypesSelect() {
const select = document.getElementById('type'); const select = document.getElementById('type');
@ -243,7 +244,12 @@
const types = JSON.parse(setting.value || '[]'); const types = JSON.parse(setting.value || '[]');
if (!Array.isArray(types) || types.length === 0) return; 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>`) .map((type) => `<option value="${type}" ${type === currentType ? 'selected' : ''}>${type}</option>`)
.join(''); .join('');
} catch (err) { } catch (err) {

File diff suppressed because it is too large Load Diff

View File

@ -4,92 +4,112 @@
{% block extra_css %} {% block extra_css %}
<style> <style>
.stage-pill { .status-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; padding: 0.25rem 0.6rem;
padding: 4px 10px;
border-radius: 999px; border-radius: 999px;
font-size: 0.8rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
background: rgba(15, 76, 117, 0.1); text-transform: uppercase;
color: var(--accent); 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 { .opportunity-row:hover {
width: 8px; background-color: rgba(0,0,0,0.02);
height: 8px; }
border-radius: 50%;
.opportunity-row td {
vertical-align: middle;
padding-top: 0.75rem;
padding-bottom: 0.75rem;
} }
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="container-fluid px-4 py-4">
<div> <div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="fw-bold mb-1">Muligheder</h2> <div>
<p class="text-muted mb-0">Hublokal salgspipeline</p> <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>
<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="card p-3 mb-4 shadow-sm border-0">
<div class="row g-2 align-items-center"> <div class="row g-3 align-items-center">
<div class="col-md-4"> <div class="col-md-4">
<input type="text" class="form-control" id="searchInput" placeholder="Søg titel eller kunde..." oninput="renderOpportunities()"> <div class="input-group">
</div> <span class="input-group-text bg-white border-end-0"><i class="bi bi-search text-muted"></i></span>
<div class="col-md-3"> <input type="text" class="form-control border-start-0 ps-0" id="searchInput" placeholder="Søg titel eller kunde..." oninput="filterOpportunities()">
<select class="form-select" id="stageFilter" onchange="renderOpportunities()"></select> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<select class="form-select" id="statusFilter" onchange="renderOpportunities()"> <select class="form-select" id="stageFilter" onchange="filterOpportunities()">
<option value="all">Alle status</option> <option value="all">Alle stages</option>
<option value="open">Åbne</option> </select>
<option value="won">Vundet</option> </div>
<option value="lost">Tabt</option> <div class="col-md-2">
</select> <select class="form-select" id="statusFilter" onchange="filterOpportunities()">
</div> <option value="all">Alle status</option>
<div class="col-md-2 text-end"> <option value="open" selected>Åbne</option>
<span class="text-muted small" id="countLabel">0 muligheder</span> <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>
</div>
<div class="card"> <div class="card shadow-sm border-0 overflow-hidden">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle mb-0"> <table class="table table-hover align-middle mb-0">
<thead class="table-light"> <thead class="bg-light">
<tr> <tr>
<th>Titel</th> <th class="ps-4">Titel</th>
<th>Kunde</th> <th>Kunde</th>
<th>Beløb</th> <th>Stage</th>
<th>Lukningsdato</th> <th class="text-end">Sandsynlighed</th>
<th>Stage</th> <th class="text-end">Beløb</th>
<th>Sandsynlighed</th> <th>Status</th>
<th class="text-end">Handling</th> <th>Deadline</th>
</tr> <th>Ansvarlig</th>
</thead> <th>Beskrivelse</th>
<tbody id="opportunitiesTable"> <th class="text-end pe-4">Handling</th>
<tr> </tr>
<td colspan="7" class="text-center py-5"> </thead>
<div class="spinner-border text-primary"></div> <tbody id="opportunitiesTable">
</td> <tr>
</tr> <td colspan="10" class="text-center py-5">
</tbody> <div class="spinner-border text-primary spinner-border-sm me-2"></div>
</table> <span class="text-muted">Indlæser muligheder...</span>
</td>
</tr>
</tbody>
</table>
</div>
</div> </div>
</div> </div>
<!-- Create Opportunity Modal --> <!-- Create Opportunity Modal -->
<div class="modal fade" id="opportunityModal" tabindex="-1"> <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-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">Opret mulighed</h5> <h5 class="modal-title">Opret mulighed</h5>
@ -97,207 +117,268 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="opportunityForm"> <form id="opportunityForm">
<div class="row g-3"> <div class="mb-3">
<div class="col-md-6"> <label class="form-label small text-uppercase text-muted fw-bold">Titel *</label>
<label class="form-label">Kunde *</label> <input type="text" class="form-control" id="title" required placeholder="Fx Opgradering af serverpark">
<select class="form-select" id="customerId" required></select> </div>
</div>
<div class="col-md-6"> <div class="mb-3">
<label class="form-label">Titel *</label> <label class="form-label small text-uppercase text-muted fw-bold">Kunde *</label>
<input type="text" class="form-control" id="title" required> <select class="form-select" id="customerId" required>
</div> <option value="">Vælg kunde...</option>
<div class="col-md-6"> </select>
<label class="form-label">Beløb</label> </div>
<input type="number" step="0.01" class="form-control" id="amount">
</div> <div class="mb-3">
<div class="col-md-6"> <label class="form-label small text-uppercase text-muted fw-bold">Forventet lukning (Deadline)</label>
<label class="form-label">Valuta</label> <input type="date" class="form-control" id="expectedCloseDate">
<select class="form-select" id="currency"> </div>
<option value="DKK">DKK</option>
<option value="EUR">EUR</option> <div class="mb-3">
<option value="USD">USD</option> <label class="form-label small text-uppercase text-muted fw-bold">Beskrivelse</label>
</select> <textarea class="form-control" id="description" rows="4" placeholder="Beskriv muligheden..."></textarea>
</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> </div>
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer bg-light">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button> <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" onclick="createOpportunity()">Gem</button> <button type="button" class="btn btn-primary px-4" onclick="createOpportunity()">Opret</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script> <script>
let opportunities = []; let allOpportunities = [];
let stages = [];
let customers = []; let customers = [];
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
try { // Load data in parallel
await loadStages(); const [custRes, oppRes] = await Promise.allSettled([
} catch (error) { fetch('/api/v1/customers?limit=1000'),
console.error('Error loading stages:', error); 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 { // Handle opportunities
await loadCustomers(); if (oppRes.status === 'fulfilled') {
} catch (error) { allOpportunities = await oppRes.value.json();
console.error('Error loading customers:', error); 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 { // Setup search listener
await loadOpportunities(); document.getElementById('searchInput').addEventListener('input', debounce(filterOpportunities, 300));
} 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';
}
}); });
async function loadStages() { function debounce(func, wait) {
const response = await fetch('/api/v1/pipeline/stages'); let timeout;
stages = await response.json(); return function executedFunction(...args) {
const later = () => {
const stageFilter = document.getElementById('stageFilter'); clearTimeout(timeout);
stageFilter.innerHTML = '<option value="all">Alle stages</option>' + func(...args);
stages.map(s => `<option value="${s.id}">${s.name}</option>`).join(''); };
clearTimeout(timeout);
const stageSelect = document.getElementById('stageId'); timeout = setTimeout(later, wait);
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 renderCustomerSelect() {
const select = document.getElementById('customerId'); 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>' + select.innerHTML = '<option value="">Vælg kunde...</option>' +
customers.map(c => `<option value="${c.id}">${escapeHtml(c.name)}</option>`).join(''); customers.map(c => `<option value="${c.id}">${escapeHtml(c.name)}</option>`).join('');
} }
async function loadOpportunities() { function renderStageFilter() {
const response = await fetch('/api/v1/opportunities'); const select = document.getElementById('stageFilter');
opportunities = await response.json(); if (!select) return;
renderOpportunities();
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 search = document.getElementById('searchInput').value.toLowerCase();
const stageFilter = document.getElementById('stageFilter').value; const stageFilter = document.getElementById('stageFilter').value;
const statusFilter = document.getElementById('statusFilter').value; const statusFilter = document.getElementById('statusFilter').value;
const filtered = opportunities.filter(o => { const filtered = allOpportunities.filter(o => {
const text = `${o.title} ${o.customer_name}`.toLowerCase(); // Search filter
if (search && !text.includes(search)) return false; const matchSearch = !search ||
if (stageFilter !== 'all' && parseInt(stageFilter) !== o.stage_id) return false; (o.titel && o.titel.toLowerCase().includes(search)) ||
if (statusFilter === 'won' && !o.is_won) return false; (o.customer_name && o.customer_name.toLowerCase().includes(search));
if (statusFilter === 'lost' && !o.is_lost) return false;
if (statusFilter === 'open' && (o.is_won || o.is_lost)) return false; // Status filter
return true; 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'); const tbody = document.getElementById('opportunitiesTable');
if (filtered.length === 0) { document.getElementById('countLabel').textContent = `${data.length} fundet`;
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-5">Ingen muligheder fundet</td></tr>';
document.getElementById('countLabel').textContent = '0 muligheder'; if (data.length === 0) {
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-muted py-5">Ingen muligheder fundet</td></tr>';
return; return;
} }
tbody.innerHTML = filtered.map(o => ` tbody.innerHTML = data.map(o => {
<tr class="opportunity-row" style="cursor:pointer" onclick="goToDetail(${o.id})"> const statusClass = (o.status === 'åben') ? 'status-open' : 'status-closed';
<td class="fw-semibold">${escapeHtml(o.title)}</td> const dateStr = o.deadline ? formatDate(o.deadline) : '<span class="text-muted">-</span>';
<td>${escapeHtml(o.customer_name || '-')}</td> const stage = o.pipeline_stage ? escapeHtml(o.pipeline_stage) : '<span class="text-muted">-</span>';
<td>${formatCurrency(o.amount, o.currency)}</td> const probability = Number.isFinite(Number(o.pipeline_probability)) ? `${Number(o.pipeline_probability)}%` : '<span class="text-muted">-</span>';
<td>${o.expected_close_date ? formatDate(o.expected_close_date) : '<span class=\"text-muted\">-</span>'}</td> 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> <td>
<span class="stage-pill"> <span class="status-badge ${statusClass}">
<span class="stage-dot" style="background:${o.stage_color || '#0f4c75'}"></span> ${escapeHtml(o.status)}
${escapeHtml(o.stage_name || '-')}
</span> </span>
</td> </td>
<td>${o.probability || 0}%</td> <td>${dateStr}</td>
<td class="text-end"> <td>${escapeHtml(o.ansvarlig_navn)}</td>
<i class="bi bi-arrow-right"></i> <td class="small text-muted">${descPreview}</td>
<td class="text-end pe-4 text-muted">
<i class="bi bi-chevron-right"></i>
</td> </td>
</tr> </tr>
`).join(''); `;
}).join('');
document.getElementById('countLabel').textContent = `${filtered.length} muligheder`;
} }
function openCreateOpportunityModal() { function openCreateOpportunityModal() {
document.getElementById('opportunityForm').reset(); document.getElementById('opportunityForm').reset();
const modal = new bootstrap.Modal(document.getElementById('opportunityModal')); new bootstrap.Modal(document.getElementById('opportunityModal')).show();
modal.show();
} }
async function createOpportunity() { 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 = { const payload = {
customer_id: parseInt(document.getElementById('customerId').value), title: title,
title: document.getElementById('title').value, customer_id: parseInt(customerId),
description: document.getElementById('description').value || null, description: desc,
amount: parseFloat(document.getElementById('amount').value || 0), expected_close_date: closeDate || null
currency: document.getElementById('currency').value,
stage_id: parseInt(document.getElementById('stageId').value || 0) || null,
expected_close_date: document.getElementById('expectedCloseDate').value || null
}; };
if (!payload.customer_id || !payload.title) { try {
alert('Kunde og titel er påkrævet'); const btn = document.querySelector('#opportunityModal .btn-primary');
return; 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) { function formatDate(dateStr) {
window.location.href = `/opportunities/${id}`; if (!dateStr) return '';
const d = new Date(dateStr);
return d.toLocaleDateString('da-DK', {day: 'numeric', month: 'short', year: 'numeric'});
} }
function formatCurrency(value, currency) { function formatCurrency(value) {
const num = parseFloat(value || 0); return new Intl.NumberFormat('da-DK', {
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(num); style: 'currency',
} currency: 'DKK',
minimumFractionDigits: 2,
function formatDate(value) { maximumFractionDigits: 2
return new Date(value).toLocaleDateString('da-DK'); }).format(Number(value || 0));
} }
function escapeHtml(text) { function escapeHtml(text) {
if (!text) return ''; if (text === null || text === undefined) return '';
const div = document.createElement('div'); return String(text)
div.textContent = text; .replace(/&/g, "&amp;")
return div.innerHTML; .replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
} }
</script> </script>
{% endblock %} {% 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) @router.get("/opportunities", response_class=HTMLResponse)
async def opportunities_page(request: Request): async def opportunities_page(request: Request):
return templates.TemplateResponse("opportunities/frontend/opportunities.html", {"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" query = "SELECT * FROM settings WHERE key = %s"
result = execute_query(query, (key,)) 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 = """ seed_query = """
INSERT INTO settings (key, value, category, description, value_type, is_public) INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES (%s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (key) DO NOTHING ON CONFLICT (key) DO NOTHING
""" """
execute_query(
seed_query, if key == "case_types":
( execute_query(
"case_types", seed_query,
'["ticket", "opgave", "ordre", "projekt", "service"]', (
"system", "case_types",
"Sags-typer", '["ticket", "opgave", "ordre", "projekt", "service"]',
"json", "system",
True, "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,)) result = execute_query(query, (key,))
if not result: if not result:

View File

@ -1043,6 +1043,34 @@ async def scan_document(file_path: str):
<div class="text-muted">Indlæser...</div> <div class="text-muted">Indlæser...</div>
</div> </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> </div>
</div> </div>
@ -1878,20 +1906,123 @@ function getCaseTypesSetting() {
return allSettings.find(setting => setting.key === 'case_types'); 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() { async function loadCaseTypesSetting() {
try { try {
const response = await fetch('/api/v1/settings/case_types'); const response = await fetch('/api/v1/settings/case_types');
if (!response.ok) { if (!response.ok) {
renderCaseTypes([]); renderCaseTypes([]);
await loadCaseTypeModuleDefaultsSetting([]);
return; return;
} }
const setting = await response.json(); const setting = await response.json();
const rawValue = setting.value || '[]'; const rawValue = setting.value || '[]';
const parsed = JSON.parse(rawValue); const parsed = JSON.parse(rawValue);
renderCaseTypes(Array.isArray(parsed) ? parsed : []); const types = Array.isArray(parsed) ? parsed : [];
renderCaseTypes(types);
await loadCaseTypeModuleDefaultsSetting(types);
} catch (error) { } catch (error) {
console.error('Error loading case types:', error); console.error('Error loading case types:', error);
renderCaseTypes([]); renderCaseTypes([]);
await loadCaseTypeModuleDefaultsSetting([]);
} }
} }
@ -1916,12 +2047,17 @@ function renderCaseTypes(types) {
async function saveCaseTypes(types) { async function saveCaseTypes(types) {
await updateSetting('case_types', JSON.stringify(types)); await updateSetting('case_types', JSON.stringify(types));
renderCaseTypes(types); renderCaseTypes(types);
caseTypeModuleDefaultsCache = normalizeCaseTypeModuleDefaults(caseTypeModuleDefaultsCache, types);
renderCaseTypeModuleTypeOptions(types);
renderCaseTypeModuleChecklist();
await updateSetting('case_type_module_defaults', JSON.stringify(caseTypeModuleDefaultsCache));
} }
async function addCaseType() { async function addCaseType() {
const input = document.getElementById('caseTypeInput'); const input = document.getElementById('caseTypeInput');
if (!input) return; if (!input) return;
const value = input.value.trim(); const value = input.value.trim().toLowerCase();
if (!value) return; if (!value) return;
const response = await fetch('/api/v1/settings/case_types'); const response = await fetch('/api/v1/settings/case_types');
@ -1947,6 +2083,48 @@ async function removeCaseType(type) {
await saveCaseTypes(filtered); 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 usersCache = [];
let groupsCache = []; let groupsCache = [];
let permissionsCache = []; 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()