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:
parent
0831715d3a
commit
891180f3f0
@ -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>
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
197
app/modules/sag/services/relation_service.py
Normal file
197
app/modules/sag/services/relation_service.py
Normal 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,))
|
||||||
@ -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
@ -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
@ -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">Hub‑lokal 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, "&")
|
||||||
return div.innerHTML;
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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
|
|
||||||
})
|
|
||||||
|
|||||||
@ -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")
|
|
||||||
)
|
|
||||||
@ -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:
|
||||||
|
|||||||
@ -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
41
apply_migration_128.py
Normal 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
41
apply_migration_129.py
Normal 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()
|
||||||
38
migrations/128_sag_pipeline_fields.sql
Normal file
38
migrations/128_sag_pipeline_fields.sql
Normal 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);
|
||||||
2
migrations/129_sag_pipeline_description.sql
Normal file
2
migrations/129_sag_pipeline_description.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE sag_sager
|
||||||
|
ADD COLUMN IF NOT EXISTS pipeline_description TEXT;
|
||||||
53
scripts/fix_relations.py
Normal file
53
scripts/fix_relations.py
Normal 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()
|
||||||
Loading…
Reference in New Issue
Block a user