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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
background: var(--calendar-bg);
|
||||
border-radius: 999px;
|
||||
padding: 0.4rem 0.75rem;
|
||||
gap: 0.5rem;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--calendar-border);
|
||||
border-radius: 8px;
|
||||
padding: 0.35rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--calendar-subtle);
|
||||
color: var(--calendar-ink);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.type-tag:hover {
|
||||
background: var(--calendar-bg);
|
||||
border-color: rgba(15, 76, 117, 0.2);
|
||||
}
|
||||
|
||||
.type-tag input {
|
||||
accent-color: var(--calendar-sea);
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.type-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
@ -431,28 +451,34 @@
|
||||
<div class="filter-block">
|
||||
<label>Event typer</label>
|
||||
<div class="type-tags">
|
||||
<label class="type-tag">
|
||||
<label class="type-tag" title="Vis deadlines">
|
||||
<input type="checkbox" class="type-filter" value="deadline" checked>
|
||||
<span class="type-dot" style="background: var(--calendar-ember);"></span>
|
||||
Deadline
|
||||
</label>
|
||||
<label class="type-tag">
|
||||
<label class="type-tag" title="Vis udsatte sager">
|
||||
<input type="checkbox" class="type-filter" value="deferred" checked>
|
||||
<span class="type-dot" style="background: var(--calendar-violet);"></span>
|
||||
Deferred
|
||||
</label>
|
||||
<label class="type-tag">
|
||||
<label class="type-tag" title="Vis møder">
|
||||
<input type="checkbox" class="type-filter" value="meeting" checked>
|
||||
Moede
|
||||
<span class="type-dot" style="background: var(--calendar-sea);"></span>
|
||||
Møde
|
||||
</label>
|
||||
<label class="type-tag">
|
||||
<label class="type-tag" title="Vis teknikerbesøg">
|
||||
<input type="checkbox" class="type-filter" value="technician_visit" checked>
|
||||
Teknikerbesoeg
|
||||
<span class="type-dot" style="background: var(--calendar-sun);"></span>
|
||||
Tekniker
|
||||
</label>
|
||||
<label class="type-tag">
|
||||
<label class="type-tag" title="Vis OBS punkter">
|
||||
<input type="checkbox" class="type-filter" value="obs" checked>
|
||||
<span class="type-dot" style="background: #5a60a8;"></span>
|
||||
OBS
|
||||
</label>
|
||||
<label class="type-tag">
|
||||
<label class="type-tag" title="Vis reminders">
|
||||
<input type="checkbox" class="type-filter" value="reminder" checked>
|
||||
<span class="type-dot" style="background: var(--calendar-mint);"></span>
|
||||
Reminder
|
||||
</label>
|
||||
</div>
|
||||
@ -479,8 +505,8 @@
|
||||
<div class="calendar-legend">
|
||||
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-ember);"></span>Deadline</div>
|
||||
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-violet);"></span>Deferred</div>
|
||||
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-sea);"></span>Moede</div>
|
||||
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-sun);"></span>Teknikerbesoeg</div>
|
||||
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-sea);"></span>Møde</div>
|
||||
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-sun);"></span>Teknikerbesøg</div>
|
||||
<div class="legend-item"><span class="legend-dot" style="background: #5a60a8;"></span>OBS</div>
|
||||
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-mint);"></span>Reminder</div>
|
||||
</div>
|
||||
@ -505,8 +531,8 @@
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Type *</label>
|
||||
<select class="form-select" id="calendarEventType">
|
||||
<option value="meeting">Moede</option>
|
||||
<option value="technician_visit">Teknikerbesoeg</option>
|
||||
<option value="meeting">Møde</option>
|
||||
<option value="technician_visit">Teknikerbesøg</option>
|
||||
<option value="obs">OBS</option>
|
||||
<option value="reminder">Reminder</option>
|
||||
</select>
|
||||
@ -517,7 +543,7 @@
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Titel *</label>
|
||||
<input type="text" class="form-control" id="calendarEventTitle" placeholder="Fx Moede om status">
|
||||
<input type="text" class="form-control" id="calendarEventTitle" placeholder="Fx Møde om status">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Besked</label>
|
||||
|
||||
@ -429,6 +429,56 @@ async def hardware_detail(request: Request, hardware_id: int):
|
||||
ORDER BY created_at DESC
|
||||
"""
|
||||
tags = execute_query(tag_query, (hardware_id,))
|
||||
|
||||
# Get linked contacts
|
||||
contacts_query = """
|
||||
SELECT hc.*, c.first_name, c.last_name, c.email, c.phone, c.mobile
|
||||
FROM hardware_contacts hc
|
||||
JOIN contacts c ON c.id = hc.contact_id
|
||||
WHERE hc.hardware_id = %s
|
||||
ORDER BY hc.role = 'primary' DESC, c.first_name ASC
|
||||
"""
|
||||
contacts = execute_query(contacts_query, (hardware_id,))
|
||||
|
||||
# Get available contacts for linking (from current owner)
|
||||
available_contacts = []
|
||||
if hardware.get('current_owner_customer_id'):
|
||||
avail_query = """
|
||||
SELECT c.id, c.first_name, c.last_name, c.email
|
||||
FROM contacts c
|
||||
JOIN contact_companies cc ON c.id = cc.contact_id
|
||||
WHERE cc.customer_id = %s
|
||||
AND c.is_active = TRUE
|
||||
AND c.id NOT IN (
|
||||
SELECT contact_id FROM hardware_contacts WHERE hardware_id = %s
|
||||
)
|
||||
ORDER BY cc.is_primary DESC, c.first_name ASC
|
||||
"""
|
||||
available_contacts = execute_query(avail_query, (hardware['current_owner_customer_id'], hardware_id))
|
||||
|
||||
# Get customers for ownership selector
|
||||
owner_customers_query = """
|
||||
SELECT id, name AS navn
|
||||
FROM customers
|
||||
ORDER BY name
|
||||
"""
|
||||
owner_customers = execute_query(owner_customers_query)
|
||||
|
||||
owner_contacts_query = """
|
||||
SELECT
|
||||
cc.customer_id,
|
||||
c.id,
|
||||
c.first_name,
|
||||
c.last_name,
|
||||
c.email,
|
||||
c.phone,
|
||||
cc.is_primary
|
||||
FROM contacts c
|
||||
JOIN contact_companies cc ON cc.contact_id = c.id
|
||||
WHERE c.is_active = TRUE
|
||||
ORDER BY cc.customer_id, cc.is_primary DESC, c.first_name ASC, c.last_name ASC
|
||||
"""
|
||||
owner_contacts = execute_query(owner_contacts_query)
|
||||
|
||||
# Get all active locations for the tree (including parent_id for structure)
|
||||
all_locations_query = """
|
||||
@ -448,6 +498,10 @@ async def hardware_detail(request: Request, hardware_id: int):
|
||||
"attachments": attachments or [],
|
||||
"cases": cases or [],
|
||||
"tags": tags or [],
|
||||
"contacts": contacts or [],
|
||||
"available_contacts": available_contacts or [],
|
||||
"owner_customers": owner_customers or [],
|
||||
"owner_contacts": owner_contacts or [],
|
||||
"location_tree": location_tree or [],
|
||||
"eset_specs": extract_eset_specs_summary(hardware)
|
||||
})
|
||||
@ -518,3 +572,113 @@ async def update_hardware_location(
|
||||
execute_query(update_asset_query, (location_id, hardware_id))
|
||||
|
||||
return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/hardware/{hardware_id}/owner")
|
||||
async def update_hardware_owner(
|
||||
request: Request,
|
||||
hardware_id: int,
|
||||
owner_customer_id: int = Form(...),
|
||||
owner_contact_id: Optional[int] = Form(None),
|
||||
notes: Optional[str] = Form(None)
|
||||
):
|
||||
"""Update hardware ownership."""
|
||||
# Verify hardware exists
|
||||
check_query = "SELECT id FROM hardware_assets WHERE id = %s AND deleted_at IS NULL"
|
||||
if not execute_query(check_query, (hardware_id,)):
|
||||
raise HTTPException(status_code=404, detail="Hardware not found")
|
||||
|
||||
# Verify customer exists
|
||||
customer_check = "SELECT id FROM customers WHERE id = %s AND deleted_at IS NULL"
|
||||
if not execute_query(customer_check, (owner_customer_id,)):
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
|
||||
# Verify owner contact belongs to selected customer
|
||||
if owner_contact_id:
|
||||
contact_check_query = """
|
||||
SELECT 1
|
||||
FROM contact_companies
|
||||
WHERE customer_id = %s AND contact_id = %s
|
||||
LIMIT 1
|
||||
"""
|
||||
if not execute_query(contact_check_query, (owner_customer_id, owner_contact_id)):
|
||||
raise HTTPException(status_code=400, detail="Selected contact does not belong to customer")
|
||||
|
||||
# Close active ownership history
|
||||
close_history_query = """
|
||||
UPDATE hardware_ownership_history
|
||||
SET end_date = %s
|
||||
WHERE hardware_id = %s AND end_date IS NULL AND deleted_at IS NULL
|
||||
"""
|
||||
execute_query(close_history_query, (date.today(), hardware_id))
|
||||
|
||||
# Insert new ownership history
|
||||
add_history_query = """
|
||||
INSERT INTO hardware_ownership_history (
|
||||
hardware_id, owner_type, owner_customer_id, start_date, notes
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
"""
|
||||
execute_query(add_history_query, (hardware_id, "customer", owner_customer_id, date.today(), notes))
|
||||
|
||||
# Update current owner on hardware
|
||||
update_asset_query = """
|
||||
UPDATE hardware_assets
|
||||
SET current_owner_type = %s, current_owner_customer_id = %s, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
"""
|
||||
execute_query(update_asset_query, ("customer", owner_customer_id, hardware_id))
|
||||
|
||||
# Optionally set owner contact as primary for this hardware
|
||||
if owner_contact_id:
|
||||
demote_query = """
|
||||
UPDATE hardware_contacts
|
||||
SET role = 'user'
|
||||
WHERE hardware_id = %s AND role = 'primary'
|
||||
"""
|
||||
execute_query(demote_query, (hardware_id,))
|
||||
|
||||
owner_contact_query = """
|
||||
INSERT INTO hardware_contacts (hardware_id, contact_id, role, source)
|
||||
VALUES (%s, %s, 'primary', 'manual')
|
||||
ON CONFLICT (hardware_id, contact_id)
|
||||
DO UPDATE SET role = EXCLUDED.role, source = EXCLUDED.source
|
||||
"""
|
||||
execute_query(owner_contact_query, (hardware_id, owner_contact_id))
|
||||
|
||||
return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/hardware/{hardware_id}/contacts/add")
|
||||
async def add_hardware_contact(
|
||||
request: Request,
|
||||
hardware_id: int,
|
||||
contact_id: int = Form(...)
|
||||
):
|
||||
"""Link a contact to hardware."""
|
||||
# Check if exists
|
||||
exists_query = "SELECT id FROM hardware_contacts WHERE hardware_id = %s AND contact_id = %s"
|
||||
if execute_query(exists_query, (hardware_id, contact_id)):
|
||||
# Already exists, just redirect
|
||||
pass
|
||||
else:
|
||||
insert_query = """
|
||||
INSERT INTO hardware_contacts (hardware_id, contact_id, role, source)
|
||||
VALUES (%s, %s, 'user', 'manual')
|
||||
"""
|
||||
execute_query(insert_query, (hardware_id, contact_id))
|
||||
|
||||
return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/hardware/{hardware_id}/contacts/{contact_id}/delete")
|
||||
async def remove_hardware_contact(
|
||||
request: Request,
|
||||
hardware_id: int,
|
||||
contact_id: int
|
||||
):
|
||||
"""Unlink a contact from hardware."""
|
||||
delete_query = "DELETE FROM hardware_contacts WHERE hardware_id = %s AND contact_id = %s"
|
||||
execute_query(delete_query, (hardware_id, contact_id))
|
||||
|
||||
return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303)
|
||||
|
||||
@ -157,6 +157,14 @@
|
||||
|
||||
<!-- Customer (Current Owner) -->
|
||||
{% set current_owner = ownership[0] if ownership else None %}
|
||||
{% set owner_contact_ns = namespace(contact=None) %}
|
||||
{% if contacts %}
|
||||
{% for c in contacts %}
|
||||
{% if c.role == 'primary' and owner_contact_ns.contact is none %}
|
||||
{% set owner_contact_ns.contact = c %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if current_owner and not current_owner.end_date %}
|
||||
<div class="quick-info-item">
|
||||
<span class="quick-info-label">Ejer:</span>
|
||||
@ -361,19 +369,26 @@
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100 shadow-sm border-0">
|
||||
<div class="card-header bg-white border-bottom-0 pt-3 ps-3">
|
||||
<div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center">
|
||||
<h6 class="text-success mb-0"><i class="bi bi-person me-2"></i>Ejer</h6>
|
||||
<button class="btn btn-sm btn-link p-0" data-bs-toggle="modal" data-bs-target="#ownerModal">Ændre</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if current_owner and not current_owner.end_date %}
|
||||
<div class="text-center py-3">
|
||||
<div class="fs-4 mb-2 text-success"><i class="bi bi-person-badge"></i></div>
|
||||
<h5 class="fw-bold">{{ current_owner.customer_name or current_owner.owner_type|title }}</h5>
|
||||
{% if owner_contact_ns.contact %}
|
||||
<p class="mb-1">
|
||||
<span class="badge bg-light text-dark border">{{ owner_contact_ns.contact.first_name }} {{ owner_contact_ns.contact.last_name }}</span>
|
||||
</p>
|
||||
{% endif %}
|
||||
<p class="text-muted small mb-0">Siden: {{ current_owner.start_date }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4 text-muted">
|
||||
<p class="mb-0">Ingen aktiv ejer</p>
|
||||
<button class="btn btn-sm btn-outline-success mt-2" data-bs-toggle="modal" data-bs-target="#ownerModal">Sæt ejer</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@ -412,6 +427,46 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contacts -->
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center">
|
||||
<h6 class="text-secondary mb-0 fw-bold small"><i class="bi bi-person-lines-fill me-2"></i>Kontaktpersoner</h6>
|
||||
<button type="button" class="btn btn-sm btn-link text-decoration-none p-0" data-bs-toggle="modal" data-bs-target="#addContactModal">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
{% if contacts %}
|
||||
{% for contact in contacts %}
|
||||
<div class="list-group-item border-0 px-3 py-2">
|
||||
<div class="d-flex w-100 justify-content-between align-items-center">
|
||||
<div class="text-truncate" style="max-width: 85%;">
|
||||
<div class="fw-bold text-dark small">
|
||||
{{ contact.first_name }} {{ contact.last_name }}
|
||||
{% if contact.role == 'primary' %}<span class="badge bg-info text-dark ms-1" style="font-size: 0.6rem;">Primær</span>{% endif %}
|
||||
{% if contact.source == 'eset' %}<span class="badge bg-light text-muted border ms-1" style="font-size: 0.6rem;">ESET</span>{% endif %}
|
||||
</div>
|
||||
<div class="text-muted small" style="font-size: 0.75rem;">
|
||||
{% if contact.email %}<a href="mailto:{{ contact.email }}" class="text-muted text-decoration-none"><i class="bi bi-envelope me-1"></i></a>{% endif %}
|
||||
{% if contact.phone %}<a href="tel:{{ contact.phone }}" class="text-muted text-decoration-none"><i class="bi bi-phone me-1"></i>{{ contact.phone }}</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<form action="/hardware/{{ hardware.id }}/contacts/{{ contact.contact_id }}/delete" method="POST" onsubmit="return confirm('Er du sikker på at du vil fjerne denne kontakt?');">
|
||||
<button type="submit" class="btn btn-sm btn-link text-danger p-0" title="Fjern kontakt">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="text-center py-3 text-muted small">
|
||||
Ingen kontakter tilknyttet
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Linked Cases -->
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-white border-bottom-0 pt-3 ps-3">
|
||||
@ -673,6 +728,65 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for Owner -->
|
||||
<div class="modal fade" id="ownerModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Skift Ejer</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form action="/hardware/{{ hardware.id }}/owner" method="post">
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Vælg kunde</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ownerCustomerSearch"
|
||||
class="form-control mb-2"
|
||||
placeholder="🔍 Søg virksomhed..."
|
||||
autocomplete="off"
|
||||
>
|
||||
<select id="ownerCustomerSelect" name="owner_customer_id" class="form-select" required>
|
||||
<option value="">-- Vælg kunde --</option>
|
||||
{% for customer in owner_customers %}
|
||||
<option value="{{ customer.id }}" {% if hardware.current_owner_customer_id == customer.id %}selected{% endif %}>
|
||||
{{ customer.navn }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div id="ownerCustomerHelp" class="form-text">Søg og vælg virksomhed.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Vælg kontaktperson</label>
|
||||
<select id="ownerContactSelect" name="owner_contact_id" class="form-select" required>
|
||||
<option value="">-- Vælg kontaktperson --</option>
|
||||
{% for contact in owner_contacts %}
|
||||
<option
|
||||
value="{{ contact.id }}"
|
||||
data-customer-id="{{ contact.customer_id }}"
|
||||
{% if owner_contact_ns.contact and owner_contact_ns.contact.contact_id == contact.id %}selected{% endif %}
|
||||
>
|
||||
{{ contact.first_name }} {{ contact.last_name }}{% if contact.email %} ({{ contact.email }}){% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div id="ownerContactHelp" class="form-text">Viser kun kontakter for valgt virksomhed.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Note (valgfri)</label>
|
||||
<textarea class="form-control" name="notes" rows="3" placeholder="F.eks. Overdraget til ny kunde"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer bg-light">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||
<button type="submit" class="btn btn-success">Gem Ejer</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for Location -->
|
||||
<div class="modal fade" id="locationModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
@ -744,6 +858,50 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Contact Modal -->
|
||||
<div class="modal fade" id="addContactModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Tilføj Kontaktperson</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form method="POST" action="/hardware/{{ hardware.id }}/contacts/add">
|
||||
<div class="modal-body">
|
||||
{% if available_contacts %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Vælg Kontakt</label>
|
||||
<select name="contact_id" class="form-select" required>
|
||||
<option value="">-- Vælg --</option>
|
||||
{% for contact in available_contacts %}
|
||||
<option value="{{ contact.id }}">
|
||||
{{ contact.first_name }} {{ contact.last_name }}
|
||||
{% if contact.email %}({{ contact.email }}){% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">Viser kun personer fra {{ hardware.customer_name if hardware.customer_name else 'kunden' }} som ikke allerede er tilknyttet.</div>
|
||||
</div>
|
||||
<input type="hidden" name="role" value="user">
|
||||
<input type="hidden" name="source" value="manual">
|
||||
{% else %}
|
||||
<div class="alert alert-warning">
|
||||
Ingen tilgængelige kontakter fundet for denne kunde.
|
||||
<br><small>Opret først kontakter under kunden.</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="modal-footer bg-light">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||
{% if available_contacts %}
|
||||
<button type="submit" class="btn btn-primary">Tilføj</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
@ -878,6 +1036,101 @@
|
||||
|
||||
// Initialize Tags
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const ownerCustomerSearch = document.getElementById('ownerCustomerSearch');
|
||||
const ownerCustomerSelect = document.getElementById('ownerCustomerSelect');
|
||||
const ownerContactSelect = document.getElementById('ownerContactSelect');
|
||||
const ownerCustomerHelp = document.getElementById('ownerCustomerHelp');
|
||||
const ownerContactHelp = document.getElementById('ownerContactHelp');
|
||||
|
||||
function filterOwnerCustomers() {
|
||||
if (!ownerCustomerSearch || !ownerCustomerSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = ownerCustomerSearch.value.toLowerCase().trim();
|
||||
const options = Array.from(ownerCustomerSelect.options);
|
||||
let visibleCount = 0;
|
||||
|
||||
options.forEach((option, index) => {
|
||||
if (index === 0) {
|
||||
option.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const label = (option.textContent || '').toLowerCase();
|
||||
const isVisible = !filter || label.includes(filter);
|
||||
option.hidden = !isVisible;
|
||||
if (isVisible) {
|
||||
visibleCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
const selectedOption = ownerCustomerSelect.selectedOptions[0];
|
||||
if (selectedOption && selectedOption.hidden) {
|
||||
ownerCustomerSelect.value = '';
|
||||
}
|
||||
|
||||
if (ownerCustomerHelp) {
|
||||
if (visibleCount === 0) {
|
||||
ownerCustomerHelp.textContent = 'Ingen virksomheder matcher søgningen.';
|
||||
} else {
|
||||
ownerCustomerHelp.textContent = `Viser ${visibleCount} virksomhed(er).`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function filterOwnerContacts() {
|
||||
if (!ownerCustomerSelect || !ownerContactSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedCustomerId = ownerCustomerSelect.value;
|
||||
const options = Array.from(ownerContactSelect.options);
|
||||
let visibleCount = 0;
|
||||
|
||||
options.forEach((option, index) => {
|
||||
if (index === 0) {
|
||||
option.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const optionCustomerId = option.getAttribute('data-customer-id');
|
||||
const isVisible = selectedCustomerId && optionCustomerId === selectedCustomerId;
|
||||
option.hidden = !isVisible;
|
||||
if (isVisible) {
|
||||
visibleCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
const selectedOption = ownerContactSelect.selectedOptions[0];
|
||||
if (!selectedOption || selectedOption.hidden) {
|
||||
ownerContactSelect.value = '';
|
||||
}
|
||||
|
||||
ownerContactSelect.disabled = !selectedCustomerId || visibleCount === 0;
|
||||
if (ownerContactHelp) {
|
||||
if (!selectedCustomerId) {
|
||||
ownerContactHelp.textContent = 'Vælg først virksomhed.';
|
||||
} else if (visibleCount === 0) {
|
||||
ownerContactHelp.textContent = 'Ingen kontaktpersoner fundet for valgt virksomhed.';
|
||||
} else {
|
||||
ownerContactHelp.textContent = 'Viser kun kontakter for valgt virksomhed.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ownerCustomerSelect && ownerContactSelect) {
|
||||
ownerCustomerSelect.addEventListener('change', filterOwnerContacts);
|
||||
if (ownerCustomerSearch) {
|
||||
ownerCustomerSearch.addEventListener('input', function() {
|
||||
filterOwnerCustomers();
|
||||
filterOwnerContacts();
|
||||
});
|
||||
}
|
||||
filterOwnerCustomers();
|
||||
filterOwnerContacts();
|
||||
}
|
||||
|
||||
if (window.renderEntityTags) {
|
||||
window.renderEntityTags('hardware', {{ hardware.id }}, 'hardware-tags');
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from app.core.database import execute_query, execute_query_single
|
||||
from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate
|
||||
from app.core.config import settings
|
||||
@ -397,6 +398,10 @@ async def update_sag(sag_id: int, updates: dict):
|
||||
if not check:
|
||||
raise HTTPException(status_code=404, detail="Case not found")
|
||||
|
||||
# Backwards compatibility: frontend sends "type", DB stores "template_key"
|
||||
if "type" in updates and "template_key" not in updates:
|
||||
updates["template_key"] = updates.get("type")
|
||||
|
||||
# Build dynamic update query
|
||||
allowed_fields = ["titel", "beskrivelse", "template_key", "status", "ansvarlig_bruger_id", "deadline", "deferred_until", "deferred_until_case_id", "deferred_until_status"]
|
||||
set_clauses = []
|
||||
@ -411,7 +416,8 @@ async def update_sag(sag_id: int, updates: dict):
|
||||
raise HTTPException(status_code=400, detail="No valid fields to update")
|
||||
|
||||
params.append(sag_id)
|
||||
query = f"UPDATE sag_sager SET {", ".join(set_clauses)} WHERE id = %s RETURNING *"
|
||||
set_sql = ", ".join(set_clauses)
|
||||
query = f"UPDATE sag_sager SET {set_sql} WHERE id = %s RETURNING *"
|
||||
|
||||
result = execute_query(query, tuple(params))
|
||||
if result:
|
||||
@ -424,6 +430,72 @@ async def update_sag(sag_id: int, updates: dict):
|
||||
logger.error("❌ Error updating case: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Failed to update case")
|
||||
|
||||
|
||||
class PipelineUpdate(BaseModel):
|
||||
amount: Optional[float] = None
|
||||
probability: Optional[int] = Field(default=None, ge=0, le=100)
|
||||
stage_id: Optional[int] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
@router.patch("/sag/{sag_id}/pipeline")
|
||||
async def update_sag_pipeline(sag_id: int, pipeline_data: PipelineUpdate):
|
||||
"""Update pipeline fields for a case."""
|
||||
try:
|
||||
exists = execute_query(
|
||||
"SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
|
||||
(sag_id,)
|
||||
)
|
||||
if not exists:
|
||||
raise HTTPException(status_code=404, detail="Case not found")
|
||||
|
||||
provided = pipeline_data.model_dump(exclude_unset=True)
|
||||
|
||||
if not provided:
|
||||
raise HTTPException(status_code=400, detail="No pipeline fields provided")
|
||||
|
||||
if "stage_id" in provided and provided["stage_id"] is not None:
|
||||
stage_exists = execute_query(
|
||||
"SELECT id FROM pipeline_stages WHERE id = %s",
|
||||
(provided["stage_id"],)
|
||||
)
|
||||
if not stage_exists:
|
||||
raise HTTPException(status_code=400, detail="Invalid pipeline stage")
|
||||
|
||||
set_clauses = []
|
||||
params = []
|
||||
|
||||
if "amount" in provided:
|
||||
set_clauses.append("pipeline_amount = %s")
|
||||
params.append(provided["amount"])
|
||||
|
||||
if "probability" in provided:
|
||||
set_clauses.append("pipeline_probability = %s")
|
||||
params.append(provided["probability"])
|
||||
|
||||
if "stage_id" in provided:
|
||||
set_clauses.append("pipeline_stage_id = %s")
|
||||
params.append(provided["stage_id"])
|
||||
|
||||
if "description" in provided:
|
||||
set_clauses.append("pipeline_description = %s")
|
||||
params.append(provided["description"])
|
||||
|
||||
params.append(sag_id)
|
||||
query = f"UPDATE sag_sager SET {', '.join(set_clauses)} WHERE id = %s RETURNING *"
|
||||
result = execute_query(query, tuple(params))
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=500, detail="Failed to update pipeline")
|
||||
|
||||
logger.info("✅ Pipeline updated for case: %s", sag_id)
|
||||
return result[0]
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("❌ Error updating pipeline for case %s: %s", sag_id, e)
|
||||
raise HTTPException(status_code=500, detail="Failed to update pipeline")
|
||||
|
||||
@router.delete("/sag/{sag_id}")
|
||||
async def delete_sag(sag_id: int):
|
||||
"""Soft-delete a case."""
|
||||
|
||||
@ -181,85 +181,11 @@ async def sag_detaljer(request: Request, sag_id: int):
|
||||
# --- Relation Tree Construction ---
|
||||
relation_tree = []
|
||||
try:
|
||||
# 1. Get all connected case IDs (Recursive CTE)
|
||||
tree_ids_query = """
|
||||
WITH RECURSIVE CaseTree AS (
|
||||
SELECT id FROM sag_sager WHERE id = %s
|
||||
UNION
|
||||
SELECT CASE WHEN sr.kilde_sag_id = ct.id THEN sr.målsag_id ELSE sr.kilde_sag_id END
|
||||
FROM sag_relationer sr
|
||||
JOIN CaseTree ct ON sr.kilde_sag_id = ct.id OR sr.målsag_id = ct.id
|
||||
WHERE sr.deleted_at IS NULL
|
||||
)
|
||||
SELECT id FROM CaseTree LIMIT 50;
|
||||
"""
|
||||
tree_ids_rows = execute_query(tree_ids_query, (sag_id,))
|
||||
tree_ids = [r['id'] for r in tree_ids_rows]
|
||||
|
||||
if tree_ids:
|
||||
# 2. Fetch details
|
||||
placeholders = ','.join(['%s'] * len(tree_ids))
|
||||
tree_cases_query = f"SELECT id, titel, status FROM sag_sager WHERE id IN ({placeholders})"
|
||||
tree_cases = {c['id']: c for c in execute_query(tree_cases_query, tuple(tree_ids))}
|
||||
|
||||
# 3. Fetch edges
|
||||
tree_edges_query = f"""
|
||||
SELECT id, kilde_sag_id, målsag_id, relationstype
|
||||
FROM sag_relationer
|
||||
WHERE deleted_at IS NULL
|
||||
AND kilde_sag_id IN ({placeholders})
|
||||
AND målsag_id IN ({placeholders})
|
||||
"""
|
||||
tree_edges = execute_query(tree_edges_query, tuple(tree_ids) * 2)
|
||||
|
||||
# 4. Build Graph
|
||||
children_map = {cid: [] for cid in tree_ids}
|
||||
parents_map = {cid: [] for cid in tree_ids}
|
||||
|
||||
for edge in tree_edges:
|
||||
k, m, rtype = edge['kilde_sag_id'], edge['målsag_id'], edge['relationstype'].lower()
|
||||
parent, child = k, m # Default (e.g. Relateret til)
|
||||
|
||||
if rtype == 'afledt af': # m is parent of k
|
||||
parent, child = m, k
|
||||
elif rtype == 'årsag til': # k is parent of m
|
||||
parent, child = k, m
|
||||
|
||||
if parent in children_map:
|
||||
children_map[parent].append({
|
||||
'id': child,
|
||||
'type': edge['relationstype'],
|
||||
'rel_id': edge['id']
|
||||
})
|
||||
if child in parents_map:
|
||||
parents_map[child].append(parent)
|
||||
|
||||
# 5. Identify Roots and Build
|
||||
roots = [cid for cid in tree_ids if not parents_map[cid]]
|
||||
if not roots and tree_ids: roots = [min(tree_ids)] # Fallback
|
||||
|
||||
def build_tree_node(cid, visited):
|
||||
if cid in visited: return None
|
||||
visited.add(cid)
|
||||
node_case = tree_cases.get(cid)
|
||||
if not node_case: return None
|
||||
|
||||
children_nodes = []
|
||||
for child_info in children_map.get(cid, []):
|
||||
c_node = build_tree_node(child_info['id'], visited.copy())
|
||||
if c_node:
|
||||
c_node['relation_type'] = child_info['type']
|
||||
c_node['relation_id'] = child_info['rel_id']
|
||||
children_nodes.append(c_node)
|
||||
|
||||
return {
|
||||
'case': node_case,
|
||||
'children': children_nodes,
|
||||
'is_current': cid == sag_id
|
||||
}
|
||||
|
||||
relation_tree = [build_tree_node(r, set()) for r in roots]
|
||||
relation_tree = [n for n in relation_tree if n]
|
||||
from app.modules.sag.services.relation_service import RelationService
|
||||
relation_tree = RelationService.get_relation_tree(sag_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error building relation tree: {e}")
|
||||
relation_tree = []
|
||||
except Exception as e:
|
||||
logger.error(f"Error building relation tree: {e}")
|
||||
relation_tree = []
|
||||
@ -438,6 +364,16 @@ async def sag_detaljer(request: Request, sag_id: int):
|
||||
logger.error("❌ Error building related case options: %s", e)
|
||||
related_case_options = []
|
||||
|
||||
pipeline_stages = []
|
||||
try:
|
||||
pipeline_stages = execute_query(
|
||||
"SELECT id, name, color, sort_order FROM pipeline_stages ORDER BY sort_order ASC, id ASC",
|
||||
(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("⚠️ Could not load pipeline stages: %s", e)
|
||||
pipeline_stages = []
|
||||
|
||||
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ())
|
||||
|
||||
return templates.TemplateResponse("modules/sag/templates/detail.html", {
|
||||
@ -460,6 +396,7 @@ async def sag_detaljer(request: Request, sag_id: int):
|
||||
"is_nextcloud": is_nextcloud,
|
||||
"nextcloud_instance": nextcloud_instance,
|
||||
"related_case_options": related_case_options,
|
||||
"pipeline_stages": pipeline_stages,
|
||||
"status_options": [s["status"] for s in statuses],
|
||||
})
|
||||
except HTTPException:
|
||||
|
||||
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>
|
||||
|
||||
<form id="createForm" novalidate>
|
||||
<!-- Section: Basic Info -->
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-12">
|
||||
<label for="titel" class="form-label">Titel <span class="text-danger">*</span></label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-light border-end-0"><i class="bi bi-type-h1"></i></span>
|
||||
<input type="text" class="form-control border-start-0 ps-0" id="titel" placeholder="Kort og præcis titel (f.eks. 'Netværksproblemer hos X')" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-12">
|
||||
<label for="beskrivelse" class="form-label">Beskrivelse</label>
|
||||
<textarea class="form-control" id="beskrivelse" rows="5" placeholder="Beskriv problemstillingen detaljeret..."></textarea>
|
||||
<div class="form-text text-end" id="charCount">0 tegn</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4 opacity-25">
|
||||
|
||||
<!-- Section: Relations -->
|
||||
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Relationer</h5>
|
||||
<div class="row g-4 mb-4">
|
||||
@ -183,13 +164,13 @@
|
||||
<div id="selectedContacts" class="mt-2 text-wrap"></div>
|
||||
</div>
|
||||
|
||||
<!-- Customer Search -->
|
||||
<!-- Company Search -->
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Kunde</label>
|
||||
<label class="form-label">Firma</label>
|
||||
<div class="search-position-relative">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-light border-end-0"><i class="bi bi-building"></i></span>
|
||||
<input type="text" id="customerSearch" class="form-control border-start-0 ps-0" placeholder="Søg kunde (min. 2 tegn)...">
|
||||
<input type="text" id="customerSearch" class="form-control border-start-0 ps-0" placeholder="Søg firma (min. 2 tegn)...">
|
||||
</div>
|
||||
<div id="customerResults" class="search-results shadow-sm d-none"></div>
|
||||
</div>
|
||||
@ -200,6 +181,25 @@
|
||||
|
||||
<hr class="my-4 opacity-25">
|
||||
|
||||
<!-- Section: Basic Info -->
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-12">
|
||||
<label for="titel" class="form-label">Titel <span class="text-danger">*</span></label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-light border-end-0"><i class="bi bi-type-h1"></i></span>
|
||||
<input type="text" class="form-control border-start-0 ps-0" id="titel" placeholder="Kort og præcis titel (f.eks. 'Netværksproblemer hos X')" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-12">
|
||||
<label for="beskrivelse" class="form-label">Beskrivelse</label>
|
||||
<textarea class="form-control" id="beskrivelse" rows="5" placeholder="Beskriv problemstillingen detaljeret..."></textarea>
|
||||
<div class="form-text text-end" id="charCount">0 tegn</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4 opacity-25">
|
||||
|
||||
<!-- Section: Hardware & AnyDesk -->
|
||||
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Hardware (AnyDesk)</h5>
|
||||
<div class="row g-4 mb-4">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -192,11 +192,12 @@
|
||||
<div class="form-group">
|
||||
<label for="type">Type *</label>
|
||||
<select class="form-select" id="type" required>
|
||||
<option value="ticket" {% if case.type == 'ticket' %}selected{% endif %}>🎫 Ticket</option>
|
||||
<option value="opgave" {% if case.type == 'opgave' %}selected{% endif %}>🧩 Opgave</option>
|
||||
<option value="ordre" {% if case.type == 'ordre' %}selected{% endif %}>🧾 Ordre</option>
|
||||
<option value="projekt" {% if case.type == 'projekt' %}selected{% endif %}>📁 Projekt</option>
|
||||
<option value="service" {% if case.type == 'service' %}selected{% endif %}>🛠️ Service</option>
|
||||
<option value="ticket" {% if (case.template_key or case.type) == 'ticket' %}selected{% endif %}>🎫 Ticket</option>
|
||||
<option value="pipeline" {% if (case.template_key or case.type) == 'pipeline' %}selected{% endif %}>📈 Pipeline</option>
|
||||
<option value="opgave" {% if (case.template_key or case.type) == 'opgave' %}selected{% endif %}>🧩 Opgave</option>
|
||||
<option value="ordre" {% if (case.template_key or case.type) == 'ordre' %}selected{% endif %}>🧾 Ordre</option>
|
||||
<option value="projekt" {% if (case.template_key or case.type) == 'projekt' %}selected{% endif %}>📁 Projekt</option>
|
||||
<option value="service" {% if (case.template_key or case.type) == 'service' %}selected{% endif %}>🛠️ Service</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@ -231,7 +232,7 @@
|
||||
|
||||
<script>
|
||||
const caseId = {{ case.id }};
|
||||
const currentType = "{{ case.type or 'ticket' }}";
|
||||
const currentType = "{{ case.template_key or case.type or 'ticket' }}";
|
||||
|
||||
async function loadCaseTypesSelect() {
|
||||
const select = document.getElementById('type');
|
||||
@ -243,7 +244,12 @@
|
||||
const types = JSON.parse(setting.value || '[]');
|
||||
if (!Array.isArray(types) || types.length === 0) return;
|
||||
|
||||
select.innerHTML = types
|
||||
const typeSet = new Set(types);
|
||||
if (currentType) {
|
||||
typeSet.add(currentType);
|
||||
}
|
||||
|
||||
select.innerHTML = Array.from(typeSet)
|
||||
.map((type) => `<option value="${type}" ${type === currentType ? 'selected' : ''}>${type}</option>`)
|
||||
.join('');
|
||||
} catch (err) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -4,92 +4,112 @@
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.stage-pill {
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
background: rgba(15, 76, 117, 0.1);
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stage-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
.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);
|
||||
}
|
||||
|
||||
.opportunity-row:hover {
|
||||
background-color: rgba(0,0,0,0.02);
|
||||
}
|
||||
|
||||
.opportunity-row td {
|
||||
vertical-align: middle;
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2 class="fw-bold mb-1">Muligheder</h2>
|
||||
<p class="text-muted mb-0">Hub‑lokal salgspipeline</p>
|
||||
<div class="container-fluid px-4 py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2 class="fw-bold mb-1">Muligheder</h2>
|
||||
<p class="text-muted mb-0">Sager markeret som pipeline</p>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-primary" onclick="openCreateOpportunityModal()">
|
||||
<i class="bi bi-plus-lg me-2"></i>Opret mulighed
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a class="btn btn-outline-primary" href="/pipeline">
|
||||
<i class="bi bi-kanban me-2"></i>Sales Board
|
||||
</a>
|
||||
<button class="btn btn-primary" onclick="openCreateOpportunityModal()">
|
||||
<i class="bi bi-plus-lg me-2"></i>Opret mulighed
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-3 mb-4">
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-md-4">
|
||||
<input type="text" class="form-control" id="searchInput" placeholder="Søg titel eller kunde..." oninput="renderOpportunities()">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select class="form-select" id="stageFilter" onchange="renderOpportunities()"></select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select class="form-select" id="statusFilter" onchange="renderOpportunities()">
|
||||
<option value="all">Alle status</option>
|
||||
<option value="open">Åbne</option>
|
||||
<option value="won">Vundet</option>
|
||||
<option value="lost">Tabt</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 text-end">
|
||||
<span class="text-muted small" id="countLabel">0 muligheder</span>
|
||||
<div class="card p-3 mb-4 shadow-sm border-0">
|
||||
<div class="row g-3 align-items-center">
|
||||
<div class="col-md-4">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search text-muted"></i></span>
|
||||
<input type="text" class="form-control border-start-0 ps-0" id="searchInput" placeholder="Søg titel eller kunde..." oninput="filterOpportunities()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select class="form-select" id="stageFilter" onchange="filterOpportunities()">
|
||||
<option value="all">Alle stages</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select class="form-select" id="statusFilter" onchange="filterOpportunities()">
|
||||
<option value="all">Alle status</option>
|
||||
<option value="open" selected>Åbne</option>
|
||||
<option value="closed">Lukkede</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 text-end">
|
||||
<span class="badge bg-light text-dark border" id="countLabel">Henter data...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Titel</th>
|
||||
<th>Kunde</th>
|
||||
<th>Beløb</th>
|
||||
<th>Lukningsdato</th>
|
||||
<th>Stage</th>
|
||||
<th>Sandsynlighed</th>
|
||||
<th class="text-end">Handling</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="opportunitiesTable">
|
||||
<tr>
|
||||
<td colspan="7" class="text-center py-5">
|
||||
<div class="spinner-border text-primary"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="card shadow-sm border-0 overflow-hidden">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="ps-4">Titel</th>
|
||||
<th>Kunde</th>
|
||||
<th>Stage</th>
|
||||
<th class="text-end">Sandsynlighed</th>
|
||||
<th class="text-end">Beløb</th>
|
||||
<th>Status</th>
|
||||
<th>Deadline</th>
|
||||
<th>Ansvarlig</th>
|
||||
<th>Beskrivelse</th>
|
||||
<th class="text-end pe-4">Handling</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="opportunitiesTable">
|
||||
<tr>
|
||||
<td colspan="10" class="text-center py-5">
|
||||
<div class="spinner-border text-primary spinner-border-sm me-2"></div>
|
||||
<span class="text-muted">Indlæser muligheder...</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Opportunity Modal -->
|
||||
<div class="modal fade" id="opportunityModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Opret mulighed</h5>
|
||||
@ -97,207 +117,268 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="opportunityForm">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Kunde *</label>
|
||||
<select class="form-select" id="customerId" required></select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Titel *</label>
|
||||
<input type="text" class="form-control" id="title" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Beløb</label>
|
||||
<input type="number" step="0.01" class="form-control" id="amount">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Valuta</label>
|
||||
<select class="form-select" id="currency">
|
||||
<option value="DKK">DKK</option>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Stage</label>
|
||||
<select class="form-select" id="stageId"></select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Forventet lukning</label>
|
||||
<input type="date" class="form-control" id="expectedCloseDate">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Beskrivelse</label>
|
||||
<textarea class="form-control" id="description" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-uppercase text-muted fw-bold">Titel *</label>
|
||||
<input type="text" class="form-control" id="title" required placeholder="Fx Opgradering af serverpark">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-uppercase text-muted fw-bold">Kunde *</label>
|
||||
<select class="form-select" id="customerId" required>
|
||||
<option value="">Vælg kunde...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-uppercase text-muted fw-bold">Forventet lukning (Deadline)</label>
|
||||
<input type="date" class="form-control" id="expectedCloseDate">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-uppercase text-muted fw-bold">Beskrivelse</label>
|
||||
<textarea class="form-control" id="description" rows="4" placeholder="Beskriv muligheden..."></textarea>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||
<button type="button" class="btn btn-primary" onclick="createOpportunity()">Gem</button>
|
||||
<div class="modal-footer bg-light">
|
||||
<button type="button" class="btn btn-link text-decoration-none text-muted" data-bs-dismiss="modal">Annuller</button>
|
||||
<button type="button" class="btn btn-primary px-4" onclick="createOpportunity()">Opret</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
let opportunities = [];
|
||||
let stages = [];
|
||||
let allOpportunities = [];
|
||||
let customers = [];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
try {
|
||||
await loadStages();
|
||||
} catch (error) {
|
||||
console.error('Error loading stages:', error);
|
||||
// Load data in parallel
|
||||
const [custRes, oppRes] = await Promise.allSettled([
|
||||
fetch('/api/v1/customers?limit=1000'),
|
||||
fetch('/api/v1/opportunities')
|
||||
]);
|
||||
|
||||
// Handle customers
|
||||
if (custRes.status === 'fulfilled') {
|
||||
const data = await custRes.value.json();
|
||||
customers = Array.isArray(data) ? data : (data.customers || []);
|
||||
renderCustomerSelect();
|
||||
}
|
||||
|
||||
try {
|
||||
await loadCustomers();
|
||||
} catch (error) {
|
||||
console.error('Error loading customers:', error);
|
||||
}
|
||||
|
||||
try {
|
||||
await loadOpportunities();
|
||||
} catch (error) {
|
||||
console.error('Error loading opportunities:', error);
|
||||
const tbody = document.getElementById('opportunitiesTable');
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-danger py-5">Fejl ved indlæsning af muligheder</td></tr>';
|
||||
document.getElementById('countLabel').textContent = '0 muligheder';
|
||||
// Handle opportunities
|
||||
if (oppRes.status === 'fulfilled') {
|
||||
allOpportunities = await oppRes.value.json();
|
||||
renderStageFilter();
|
||||
filterOpportunities();
|
||||
} else {
|
||||
document.getElementById('opportunitiesTable').innerHTML =
|
||||
'<tr><td colspan="10" class="text-center text-danger py-5">Kunne ikke hente data</td></tr>';
|
||||
}
|
||||
|
||||
// Setup search listener
|
||||
document.getElementById('searchInput').addEventListener('input', debounce(filterOpportunities, 300));
|
||||
});
|
||||
|
||||
async function loadStages() {
|
||||
const response = await fetch('/api/v1/pipeline/stages');
|
||||
stages = await response.json();
|
||||
|
||||
const stageFilter = document.getElementById('stageFilter');
|
||||
stageFilter.innerHTML = '<option value="all">Alle stages</option>' +
|
||||
stages.map(s => `<option value="${s.id}">${s.name}</option>`).join('');
|
||||
|
||||
const stageSelect = document.getElementById('stageId');
|
||||
stageSelect.innerHTML = stages.map(s => `<option value="${s.id}">${s.name}</option>`).join('');
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
const response = await fetch('/api/v1/customers?limit=1000');
|
||||
const data = await response.json();
|
||||
customers = Array.isArray(data) ? data : (data.customers || []);
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
};
|
||||
|
||||
function renderCustomerSelect() {
|
||||
const select = document.getElementById('customerId');
|
||||
// Sort customers by name
|
||||
customers.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
|
||||
select.innerHTML = '<option value="">Vælg kunde...</option>' +
|
||||
customers.map(c => `<option value="${c.id}">${escapeHtml(c.name)}</option>`).join('');
|
||||
}
|
||||
|
||||
async function loadOpportunities() {
|
||||
const response = await fetch('/api/v1/opportunities');
|
||||
opportunities = await response.json();
|
||||
renderOpportunities();
|
||||
function renderStageFilter() {
|
||||
const select = document.getElementById('stageFilter');
|
||||
if (!select) return;
|
||||
|
||||
const current = select.value || 'all';
|
||||
const stages = [...new Set(
|
||||
allOpportunities
|
||||
.map(o => (o.pipeline_stage || '').trim())
|
||||
.filter(Boolean)
|
||||
)].sort((a, b) => a.localeCompare(b, 'da'));
|
||||
|
||||
select.innerHTML = '<option value="all">Alle stages</option>' +
|
||||
stages.map(stage => `<option value="${escapeHtml(stage)}">${escapeHtml(stage)}</option>`).join('');
|
||||
|
||||
if (current !== 'all' && stages.includes(current)) {
|
||||
select.value = current;
|
||||
}
|
||||
}
|
||||
|
||||
function renderOpportunities() {
|
||||
function filterOpportunities() {
|
||||
const search = document.getElementById('searchInput').value.toLowerCase();
|
||||
const stageFilter = document.getElementById('stageFilter').value;
|
||||
const statusFilter = document.getElementById('statusFilter').value;
|
||||
|
||||
const filtered = opportunities.filter(o => {
|
||||
const text = `${o.title} ${o.customer_name}`.toLowerCase();
|
||||
if (search && !text.includes(search)) return false;
|
||||
if (stageFilter !== 'all' && parseInt(stageFilter) !== o.stage_id) return false;
|
||||
if (statusFilter === 'won' && !o.is_won) return false;
|
||||
if (statusFilter === 'lost' && !o.is_lost) return false;
|
||||
if (statusFilter === 'open' && (o.is_won || o.is_lost)) return false;
|
||||
return true;
|
||||
const filtered = allOpportunities.filter(o => {
|
||||
// Search filter
|
||||
const matchSearch = !search ||
|
||||
(o.titel && o.titel.toLowerCase().includes(search)) ||
|
||||
(o.customer_name && o.customer_name.toLowerCase().includes(search));
|
||||
|
||||
// Status filter
|
||||
let matchStatus = true;
|
||||
if (statusFilter === 'open') matchStatus = o.status === 'åben';
|
||||
if (statusFilter === 'closed') matchStatus = o.status !== 'åben';
|
||||
|
||||
let matchStage = true;
|
||||
if (stageFilter !== 'all') {
|
||||
matchStage = (o.pipeline_stage || '') === stageFilter;
|
||||
}
|
||||
|
||||
return matchSearch && matchStatus && matchStage;
|
||||
});
|
||||
|
||||
renderTable(filtered);
|
||||
}
|
||||
|
||||
function renderTable(data) {
|
||||
const tbody = document.getElementById('opportunitiesTable');
|
||||
if (filtered.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-5">Ingen muligheder fundet</td></tr>';
|
||||
document.getElementById('countLabel').textContent = '0 muligheder';
|
||||
document.getElementById('countLabel').textContent = `${data.length} fundet`;
|
||||
|
||||
if (data.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-muted py-5">Ingen muligheder fundet</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = filtered.map(o => `
|
||||
<tr class="opportunity-row" style="cursor:pointer" onclick="goToDetail(${o.id})">
|
||||
<td class="fw-semibold">${escapeHtml(o.title)}</td>
|
||||
<td>${escapeHtml(o.customer_name || '-')}</td>
|
||||
<td>${formatCurrency(o.amount, o.currency)}</td>
|
||||
<td>${o.expected_close_date ? formatDate(o.expected_close_date) : '<span class=\"text-muted\">-</span>'}</td>
|
||||
tbody.innerHTML = data.map(o => {
|
||||
const statusClass = (o.status === 'åben') ? 'status-open' : 'status-closed';
|
||||
const dateStr = o.deadline ? formatDate(o.deadline) : '<span class="text-muted">-</span>';
|
||||
const stage = o.pipeline_stage ? escapeHtml(o.pipeline_stage) : '<span class="text-muted">-</span>';
|
||||
const probability = Number.isFinite(Number(o.pipeline_probability)) ? `${Number(o.pipeline_probability)}%` : '<span class="text-muted">-</span>';
|
||||
const amount = (o.pipeline_amount === null || o.pipeline_amount === undefined)
|
||||
? '<span class="text-muted">-</span>'
|
||||
: formatCurrency(o.pipeline_amount);
|
||||
const descPreview = o.beskrivelse ?
|
||||
(o.beskrivelse.length > 50 ? escapeHtml(o.beskrivelse.substring(0, 50)) + '...' : escapeHtml(o.beskrivelse))
|
||||
: '<span class="text-muted fst-italic">Ingen beskrivelse</span>';
|
||||
|
||||
return `
|
||||
<tr class="opportunity-row" style="cursor:pointer" onclick="window.location.href='/sag/${o.id}'">
|
||||
<td class="fw-semibold ps-4">${escapeHtml(o.titel)}</td>
|
||||
<td>${escapeHtml(o.customer_name)}</td>
|
||||
<td>${stage}</td>
|
||||
<td class="text-end">${probability}</td>
|
||||
<td class="text-end">${amount}</td>
|
||||
<td>
|
||||
<span class="stage-pill">
|
||||
<span class="stage-dot" style="background:${o.stage_color || '#0f4c75'}"></span>
|
||||
${escapeHtml(o.stage_name || '-')}
|
||||
<span class="status-badge ${statusClass}">
|
||||
${escapeHtml(o.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td>${o.probability || 0}%</td>
|
||||
<td class="text-end">
|
||||
<i class="bi bi-arrow-right"></i>
|
||||
<td>${dateStr}</td>
|
||||
<td>${escapeHtml(o.ansvarlig_navn)}</td>
|
||||
<td class="small text-muted">${descPreview}</td>
|
||||
<td class="text-end pe-4 text-muted">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
document.getElementById('countLabel').textContent = `${filtered.length} muligheder`;
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function openCreateOpportunityModal() {
|
||||
document.getElementById('opportunityForm').reset();
|
||||
const modal = new bootstrap.Modal(document.getElementById('opportunityModal'));
|
||||
modal.show();
|
||||
new bootstrap.Modal(document.getElementById('opportunityModal')).show();
|
||||
}
|
||||
|
||||
async function createOpportunity() {
|
||||
const title = document.getElementById('title').value;
|
||||
const customerId = document.getElementById('customerId').value;
|
||||
const desc = document.getElementById('description').value;
|
||||
const closeDate = document.getElementById('expectedCloseDate').value;
|
||||
|
||||
if (!title || !customerId) {
|
||||
alert('Titel og Kunde skal udfyldes');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
customer_id: parseInt(document.getElementById('customerId').value),
|
||||
title: document.getElementById('title').value,
|
||||
description: document.getElementById('description').value || null,
|
||||
amount: parseFloat(document.getElementById('amount').value || 0),
|
||||
currency: document.getElementById('currency').value,
|
||||
stage_id: parseInt(document.getElementById('stageId').value || 0) || null,
|
||||
expected_close_date: document.getElementById('expectedCloseDate').value || null
|
||||
title: title,
|
||||
customer_id: parseInt(customerId),
|
||||
description: desc,
|
||||
expected_close_date: closeDate || null
|
||||
};
|
||||
|
||||
if (!payload.customer_id || !payload.title) {
|
||||
alert('Kunde og titel er påkrævet');
|
||||
return;
|
||||
try {
|
||||
const btn = document.querySelector('#opportunityModal .btn-primary');
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = 'Gemmer...';
|
||||
btn.disabled = true;
|
||||
|
||||
const res = await fetch('/api/v1/opportunities', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.detail || 'Kunne ikke oprette');
|
||||
}
|
||||
|
||||
const newCase = await res.json();
|
||||
|
||||
// Success
|
||||
bootstrap.Modal.getInstance(document.getElementById('opportunityModal')).hide();
|
||||
|
||||
// Reload list
|
||||
const oppRes = await fetch('/api/v1/opportunities');
|
||||
allOpportunities = await oppRes.json();
|
||||
filterOpportunities();
|
||||
|
||||
// Reset btn
|
||||
btn.textContent = originalText;
|
||||
btn.disabled = false;
|
||||
|
||||
} catch (e) {
|
||||
alert('Fejl: ' + e.message);
|
||||
const btn = document.querySelector('#opportunityModal .btn-primary');
|
||||
btn.textContent = 'Opret';
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/v1/opportunities', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
alert('Kunne ikke oprette mulighed');
|
||||
return;
|
||||
}
|
||||
|
||||
bootstrap.Modal.getInstance(document.getElementById('opportunityModal')).hide();
|
||||
await loadOpportunities();
|
||||
}
|
||||
|
||||
function goToDetail(id) {
|
||||
window.location.href = `/opportunities/${id}`;
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('da-DK', {day: 'numeric', month: 'short', year: 'numeric'});
|
||||
}
|
||||
|
||||
function formatCurrency(value, currency) {
|
||||
const num = parseFloat(value || 0);
|
||||
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(num);
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
return new Date(value).toLocaleDateString('da-DK');
|
||||
function formatCurrency(value) {
|
||||
return new Intl.NumberFormat('da-DK', {
|
||||
style: 'currency',
|
||||
currency: 'DKK',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}).format(Number(value || 0));
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
if (text === null || text === undefined) return '';
|
||||
return String(text)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
</script>
|
||||
{% 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)
|
||||
async def opportunities_page(request: Request):
|
||||
return templates.TemplateResponse("opportunities/frontend/opportunities.html", {"request": request})
|
||||
|
||||
|
||||
@router.get("/opportunities/{opportunity_id}", response_class=HTMLResponse)
|
||||
async def opportunity_detail_page(request: Request, opportunity_id: int):
|
||||
return templates.TemplateResponse("opportunities/frontend/opportunity_detail.html", {
|
||||
"request": request,
|
||||
"opportunity_id": opportunity_id
|
||||
})
|
||||
|
||||
@ -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"
|
||||
result = execute_query(query, (key,))
|
||||
|
||||
if not result and key == "case_types":
|
||||
if not result and key in {"case_types", "case_type_module_defaults"}:
|
||||
seed_query = """
|
||||
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
"""
|
||||
execute_query(
|
||||
seed_query,
|
||||
(
|
||||
"case_types",
|
||||
'["ticket", "opgave", "ordre", "projekt", "service"]',
|
||||
"system",
|
||||
"Sags-typer",
|
||||
"json",
|
||||
True,
|
||||
|
||||
if key == "case_types":
|
||||
execute_query(
|
||||
seed_query,
|
||||
(
|
||||
"case_types",
|
||||
'["ticket", "opgave", "ordre", "projekt", "service"]',
|
||||
"system",
|
||||
"Sags-typer",
|
||||
"json",
|
||||
True,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if key == "case_type_module_defaults":
|
||||
execute_query(
|
||||
seed_query,
|
||||
(
|
||||
"case_type_module_defaults",
|
||||
'{"ticket": ["relations", "call-history", "files", "emails", "hardware", "locations", "contacts", "customers", "wiki", "todo-steps", "time", "solution", "sales", "subscription", "reminders", "calendar"], "opgave": ["relations", "call-history", "files", "emails", "hardware", "locations", "contacts", "customers", "wiki", "todo-steps", "time", "solution", "sales", "subscription", "reminders", "calendar"], "ordre": ["relations", "call-history", "files", "emails", "hardware", "locations", "contacts", "customers", "wiki", "todo-steps", "time", "solution", "sales", "subscription", "reminders", "calendar"], "projekt": ["relations", "call-history", "files", "emails", "hardware", "locations", "contacts", "customers", "wiki", "todo-steps", "time", "solution", "sales", "subscription", "reminders", "calendar"], "service": ["relations", "call-history", "files", "emails", "hardware", "locations", "contacts", "customers", "wiki", "todo-steps", "time", "solution", "sales", "subscription", "reminders", "calendar"]}',
|
||||
"system",
|
||||
"Standard moduler pr. sagstype",
|
||||
"json",
|
||||
True,
|
||||
)
|
||||
)
|
||||
|
||||
result = execute_query(query, (key,))
|
||||
|
||||
if not result:
|
||||
|
||||
@ -1043,6 +1043,34 @@ async def scan_document(file_path: str):
|
||||
<div class="text-muted">Indlæser...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-4 mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h5 class="mb-1 fw-bold">Standardmoduler pr. sagstype</h5>
|
||||
<p class="text-muted mb-0">Vælg hvilke moduler der vises som standard for hver sagstype. Moduler med indhold vises altid.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 align-items-end mb-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Sagstype</label>
|
||||
<select id="caseTypeModulesTypeSelect" class="form-select" onchange="renderCaseTypeModuleChecklist()">
|
||||
<option value="">Vælg sagstype...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-8 text-md-end">
|
||||
<button class="btn btn-outline-secondary me-2" onclick="resetCaseTypeModuleDefaults()">
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i>Nulstil til standard
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick="saveCaseTypeModuleDefaults()">
|
||||
<i class="bi bi-save me-1"></i>Gem standardmoduler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="caseTypeModuleChecklist" class="row g-2">
|
||||
<div class="text-muted">Indlæser...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1878,20 +1906,123 @@ function getCaseTypesSetting() {
|
||||
return allSettings.find(setting => setting.key === 'case_types');
|
||||
}
|
||||
|
||||
const CASE_MODULE_OPTIONS = [
|
||||
'relations', 'call-history', 'files', 'emails', 'hardware', 'locations',
|
||||
'contacts', 'customers', 'wiki', 'todo-steps', 'time', 'solution',
|
||||
'sales', 'subscription', 'reminders', 'calendar'
|
||||
];
|
||||
|
||||
const CASE_MODULE_LABELS = {
|
||||
'relations': 'Relationer',
|
||||
'call-history': 'Opkaldshistorik',
|
||||
'files': 'Filer',
|
||||
'emails': 'E-mails',
|
||||
'hardware': 'Hardware',
|
||||
'locations': 'Lokationer',
|
||||
'contacts': 'Kontakter',
|
||||
'customers': 'Kunder',
|
||||
'wiki': 'Wiki',
|
||||
'todo-steps': 'Todo-opgaver',
|
||||
'time': 'Tid',
|
||||
'solution': 'Løsning',
|
||||
'sales': 'Varekøb & salg',
|
||||
'subscription': 'Abonnement',
|
||||
'reminders': 'Påmindelser',
|
||||
'calendar': 'Kalender'
|
||||
};
|
||||
|
||||
let caseTypeModuleDefaultsCache = {};
|
||||
|
||||
function normalizeCaseTypeModuleDefaults(raw, caseTypes) {
|
||||
const normalized = {};
|
||||
const rawObj = raw && typeof raw === 'object' ? raw : {};
|
||||
const validTypes = Array.isArray(caseTypes) ? caseTypes : [];
|
||||
|
||||
validTypes.forEach(type => {
|
||||
const existing = rawObj[type];
|
||||
const asList = Array.isArray(existing) ? existing : CASE_MODULE_OPTIONS;
|
||||
normalized[type] = asList.filter(m => CASE_MODULE_OPTIONS.includes(m));
|
||||
});
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function loadCaseTypeModuleDefaultsSetting(caseTypes) {
|
||||
try {
|
||||
const response = await fetch('/api/v1/settings/case_type_module_defaults');
|
||||
if (!response.ok) {
|
||||
caseTypeModuleDefaultsCache = normalizeCaseTypeModuleDefaults({}, caseTypes);
|
||||
} else {
|
||||
const setting = await response.json();
|
||||
const parsed = JSON.parse(setting.value || '{}');
|
||||
caseTypeModuleDefaultsCache = normalizeCaseTypeModuleDefaults(parsed, caseTypes);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading case type module defaults:', error);
|
||||
caseTypeModuleDefaultsCache = normalizeCaseTypeModuleDefaults({}, caseTypes);
|
||||
}
|
||||
|
||||
renderCaseTypeModuleTypeOptions(caseTypes);
|
||||
renderCaseTypeModuleChecklist();
|
||||
}
|
||||
|
||||
function renderCaseTypeModuleTypeOptions(caseTypes) {
|
||||
const select = document.getElementById('caseTypeModulesTypeSelect');
|
||||
if (!select) return;
|
||||
|
||||
const previous = select.value;
|
||||
select.innerHTML = '<option value="">Vælg sagstype...</option>' +
|
||||
(caseTypes || []).map(type => `<option value="${type}">${type}</option>`).join('');
|
||||
|
||||
if (previous && (caseTypes || []).includes(previous)) {
|
||||
select.value = previous;
|
||||
} else if ((caseTypes || []).length > 0) {
|
||||
select.value = caseTypes[0];
|
||||
}
|
||||
}
|
||||
|
||||
function renderCaseTypeModuleChecklist() {
|
||||
const container = document.getElementById('caseTypeModuleChecklist');
|
||||
const select = document.getElementById('caseTypeModulesTypeSelect');
|
||||
if (!container || !select) return;
|
||||
|
||||
const type = select.value;
|
||||
if (!type) {
|
||||
container.innerHTML = '<div class="text-muted">Vælg en sagstype for at redigere standardmoduler.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const enabledModules = new Set(caseTypeModuleDefaultsCache[type] || CASE_MODULE_OPTIONS);
|
||||
container.innerHTML = CASE_MODULE_OPTIONS.map(moduleKey => `
|
||||
<div class="col-md-4 col-sm-6">
|
||||
<div class="form-check border rounded p-2">
|
||||
<input class="form-check-input" type="checkbox" id="ctmod_${moduleKey}" ${enabledModules.has(moduleKey) ? 'checked' : ''}>
|
||||
<label class="form-check-label" for="ctmod_${moduleKey}">
|
||||
${CASE_MODULE_LABELS[moduleKey] || moduleKey}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function loadCaseTypesSetting() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/settings/case_types');
|
||||
if (!response.ok) {
|
||||
renderCaseTypes([]);
|
||||
await loadCaseTypeModuleDefaultsSetting([]);
|
||||
return;
|
||||
}
|
||||
const setting = await response.json();
|
||||
const rawValue = setting.value || '[]';
|
||||
const parsed = JSON.parse(rawValue);
|
||||
renderCaseTypes(Array.isArray(parsed) ? parsed : []);
|
||||
const types = Array.isArray(parsed) ? parsed : [];
|
||||
renderCaseTypes(types);
|
||||
await loadCaseTypeModuleDefaultsSetting(types);
|
||||
} catch (error) {
|
||||
console.error('Error loading case types:', error);
|
||||
renderCaseTypes([]);
|
||||
await loadCaseTypeModuleDefaultsSetting([]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1916,12 +2047,17 @@ function renderCaseTypes(types) {
|
||||
async function saveCaseTypes(types) {
|
||||
await updateSetting('case_types', JSON.stringify(types));
|
||||
renderCaseTypes(types);
|
||||
|
||||
caseTypeModuleDefaultsCache = normalizeCaseTypeModuleDefaults(caseTypeModuleDefaultsCache, types);
|
||||
renderCaseTypeModuleTypeOptions(types);
|
||||
renderCaseTypeModuleChecklist();
|
||||
await updateSetting('case_type_module_defaults', JSON.stringify(caseTypeModuleDefaultsCache));
|
||||
}
|
||||
|
||||
async function addCaseType() {
|
||||
const input = document.getElementById('caseTypeInput');
|
||||
if (!input) return;
|
||||
const value = input.value.trim();
|
||||
const value = input.value.trim().toLowerCase();
|
||||
if (!value) return;
|
||||
|
||||
const response = await fetch('/api/v1/settings/case_types');
|
||||
@ -1947,6 +2083,48 @@ async function removeCaseType(type) {
|
||||
await saveCaseTypes(filtered);
|
||||
}
|
||||
|
||||
async function saveCaseTypeModuleDefaults() {
|
||||
const select = document.getElementById('caseTypeModulesTypeSelect');
|
||||
const type = select ? select.value : '';
|
||||
if (!type) {
|
||||
alert('Vælg en sagstype først');
|
||||
return;
|
||||
}
|
||||
|
||||
const enabled = CASE_MODULE_OPTIONS.filter(moduleKey => {
|
||||
const checkbox = document.getElementById(`ctmod_${moduleKey}`);
|
||||
return checkbox ? checkbox.checked : false;
|
||||
});
|
||||
|
||||
caseTypeModuleDefaultsCache[type] = enabled;
|
||||
await updateSetting('case_type_module_defaults', JSON.stringify(caseTypeModuleDefaultsCache));
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Standardmoduler gemt', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
async function resetCaseTypeModuleDefaults() {
|
||||
const select = document.getElementById('caseTypeModulesTypeSelect');
|
||||
const type = select ? select.value : '';
|
||||
if (!type) {
|
||||
alert('Vælg en sagstype først');
|
||||
return;
|
||||
}
|
||||
|
||||
caseTypeModuleDefaultsCache[type] = [...CASE_MODULE_OPTIONS];
|
||||
renderCaseTypeModuleChecklist();
|
||||
try {
|
||||
await updateSetting('case_type_module_defaults', JSON.stringify(caseTypeModuleDefaultsCache));
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Standardmoduler nulstillet', 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Kunne ikke nulstille standardmoduler', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let usersCache = [];
|
||||
let groupsCache = [];
|
||||
let permissionsCache = [];
|
||||
|
||||
41
apply_migration_128.py
Normal file
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