release: v2.8.18
This commit is contained in:
parent
06dffbc521
commit
20e6dc14b6
@ -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
|
||||||
@ -208,6 +222,11 @@ async def list_customers(
|
|||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
"""
|
"""
|
||||||
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:
|
||||||
@ -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 []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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');
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -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)
|
||||||
):
|
):
|
||||||
@ -251,9 +252,20 @@ def list_locations_view(
|
|||||||
if is_active_bool is not None:
|
if is_active_bool is not None:
|
||||||
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 []
|
||||||
|
|||||||
@ -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 }};
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
175
fremtidige planer/sag-opretter-husbrugere-og-eksterne.md
Normal file
175
fremtidige planer/sag-opretter-husbrugere-og-eksterne.md
Normal 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.
|
||||||
61
migrations/1047_telefoni_reused_callids.sql
Normal file
61
migrations/1047_telefoni_reused_callids.sql
Normal 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);
|
||||||
14
migrations/1048_location_access_files.sql
Normal file
14
migrations/1048_location_access_files.sql
Normal 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;
|
||||||
2
migrations/1049_location_access_file_comments.sql
Normal file
2
migrations/1049_location_access_file_comments.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE locations_access_files
|
||||||
|
ADD COLUMN IF NOT EXISTS comment TEXT;
|
||||||
14
migrations/1050_location_files.sql
Normal file
14
migrations/1050_location_files.sql
Normal 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;
|
||||||
35
migrations/1051_house_users_and_external_companies.sql
Normal file
35
migrations/1051_house_users_and_external_companies.sql
Normal 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;
|
||||||
24
migrations/1052_sag_status_constraint_compatibility.sql
Normal file
24
migrations/1052_sag_status_constraint_compatibility.sql
Normal 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);
|
||||||
@ -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")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user