release: v2.8.18

This commit is contained in:
Christian 2026-09-25 11:33:53 +02:00
parent 06dffbc521
commit 20e6dc14b6
21 changed files with 1584 additions and 148 deletions

View File

@ -1 +1 @@
2.8.17 2.8.18

View File

@ -168,6 +168,19 @@ class ContactSyncRequest(BaseModel):
add_sync_ok_tag: Optional[bool] = False add_sync_ok_tag: Optional[bool] = False
class HouseOrganizationCreate(BaseModel):
name: str
location_id: Optional[int] = None
notes: Optional[str] = None
class HouseUserCreate(ContactCreate):
location_id: Optional[int] = None
external_customer_id: Optional[int] = None
external_company_name: Optional[str] = None
notes: Optional[str] = None
@router.get("/customers") @router.get("/customers")
async def list_customers( async def list_customers(
limit: int = Query(default=50, ge=1, le=1000), limit: int = Query(default=50, ge=1, le=1000),
@ -175,7 +188,8 @@ async def list_customers(
search: Optional[str] = Query(default=None), search: Optional[str] = Query(default=None),
source: Optional[str] = Query(default=None), # 'vtiger', 'local', or None source: Optional[str] = Query(default=None), # 'vtiger', 'local', or None
is_active: Optional[bool] = Query(default=None), is_active: Optional[bool] = Query(default=None),
vip: Optional[bool] = Query(default=None) vip: Optional[bool] = Query(default=None),
include_house_companies: bool = Query(default=False)
): ):
""" """
List customers with pagination and filtering List customers with pagination and filtering
@ -209,6 +223,11 @@ async def list_customers(
""" """
params = [] params = []
if not include_house_companies:
# Existing customers remain visible. New firms registered only as house
# users are intentionally hidden from the ordinary customer picker.
query += " AND COALESCE(c.show_in_customer_list, TRUE) = TRUE"
# Add search filter # Add search filter
if search: if search:
query += """ AND ( query += """ AND (
@ -300,6 +319,81 @@ async def list_customers(
} }
@router.get("/customers/duplicates")
async def find_customer_duplicates():
"""Read-only duplicate candidates grouped by normalized customer name."""
rows = execute_query(
"""
WITH normalized_customers AS (
SELECT
c.id,
c.name,
c.economic_customer_number,
c.cvr_number,
c.email,
c.is_active,
c.is_bmc_customer,
c.show_in_customer_list,
c.created_at,
LOWER(REGEXP_REPLACE(BTRIM(c.name), '[[:space:]]+', ' ', 'g')) AS duplicate_key
FROM customers c
WHERE COALESCE(c.is_active, TRUE) = TRUE
), grouped AS (
SELECT duplicate_key
FROM normalized_customers
GROUP BY duplicate_key
HAVING COUNT(*) > 1
)
SELECT
nc.duplicate_key,
JSONB_AGG(
JSONB_BUILD_OBJECT(
'id', nc.id,
'name', nc.name,
'economic_customer_number', nc.economic_customer_number,
'cvr_number', nc.cvr_number,
'email', nc.email,
'is_bmc_customer', nc.is_bmc_customer,
'show_in_customer_list', nc.show_in_customer_list,
'created_at', nc.created_at,
'relation_counts', JSONB_BUILD_OBJECT(
'contacts', (SELECT COUNT(*) FROM contact_companies cc WHERE cc.customer_id = nc.id),
'sager', (SELECT COUNT(*) FROM sag_sager ss WHERE ss.customer_id = nc.id AND ss.deleted_at IS NULL),
'lokationer', (SELECT COUNT(*) FROM locations_locations ll WHERE ll.customer_id = nc.id),
'hardware', (SELECT COUNT(*) FROM hardware_assets ha WHERE ha.current_owner_customer_id = nc.id),
'abonnementer', (SELECT COUNT(*) FROM sag_subscriptions sub WHERE sub.customer_id = nc.id)
)
)
ORDER BY
(nc.economic_customer_number IS NOT NULL) DESC,
(nc.cvr_number IS NOT NULL) DESC,
nc.created_at ASC,
nc.id ASC
) AS candidates,
ARRAY_REMOVE(ARRAY_AGG(DISTINCT nc.economic_customer_number), NULL) AS economic_numbers
FROM normalized_customers nc
JOIN grouped g ON g.duplicate_key = nc.duplicate_key
GROUP BY nc.duplicate_key
ORDER BY nc.duplicate_key
"""
) or []
groups = []
for row in rows:
numbers = row.get("economic_numbers") or []
groups.append({
"key": row.get("duplicate_key"),
"candidates": row.get("candidates") or [],
"economic_numbers": numbers,
"economic_status": (
"same_economic" if len(numbers) == 1 else
"conflicting_economic" if len(numbers) > 1 else
"missing_economic"
),
})
return {"groups": groups, "total_groups": len(groups)}
@router.get("/customers/verify-linking") @router.get("/customers/verify-linking")
async def verify_customer_linking(): async def verify_customer_linking():
""" """
@ -1342,21 +1436,163 @@ async def lock_customer_subscriptions(customer_id: int, lock_request: dict):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/customers/{customer_id}/house-users")
async def get_customer_house_users(customer_id: int):
"""Return external firms and people using this customer's house/location."""
customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,))
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
organizations = execute_query(
"""
SELECT cho.id, cho.location_id, cho.notes, cho.relationship_type,
ext.id AS external_customer_id, ext.name, ext.email, ext.phone,
loc.name AS location_name
FROM customer_house_organizations cho
JOIN customers ext ON ext.id = cho.external_customer_id
LEFT JOIN locations_locations loc ON loc.id = cho.location_id
WHERE cho.host_customer_id = %s AND cho.is_active = TRUE
ORDER BY ext.name, loc.name NULLS FIRST
""",
(customer_id,),
) or []
users = execute_query(
"""
SELECT clu.id, clu.location_id, clu.external_customer_id, clu.relation_type,
clu.role, clu.notes, c.id AS contact_id, c.first_name, c.last_name,
c.email, c.phone, c.mobile, ext.name AS external_company_name,
loc.name AS location_name
FROM contact_location_users clu
JOIN contacts c ON c.id = clu.contact_id
LEFT JOIN customers ext ON ext.id = clu.external_customer_id
LEFT JOIN locations_locations loc ON loc.id = clu.location_id
WHERE clu.host_customer_id = %s AND clu.is_active = TRUE AND c.is_active = TRUE
ORDER BY c.first_name, c.last_name
""",
(customer_id,),
) or []
return {"organizations": organizations, "users": users}
@router.post("/customers/{customer_id}/house-organizations")
async def create_customer_house_organization(customer_id: int, organization: HouseOrganizationCreate):
"""Register a firm that uses a customer's house without making it a BMC customer."""
host = execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,))
if not host:
raise HTTPException(status_code=404, detail="Customer not found")
name = organization.name.strip()
if not name:
raise HTTPException(status_code=400, detail="Firmanavn er påkrævet")
external_customer_id = execute_insert(
"""
INSERT INTO customers (name, is_active, is_bmc_customer, show_in_customer_list)
VALUES (%s, TRUE, FALSE, FALSE)
RETURNING id
""",
(name,),
)
execute_update(
"""
INSERT INTO customer_house_organizations
(host_customer_id, external_customer_id, location_id, notes)
VALUES (%s, %s, %s, %s)
ON CONFLICT (host_customer_id, external_customer_id, location_id)
DO UPDATE SET is_active = TRUE, notes = COALESCE(EXCLUDED.notes, customer_house_organizations.notes), updated_at = NOW()
""",
(customer_id, external_customer_id, organization.location_id, organization.notes),
)
return execute_query_single("SELECT * FROM customers WHERE id = %s", (external_customer_id,))
@router.post("/customers/{customer_id}/house-users")
async def create_customer_house_user(customer_id: int, user: HouseUserCreate):
"""Create a person who uses this house, optionally under an external firm."""
host = execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,))
if not host:
raise HTTPException(status_code=404, detail="Customer not found")
external_customer_id = user.external_customer_id
company_name = (user.external_company_name or "").strip()
if company_name and not external_customer_id:
external_customer_id = execute_insert(
"""
INSERT INTO customers (name, is_active, is_bmc_customer, show_in_customer_list)
VALUES (%s, TRUE, FALSE, FALSE)
RETURNING id
""",
(company_name,),
)
if external_customer_id:
external = execute_query_single("SELECT id FROM customers WHERE id = %s", (external_customer_id,))
if not external:
raise HTTPException(status_code=404, detail="Eksternt firma blev ikke fundet")
execute_update(
"""
INSERT INTO customer_house_organizations (host_customer_id, external_customer_id, location_id)
VALUES (%s, %s, %s)
ON CONFLICT (host_customer_id, external_customer_id, location_id)
DO UPDATE SET is_active = TRUE, updated_at = NOW()
""",
(customer_id, external_customer_id, user.location_id),
)
normalized_email = (user.email or "").strip().lower() or None
contact_id = None
if normalized_email:
existing = execute_query_single(
"SELECT id FROM contacts WHERE LOWER(COALESCE(email, '')) = %s ORDER BY id LIMIT 1",
(normalized_email,),
)
contact_id = existing.get("id") if existing else None
if not contact_id:
contact_id = execute_insert(
"""
INSERT INTO contacts (first_name, last_name, email, phone, mobile, title, department)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(user.first_name.strip(), user.last_name.strip(), normalized_email, user.phone, user.mobile, user.title, user.department),
)
if external_customer_id:
execute_update(
"""
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
VALUES (%s, %s, FALSE, %s)
ON CONFLICT (contact_id, customer_id) DO NOTHING
""",
(contact_id, external_customer_id, user.role),
)
execute_update(
"""
INSERT INTO contact_location_users
(contact_id, host_customer_id, location_id, external_customer_id, relation_type, role, notes)
VALUES (%s, %s, %s, %s, 'house_user', %s, %s)
""",
(contact_id, customer_id, user.location_id, external_customer_id, user.role, user.notes),
)
return execute_query_single("SELECT * FROM contacts WHERE id = %s", (contact_id,))
@router.get("/customers/{customer_id}/contacts") @router.get("/customers/{customer_id}/contacts")
async def get_customer_contacts(customer_id: int): async def get_customer_contacts(customer_id: int):
"""Get all contacts for a specific customer""" """Get direct contacts plus people registered as users of this customer's house."""
rows = execute_query(""" rows = execute_query("""
SELECT SELECT DISTINCT ON (c.id)
c.*, c.*,
cc.is_primary, COALESCE(cc.is_primary, FALSE) AS is_primary,
cc.role, COALESCE(cc.role, clu.role) AS role,
cc.notes COALESCE(cc.notes, clu.notes) AS notes,
ext.name AS external_company_name,
CASE WHEN clu.id IS NULL THEN FALSE ELSE TRUE END AS is_house_user
FROM contacts c FROM contacts c
JOIN contact_companies cc ON c.id = cc.contact_id LEFT JOIN contact_companies cc ON c.id = cc.contact_id AND cc.customer_id = %s
WHERE cc.customer_id = %s AND c.is_active = TRUE LEFT JOIN contact_location_users clu
ORDER BY cc.is_primary DESC, c.first_name, c.last_name ON clu.contact_id = c.id AND clu.host_customer_id = %s AND clu.is_active = TRUE
""", (customer_id,)) LEFT JOIN customers ext ON ext.id = clu.external_customer_id
WHERE c.is_active = TRUE AND (cc.customer_id IS NOT NULL OR clu.id IS NOT NULL)
ORDER BY c.id, cc.is_primary DESC, clu.id DESC
""", (customer_id, customer_id))
return rows or [] return rows or []

View File

@ -879,6 +879,11 @@
<i class="bi bi-people"></i>Kontakter <i class="bi bi-people"></i>Kontakter
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#house-users">
<i class="bi bi-building-add"></i>Brugere i huset
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#cases"> <a class="nav-link" data-bs-toggle="tab" href="#cases">
<i class="bi bi-list-check"></i>Sager <i class="bi bi-list-check"></i>Sager
@ -1139,6 +1144,22 @@
</div> </div>
</div> </div>
<!-- House Users Tab -->
<div class="tab-pane fade" id="house-users">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h5 class="fw-bold mb-0">Brugere i huset</h5>
<small class="text-muted">Virksomheder og personer, der bruger adressen uden at være BMC-kunder</small>
</div>
<button class="btn btn-primary btn-sm" onclick="showHouseUserModal()">
<i class="bi bi-person-plus me-2"></i>Tilføj bruger
</button>
</div>
<div id="houseUsersContainer" class="row g-3">
<div class="col-12 text-center py-5 text-muted">Åbn fanen for at hente brugere.</div>
</div>
</div>
<!-- Contacts Tab --> <!-- Contacts Tab -->
<div class="tab-pane fade" id="contacts"> <div class="tab-pane fade" id="contacts">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
@ -1889,6 +1910,27 @@
</div> </div>
<!-- Edit Customer Modal --> <!-- Edit Customer Modal -->
<div class="modal fade" id="houseUserModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<form class="modal-content" id="houseUserForm">
<div class="modal-header"><h5 class="modal-title">Tilføj bruger i huset</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body">
<p class="small text-muted">Personen bliver vist på denne adresse og kan vælges, når der oprettes en sag.</p>
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Fornavn *</label><input id="houseUserFirstName" class="form-control" required></div>
<div class="col-md-6"><label class="form-label">Efternavn *</label><input id="houseUserLastName" class="form-control" required></div>
<div class="col-12"><label class="form-label">Eksternt firma</label><input id="houseUserCompany" class="form-control" placeholder="Fx firmaet personen arbejder for"></div>
<div class="col-md-6"><label class="form-label">E-mail</label><input id="houseUserEmail" type="email" class="form-control"></div>
<div class="col-md-6"><label class="form-label">Telefon</label><input id="houseUserPhone" class="form-control"></div>
<div class="col-12"><label class="form-label">Rolle</label><input id="houseUserRole" class="form-control" placeholder="Fx beboer, vicevært eller ekstern leverandør"></div>
</div>
<div id="houseUserError" class="alert alert-danger d-none mt-3 mb-0"></div>
</div>
<div class="modal-footer"><button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary" type="submit">Gem bruger</button></div>
</form>
</div>
</div>
<div class="modal fade" id="editCustomerModal" tabindex="-1"> <div class="modal fade" id="editCustomerModal" tabindex="-1">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
<div class="modal-content"> <div class="modal-content">
@ -2343,6 +2385,11 @@ document.addEventListener('DOMContentLoaded', () => {
loadCustomer(); loadCustomer();
// Load contacts when tab is shown // Load contacts when tab is shown
const houseUsersTab = document.querySelector('a[href="#house-users"]');
if (houseUsersTab) {
houseUsersTab.addEventListener('shown.bs.tab', () => loadHouseUsers(), { once: false });
}
const contactsTab = document.querySelector('a[href="#contacts"]'); const contactsTab = document.querySelector('a[href="#contacts"]');
if (contactsTab) { if (contactsTab) {
contactsTab.addEventListener('shown.bs.tab', () => { contactsTab.addEventListener('shown.bs.tab', () => {
@ -3858,6 +3905,70 @@ function initContactsFilterControls() {
contactsFilterControlsInitialized = true; contactsFilterControlsInitialized = true;
} }
function houseUserText(value) {
const element = document.createElement('span');
element.textContent = value || '';
return element.innerHTML;
}
async function loadHouseUsers() {
const container = document.getElementById('houseUsersContainer');
if (!container) return;
container.innerHTML = '<div class="col-12 text-center py-5"><div class="spinner-border text-primary"></div></div>';
try {
const response = await fetch(`/api/v1/customers/${customerId}/house-users`, { credentials: 'include' });
if (!response.ok) throw new Error('Kunne ikke hente brugere i huset');
const data = await response.json();
const organizations = Array.isArray(data.organizations) ? data.organizations : [];
const users = Array.isArray(data.users) ? data.users : [];
const companyCards = organizations.map(company => `
<div class="col-md-6"><div class="card h-100 border-0 shadow-sm"><div class="card-body">
<div class="d-flex gap-2 align-items-center"><span class="badge text-bg-light border"><i class="bi bi-buildings"></i></span><div><div class="fw-semibold">${houseUserText(company.name)}</div><small class="text-muted">${houseUserText(company.location_name || 'Hele huset')}</small></div></div>
</div></div></div>`).join('');
const userCards = users.map(user => `
<div class="col-md-6"><div class="card h-100 border-0 shadow-sm"><div class="card-body">
<div class="d-flex gap-2 align-items-center"><span class="badge text-bg-primary"><i class="bi bi-person"></i></span><div class="min-w-0"><div class="fw-semibold">${houseUserText(`${user.first_name || ''} ${user.last_name || ''}`.trim())}</div><small class="text-muted d-block">${houseUserText(user.external_company_name || 'Husets bruger')}</small><small class="text-muted">${houseUserText(user.role || user.email || user.phone || user.mobile || '')}</small></div></div>
</div></div></div>`).join('');
if (!companyCards && !userCards) {
container.innerHTML = '<div class="col-12"><div class="border rounded-3 p-4 text-center text-muted">Der er endnu ikke registreret virksomheder eller brugere i huset.</div></div>';
return;
}
container.innerHTML = `${companyCards}${userCards}`;
} catch (error) {
container.innerHTML = `<div class="col-12"><div class="alert alert-danger mb-0">${houseUserText(error.message)}</div></div>`;
}
}
function showHouseUserModal() {
document.getElementById('houseUserForm')?.reset();
document.getElementById('houseUserError')?.classList.add('d-none');
bootstrap.Modal.getOrCreateInstance(document.getElementById('houseUserModal')).show();
}
document.getElementById('houseUserForm')?.addEventListener('submit', async (event) => {
event.preventDefault();
const error = document.getElementById('houseUserError');
error.classList.add('d-none');
const payload = {
first_name: document.getElementById('houseUserFirstName').value.trim(),
last_name: document.getElementById('houseUserLastName').value.trim(),
external_company_name: document.getElementById('houseUserCompany').value.trim() || null,
email: document.getElementById('houseUserEmail').value.trim() || null,
phone: document.getElementById('houseUserPhone').value.trim() || null,
role: document.getElementById('houseUserRole').value.trim() || null
};
try {
const response = await fetch(`/api/v1/customers/${customerId}/house-users`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Kunne ikke gemme brugeren');
bootstrap.Modal.getInstance(document.getElementById('houseUserModal'))?.hide();
loadHouseUsers();
} catch (err) {
error.textContent = err.message;
error.classList.remove('d-none');
}
});
async function loadContacts() { async function loadContacts() {
const container = document.getElementById('contactsContainer'); const container = document.getElementById('contactsContainer');

View File

@ -106,9 +106,14 @@
</button> </button>
</div> </div>
</div> </div>
<button type="button" id="openCreateCustomerBtn" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createCustomerModal"> <div class="d-flex gap-2">
<i class="bi bi-plus-lg me-2"></i>Opret Kunde <button type="button" class="btn btn-outline-secondary" onclick="openDuplicateCustomersModal()">
</button> <i class="bi bi-intersect me-2"></i>Find dubletter
</button>
<button type="button" id="openCreateCustomerBtn" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createCustomerModal">
<i class="bi bi-plus-lg me-2"></i>Opret Kunde
</button>
</div>
</div> </div>
<div class="mb-4 d-flex gap-2"> <div class="mb-4 d-flex gap-2">
@ -150,6 +155,18 @@
</div> </div>
</div> </div>
<div class="modal fade" id="duplicateCustomersModal" tabindex="-1" aria-labelledby="duplicateCustomersModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div><h5 class="modal-title" id="duplicateCustomersModalLabel">Mulige kundedubletter</h5><small class="text-muted">Kun oversigt. Intet flyttes eller slettes her.</small></div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body" id="duplicateCustomersContent"><div class="text-center py-5"><div class="spinner-border text-primary"></div></div></div>
</div>
</div>
</div>
<div class="modal fade" id="createCustomerModal" tabindex="-1" aria-labelledby="createCustomerModalLabel" aria-hidden="true"> <div class="modal fade" id="createCustomerModal" tabindex="-1" aria-labelledby="createCustomerModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable"> <div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content"> <div class="modal-content">
@ -518,6 +535,53 @@ async function loadCustomers(page = 1) {
} }
} }
function duplicateEscape(value) {
return escapeHtml(value === null || value === undefined || value === '' ? '–' : value);
}
function duplicateRelationSummary(counts) {
const labels = { contacts: 'kontakter', sager: 'sager', lokationer: 'lokationer', hardware: 'hardware', abonnementer: 'abonnementer' };
return Object.entries(labels).map(([key, label]) => `${counts?.[key] || 0} ${label}`).join(' · ');
}
async function openDuplicateCustomersModal() {
const modalElement = document.getElementById('duplicateCustomersModal');
const content = document.getElementById('duplicateCustomersContent');
bootstrap.Modal.getOrCreateInstance(modalElement).show();
content.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div><div class="text-muted mt-2">Finder dubletter…</div></div>';
try {
const response = await fetch('/api/v1/customers/duplicates', { credentials: 'include' });
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Kunne ikke hente dubletter');
const groups = Array.isArray(data.groups) ? data.groups : [];
if (!groups.length) {
content.innerHTML = '<div class="alert alert-success mb-0"><i class="bi bi-check-circle me-2"></i>Ingen aktive kunder med samme navn blev fundet.</div>';
return;
}
const status = {
same_economic: ['success', 'Samme e-conomic-relation'],
conflicting_economic: ['danger', 'Forskellige e-conomic-relationer – kræver kontrol'],
missing_economic: ['warning', 'Mangler e-conomic-relation']
};
content.innerHTML = `<p class="text-muted small">${groups.length} grupper fundet. Den fremhævede kandidat er kun et forslag baseret på e-conomic-nummer, CVR og oprettelsesdato.</p>` + groups.map(group => {
const [colour, label] = status[group.economic_status] || status.missing_economic;
const rows = group.candidates.map((customer, index) => `
<tr class="${index === 0 ? 'table-light' : ''}">
<td><a href="/customers/${customer.id}" class="fw-semibold">${duplicateEscape(customer.name)}</a>${index === 0 ? '<span class="badge text-bg-primary ms-2">Foreslået hovedkunde</span>' : ''}</td>
<td>${duplicateEscape(customer.economic_customer_number)}</td>
<td>${duplicateEscape(customer.cvr_number)}</td>
<td class="small text-muted">${duplicateRelationSummary(customer.relation_counts)}</td>
</tr>`).join('');
return `<section class="border rounded-3 mb-3 overflow-hidden">
<div class="d-flex justify-content-between gap-3 align-items-center px-3 py-2 bg-light"><strong>${duplicateEscape(group.candidates?.[0]?.name || group.key)}</strong><span class="badge text-bg-${colour}">${label}</span></div>
<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Kunde</th><th>e-conomic</th><th>CVR</th><th>Data knyttet til posten</th></tr></thead><tbody>${rows}</tbody></table></div>
</section>`;
}).join('');
} catch (error) {
content.innerHTML = `<div class="alert alert-danger mb-0">${duplicateEscape(error.message)}</div>`;
}
}
function toggleClearButton(value) { function toggleClearButton(value) {
document.getElementById('searchClearBtn')?.classList.toggle('d-none', !value); document.getElementById('searchClearBtn')?.classList.toggle('d-none', !value);
} }

View File

@ -168,11 +168,16 @@
font-size: 0.82rem; font-size: 0.82rem;
color: var(--text-secondary); color: var(--text-secondary);
} }
#hwTabs { gap: .45rem; border-bottom: 0; }
#hwTabs .nav-link { border: 1px solid rgba(0,0,0,.1); border-radius: 999px; color: var(--text-secondary); font-weight: 650; padding: .45rem .8rem; }
#hwTabs .nav-link.active { color: var(--accent); background: var(--accent-light); border-color: rgba(15,76,117,.35); }
.hardware-detail-page .card { border: 1px solid rgba(0,0,0,.08) !important; border-radius: .8rem; box-shadow: 0 5px 16px rgba(15,76,117,.06); }
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="container-fluid" style="margin-top: 2rem; margin-bottom: 2rem; max-width: 1400px;"> <div class="container-fluid hardware-detail-page" style="margin-top: 2rem; margin-bottom: 2rem; max-width: 1400px;">
<!-- Top Bar: Back Link + Global Tags --> <!-- Top Bar: Back Link + Global Tags -->
<div class="d-flex justify-content-between align-items-start mb-2"> <div class="d-flex justify-content-between align-items-start mb-2">
@ -275,36 +280,48 @@
<i class="bi bi-card-text me-2"></i>Hardware Detaljer <i class="bi bi-card-text me-2"></i>Hardware Detaljer
</button> </button>
</li> </li>
{% if hardware.eset_uuid or hardware.hardware_specs or uisp_device or switch_ports %}
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="eset-tab" data-bs-toggle="tab" data-bs-target="#eset-specs" type="button" role="tab"> <button class="nav-link" id="eset-tab" data-bs-toggle="tab" data-bs-target="#eset-specs" type="button" role="tab">
<i class="bi bi-cpu me-2"></i>Specifikationer <i class="bi bi-cpu me-2"></i>Specifikationer
</button> </button>
</li> </li>
{% endif %}
{% if locations or ownership %}
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="history-tab" data-bs-toggle="tab" data-bs-target="#history" type="button" role="tab"> <button class="nav-link" id="history-tab" data-bs-toggle="tab" data-bs-target="#history" type="button" role="tab">
<i class="bi bi-clock-history me-2"></i>Historik <i class="bi bi-clock-history me-2"></i>Historik
</button> </button>
</li> </li>
{% endif %}
{% if rental_stats.total_bindings or rental_stats.order_count %}
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="prices-tab" data-bs-toggle="tab" data-bs-target="#prices" type="button" role="tab"> <button class="nav-link" id="prices-tab" data-bs-toggle="tab" data-bs-target="#prices" type="button" role="tab">
<i class="bi bi-cash-coin me-2"></i>Priser <i class="bi bi-cash-coin me-2"></i>Priser
</button> </button>
</li> </li>
{% endif %}
{% if rental_stats.total_bindings or rental_stats.order_count %}
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="statistics-tab" data-bs-toggle="tab" data-bs-target="#statistics" type="button" role="tab"> <button class="nav-link" id="statistics-tab" data-bs-toggle="tab" data-bs-target="#statistics" type="button" role="tab">
<i class="bi bi-bar-chart-line me-2"></i>Statistik <i class="bi bi-bar-chart-line me-2"></i>Statistik
</button> </button>
</li> </li>
{% endif %}
{% if attachments %}
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="files-tab" data-bs-toggle="tab" data-bs-target="#files" type="button" role="tab"> <button class="nav-link" id="files-tab" data-bs-toggle="tab" data-bs-target="#files" type="button" role="tab">
<i class="bi bi-paperclip me-2"></i>Filer ({{ attachments|length }}) <i class="bi bi-paperclip me-2"></i>Filer ({{ attachments|length }})
</button> </button>
</li> </li>
{% endif %}
{% if hardware.notes %}
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="notes-tab" data-bs-toggle="tab" data-bs-target="#notes" type="button" role="tab"> <button class="nav-link" id="notes-tab" data-bs-toggle="tab" data-bs-target="#notes" type="button" role="tab">
<i class="bi bi-sticky me-2"></i>Noter <i class="bi bi-sticky me-2"></i>Noter
</button> </button>
</li> </li>
{% endif %}
</ul> </ul>
<div class="tab-content" id="hwTabsContent"> <div class="tab-content" id="hwTabsContent">
@ -386,6 +403,7 @@
<!-- AnyDesk Card --> <!-- AnyDesk Card -->
{% set anydesk_url = hardware.anydesk_id and ('anydesk://' ~ hardware.anydesk_id) %} {% set anydesk_url = hardware.anydesk_id and ('anydesk://' ~ hardware.anydesk_id) %}
{% if anydesk_url %}
<div class="card mb-4 shadow-sm border-0"> <div class="card mb-4 shadow-sm border-0">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center"> <div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center">
<h6 class="text-danger mb-0"><i class="bi bi-display me-2"></i>Remote Access</h6> <h6 class="text-danger mb-0"><i class="bi bi-display me-2"></i>Remote Access</h6>
@ -418,6 +436,7 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
<!-- Location & Owner Grid --> <!-- Location & Owner Grid -->
<div class="row"> <div class="row">

View File

@ -22,12 +22,14 @@ Phase 2.5 Endpoints (Bulk Operations & Advanced Queries):
13. GET /api/v1/locations/stats - Statistics about all locations 13. GET /api/v1/locations/stats - Statistics about all locations
""" """
from fastapi import APIRouter, HTTPException, Query, Request from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File, Form
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse, FileResponse
from typing import Optional, List, Any from typing import Optional, List, Any
from datetime import datetime, time, date from datetime import datetime, time, date
import logging import logging
import json import json
from pathlib import Path
from uuid import uuid4
from pydantic import ValidationError from pydantic import ValidationError
from app.core.database import execute_query from app.core.database import execute_query
@ -2429,6 +2431,102 @@ async def create_hours(location_id: int, data: OperatingHoursCreate):
# 3. PATCH /api/v1/locations/{location_id}/hours/{day_id} - Update hours # 3. PATCH /api/v1/locations/{location_id}/hours/{day_id} - Update hours
# ============================================================================ # ============================================================================
_ACCESS_FILE_EXTENSIONS = {"pdf", "jpg", "jpeg", "png", "doc", "docx", "xls", "xlsx"}
_ACCESS_FILE_MAX_BYTES = 15 * 1024 * 1024
@router.post("/locations/{location_id}/access-files")
async def upload_access_files(
location_id: int,
files: List[UploadFile] = File(...),
comment: Optional[str] = Form(None),
):
"""Upload access instructions, photos, or key documentation for a location."""
location = execute_query(
"SELECT id FROM locations_locations WHERE id = %s AND deleted_at IS NULL", (location_id,)
) or []
if not location:
raise HTTPException(status_code=404, detail="Lokationen blev ikke fundet")
upload_dir = Path("uploads") / "location_access" / str(location_id)
upload_dir.mkdir(parents=True, exist_ok=True)
saved = []
for upload in files:
original_name = Path(upload.filename or "").name
extension = Path(original_name).suffix.lower().lstrip(".")
if not original_name or extension not in _ACCESS_FILE_EXTENSIONS:
raise HTTPException(status_code=400, detail="Filtypen er ikke tilladt")
content = await upload.read()
if not content or len(content) > _ACCESS_FILE_MAX_BYTES:
raise HTTPException(status_code=400, detail="Filen skal være mellem 1 byte og 15 MB")
stored_name = f"{uuid4().hex}.{extension}"
(upload_dir / stored_name).write_bytes(content)
row = execute_query(
"""
INSERT INTO locations_access_files (location_id, original_filename, stored_filename, content_type, file_size, comment)
VALUES (%s, %s, %s, %s, %s, %s) RETURNING id, original_filename, file_size, comment, created_at
""",
(location_id, original_name, stored_name, upload.content_type, len(content), (comment or "").strip() or None),
)
saved.append(row[0])
return {"files": saved}
@router.get("/locations/{location_id}/access-files/{file_id}")
async def download_access_file(location_id: int, file_id: int):
rows = execute_query(
"""SELECT original_filename, stored_filename, content_type FROM locations_access_files
WHERE id = %s AND location_id = %s AND deleted_at IS NULL""",
(file_id, location_id),
) or []
if not rows:
raise HTTPException(status_code=404, detail="Filen blev ikke fundet")
row = rows[0]
path = Path("uploads") / "location_access" / str(location_id) / row["stored_filename"]
if not path.is_file():
raise HTTPException(status_code=404, detail="Filen findes ikke på serveren")
return FileResponse(path, media_type=row.get("content_type") or "application/octet-stream", filename=row["original_filename"])
@router.post("/locations/{location_id}/files")
async def upload_location_files(location_id: int, files: List[UploadFile] = File(...), comment: Optional[str] = Form(None)):
location = execute_query("SELECT id FROM locations_locations WHERE id = %s AND deleted_at IS NULL", (location_id,)) or []
if not location:
raise HTTPException(status_code=404, detail="Lokationen blev ikke fundet")
upload_dir = Path("uploads") / "locations" / str(location_id)
upload_dir.mkdir(parents=True, exist_ok=True)
saved = []
for upload in files:
original_name = Path(upload.filename or "").name
extension = Path(original_name).suffix.lower().lstrip(".")
if extension not in {"jpg", "jpeg", "png", "gif", "webp"}:
raise HTTPException(status_code=400, detail="Upload kun billeder (JPG, PNG, GIF eller WebP)")
content = await upload.read()
if not content or len(content) > _ACCESS_FILE_MAX_BYTES:
raise HTTPException(status_code=400, detail="Billedet skal være mellem 1 byte og 15 MB")
stored_name = f"{uuid4().hex}.{extension}"
(upload_dir / stored_name).write_bytes(content)
row = execute_query(
"""INSERT INTO locations_files (location_id, original_filename, stored_filename, content_type, file_size, comment)
VALUES (%s, %s, %s, %s, %s, %s) RETURNING id, original_filename, comment""",
(location_id, original_name, stored_name, upload.content_type, len(content), (comment or "").strip() or None),
)
saved.append(row[0])
return {"files": saved}
@router.get("/locations/{location_id}/files/{file_id}")
async def download_location_file(location_id: int, file_id: int):
rows = execute_query("""SELECT original_filename, stored_filename, content_type FROM locations_files
WHERE id = %s AND location_id = %s AND deleted_at IS NULL""", (file_id, location_id)) or []
if not rows:
raise HTTPException(status_code=404, detail="Billedet blev ikke fundet")
row = rows[0]
path = Path("uploads") / "locations" / str(location_id) / row["stored_filename"]
if not path.is_file():
raise HTTPException(status_code=404, detail="Billedet findes ikke på serveren")
return FileResponse(path, media_type=row.get("content_type") or "application/octet-stream", filename=row["original_filename"])
@router.patch("/locations/{location_id}/hours/{day_id}", response_model=OperatingHours) @router.patch("/locations/{location_id}/hours/{day_id}", response_model=OperatingHours)
async def update_hours(location_id: int, day_id: int, data: OperatingHoursUpdate): async def update_hours(location_id: int, day_id: int, data: OperatingHoursUpdate):
""" """

View File

@ -213,6 +213,7 @@ def calculate_pagination(total: int, limit: int, skip: int) -> dict:
def list_locations_view( def list_locations_view(
location_type: Optional[str] = Query(None, description="Filter by type"), location_type: Optional[str] = Query(None, description="Filter by type"),
is_active: Optional[str] = Query(None, description="Filter by active status"), is_active: Optional[str] = Query(None, description="Filter by active status"),
q: Optional[str] = Query(None, max_length=120, description="Search name or city"),
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100) limit: int = Query(50, ge=1, le=100)
): ):
@ -252,8 +253,19 @@ def list_locations_view(
where_clauses.append("is_active = %s") where_clauses.append("is_active = %s")
query_params.append(is_active_bool) query_params.append(is_active_bool)
search_term = (q or "").strip()
if search_term:
where_clauses.append("(name ILIKE %s OR COALESCE(address_city, '') ILIKE %s)")
query_params.extend([f"%{search_term}%", f"%{search_term}%"])
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1" where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
total_rows = execute_query(
f"SELECT COUNT(*) AS count FROM locations_locations WHERE {where_sql}",
tuple(query_params),
) or []
total = int(total_rows[0].get("count", 0)) if total_rows else 0
query = f""" query = f"""
SELECT * FROM locations_locations SELECT * FROM locations_locations
WHERE {where_sql} WHERE {where_sql}
@ -301,10 +313,6 @@ def list_locations_view(
location_tree = build_tree(locations if isinstance(locations, list) else []) location_tree = build_tree(locations if isinstance(locations, list) else [])
# Get total count (API returns full list, so count locally)
# In production, the API should return {data: [...], total: N}
total = len(locations) if isinstance(locations, list) else locations.get("total", 0)
# Calculate pagination info # Calculate pagination info
pagination = calculate_pagination(total, limit, skip) pagination = calculate_pagination(total, limit, skip)
@ -317,6 +325,7 @@ def list_locations_view(
limit=limit, limit=limit,
location_type=location_type, location_type=location_type,
is_active=is_active_bool, # Use boolean value for template is_active=is_active_bool, # Use boolean value for template
q=search_term,
page_number=pagination["page_number"], page_number=pagination["page_number"],
total_pages=pagination["total_pages"], total_pages=pagination["total_pages"],
has_prev=pagination["has_prev"], has_prev=pagination["has_prev"],
@ -563,7 +572,13 @@ def detail_location_view(id: int = Path(..., gt=0)):
# Query location details directly # Query location details directly
location = execute_query( location = execute_query(
"SELECT * FROM locations_locations WHERE id = %s", """
SELECT l.*, p.name AS parent_location_name, c.name AS customer_name
FROM locations_locations l
LEFT JOIN locations_locations p ON p.id = l.parent_location_id
LEFT JOIN customers c ON c.id = l.customer_id
WHERE l.id = %s AND l.deleted_at IS NULL
""",
(id,) (id,)
) )
@ -596,6 +611,68 @@ def detail_location_view(id: int = Path(..., gt=0)):
(id,) (id,)
) )
tree_root_id = hierarchy[0]["id"] if hierarchy else id
relation_rows = execute_query(
"""
WITH RECURSIVE descendants AS (
SELECT id, name, location_type, parent_location_id, is_active,
0 AS depth, ARRAY[id] AS path
FROM locations_locations
WHERE id = %s AND deleted_at IS NULL
UNION ALL
SELECT child.id, child.name, child.location_type, child.parent_location_id, child.is_active,
descendants.depth + 1, descendants.path || child.id
FROM locations_locations child
JOIN descendants ON child.parent_location_id = descendants.id
WHERE child.deleted_at IS NULL
AND NOT child.id = ANY(descendants.path)
)
SELECT id, name, location_type, parent_location_id, is_active, depth
FROM descendants
ORDER BY path
""",
(tree_root_id,),
) or []
relation_nodes = {
row["id"]: {**row, "children": []}
for row in relation_rows
if row.get("id") is not None
}
relation_tree = relation_nodes.get(tree_root_id)
for node in relation_nodes.values():
parent_id = node.get("parent_location_id")
if parent_id in relation_nodes and parent_id != node.get("id"):
relation_nodes[parent_id]["children"].append(node)
for node in relation_nodes.values():
node["children"].sort(key=lambda child: (child.get("name") or "").lower())
def add_descendant_counts(node: dict) -> int:
descendant_count = sum(1 + add_descendant_counts(child) for child in node["children"])
node["descendant_count"] = descendant_count
return descendant_count
if relation_tree:
add_descendant_counts(relation_tree)
direct_child_nodes = relation_nodes.get(id, {}).get("children", [])
tree_location_ids = list(relation_nodes)
same_customer_locations = []
if location.get("customer_id"):
same_customer_locations = execute_query(
"""
SELECT id, name, location_type, parent_location_id, is_active
FROM locations_locations
WHERE customer_id = %s
AND deleted_at IS NULL
AND id <> ALL(%s)
ORDER BY name
""",
(location["customer_id"], tree_location_ids),
) or []
contacts = execute_query( contacts = execute_query(
""" """
SELECT id, location_id, related_contact_id, contact_name, contact_email, contact_phone, SELECT id, location_id, related_contact_id, contact_name, contact_email, contact_phone,
@ -627,6 +704,20 @@ def detail_location_view(id: int = Path(..., gt=0)):
(id,) (id,)
) )
access_files = execute_query(
"""SELECT id, original_filename, file_size, comment, created_at
FROM locations_access_files WHERE location_id = %s AND deleted_at IS NULL
ORDER BY created_at DESC""",
(id,),
) or []
location_files = execute_query(
"""SELECT id, original_filename, comment, created_at
FROM locations_files WHERE location_id = %s AND deleted_at IS NULL
ORDER BY created_at DESC""",
(id,),
) or []
services = execute_query( services = execute_query(
""" """
SELECT id, location_id, service_name, is_available, created_at SELECT id, location_id, service_name, is_available, created_at
@ -815,8 +906,14 @@ def detail_location_view(id: int = Path(..., gt=0)):
location["hierarchy"] = hierarchy location["hierarchy"] = hierarchy
location["children"] = children location["children"] = children
location["relation_tree"] = relation_tree
location["direct_child_nodes"] = direct_child_nodes
location["related_location_count"] = max(len(relation_rows) - 1, 0)
location["same_customer_locations"] = same_customer_locations
location["contacts"] = contacts or [] location["contacts"] = contacts or []
location["operating_hours"] = operating_hours or [] location["operating_hours"] = operating_hours or []
location["access_files"] = access_files
location["files"] = location_files
location["services"] = services or [] location["services"] = services or []
location["capacity"] = capacity or [] location["capacity"] = capacity or []
location["hardware"] = hardware or [] location["hardware"] = hardware or []

View File

@ -210,6 +210,162 @@
display: none; display: none;
} }
.locations-detail-page .relation-overview {
background: linear-gradient(135deg, rgba(15, 76, 117, 0.08), rgba(255, 255, 255, 0.9));
}
.locations-detail-page .relation-path {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.locations-detail-page .relation-path a,
.locations-detail-page .relation-current {
display: inline-flex;
align-items: center;
min-height: 2rem;
padding: 0.35rem 0.65rem;
border: 1px solid rgba(15, 76, 117, 0.16);
border-radius: 999px;
background: var(--bg-card, #fff);
font-size: 0.84rem;
font-weight: 650;
text-decoration: none;
}
.locations-detail-page .relation-current {
color: #fff;
background: var(--loc-accent);
border-color: var(--loc-accent);
}
.locations-detail-page .location-structure-focus {
border: 1px solid rgba(15, 76, 117, 0.16);
border-radius: 0.85rem;
background: var(--bg-card, #fff);
padding: 0.85rem 1rem;
}
.locations-detail-page .location-structure-children {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 0.65rem;
margin-top: 0.75rem;
}
.locations-detail-page .location-child-card {
display: block;
min-height: 5.3rem;
padding: 0.75rem 0.85rem;
border: 1px solid rgba(15, 76, 117, 0.14);
border-radius: 0.7rem;
background: linear-gradient(180deg, #fff, rgba(15, 76, 117, 0.035));
color: var(--text-primary);
text-decoration: none;
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
}
.locations-detail-page .location-child-card:hover {
transform: translateY(-2px);
border-color: rgba(15, 76, 117, 0.42);
box-shadow: 0 8px 18px rgba(15, 76, 117, 0.12);
color: var(--loc-accent);
}
.locations-detail-page .location-child-card-name { font-weight: 750; }
.locations-detail-page .location-child-card-meta { font-size: 0.78rem; color: var(--text-secondary); margin-top: 0.3rem; }
.locations-detail-page .location-full-tree,
.locations-detail-page .location-full-tree ul {
list-style: none;
margin: 0;
padding-left: 0;
}
.locations-detail-page .location-full-tree > li { margin-top: 0.85rem; }
.locations-detail-page .location-tree-canvas {
padding: 0.35rem 0.25rem 0.6rem;
border-radius: 0.8rem;
background: radial-gradient(circle at top right, rgba(21, 111, 166, 0.1), transparent 38%), #f8fafc;
}
.locations-detail-page .location-full-tree ul {
margin: 0.35rem 0 0.2rem 1rem;
padding-left: 1.25rem;
border-left: 2px solid rgba(15, 76, 117, 0.18);
}
.locations-detail-page .location-full-tree li { position: relative; margin: 0.45rem 0; }
.locations-detail-page .location-full-tree ul > li::before {
content: "";
position: absolute;
top: 1.35rem;
left: -1.25rem;
width: 1.1rem;
border-top: 2px solid rgba(15, 76, 117, 0.18);
}
.locations-detail-page .location-tree-card {
display: inline-flex;
align-items: center;
gap: 0.7rem;
min-width: min(100%, 25rem);
padding: 0.62rem 0.8rem;
border: 1px solid rgba(15, 76, 117, 0.16);
border-radius: 0.75rem;
background: var(--bg-card, #fff);
color: var(--text-primary);
text-decoration: none;
box-shadow: 0 3px 8px rgba(15, 76, 117, 0.06);
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
}
.locations-detail-page .location-tree-card:hover { transform: translateX(3px); border-color: var(--loc-accent); background: rgba(15, 76, 117, 0.06); color: var(--loc-accent); box-shadow: 0 7px 16px rgba(15, 76, 117, 0.12); }
.locations-detail-page .location-tree-card.current { background: var(--loc-accent); border-color: var(--loc-accent); color: #fff; font-weight: 750; }
.locations-detail-page .tree-card-icon { display: inline-flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; border-radius: 0.55rem; background: rgba(15, 76, 117, 0.1); color: var(--loc-accent); flex: 0 0 auto; }
.locations-detail-page .location-tree-card.current .tree-card-icon { background: rgba(255, 255, 255, 0.18); color: #fff; }
.locations-detail-page .tree-card-title { font-weight: 750; }
.locations-detail-page .location-tree-card .tree-card-meta { color: var(--text-secondary); font-size: 0.74rem; margin-left: auto; text-align: right; }
.locations-detail-page .location-tree-card.current .tree-card-meta { color: rgba(255, 255, 255, 0.82); }
.locations-detail-page .location-photo-thumb { width: 180px; border: 1px solid rgba(15,76,117,.14); border-radius: .7rem; background: #fff; overflow: hidden; padding: 0; text-align: left; }
.locations-detail-page .location-photo-thumb img { display: block; width: 100%; height: 120px; object-fit: cover; }
.locations-detail-page .location-photo-thumb:hover { border-color: var(--loc-accent); box-shadow: 0 6px 16px rgba(15,76,117,.13); }
.locations-detail-page .location-photo-caption { padding: .5rem .6rem; font-size: .78rem; }
#locationPhotoPreviewImage { max-height: min(70vh, 720px); max-width: 100%; object-fit: contain; }
.locations-detail-page .relation-tree,
.locations-detail-page .relation-tree ul {
list-style: none;
margin: 0;
padding-left: 0;
}
.locations-detail-page .relation-tree ul {
margin: 0.35rem 0 0.2rem 0.8rem;
padding-left: 1rem;
border-left: 1px solid rgba(15, 76, 117, 0.2);
}
.locations-detail-page .relation-tree li { margin: 0.3rem 0; }
.locations-detail-page .relation-tree-link,
.locations-detail-page .relation-tree-current {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.38rem 0.6rem;
border-radius: 0.55rem;
text-decoration: none;
}
.locations-detail-page .relation-tree-link:hover { background: rgba(15, 76, 117, 0.08); }
.locations-detail-page .relation-tree-current { background: rgba(15, 76, 117, 0.12); color: var(--loc-accent); font-weight: 750; }
.locations-detail-page .relation-tree-type { color: var(--text-secondary); font-size: 0.76rem; font-weight: 500; }
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.locations-detail-page { .locations-detail-page {
padding-left: 0.5rem; padding-left: 0.5rem;
@ -312,8 +468,8 @@
<div class="hero-meta-value">{{ location.contacts|length if location.contacts else 0 }}</div> <div class="hero-meta-value">{{ location.contacts|length if location.contacts else 0 }}</div>
</div> </div>
<div class="case-meta-cell"> <div class="case-meta-cell">
<div class="hero-meta-label">Tjenester</div> <div class="hero-meta-label">Underlokationer</div>
<div class="hero-meta-value">{{ location.services|length if location.services else 0 }}</div> <div class="hero-meta-value">{{ location.children|length if location.children else 0 }}</div>
</div> </div>
</div> </div>
</div> </div>
@ -335,19 +491,7 @@
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="hoursTab" data-bs-toggle="tab" data-bs-target="#hoursContent" type="button" role="tab" aria-controls="hoursContent" aria-selected="false"> <button class="nav-link" id="hoursTab" data-bs-toggle="tab" data-bs-target="#hoursContent" type="button" role="tab" aria-controls="hoursContent" aria-selected="false">
<i class="bi bi-clock me-2"></i>Åbningstider <i class="bi bi-key me-2"></i>Adgangsforhold
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="servicesTab" data-bs-toggle="tab" data-bs-target="#servicesContent" type="button" role="tab" aria-controls="servicesContent" aria-selected="false">
<i class="bi bi-tools me-2"></i>Tjenester
<span class="location-tab-count-badge ms-1">{{ location.services|length if location.services else 0 }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="capacityTab" data-bs-toggle="tab" data-bs-target="#capacityContent" type="button" role="tab" aria-controls="capacityContent" aria-selected="false">
<i class="bi bi-graph-up me-2"></i>Kapacitet
<span class="location-tab-count-badge ms-1">{{ location.capacity|length if location.capacity else 0 }}</span>
</button> </button>
</li> </li>
{% if location.location_type == 'rum' and location.has_cross_field %} {% if location.location_type == 'rum' and location.has_cross_field %}
@ -379,6 +523,61 @@
<div class="tab-content"> <div class="tab-content">
<!-- Tab 1: Information --> <!-- Tab 1: Information -->
<div class="tab-pane fade show active" id="infoContent" role="tabpanel" aria-labelledby="infoTab"> <div class="tab-pane fade show active" id="infoContent" role="tabpanel" aria-labelledby="infoTab">
<section class="card border-0 mb-4">
<div class="card-header bg-transparent border-bottom">
<h5 class="card-title mb-0"><i class="bi bi-diagram-3 me-2 text-primary"></i>Lokationens relationer</h5>
<div class="small text-muted mt-1">Viser kun direkte overordnet og direkte underlokationer.</div>
</div>
<div class="card-body">
<div class="location-structure-focus mb-3">
<div class="small text-muted text-uppercase fw-bold mb-1">Aktuel lokation</div>
<div class="d-flex align-items-center gap-2">
<span class="tree-card-icon"><i class="bi bi-geo-alt-fill"></i></span>
<div><div class="fw-bold">{{ location.name }}</div><div class="small text-muted">{{ type_label }}</div></div>
</div>
</div>
<div class="row g-3">
<div class="col-lg-5">
<div class="border rounded-3 p-3 h-100 bg-light">
<div class="small text-muted text-uppercase fw-bold mb-2">Overordnet lokation</div>
{% if location.parent_location_id and location.parent_location_name %}
<a href="/app/locations/{{ location.parent_location_id }}" class="location-tree-card w-100">
<span class="tree-card-icon"><i class="bi bi-arrow-up"></i></span>
<span class="tree-card-title">{{ location.parent_location_name }}</span>
<span class="tree-card-meta">Åbn</span>
</a>
{% else %}
<span class="text-muted small">Denne lokation er øverst i strukturen.</span>
{% endif %}
</div>
</div>
<div class="col-lg-7">
<div class="border rounded-3 p-3 h-100 bg-light">
<div class="small text-muted text-uppercase fw-bold mb-2">Underlokationer</div>
{% if location.children %}
<div class="location-structure-children mt-0">
{% for child in location.children %}
<a href="/app/locations/{{ child.id }}" class="location-child-card">
<div class="location-child-card-name"><i class="bi bi-arrow-return-right me-2 text-primary"></i>{{ child.name }}</div>
<div class="location-child-card-meta">{{ child.location_type or 'Lokation' }}</div>
</a>
{% endfor %}
</div>
{% else %}
<span class="text-muted small">Ingen direkte underlokationer.</span>
{% endif %}
</div>
</div>
</div>
</div>
</section>
<section class="card border-0 mb-4">
<div class="card-header bg-transparent border-bottom d-flex justify-content-between align-items-center"><div><h5 class="card-title mb-0"><i class="bi bi-images me-2 text-primary"></i>Billeder på lokation</h5><div class="small text-muted">Foto af adgang, rum, installationer eller skiltning.</div></div><button type="button" class="btn btn-primary btn-sm" id="addLocationPhotoBtn"><i class="bi bi-upload me-1"></i>Upload billeder</button></div>
<div class="card-body">
<div id="locationPhotoInlineDropzone" class="border border-2 border-dashed rounded-3 p-3 text-center bg-light mb-3" role="button" tabindex="0"><i class="bi bi-cloud-arrow-up me-2"></i><strong>Træk billeder hertil</strong><span class="text-muted small ms-1">eller klik for at vælge filer</span></div>
{% if location.files %}<div class="d-flex flex-wrap gap-3">{% for file in location.files %}<button type="button" class="location-photo-thumb location-photo-preview" data-index="{{ loop.index0 }}" data-url="/api/v1/locations/{{ location.id }}/files/{{ file.id }}" data-name="{{ file.original_filename }}" data-comment="{{ file.comment or '' }}"><img src="/api/v1/locations/{{ location.id }}/files/{{ file.id }}" alt="{{ file.comment or file.original_filename }}" loading="lazy"><div class="location-photo-caption"><div class="fw-bold text-truncate">{{ file.original_filename }}</div>{% if file.comment %}<div class="text-muted mt-1">{{ file.comment }}</div>{% endif %}</div></button>{% endfor %}</div>{% else %}<span class="text-muted small">Ingen billeder uploadet endnu.</span>{% endif %}
</div>
</section>
<div class="row"> <div class="row">
<div class="col-lg-6"> <div class="col-lg-6">
<div class="card border-0 mb-4"> <div class="card border-0 mb-4">
@ -487,44 +686,6 @@
</div> </div>
</div> </div>
<div class="card border-0">
<div class="card-header bg-transparent border-bottom">
<h5 class="card-title mb-0">Hierarki (træ)</h5>
</div>
<div class="card-body">
{% if location.hierarchy or location.children %}
<ul class="list-unstyled mb-0">
{% for node in location.hierarchy %}
<li class="mb-1">
<i class="bi bi-diagram-3 me-2 text-muted"></i>
<a href="/app/locations/{{ node.id }}" class="text-decoration-none">
{{ node.name }}{% if node.location_type %} ({{ node.location_type }}){% endif %}
</a>
</li>
{% endfor %}
<li class="mb-1 fw-600">
<i class="bi bi-pin-map me-2"></i>{{ location.name }}
</li>
{% if location.children %}
<li>
<ul class="list-unstyled ms-4 mt-2">
{% for child in location.children %}
<li class="mb-1">
<i class="bi bi-arrow-return-right me-2 text-muted"></i>
<a href="/app/locations/{{ child.id }}" class="text-decoration-none">
{{ child.name }}{% if child.location_type %} ({{ child.location_type }}){% endif %}
</a>
</li>
{% endfor %}
</ul>
</li>
{% endif %}
</ul>
{% else %}
<span class="text-muted">Ingen relationer registreret</span>
{% endif %}
</div>
</div>
</div> </div>
<!-- Tab 2: Contacts --> <!-- Tab 2: Contacts -->
@ -580,11 +741,12 @@
</div> </div>
</div> </div>
<!-- Tab 3: Operating Hours --> <!-- Tab 3: Access conditions -->
<div class="tab-pane fade" id="hoursContent" role="tabpanel" aria-labelledby="hoursTab"> <div class="tab-pane fade" id="hoursContent" role="tabpanel" aria-labelledby="hoursTab">
<div class="card border-0"> <div class="card border-0">
<div class="card-header bg-transparent border-bottom"> <div class="card-header bg-transparent border-bottom d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">Åbningstider</h5> <h5 class="card-title mb-0">Adgangsforhold</h5>
<button type="button" class="btn btn-primary btn-sm" id="editAccessBtn"><i class="bi bi-pencil me-1"></i>Tilføj adgangsforhold</button>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if location.operating_hours %} {% if location.operating_hours %}
@ -613,7 +775,7 @@
{% endif %} {% endif %}
</td> </td>
<td> <td>
<button type="button" class="btn btn-sm btn-outline-secondary edit-hours-btn" data-hours-id="{{ hours.id }}"> <button type="button" class="btn btn-sm btn-outline-secondary edit-hours-btn" data-hours-id="{{ hours.id }}" data-day="{{ hours.day_of_week }}" data-open="{{ 'true' if hours.is_open else 'false' }}" data-from="{{ hours.open_time or '' }}" data-to="{{ hours.close_time or '' }}" data-notes="{{ hours.notes or '' }}">
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</button> </button>
</td> </td>
@ -623,7 +785,12 @@
</table> </table>
</div> </div>
{% else %} {% else %}
<p class="text-muted text-center py-4">Ingen åbningstider registreret</p> <p class="text-muted text-center py-4">Ingen adgangsforhold registreret</p>
{% endif %}
{% if location.access_files %}
<div class="border-top mt-3 pt-3"><div class="small text-muted fw-bold mb-2">Filer til adgangsforhold</div>
{% for file in location.access_files %}<div class="d-inline-block me-2 mb-2"><a class="btn btn-outline-secondary btn-sm" href="/api/v1/locations/{{ location.id }}/access-files/{{ file.id }}"><i class="bi bi-paperclip me-1"></i>{{ file.original_filename }}</a>{% if file.comment %}<div class="small text-muted mt-1">{{ file.comment }}</div>{% endif %}</div>{% endfor %}
</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@ -704,6 +871,58 @@
<!-- Tab 6: Relations --> <!-- Tab 6: Relations -->
<div class="tab-pane fade" id="relationsContent" role="tabpanel" aria-labelledby="relationsTab"> <div class="tab-pane fade" id="relationsContent" role="tabpanel" aria-labelledby="relationsTab">
<div class="card border-0 mb-4">
<div class="card-header bg-transparent border-bottom d-flex justify-content-between align-items-center gap-2 flex-wrap">
<div>
<h5 class="card-title mb-0">Alle relaterede lokationer</h5>
<div class="small text-muted">Overordnede, søskende og underlokationer samlet ét sted. Klik på en lokation for at åbne den.</div>
</div>
<span class="badge text-bg-primary">{{ location.related_location_count }} relaterede</span>
</div>
<div class="card-body">
{% macro render_relation_node(node) %}
<li>
{% if node.id == location.id %}
<span class="relation-tree-current"><i class="bi bi-geo-alt-fill"></i>{{ node.name }} <span class="relation-tree-type">Denne lokation</span></span>
{% else %}
<a href="/app/locations/{{ node.id }}" class="relation-tree-link">
<i class="bi bi-geo-alt"></i>{{ node.name }}
{% if node.location_type %}<span class="relation-tree-type">{{ node.location_type }}</span>{% endif %}
</a>
{% endif %}
{% if node.children %}
<ul>
{% for child in node.children %}{{ render_relation_node(child) }}{% endfor %}
</ul>
{% endif %}
</li>
{% endmacro %}
{% if location.relation_tree %}
<ul class="relation-tree">{{ render_relation_node(location.relation_tree) }}</ul>
{% else %}
<span class="text-muted">Ingen relationer registreret.</span>
{% endif %}
</div>
</div>
{% if location.same_customer_locations %}
<div class="card border-0 mb-4">
<div class="card-header bg-transparent border-bottom d-flex justify-content-between align-items-center gap-2 flex-wrap">
<div>
<h5 class="card-title mb-0">Andre lokationer hos samme kunde</h5>
<div class="small text-muted">De er knyttet til samme kunde, men ligger ikke i dette lokationstræ.</div>
</div>
<span class="badge text-bg-secondary">{{ location.same_customer_locations|length }}</span>
</div>
<div class="list-group list-group-flush">
{% for related in location.same_customer_locations %}
<a href="/app/locations/{{ related.id }}" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center gap-3 py-3">
<span><i class="bi bi-geo-alt me-2 text-primary"></i><strong>{{ related.name }}</strong></span>
<span class="relation-tree-type">{{ related.location_type or 'Lokation' }}</span>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<div class="row"> <div class="row">
<div class="col-lg-6"> <div class="col-lg-6">
<div class="card border-0 mb-4"> <div class="card border-0 mb-4">
@ -824,44 +1043,6 @@
</div> </div>
</div> </div>
<div class="card border-0 mt-4">
<div class="card-header bg-transparent border-bottom">
<h5 class="card-title mb-0">Hierarki (træ)</h5>
</div>
<div class="card-body">
{% if location.hierarchy or location.children %}
<ul class="list-unstyled mb-0">
{% for node in location.hierarchy %}
<li class="mb-1">
<i class="bi bi-diagram-3 me-2 text-muted"></i>
<a href="/app/locations/{{ node.id }}" class="text-decoration-none">
{{ node.name }}{% if node.location_type %} ({{ node.location_type }}){% endif %}
</a>
</li>
{% endfor %}
<li class="mb-1 fw-600">
<i class="bi bi-pin-map me-2"></i>{{ location.name }}
</li>
{% if location.children %}
<li>
<ul class="list-unstyled ms-4 mt-2">
{% for child in location.children %}
<li class="mb-1">
<i class="bi bi-arrow-return-right me-2 text-muted"></i>
<a href="/app/locations/{{ child.id }}" class="text-decoration-none">
{{ child.name }}{% if child.location_type %} ({{ child.location_type }}){% endif %}
</a>
</li>
{% endfor %}
</ul>
</li>
{% endif %}
</ul>
{% else %}
<span class="text-muted">Ingen relationer registreret</span>
{% endif %}
</div>
</div>
</div> </div>
{% if location.location_type == 'rum' and location.has_cross_field %} {% if location.location_type == 'rum' and location.has_cross_field %}
@ -897,8 +1078,9 @@
<!-- Tab 7: Hardware --> <!-- Tab 7: Hardware -->
<div class="tab-pane fade" id="hardwareContent" role="tabpanel" aria-labelledby="hardwareTab"> <div class="tab-pane fade" id="hardwareContent" role="tabpanel" aria-labelledby="hardwareTab">
<div class="card border-0"> <div class="card border-0">
<div class="card-header bg-transparent border-bottom"> <div class="card-header bg-transparent border-bottom d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">Hardware på lokation</h5> <h5 class="card-title mb-0">Hardware på lokation</h5>
<button type="button" class="btn btn-primary btn-sm" id="addLocationHardwareBtn"><i class="bi bi-plus-lg me-1"></i>Tilføj kundehardware</button>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if location.hardware %} {% if location.hardware %}
@ -1173,6 +1355,46 @@
</div></div> </div></div>
</div> </div>
<div class="modal fade" id="accessModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog"><div class="modal-content">
<form id="accessForm">
<div class="modal-header"><h5 class="modal-title">Adgangsforhold</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body"><div class="row g-3">
<div class="col-md-6"><label class="form-label">Dag</label><select class="form-select" id="accessDay" required><option value="0">Mandag</option><option value="1">Tirsdag</option><option value="2">Onsdag</option><option value="3">Torsdag</option><option value="4">Fredag</option><option value="5">Lørdag</option><option value="6">Søndag</option></select></div>
<div class="col-md-6 d-flex align-items-end"><div class="form-check form-switch mb-2"><input class="form-check-input" type="checkbox" id="accessOpen"><label class="form-check-label" for="accessOpen">Adgang mulig</label></div></div>
<div class="col-md-6"><label class="form-label">Fra</label><input class="form-control" type="time" id="accessFrom"></div>
<div class="col-md-6"><label class="form-label">Til</label><input class="form-control" type="time" id="accessTo"></div>
<div class="col-12"><label class="form-label">Bemærkning</label><div class="d-flex flex-wrap gap-2 mb-2"><button class="btn btn-outline-secondary btn-sm access-preset" type="button" data-note="Adgang efter aftale.">Efter aftale</button><button class="btn btn-outline-secondary btn-sm access-preset" type="button" data-note="Nøgleboks / nøgle udleveres efter aftale.">Nøgleboks</button><button class="btn btn-outline-secondary btn-sm access-preset" type="button" data-note="Ring på dørtelefon ved ankomst.">Dørtelefon</button></div><textarea class="form-control" id="accessNotes" rows="3" placeholder="Fx nøgleboks, dørtelefon, ledsagelse eller adgangskode"></textarea></div>
<div class="col-12"><label class="form-label">Filer</label><div id="accessDropzone" class="border border-2 border-dashed rounded-3 p-3 text-center bg-light"><i class="bi bi-cloud-arrow-up fs-4 d-block"></i><strong>Træk filer hertil</strong><div class="small text-muted">eller klik for at vælge. PDF, billeder og Office-filer, maks. 15 MB pr. fil.</div><input class="d-none" id="accessFiles" type="file" multiple accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.xls,.xlsx"></div><div class="small text-muted mt-2" id="accessFileNames"></div></div>
<div class="col-12"><label class="form-label" for="accessFileComment">Kommentar til foto eller fil</label><input class="form-control" id="accessFileComment" maxlength="1000" placeholder="Fx foto af nøgleboks ved hovedindgangen"></div>
</div></div>
<div class="modal-footer"><button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary" type="submit">Gem adgangsforhold</button></div>
</form>
</div></div>
</div>
<div class="modal fade" id="locationHardwareModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog"><div class="modal-content">
<form id="locationHardwareForm">
<div class="modal-header"><h5 class="modal-title">Tilføj kundehardware på lokation</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body"><div class="row g-3">
<div class="col-12"><label class="form-label">Kunde *</label><select class="form-select" id="locationHardwareCustomer" required><option value="">Vælg kunde</option>{% for customer in customers %}<option value="{{ customer.id }}" {% if location.customer_id == customer.id %}selected{% endif %}>{{ customer.name }}</option>{% endfor %}</select></div>
<div class="col-md-6"><label class="form-label">Type *</label><select class="form-select" id="locationHardwareType" required><option value="pc">PC</option><option value="laptop">Laptop</option><option value="printer">Printer</option><option value="skærm">Skærm</option><option value="telefon">Telefon</option><option value="server">Server</option><option value="netværk">Netværk</option><option value="andet">Andet</option></select></div>
<div class="col-md-6"><label class="form-label">Status</label><select class="form-select" id="locationHardwareStatus"><option value="active">Aktiv</option><option value="inactive">Inaktiv</option><option value="stock">På lager</option></select></div>
<div class="col-md-6"><label class="form-label">Mærke</label><input class="form-control" id="locationHardwareBrand" placeholder="Fx HP, Lenovo eller Ubiquiti"></div>
<div class="col-md-6"><label class="form-label">Model</label><input class="form-control" id="locationHardwareModel"></div>
<div class="col-12"><label class="form-label">Serienummer</label><input class="form-control" id="locationHardwareSerial"></div>
<div class="col-12"><label class="form-label">Note</label><textarea class="form-control" id="locationHardwareNotes" rows="3"></textarea></div>
</div></div>
<div class="modal-footer"><button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary" type="submit">Opret hardware</button></div>
</form>
</div></div>
</div>
<div class="modal fade" id="locationPhotoModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog"><div class="modal-content"><form id="locationPhotoForm"><div class="modal-header"><h5 class="modal-title">Upload billeder til lokationen</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><div id="locationPhotoDropzone" class="border border-2 border-dashed rounded-3 p-4 text-center bg-light"><i class="bi bi-images fs-3 d-block"></i><strong>Træk billeder hertil</strong><div class="small text-muted">eller klik for at vælge JPG, PNG, GIF eller WebP.</div><input id="locationPhotoFiles" class="d-none" type="file" multiple accept="image/jpeg,image/png,image/gif,image/webp"></div><div id="locationPhotoNames" class="small text-muted mt-2"></div><label class="form-label mt-3">Kommentar til billederne</label><input id="locationPhotoComment" class="form-control" maxlength="1000" placeholder="Fx hovedindgang og nøgleboks"></div><div class="modal-footer"><button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary" type="submit">Upload</button></div></form></div></div></div>
<div class="modal fade" id="locationPhotoPreviewModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog modal-xl modal-dialog-centered"><div class="modal-content bg-dark text-white"><div class="modal-header border-secondary"><div><h5 class="modal-title" id="locationPhotoPreviewTitle"></h5><div class="small text-white-50" id="locationPhotoPreviewComment"></div></div><button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button></div><div class="modal-body text-center position-relative"><button type="button" class="btn btn-dark position-absolute top-50 start-0 translate-middle-y ms-2" id="locationPhotoPrevious"><i class="bi bi-chevron-left"></i></button><img id="locationPhotoPreviewImage" alt=""><button type="button" class="btn btn-dark position-absolute top-50 end-0 translate-middle-y me-2" id="locationPhotoNext"><i class="bi bi-chevron-right"></i></button></div></div></div></div>
<!-- Delete Confirmation Modal --> <!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="deleteModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
@ -1206,6 +1428,123 @@ document.addEventListener('DOMContentLoaded', function() {
} }
const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal')); const deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
const locationId = '{{ location.id }}'; const locationId = '{{ location.id }}';
const accessModalElement = document.getElementById('accessModal');
const accessModal = accessModalElement ? new bootstrap.Modal(accessModalElement) : null;
const accessForm = document.getElementById('accessForm');
const accessDay = document.getElementById('accessDay');
const accessOpen = document.getElementById('accessOpen');
const accessFrom = document.getElementById('accessFrom');
const accessTo = document.getElementById('accessTo');
const accessNotes = document.getElementById('accessNotes');
const accessFiles = document.getElementById('accessFiles');
const accessDropzone = document.getElementById('accessDropzone');
const accessFileNames = document.getElementById('accessFileNames');
const accessFileComment = document.getElementById('accessFileComment');
let droppedAccessFiles = [];
function setAccessFiles(files) {
droppedAccessFiles = Array.from(files || []);
accessFileNames.textContent = droppedAccessFiles.map(file => file.name).join(' · ');
}
accessDropzone?.addEventListener('click', () => accessFiles.click());
accessFiles?.addEventListener('change', () => setAccessFiles(accessFiles.files));
['dragenter', 'dragover'].forEach(eventName => accessDropzone?.addEventListener(eventName, event => { event.preventDefault(); accessDropzone.classList.add('border-primary'); }));
['dragleave', 'drop'].forEach(eventName => accessDropzone?.addEventListener(eventName, event => { event.preventDefault(); accessDropzone.classList.remove('border-primary'); }));
accessDropzone?.addEventListener('drop', event => setAccessFiles(event.dataTransfer.files));
document.querySelectorAll('.access-preset').forEach(button => button.addEventListener('click', () => {
accessNotes.value = button.dataset.note || '';
accessOpen.checked = true;
}));
function openAccessModal(button) {
if (!accessModal) return;
accessForm.reset();
droppedAccessFiles = [];
accessFileNames.textContent = '';
accessFileComment.value = '';
if (button) {
accessDay.value = button.dataset.day || '0';
accessOpen.checked = button.dataset.open === 'true';
accessFrom.value = button.dataset.from || '';
accessTo.value = button.dataset.to || '';
accessNotes.value = button.dataset.notes || '';
}
accessModal.show();
}
document.getElementById('editAccessBtn')?.addEventListener('click', () => openAccessModal());
document.querySelectorAll('.edit-hours-btn').forEach(button => button.addEventListener('click', () => openAccessModal(button)));
accessForm?.addEventListener('submit', async event => {
event.preventDefault();
const payload = {
day_of_week: Number(accessDay.value),
is_open: accessOpen.checked,
open_time: accessOpen.checked ? (accessFrom.value || null) : null,
close_time: accessOpen.checked ? (accessTo.value || null) : null,
notes: accessNotes.value.trim() || null,
};
if (payload.is_open && (!payload.open_time || !payload.close_time)) {
alert('Angiv både fra- og til-tid, når adgang er mulig.');
return;
}
const response = await fetch(`/api/v1/locations/${locationId}/hours`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)});
if (response.ok) {
if (droppedAccessFiles.length) {
const files = new FormData();
droppedAccessFiles.forEach(file => files.append('files', file));
files.append('comment', accessFileComment.value.trim());
const upload = await fetch(`/api/v1/locations/${locationId}/access-files`, {method: 'POST', body: files});
if (!upload.ok) { const error = await upload.json().catch(() => ({})); alert(error.detail || 'Adgangsforholdet er gemt, men filerne kunne ikke uploades'); }
}
location.reload();
}
else { const error = await response.json().catch(() => ({})); alert(error.detail || 'Adgangsforholdet kunne ikke gemmes'); }
});
const locationPhotoModal = new bootstrap.Modal(document.getElementById('locationPhotoModal'));
const locationPhotoFiles = document.getElementById('locationPhotoFiles');
const locationPhotoDropzone = document.getElementById('locationPhotoDropzone');
const locationPhotoInlineDropzone = document.getElementById('locationPhotoInlineDropzone');
const locationPhotoNames = document.getElementById('locationPhotoNames');
let droppedLocationPhotos = [];
const setLocationPhotos = files => { droppedLocationPhotos = Array.from(files || []); locationPhotoNames.textContent = droppedLocationPhotos.map(file => file.name).join(' · '); };
document.getElementById('addLocationPhotoBtn')?.addEventListener('click', () => { document.getElementById('locationPhotoForm').reset(); droppedLocationPhotos = []; locationPhotoNames.textContent = ''; locationPhotoModal.show(); });
locationPhotoDropzone?.addEventListener('click', () => locationPhotoFiles.click());
locationPhotoFiles?.addEventListener('change', () => setLocationPhotos(locationPhotoFiles.files));
['dragenter', 'dragover'].forEach(name => locationPhotoDropzone?.addEventListener(name, event => { event.preventDefault(); locationPhotoDropzone.classList.add('border-primary'); }));
['dragleave', 'drop'].forEach(name => locationPhotoDropzone?.addEventListener(name, event => { event.preventDefault(); locationPhotoDropzone.classList.remove('border-primary'); }));
locationPhotoDropzone?.addEventListener('drop', event => setLocationPhotos(event.dataTransfer.files));
locationPhotoInlineDropzone?.addEventListener('click', () => document.getElementById('addLocationPhotoBtn').click());
['dragenter', 'dragover'].forEach(name => locationPhotoInlineDropzone?.addEventListener(name, event => { event.preventDefault(); locationPhotoInlineDropzone.classList.add('border-primary'); }));
['dragleave', 'drop'].forEach(name => locationPhotoInlineDropzone?.addEventListener(name, event => { event.preventDefault(); locationPhotoInlineDropzone.classList.remove('border-primary'); }));
locationPhotoInlineDropzone?.addEventListener('drop', event => { setLocationPhotos(event.dataTransfer.files); locationPhotoModal.show(); });
document.getElementById('locationPhotoForm')?.addEventListener('submit', async event => { event.preventDefault(); if (!droppedLocationPhotos.length) { alert('Vælg mindst ét billede'); return; } const data = new FormData(); droppedLocationPhotos.forEach(file => data.append('files', file)); data.append('comment', document.getElementById('locationPhotoComment').value.trim()); const response = await fetch(`/api/v1/locations/${locationId}/files`, {method: 'POST', body: data}); if (response.ok) location.reload(); else { const error = await response.json().catch(() => ({})); alert(error.detail || 'Billederne kunne ikke uploades'); } });
const locationPhotos = Array.from(document.querySelectorAll('.location-photo-preview'));
const locationPhotoPreviewModal = document.getElementById('locationPhotoPreviewModal') ? new bootstrap.Modal(document.getElementById('locationPhotoPreviewModal')) : null;
let activeLocationPhoto = 0;
function showLocationPhoto(index) { if (!locationPhotos.length) return; activeLocationPhoto = (index + locationPhotos.length) % locationPhotos.length; const photo = locationPhotos[activeLocationPhoto]; document.getElementById('locationPhotoPreviewImage').src = photo.dataset.url; document.getElementById('locationPhotoPreviewTitle').textContent = photo.dataset.name; document.getElementById('locationPhotoPreviewComment').textContent = photo.dataset.comment; locationPhotoPreviewModal.show(); }
locationPhotos.forEach((photo, index) => photo.addEventListener('click', () => showLocationPhoto(index)));
document.getElementById('locationPhotoPrevious')?.addEventListener('click', () => showLocationPhoto(activeLocationPhoto - 1));
document.getElementById('locationPhotoNext')?.addEventListener('click', () => showLocationPhoto(activeLocationPhoto + 1));
const locationHardwareModalElement = document.getElementById('locationHardwareModal');
const locationHardwareModal = locationHardwareModalElement ? new bootstrap.Modal(locationHardwareModalElement) : null;
document.getElementById('addLocationHardwareBtn')?.addEventListener('click', () => { document.getElementById('locationHardwareForm').reset(); locationHardwareModal?.show(); });
document.getElementById('locationHardwareForm')?.addEventListener('submit', async event => {
event.preventDefault();
const value = id => document.getElementById(id).value.trim() || null;
const customerId = Number(document.getElementById('locationHardwareCustomer').value);
if (!customerId) { alert('Vælg kunden, som ejer hardwaren.'); return; }
const response = await fetch('/api/v1/hardware', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({
asset_type: document.getElementById('locationHardwareType').value,
brand: value('locationHardwareBrand'), model: value('locationHardwareModel'), serial_number: value('locationHardwareSerial'),
notes: value('locationHardwareNotes'), status: document.getElementById('locationHardwareStatus').value,
current_location_id: Number(locationId),
current_owner_type: 'customer', current_owner_customer_id: customerId,
})});
if (response.ok) location.reload();
else { const error = await response.json().catch(() => ({})); alert(error.detail || 'Hardwaren kunne ikke oprettes'); }
});
const locationHardware = {{ location.hardware | tojson }}; const locationHardware = {{ location.hardware | tojson }};
const locationWallOutlets = {{ location.wall_outlets | tojson }}; const locationWallOutlets = {{ location.wall_outlets | tojson }};
const locationCrossFields = {{ location.cross_fields | tojson }}; const locationCrossFields = {{ location.cross_fields | tojson }};

View File

@ -361,7 +361,7 @@
<label for="locationSearch" class="form-label small text-muted">Søg</label> <label for="locationSearch" class="form-label small text-muted">Søg</label>
<div class="input-group"> <div class="input-group">
<span class="input-group-text"><i class="bi bi-search"></i></span> <span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" id="locationSearch" placeholder="Søg efter lokation eller by..."> <input type="text" class="form-control" id="locationSearch" name="q" value="{{ q or '' }}" placeholder="Søg efter lokation eller by...">
</div> </div>
</div> </div>
@ -417,6 +417,12 @@
</div> </div>
</div> </div>
<div class="d-flex gap-2 flex-wrap"> <div class="d-flex gap-2 flex-wrap">
<button type="button" class="btn btn-outline-primary btn-sm" id="expandAllBtn">
<i class="bi bi-arrows-expand me-1"></i>Fold alt ud
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="collapseAllBtn">
<i class="bi bi-arrows-collapse me-1"></i>Fold alt ind
</button>
<span class="list-status-note"> <span class="list-status-note">
<i class="bi bi-check2-circle"></i> <i class="bi bi-check2-circle"></i>
Klik kun på navn eller handlinger for at åbne Klik kun på navn eller handlinger for at åbne
@ -570,7 +576,7 @@
<ul class="pagination justify-content-center"> <ul class="pagination justify-content-center">
{% if page_number > 1 %} {% if page_number > 1 %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="?skip={{ (page_number - 2) * limit }}&limit={{ limit }}{% if location_type %}&location_type={{ location_type }}{% endif %}{% if is_active is not none %}&is_active={{ is_active }}{% endif %}"> <a class="page-link" href="?skip={{ (page_number - 2) * limit }}&limit={{ limit }}{% if q %}&q={{ q }}{% endif %}{% if location_type %}&location_type={{ location_type }}{% endif %}{% if is_active is not none %}&is_active={{ is_active }}{% endif %}">
<i class="bi bi-chevron-left"></i> Forrige <i class="bi bi-chevron-left"></i> Forrige
</a> </a>
</li> </li>
@ -587,7 +593,7 @@
</li> </li>
{% elif page_num == 1 or page_num == total_pages or (page_num >= page_number - 1 and page_num <= page_number + 1) %} {% elif page_num == 1 or page_num == total_pages or (page_num >= page_number - 1 and page_num <= page_number + 1) %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="?skip={{ (page_num - 1) * limit }}&limit={{ limit }}{% if location_type %}&location_type={{ location_type }}{% endif %}{% if is_active is not none %}&is_active={{ is_active }}{% endif %}"> <a class="page-link" href="?skip={{ (page_num - 1) * limit }}&limit={{ limit }}{% if q %}&q={{ q }}{% endif %}{% if location_type %}&location_type={{ location_type }}{% endif %}{% if is_active is not none %}&is_active={{ is_active }}{% endif %}">
{{ page_num }} {{ page_num }}
</a> </a>
</li> </li>
@ -600,7 +606,7 @@
{% if page_number < total_pages %} {% if page_number < total_pages %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="?skip={{ page_number * limit }}&limit={{ limit }}{% if location_type %}&location_type={{ location_type }}{% endif %}{% if is_active is not none %}&is_active={{ is_active }}{% endif %}"> <a class="page-link" href="?skip={{ page_number * limit }}&limit={{ limit }}{% if q %}&q={{ q }}{% endif %}{% if location_type %}&location_type={{ location_type }}{% endif %}{% if is_active is not none %}&is_active={{ is_active }}{% endif %}">
Næste <i class="bi bi-chevron-right"></i> Næste <i class="bi bi-chevron-right"></i>
</a> </a>
</li> </li>
@ -733,6 +739,16 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
} }
function expandAll() {
rows.forEach(row => {
row.classList.remove('d-none');
if (row.getAttribute('data-has-children') === 'true') {
updateToggleIcon(row, true);
}
});
updateSummaryStats();
}
function showAncestors(id) { function showAncestors(id) {
let current = parentById.get(id); let current = parentById.get(id);
while (current) { while (current) {
@ -772,6 +788,12 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}); });
document.getElementById('expandAllBtn')?.addEventListener('click', expandAll);
document.getElementById('collapseAllBtn')?.addEventListener('click', function() {
collapseAll();
updateSummaryStats();
});
document.querySelectorAll('.location-row.has-children').forEach(row => { document.querySelectorAll('.location-row.has-children').forEach(row => {
row.addEventListener('click', function(e) { row.addEventListener('click', function(e) {
const target = e.target; const target = e.target;

View File

@ -499,6 +499,27 @@
</div> </div>
</div> </div>
<div class="modal fade" id="caseContactCreateModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<form class="modal-content" id="caseContactCreateForm">
<div class="modal-header"><h5 class="modal-title" id="caseContactCreateTitle">Opret kontakt</h5><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button></div>
<div class="modal-body">
<p id="caseContactCreateHelp" class="small text-muted"></p>
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Fornavn *</label><input class="form-control" id="caseContactFirstName" required></div>
<div class="col-md-6"><label class="form-label">Efternavn *</label><input class="form-control" id="caseContactLastName" required></div>
<div class="col-12 d-none" id="caseExternalCompanyWrap"><label class="form-label">Firma</label><input class="form-control" id="caseExternalCompany" placeholder="Firmaet personen kommer fra"></div>
<div class="col-md-6"><label class="form-label">E-mail</label><input class="form-control" id="caseContactEmail" type="email"></div>
<div class="col-md-6"><label class="form-label">Telefon</label><input class="form-control" id="caseContactPhone"></div>
<div class="col-12"><label class="form-label">Rolle</label><input class="form-control" id="caseContactRole" placeholder="Fx beboer, vicevært eller medarbejder"></div>
</div>
<div id="caseContactCreateError" class="alert alert-danger d-none mt-3 mb-0"></div>
</div>
<div class="modal-footer"><button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button><button type="submit" class="btn btn-primary">Opret og vælg</button></div>
</form>
</div>
</div>
<div class="modal fade" id="caseCreateRewriteModal" tabindex="-1" aria-labelledby="caseCreateRewriteModalLabel" aria-hidden="true"> <div class="modal fade" id="caseCreateRewriteModal" tabindex="-1" aria-labelledby="caseCreateRewriteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable"> <div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content"> <div class="modal-content">
@ -539,6 +560,7 @@
let orderLineCounter = 0; let orderLineCounter = 0;
let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null, internetConnectionId: null }; let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null, internetConnectionId: null };
let topAlertLoadToken = 0; let topAlertLoadToken = 0;
let caseContactCreateKind = 'employee';
function escapeTopAlertHtml(value) { function escapeTopAlertHtml(value) {
return String(value ?? '') return String(value ?? '')
@ -857,6 +879,14 @@
return details.join(' · ') || 'Ingen kontaktoplysninger'; return details.join(' · ') || 'Ingen kontaktoplysninger';
} }
function contactCreateActions() {
if (!selectedCustomer) return '';
return `<div class="border-top mt-1 p-2 d-flex gap-2 flex-wrap">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="openCaseContactCreateModal('employee')"><i class="bi bi-person-plus me-1"></i>Opret medarbejder</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openCaseContactCreateModal('external')"><i class="bi bi-buildings me-1"></i>Opret ekstern</button>
</div>`;
}
function renderCustomerContactResults(query = '') { function renderCustomerContactResults(query = '') {
const resultsDiv = document.getElementById('contactResults'); const resultsDiv = document.getElementById('contactResults');
if (!resultsDiv || !selectedCustomer) return; if (!resultsDiv || !selectedCustomer) return;
@ -882,7 +912,7 @@
selectedCustomerContacts.length selectedCustomerContacts.length
? 'Ingen af firmaets kontakter matcher søgningen.' ? 'Ingen af firmaets kontakter matcher søgningen.'
: 'Firmaet har ingen registrerede kontakter.' : 'Firmaet har ingen registrerede kontakter.'
}</div>`; }</div>${contactCreateActions()}`;
} else { } else {
resultsDiv.innerHTML = contacts.map((contact) => { resultsDiv.innerHTML = contacts.map((contact) => {
const name = contactDisplayName(contact); const name = contactDisplayName(contact);
@ -892,7 +922,7 @@
<div class="search-result-meta">${escapeTopAlertHtml(contactDisplayMeta(contact))}</div> <div class="search-result-meta">${escapeTopAlertHtml(contactDisplayMeta(contact))}</div>
</div> </div>
`; `;
}).join(''); }).join('') + contactCreateActions();
resultsDiv.querySelectorAll('[data-contact-id]').forEach((row) => { resultsDiv.querySelectorAll('[data-contact-id]').forEach((row) => {
row.addEventListener('click', () => { row.addEventListener('click', () => {
const contact = contacts.find((item) => Number(item.id) === Number(row.dataset.contactId)); const contact = contacts.find((item) => Number(item.id) === Number(row.dataset.contactId));
@ -1333,6 +1363,53 @@
} }
} }
function openCaseContactCreateModal(kind) {
if (!selectedCustomer) return;
caseContactCreateKind = kind;
const currentName = document.getElementById('contactSearch').value.trim();
const parts = currentName.split(/\s+/).filter(Boolean);
document.getElementById('caseContactCreateForm').reset();
document.getElementById('caseContactFirstName').value = parts.shift() || '';
document.getElementById('caseContactLastName').value = parts.join(' ');
document.getElementById('caseContactCreateError').classList.add('d-none');
const external = kind === 'external';
document.getElementById('caseExternalCompanyWrap').classList.toggle('d-none', !external);
document.getElementById('caseContactCreateTitle').textContent = external ? 'Opret ekstern bruger' : 'Opret medarbejder';
document.getElementById('caseContactCreateHelp').textContent = external
? `Personen vises under ${selectedCustomer.name} og kan tilknyttes sit eget firma.`
: `Personen oprettes som kontakt hos ${selectedCustomer.name}.`;
bootstrap.Modal.getOrCreateInstance(document.getElementById('caseContactCreateModal')).show();
}
document.getElementById('caseContactCreateForm')?.addEventListener('submit', async (event) => {
event.preventDefault();
if (!selectedCustomer) return;
const error = document.getElementById('caseContactCreateError');
error.classList.add('d-none');
const payload = {
first_name: document.getElementById('caseContactFirstName').value.trim(),
last_name: document.getElementById('caseContactLastName').value.trim(),
email: document.getElementById('caseContactEmail').value.trim() || null,
phone: document.getElementById('caseContactPhone').value.trim() || null,
role: document.getElementById('caseContactRole').value.trim() || null
};
try {
const path = caseContactCreateKind === 'external' ? 'house-users' : 'contacts';
if (caseContactCreateKind === 'external') payload.external_company_name = document.getElementById('caseExternalCompany').value.trim() || null;
const response = await fetch(`/api/v1/customers/${selectedCustomer.id}/${path}`, {
method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Kunne ikke oprette kontakt');
bootstrap.Modal.getInstance(document.getElementById('caseContactCreateModal'))?.hide();
await loadSelectedCustomerContacts(selectedCustomer.id);
await selectContact(data.id, `${data.first_name || ''} ${data.last_name || ''}`.trim());
} catch (err) {
error.textContent = err.message;
error.classList.remove('d-none');
}
});
const caseTypeLabels = { const caseTypeLabels = {
ticket: '🎫 Ticket', salg: '💼 Salg', bogholderi: '🧾 Bogholderi', ticket: '🎫 Ticket', salg: '💼 Salg', bogholderi: '🧾 Bogholderi',
admin: '⚙️ Admin', projekt: '📁 Projekt', postit: '🗒️ Post-it', admin: '⚙️ Admin', projekt: '📁 Projekt', postit: '🗒️ Post-it',

View File

@ -448,7 +448,12 @@ async def yealink_terminated(
if duration_value is None: if duration_value is None:
logger.info("⚠️ Telefoni terminated with unparseable duration='%s' (callid=%s)", duration_raw, resolved_callid) logger.info("⚠️ Telefoni terminated with unparseable duration='%s' (callid=%s)", duration_raw, resolved_callid)
existed = execute_query_single("SELECT id,bruger_id FROM telefoni_opkald WHERE callid=%s", (resolved_callid,)) existed = execute_query_single(
"""SELECT id,bruger_id FROM telefoni_opkald
WHERE callid=%s AND ended_at IS NULL
ORDER BY started_at DESC, id DESC LIMIT 1""",
(resolved_callid,),
)
updated = TelefoniService.terminate_call(resolved_callid, duration_value) updated = TelefoniService.terminate_call(resolved_callid, duration_value)
if not updated: if not updated:
logger.info("⚠️ Telefoni terminated without established (callid=%s)", resolved_callid) logger.info("⚠️ Telefoni terminated without established (callid=%s)", resolved_callid)
@ -648,6 +653,8 @@ def _register_completed_call_time(callid: str) -> Optional[int]:
JOIN sag_sager s ON s.id = t.sag_id AND s.deleted_at IS NULL JOIN sag_sager s ON s.id = t.sag_id AND s.deleted_at IS NULL
LEFT JOIN users u ON u.user_id = t.bruger_id LEFT JOIN users u ON u.user_id = t.bruger_id
WHERE t.callid = %s WHERE t.callid = %s
ORDER BY t.started_at DESC, t.id DESC
LIMIT 1
""", """,
(callid,), (callid,),
) )
@ -1040,7 +1047,10 @@ async def update_call_links(call_id: int, data: TelefoniCallLinkUpdate):
COALESCE(m.last_payload, '{}'::jsonb) COALESCE(m.last_payload, '{}'::jsonb)
FROM mission_call_state m FROM mission_call_state m
WHERE m.call_id = %s WHERE m.call_id = %s
ON CONFLICT (callid) DO NOTHING AND NOT EXISTS (
SELECT 1 FROM telefoni_opkald t
WHERE t.callid = m.call_id AND t.started_at = m.started_at
)
RETURNING id RETURNING id
""", """,
(data.callid.strip(),), (data.callid.strip(),),
@ -1049,7 +1059,10 @@ async def update_call_links(call_id: int, data: TelefoniCallLinkUpdate):
call_id = int(promoted[0]["id"]) call_id = int(promoted[0]["id"])
existing = {"id": call_id} existing = {"id": call_id}
else: else:
existing = execute_query_single("SELECT id FROM telefoni_opkald WHERE callid = %s", (data.callid.strip(),)) existing = execute_query_single(
"SELECT id FROM telefoni_opkald WHERE callid = %s ORDER BY started_at DESC, id DESC LIMIT 1",
(data.callid.strip(),),
)
if existing: if existing:
call_id = int(existing["id"]) call_id = int(existing["id"])
if not existing: if not existing:

View File

@ -192,7 +192,7 @@ class TelefoniService:
(callid, bruger_id, direction, ekstern_nummer, intern_extension, kontakt_id, started_at, raw_payload) (callid, bruger_id, direction, ekstern_nummer, intern_extension, kontakt_id, started_at, raw_payload)
VALUES VALUES
(%s, %s, %s, %s, %s, %s, %s, %s::jsonb) (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
ON CONFLICT (callid) ON CONFLICT (callid) WHERE ended_at IS NULL
DO UPDATE SET DO UPDATE SET
raw_payload = EXCLUDED.raw_payload, raw_payload = EXCLUDED.raw_payload,
direction = EXCLUDED.direction, direction = EXCLUDED.direction,
@ -226,25 +226,41 @@ class TelefoniService:
rows = execute_query( rows = execute_query(
""" """
INSERT INTO telefoni_opkald WITH completed AS (
(callid, direction, started_at, ended_at, duration_sec, raw_payload, lifecycle_status, termination_source, last_event_at) UPDATE telefoni_opkald
VALUES SET ended_at = NOW(),
(%s, 'inbound', NOW(), NOW(), %s, '{}'::jsonb, 'orphan_terminated', 'callback_without_start', NOW()) duration_sec = COALESCE(
ON CONFLICT (callid) %s,
DO UPDATE SET CASE
ended_at = COALESCE(telefoni_opkald.ended_at, NOW()), WHEN started_at IS NOT NULL
duration_sec = COALESCE( AND NOW() - started_at BETWEEN INTERVAL '0 seconds' AND INTERVAL '12 hours'
EXCLUDED.duration_sec, THEN EXTRACT(EPOCH FROM (NOW() - started_at))::int
CASE ELSE NULL
WHEN telefoni_opkald.started_at IS NOT NULL END
AND NOW() - telefoni_opkald.started_at BETWEEN INTERVAL '0 seconds' AND INTERVAL '12 hours' ),
THEN EXTRACT(EPOCH FROM (NOW() - telefoni_opkald.started_at))::int lifecycle_status = 'completed',
ELSE NULL termination_source = 'callback',
END last_event_at = NOW()
), lifecycle_status = 'completed', termination_source = 'callback', last_event_at = NOW() WHERE id = (
RETURNING id SELECT id
FROM telefoni_opkald
WHERE callid = %s AND ended_at IS NULL
ORDER BY started_at DESC, id DESC
LIMIT 1
)
RETURNING id
), orphan AS (
INSERT INTO telefoni_opkald
(callid, direction, started_at, ended_at, duration_sec, raw_payload, lifecycle_status, termination_source, last_event_at)
SELECT %s, 'inbound', NOW(), NOW(), %s, '{}'::jsonb, 'orphan_terminated', 'callback_without_start', NOW()
WHERE NOT EXISTS (SELECT 1 FROM completed)
RETURNING id
)
SELECT id FROM completed
UNION ALL
SELECT id FROM orphan
""", """,
(callid, duration_sec), (duration_sec, callid, callid, duration_sec),
) )
return bool(rows) return bool(rows)

View File

@ -9,6 +9,7 @@ Denne mappe er det faste sted for idéer og udviklingsplaner, som er aftalt, men
- [Sikkert fakturaudtræk med layoutanalyse og AI](sikker-fakturaudtraek-hybrid-ai.md) — revisionssikker hybridpipeline med felt-evidens, validering og manuel kontrol. - [Sikkert fakturaudtræk med layoutanalyse og AI](sikker-fakturaudtraek-hybrid-ai.md) — revisionssikker hybridpipeline med felt-evidens, validering og manuel kontrol.
- [Kuando Busylight og central Presence](kuando-busylight-integration.md) — fælles presence, direkte intern KuandoHUB-styring og senere notifikationsalarmer. - [Kuando Busylight og central Presence](kuando-busylight-integration.md) — fælles presence, direkte intern KuandoHUB-styring og senere notifikationsalarmer.
- [Sag: topbar-filupload og personlig Buzzwords-visning](sag-filupload-og-buzzwords.md) — upload fra papirclips i topbaren, skjult tomt filmodul og personlig Buzzwords-visning. - [Sag: topbar-filupload og personlig Buzzwords-visning](sag-filupload-og-buzzwords.md) — upload fra papirclips i topbaren, skjult tomt filmodul og personlig Buzzwords-visning.
- [Sagsopretter: husets brugere, medarbejdere og eksterne](sag-opretter-husbrugere-og-eksterne.md) — kontakt-søgning og oprettelse, hvor huset forbliver kunde på sagen.
## Arbejdsgang ## Arbejdsgang

View File

@ -0,0 +1,175 @@
# PLAN – Sagsopretter: husets brugere, medarbejdere og eksterne
> **Status: Fremtidig plan – ikke implementeret.**
>
> Planen ændrer ikke eksisterende sager, kunder eller kontaktpersoner. Før
> udvikling kortlægges den nuværende sagsopretter, kontakt-søgning og
> kunde-/kontaktrelationer.
## 1. Formål
Når der oprettes en sag for eksempelvis et PFA-hus, er **huset/adressen**
kunden på sagen. Den person, som ringer eller bruger huset, skal registreres
som kontaktperson på sagen.
Personen kan være:
- medarbejder hos kunden
- husets bruger fra et andet firma
- ekstern, fx leverandør, håndværker eller gæst
Sagen skal fortsat have huset som kunde, også når personen arbejder for et
andet firma.
## 2. Arbejdsgang i sagsopretteren
1. Brugeren vælger først kunde/hus og eventuelt lokation.
2. Feltet **Kontaktperson** søger mens der skrives på navn, telefon og e-mail.
3. Resultater viser navn, firma, rolle og eventuel tidligere tilknytning til
huset.
4. Brugeren vælger en eksisterende person, eller fortsætter med en af de to
handlinger nederst i resultatlisten:
- **Opret medarbejder**
- **Opret ekstern / husets bruger**
5. Den oprettede kontakt vælges automatisk på sagen.
6. Kunde og lokation på sagen må ikke ændres af kontaktoprettelsen.
## 3. Opret medarbejder
Formularen skal være kort:
- navn
- telefon og/eller e-mail
- rolle eller afdeling, valgfrit
Den nye person knyttes til den valgte kunde/hus som medarbejder og tilføjes
som kontaktperson på sagen.
## 4. Opret ekstern / husets bruger
Formularen skal indeholde:
- navn
- telefon og/eller e-mail
- eget firma: vælg eksisterende eller opret nyt
- rolle ved huset, fx bruger, leverandør, håndværker eller gæst
- valgfri note
Personen får både sin egen firmatilknytning og en særskilt tilknytning til
det valgte hus/lokation. Det betyder, at samme person senere kan findes via
navn, eget firma og hus.
## 5. Datamodel og regler
Før implementering kortlægges eksisterende tabeller og API'er for:
- kontaktpersoner
- firma-/kunde-tilknytning
- sagens kontaktpersoner
- lokationer
Hvis den nuværende model ikke kan repræsentere en ekstern bruger af et hus,
oprettes en selvstændig, bagudkompatibel relation:
`kontakt_lokationsbrugere`
Foreslåede felter:
- `contact_id`
- `location_id`
- `customer_id` for huset
- `relation_type` (`employee`, `house_user`, `external_supplier`, `guest`)
- `company_id` for personens eget firma, valgfri
- `role`, `notes`
- `is_active`, oprettet/opdateret-tidspunkt
En person må have flere aktive relationer til flere huse. Eksisterende
kontakt- og kundedata må ikke omskrives eller flyttes.
### Firmaer i huset
Et firma, der bruger et hus men ikke er BMC-kunde, skal registreres som en
organisation med typen `external_house_company`.
Det skal have:
- et tydeligt tag, fx **Firma i huset**
- feltet `is_bmc_customer = false`
- feltet `show_in_customer_list = false`
- relation til hovedkunden og den konkrete lokation/hus
Det betyder, at firmaet kan have egen profil, egne brugere og egne kontaktdata
uden at optræde i den normale kundeliste eller blive valgt som fakturakunde.
Den normale kundeliste viser kun BMC-kunder som standard. En eksplicit filter
**Vis firmaer i huse og eksterne organisationer** viser de skjulte firmaer,
når det er nødvendigt.
Relationen skal være mange-til-mange, da ét firma kan bruge flere huse, og ét
hus kan rumme flere firmaer.
## 5.1 Placering i brugerfladen
På hovedkunden/huset tilføjes en fane:
**Brugere i huset**
Fanens to sektioner:
1. **Personer** – medarbejdere, husets brugere, leverandører og gæster.
2. **Firmaer i huset** – eksterne firmaer med antal brugere og de lokationer,
de er knyttet til.
På den konkrete lokation kan samme data vises som en mindre, filtreret
oversigt. Sagsopretteren søger først i personerne og firmaerne under det
valgte hus.
## 6. Søgning og dubletkontrol
Søgning skal prioritere:
1. personer, som allerede er knyttet til det valgte hus
2. personer hos den valgte kunde
3. øvrige kendte kontakter
Før oprettelse vises mulige dubletter ved samme e-mail eller telefon. Brugeren
skal kunne vælge den eksisterende kontakt i stedet for at oprette en ny.
## 7. Visning på sagen
Sagen skal vise:
- kunde: huset/adressen
- lokation, hvis valgt
- kontaktpersonens navn
- personens eget firma, hvis det er et andet firma
- relation til huset, fx `Husets bruger` eller `Ekstern leverandør`
Dette skal være tydeligt i både sagsdetalje og ved senere genåbning af sagen.
## 8. Afgrænsning for første version
Første version omfatter kun søgning, oprettelse og tilknytning af personer
under sagsoprettelse. Den må ikke ændre fakturakunde, eksisterende
kundehierarki, rettigheder eller historiske sager.
## 9. Implementeringsrækkefølge
1. Kortlæg nuværende kontakt- og sagsrelationer.
2. Beslut om eksisterende relationer er nok; opret kun ny migration ved behov.
3. Tilføj søge-API med kundens/husets relationer først.
4. Tilføj de to opret-flow i sagsopretteren.
5. Tilføj relationen for firmaer i huset og fanen **Brugere i huset**.
6. Tilføj automatisk valg af den nye kontakt på sagen.
7. Vis relationen tydeligt på sagsdetaljen.
8. Tilføj dubletkontrol og audit-log.
## 10. Acceptkriterier
- En PFA-lokation kan vælges som kunde på sagen.
- En bruger fra et andet firma kan oprettes uden at ændre sagens kunde.
- Personen kan findes igen ved navn, eget firma og hus.
- Medarbejder og ekstern kan oprettes fra samme kontakt-søgning.
- Firmaer i huset er skjult fra den normale kundeliste som standard.
- Firmaer i huset kan findes fra hovedkunden, lokationen og sagsopretteren.
- Eksisterende sager og kontakter virker uændret.

View File

@ -0,0 +1,61 @@
-- Yealink/PBX call IDs are reused after a phone or PBX lifecycle.
-- Keep historical calls and require uniqueness only while a call is active.
DROP INDEX IF EXISTS ux_telefoni_opkald_callid;
CREATE UNIQUE INDEX IF NOT EXISTS ux_telefoni_opkald_active_callid
ON telefoni_opkald(callid)
WHERE ended_at IS NULL;
-- Recover calls that were previously merged into an older row with the same
-- reused call ID. System events are written from the successful callback and
-- retain the callback metadata required for a read-safe reconstruction.
INSERT INTO telefoni_opkald (
callid, bruger_id, direction, ekstern_nummer, intern_extension,
started_at, ended_at, duration_sec, raw_payload,
lifecycle_status, termination_source, last_event_at
)
SELECT
established.correlation_id,
established.user_id,
COALESCE(NULLIF(established.details->'payload'->>'direction', ''), 'inbound'),
NULLIF(established.details->>'external_number', ''),
NULLIF(established.details->>'extension', ''),
established.occurred_at AT TIME ZONE 'UTC',
terminated.occurred_at AT TIME ZONE 'UTC',
CASE
WHEN NULLIF(terminated.details->>'duration_sec', '') ~ '^[0-9]+$'
THEN (terminated.details->>'duration_sec')::INTEGER
WHEN terminated.occurred_at IS NOT NULL
THEN GREATEST(EXTRACT(EPOCH FROM (terminated.occurred_at - established.occurred_at))::INTEGER, 0)
ELSE NULL
END,
COALESCE(established.details->'payload', '{}'::jsonb),
CASE WHEN terminated.id IS NULL THEN 'active' ELSE 'completed' END,
CASE WHEN terminated.id IS NULL THEN NULL ELSE 'system_log_recovery' END,
COALESCE(terminated.occurred_at, established.occurred_at)
FROM system_event_log established
LEFT JOIN LATERAL (
SELECT term.id, term.occurred_at, term.details
FROM system_event_log term
WHERE term.source = 'voip'
AND term.event_type = 'voip.terminated'
AND term.correlation_id = established.correlation_id
AND term.occurred_at >= established.occurred_at
ORDER BY term.occurred_at ASC, term.id ASC
LIMIT 1
) terminated ON TRUE
WHERE established.source = 'voip'
AND established.event_type = 'voip.established'
AND NULLIF(TRIM(established.correlation_id), '') IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM telefoni_opkald current_call
WHERE current_call.callid = established.correlation_id
AND current_call.started_at BETWEEN
(established.occurred_at AT TIME ZONE 'UTC') - INTERVAL '5 minutes'
AND (established.occurred_at AT TIME ZONE 'UTC') + INTERVAL '5 minutes'
);
CREATE INDEX IF NOT EXISTS idx_telefoni_opkald_callid_started
ON telefoni_opkald(callid, started_at DESC);

View File

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS locations_access_files (
id SERIAL PRIMARY KEY,
location_id INTEGER NOT NULL REFERENCES locations_locations(id) ON DELETE CASCADE,
original_filename VARCHAR(255) NOT NULL,
stored_filename VARCHAR(255) NOT NULL,
content_type VARCHAR(150),
file_size BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_locations_access_files_location
ON locations_access_files(location_id, created_at DESC)
WHERE deleted_at IS NULL;

View File

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

View File

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS locations_files (
id SERIAL PRIMARY KEY,
location_id INTEGER NOT NULL REFERENCES locations_locations(id) ON DELETE CASCADE,
original_filename VARCHAR(255) NOT NULL,
stored_filename VARCHAR(255) NOT NULL,
content_type VARCHAR(150),
file_size BIGINT NOT NULL DEFAULT 0,
comment TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_locations_files_location
ON locations_files(location_id, created_at DESC) WHERE deleted_at IS NULL;

View File

@ -0,0 +1,35 @@
ALTER TABLE customers
ADD COLUMN IF NOT EXISTS is_bmc_customer BOOLEAN NOT NULL DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS show_in_customer_list BOOLEAN NOT NULL DEFAULT TRUE;
CREATE TABLE IF NOT EXISTS customer_house_organizations (
id SERIAL PRIMARY KEY,
host_customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
external_customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
location_id INTEGER REFERENCES locations_locations(id) ON DELETE SET NULL,
relationship_type VARCHAR(40) NOT NULL DEFAULT 'house_user_company',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
notes TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (host_customer_id, external_customer_id, location_id)
);
CREATE TABLE IF NOT EXISTS contact_location_users (
id SERIAL PRIMARY KEY,
contact_id INTEGER NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
host_customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
location_id INTEGER REFERENCES locations_locations(id) ON DELETE SET NULL,
external_customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
relation_type VARCHAR(40) NOT NULL DEFAULT 'house_user',
role VARCHAR(120),
notes TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_customer_house_organizations_host
ON customer_house_organizations(host_customer_id) WHERE is_active = TRUE;
CREATE INDEX IF NOT EXISTS idx_contact_location_users_host
ON contact_location_users(host_customer_id) WHERE is_active = TRUE;

View File

@ -0,0 +1,24 @@
-- Repair installations that still have the legacy sag_sager status constraint.
-- The case module manages its allowed statuses in settings; the database only
-- guarantees that a case status is present.
DO $$
DECLARE
constraint_row RECORD;
BEGIN
FOR constraint_row IN
SELECT c.conname
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'public'
AND t.relname = 'sag_sager'
AND c.contype = 'c'
AND c.conname IN ('sag_sager_status_check', 'sag_sager_status_nonempty_check')
LOOP
EXECUTE format('ALTER TABLE public.sag_sager DROP CONSTRAINT IF EXISTS %I', constraint_row.conname);
END LOOP;
END $$;
ALTER TABLE public.sag_sager
ADD CONSTRAINT sag_sager_status_nonempty_check
CHECK (length(trim(status)) > 0);

View File

@ -120,6 +120,24 @@ def test_stale_termination_duration_is_not_derived_from_current_time(monkeypatch
assert "INTERVAL '12 hours'" in queries[0] assert "INTERVAL '12 hours'" in queries[0]
def test_reused_callids_only_conflict_while_the_call_is_active():
service = Path("app/modules/telefoni/backend/service.py").read_text(encoding="utf-8")
migration = Path("migrations/1047_telefoni_reused_callids.sql").read_text(encoding="utf-8")
assert "ON CONFLICT (callid) WHERE ended_at IS NULL" in service
assert "WHERE callid = %s AND ended_at IS NULL" in service
assert "DROP INDEX IF EXISTS ux_telefoni_opkald_callid" in migration
assert "ux_telefoni_opkald_active_callid" in migration
def test_callid_recovery_uses_system_log_without_overwriting_existing_current_calls():
migration = Path("migrations/1047_telefoni_reused_callids.sql").read_text(encoding="utf-8")
assert "system_event_log established" in migration
assert "system_log_recovery" in migration
assert "current_call.started_at BETWEEN" in migration
def test_case_click_to_call_contract_tracks_case_contact_and_time(): def test_case_click_to_call_contract_tracks_case_contact_and_time():
schema = Path("app/modules/telefoni/backend/schemas.py").read_text(encoding="utf-8") schema = Path("app/modules/telefoni/backend/schemas.py").read_text(encoding="utf-8")
router = Path("app/modules/telefoni/backend/router.py").read_text(encoding="utf-8") router = Path("app/modules/telefoni/backend/router.py").read_text(encoding="utf-8")