feat: Add case types to settings if not found

feat: Update frontend navigation and links for support and CRM sections

fix: Modify subscription listing and stats endpoints to support 'all' status

feat: Implement subscription status filter in the subscriptions list view

feat: Redirect ticket routes to the new sag path

feat: Integrate devportal routes into the main application

feat: Create a wizard for location creation with nested floors and rooms

feat: Add product suppliers table to track multiple suppliers per product

feat: Implement product audit log to track changes in products

feat: Extend location types to include kantine and moedelokale

feat: Add last_2fa_at column to users table for 2FA grace period tracking
This commit is contained in:
Christian 2026-02-09 15:30:07 +01:00
parent 6320809f17
commit 693ac4cfd6
31 changed files with 2703 additions and 122 deletions

View File

@ -5,6 +5,7 @@ from fastapi import APIRouter, HTTPException, status, Request, Depends, Response
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
from app.core.auth_service import AuthService from app.core.auth_service import AuthService
from app.core.config import settings
from app.core.auth_dependencies import get_current_user from app.core.auth_dependencies import get_current_user
import logging import logging
@ -26,7 +27,7 @@ class LoginResponse(BaseModel):
class LogoutRequest(BaseModel): class LogoutRequest(BaseModel):
token_jti: str token_jti: Optional[str] = None
class TwoFactorCodeRequest(BaseModel): class TwoFactorCodeRequest(BaseModel):
@ -74,8 +75,8 @@ async def login(request: Request, credentials: LoginRequest, response: Response)
key="access_token", key="access_token",
value=access_token, value=access_token,
httponly=True, httponly=True,
samesite="lax", samesite=settings.COOKIE_SAMESITE,
secure=False secure=settings.COOKIE_SECURE
) )
return LoginResponse( return LoginResponse(
@ -86,18 +87,20 @@ async def login(request: Request, credentials: LoginRequest, response: Response)
@router.post("/logout") @router.post("/logout")
async def logout( async def logout(
request: LogoutRequest,
response: Response, response: Response,
current_user: dict = Depends(get_current_user) current_user: dict = Depends(get_current_user),
request: Optional[LogoutRequest] = None
): ):
""" """
Revoke JWT token (logout) Revoke JWT token (logout)
""" """
AuthService.revoke_token( token_jti = request.token_jti if request and request.token_jti else current_user.get("token_jti")
request.token_jti, if token_jti:
current_user['id'], AuthService.revoke_token(
current_user.get('is_shadow_admin', False) token_jti,
) current_user['id'],
current_user.get('is_shadow_admin', False)
)
response.delete_cookie("access_token") response.delete_cookie("access_token")

View File

@ -50,6 +50,7 @@ async def get_current_user(
username = payload.get("username") username = payload.get("username")
is_superadmin = payload.get("is_superadmin", False) is_superadmin = payload.get("is_superadmin", False)
is_shadow_admin = payload.get("shadow_admin", False) is_shadow_admin = payload.get("shadow_admin", False)
token_jti = payload.get("jti")
# Add IP address to user info # Add IP address to user info
ip_address = request.client.host if request.client else None ip_address = request.client.host if request.client else None
@ -64,6 +65,7 @@ async def get_current_user(
"is_shadow_admin": True, "is_shadow_admin": True,
"is_2fa_enabled": True, "is_2fa_enabled": True,
"ip_address": ip_address, "ip_address": ip_address,
"token_jti": token_jti,
"permissions": AuthService.get_all_permissions() "permissions": AuthService.get_all_permissions()
} }
@ -81,6 +83,7 @@ async def get_current_user(
"is_shadow_admin": False, "is_shadow_admin": False,
"is_2fa_enabled": user_details.get('is_2fa_enabled') if user_details else False, "is_2fa_enabled": user_details.get('is_2fa_enabled') if user_details else False,
"ip_address": ip_address, "ip_address": ip_address,
"token_jti": token_jti,
"permissions": AuthService.get_user_permissions(user_id) "permissions": AuthService.get_user_permissions(user_id)
} }

View File

@ -16,7 +16,7 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# JWT Settings # JWT Settings
SECRET_KEY = getattr(settings, 'JWT_SECRET_KEY', 'your-secret-key-change-in-production') SECRET_KEY = settings.JWT_SECRET_KEY
ALGORITHM = "HS256" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 8 # 8 timer ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 8 # 8 timer
@ -267,7 +267,7 @@ class AuthService:
user = execute_query_single( user = execute_query_single(
"""SELECT user_id, username, email, password_hash, full_name, """SELECT user_id, username, email, password_hash, full_name,
is_active, is_superadmin, failed_login_attempts, locked_until, is_active, is_superadmin, failed_login_attempts, locked_until,
is_2fa_enabled, totp_secret is_2fa_enabled, totp_secret, last_2fa_at
FROM users FROM users
WHERE username = %s OR email = %s""", WHERE username = %s OR email = %s""",
(username, username)) (username, username))
@ -322,17 +322,29 @@ class AuthService:
logger.warning(f"❌ Login failed: Invalid password - {username} (attempt {failed_attempts})") logger.warning(f"❌ Login failed: Invalid password - {username} (attempt {failed_attempts})")
return None, "Invalid username or password" return None, "Invalid username or password"
# 2FA check # 2FA check (only once per grace window)
if user.get('is_2fa_enabled'): if user.get('is_2fa_enabled'):
if not user.get('totp_secret'): if not user.get('totp_secret'):
return None, "2FA not configured" return None, "2FA not configured"
if not otp_code: last_2fa_at = user.get("last_2fa_at")
return None, "2FA code required" grace_hours = max(1, int(settings.TWO_FA_GRACE_HOURS))
grace_window = timedelta(hours=grace_hours)
now = datetime.utcnow()
within_grace = bool(last_2fa_at and (now - last_2fa_at) < grace_window)
if not AuthService.verify_totp_code(user['totp_secret'], otp_code): if not within_grace:
logger.warning(f"❌ Login failed: Invalid 2FA - {username}") if not otp_code:
return None, "Invalid 2FA code" return None, "2FA code required"
if not AuthService.verify_totp_code(user['totp_secret'], otp_code):
logger.warning(f"❌ Login failed: Invalid 2FA - {username}")
return None, "Invalid 2FA code"
execute_update(
"UPDATE users SET last_2fa_at = CURRENT_TIMESTAMP WHERE user_id = %s",
(user['user_id'],)
)
# Success! Reset failed attempts and update last login # Success! Reset failed attempts and update last login
execute_update( execute_update(

View File

@ -33,6 +33,9 @@ class Settings(BaseSettings):
# Security # Security
SECRET_KEY: str = "dev-secret-key-change-in-production" SECRET_KEY: str = "dev-secret-key-change-in-production"
JWT_SECRET_KEY: str = "dev-jwt-secret-key-change-in-production"
COOKIE_SECURE: bool = False
COOKIE_SAMESITE: str = "lax"
ALLOWED_ORIGINS: List[str] = ["http://localhost:8000", "http://localhost:3000"] ALLOWED_ORIGINS: List[str] = ["http://localhost:8000", "http://localhost:3000"]
CORS_ORIGINS: str = "http://localhost:8000,http://localhost:3000" CORS_ORIGINS: str = "http://localhost:8000,http://localhost:3000"
@ -43,6 +46,9 @@ class Settings(BaseSettings):
SHADOW_ADMIN_TOTP_SECRET: str = "" SHADOW_ADMIN_TOTP_SECRET: str = ""
SHADOW_ADMIN_EMAIL: str = "shadowadmin@bmcnetworks.dk" SHADOW_ADMIN_EMAIL: str = "shadowadmin@bmcnetworks.dk"
SHADOW_ADMIN_FULL_NAME: str = "Shadow Administrator" SHADOW_ADMIN_FULL_NAME: str = "Shadow Administrator"
# 2FA grace period (hours) before re-prompting
TWO_FA_GRACE_HOURS: int = 24
# Logging # Logging
LOG_LEVEL: str = "INFO" LOG_LEVEL: str = "INFO"

View File

@ -311,6 +311,11 @@
<i class="bi bi-table"></i>Abonnements Matrix <i class="bi bi-table"></i>Abonnements Matrix
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#locations">
<i class="bi bi-geo-alt"></i>Lokationer
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#hardware"> <a class="nav-link" data-bs-toggle="tab" href="#hardware">
<i class="bi bi-hdd"></i>Hardware <i class="bi bi-hdd"></i>Hardware
@ -459,10 +464,26 @@
<i class="bi bi-plus-lg me-2"></i>Tilføj Kontakt <i class="bi bi-plus-lg me-2"></i>Tilføj Kontakt
</button> </button>
</div> </div>
<div class="row g-4" id="contactsContainer"> <div class="table-responsive" id="contactsContainer">
<div class="col-12 text-center py-5"> <table class="table table-hover align-middle mb-0">
<div class="spinner-border text-primary"></div> <thead class="table-light">
</div> <tr>
<th>Navn</th>
<th>Titel</th>
<th>Email</th>
<th>Telefon</th>
<th>Mobil</th>
<th>Primær</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="6" class="text-center py-4">
<div class="spinner-border text-primary"></div>
</td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
@ -610,11 +631,71 @@
</div> </div>
</div> </div>
<!-- Locations Tab -->
<div class="tab-pane fade" id="locations">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h5 class="fw-bold mb-0">Lokationer</h5>
<small class="text-muted">Lokationer knyttet til kunden</small>
</div>
<div class="d-flex gap-2">
<a href="/app/locations/wizard" class="btn btn-sm btn-primary">
<i class="bi bi-plus-lg me-2"></i>Opret lokation
</a>
<a href="/app/locations" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-list-ul me-2"></i>Se alle
</a>
</div>
</div>
<div id="customerLocationsList" class="list-group mb-3"></div>
<div id="customerLocationsEmpty" class="text-center py-5 text-muted">
Ingen lokationer fundet for denne kunde
</div>
</div>
<!-- Hardware Tab --> <!-- Hardware Tab -->
<div class="tab-pane fade" id="hardware"> <div class="tab-pane fade" id="hardware">
<h5 class="fw-bold mb-4">Hardware</h5> <div class="d-flex justify-content-between align-items-center mb-4">
<div class="text-muted text-center py-5"> <div>
Hardwaremodul kommer snart... <h5 class="fw-bold mb-0">Hardware</h5>
<small class="text-muted">Hardware knyttet til kunden</small>
</div>
<div class="d-flex align-items-center gap-2">
<a class="btn btn-sm btn-primary" href="/hardware/new">
<i class="bi bi-plus-lg me-2"></i>Tilføj hardware
</a>
<label for="hardwareGroupBy" class="form-label mb-0 small text-muted">Gruppér efter</label>
<select class="form-select form-select-sm" id="hardwareGroupBy" style="min-width: 180px;">
<option value="location">Lokation</option>
<option value="type">Type</option>
<option value="model">Model/Version</option>
<option value="vendor">Leverandør</option>
</select>
</div>
</div>
<div class="table-responsive" id="customerHardwareContainer">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Hardware</th>
<th>Type</th>
<th>Serienr.</th>
<th>Lokation</th>
<th>Status</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="6" class="text-center py-4">
<div class="spinner-border text-primary"></div>
</td>
</tr>
</tbody>
</table>
</div>
<div id="customerHardwareEmpty" class="text-center py-5 text-muted d-none">
Ingen hardware fundet for denne kunde
</div> </div>
</div> </div>
@ -794,6 +875,118 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Nextcloud Create User Modal -->
<div class="modal fade" id="nextcloudCreateUserModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title"><i class="bi bi-person-plus me-2"></i>Opret Nextcloud bruger</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Søg eksisterende bruger</label>
<input type="text" class="form-control" id="ncCreateSearch" placeholder="Skriv for at søge">
</div>
<div class="mb-3">
<label class="form-label">Vælg bruger (til grupper)</label>
<select class="form-select" id="ncCreateUserSelect"></select>
<div class="form-text">Vælg en eksisterende bruger for at kopiere grupper.</div>
</div>
<div class="mb-3">
<label class="form-label">Brugernavn *</label>
<input type="text" class="form-control" id="ncCreateUid" placeholder="f.eks. fornavn.efternavn" required>
</div>
<div class="mb-3">
<label class="form-label">Navn (display)</label>
<input type="text" class="form-control" id="ncCreateDisplayName" placeholder="Visningsnavn">
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" class="form-control" id="ncCreateEmail" placeholder="mail@firma.dk">
</div>
<div class="mb-3">
<label class="form-label">Grupper</label>
<select class="form-select" id="ncCreateGroups" multiple size="6"></select>
<div class="form-text">Hold Cmd/Ctrl nede for at vælge flere.</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="createNextcloudUser()">Opret bruger</button>
</div>
</div>
</div>
</div>
<!-- Nextcloud Reset Password Modal -->
<div class="modal fade" id="nextcloudResetPasswordModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-secondary text-white">
<h5 class="modal-title"><i class="bi bi-key me-2"></i>Reset Nextcloud password</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Søg bruger</label>
<input type="text" class="form-control" id="ncResetSearch" placeholder="Skriv for at søge">
</div>
<div class="mb-3">
<label class="form-label">Vælg bruger</label>
<select class="form-select" id="ncResetUserSelect"></select>
</div>
<div class="mb-3">
<label class="form-label">Brugernavn *</label>
<input type="text" class="form-control" id="ncResetUid" placeholder="f.eks. fornavn.efternavn" required readonly>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="ncResetSendEmail" checked>
<label class="form-check-label" for="ncResetSendEmail">Send email med nyt password</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-outline-secondary" onclick="resetNextcloudPassword()">Reset</button>
</div>
</div>
</div>
</div>
<!-- Nextcloud Disable User Modal -->
<div class="modal fade" id="nextcloudDisableUserModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title"><i class="bi bi-person-x me-2"></i>Luk Nextcloud bruger</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Søg bruger</label>
<input type="text" class="form-control" id="ncDisableSearch" placeholder="Skriv for at søge">
</div>
<div class="mb-3">
<label class="form-label">Vælg bruger</label>
<select class="form-select" id="ncDisableUserSelect"></select>
</div>
<div class="mb-3">
<label class="form-label">Brugernavn *</label>
<input type="text" class="form-control" id="ncDisableUid" placeholder="f.eks. fornavn.efternavn" required readonly>
</div>
<div class="alert alert-warning mb-0">
<i class="bi bi-exclamation-triangle me-2"></i>
Brugeren bliver deaktiveret i Nextcloud.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-danger" onclick="disableNextcloudUser()">Luk bruger</button>
</div>
</div>
</div>
</div>
<!-- Subscription Modal --> <!-- Subscription Modal -->
<div class="modal fade" id="subscriptionModal" tabindex="-1"> <div class="modal fade" id="subscriptionModal" tabindex="-1">
@ -948,6 +1141,22 @@ document.addEventListener('DOMContentLoaded', () => {
loadCustomerPipeline(); loadCustomerPipeline();
}, { once: false }); }, { once: false });
} }
// Load locations when tab is shown
const locationsTab = document.querySelector('a[href="#locations"]');
if (locationsTab) {
locationsTab.addEventListener('shown.bs.tab', () => {
loadCustomerLocations();
}, { once: false });
}
// Load hardware when tab is shown
const hardwareTab = document.querySelector('a[href="#hardware"]');
if (hardwareTab) {
hardwareTab.addEventListener('shown.bs.tab', () => {
loadCustomerHardware();
}, { once: false });
}
// Load activity when tab is shown // Load activity when tab is shown
const activityTab = document.querySelector('a[href="#activity"]'); const activityTab = document.querySelector('a[href="#activity"]');
@ -1105,6 +1314,81 @@ async function loadCustomerTags() {
} }
} }
async function loadCustomerLocations() {
const list = document.getElementById('customerLocationsList');
const empty = document.getElementById('customerLocationsEmpty');
if (!list || !empty) return;
list.innerHTML = `
<div class="text-center py-4">
<div class="spinner-border text-primary"></div>
</div>
`;
empty.classList.add('d-none');
try {
const response = await fetch(`/api/v1/locations/by-customer/${customerId}`);
if (!response.ok) {
throw new Error('Kunne ikke hente lokationer');
}
const locations = await response.json();
if (!Array.isArray(locations) || locations.length === 0) {
list.innerHTML = '';
empty.classList.remove('d-none');
return;
}
const typeLabels = {
kompleks: 'Kompleks',
bygning: 'Bygning',
etage: 'Etage',
customer_site: 'Kundesite',
rum: 'Rum',
kantine: 'Kantine',
moedelokale: 'Mødelokale',
vehicle: 'Køretøj'
};
const typeColors = {
kompleks: '#0f4c75',
bygning: '#1abc9c',
etage: '#3498db',
customer_site: '#9b59b6',
rum: '#e67e22',
kantine: '#d35400',
moedelokale: '#16a085',
vehicle: '#8e44ad'
};
list.innerHTML = locations.map(loc => {
const typeLabel = typeLabels[loc.location_type] || loc.location_type || '-';
const typeColor = typeColors[loc.location_type] || '#6c757d';
const city = loc.address_city ? escapeHtml(loc.address_city) : '—';
const parent = loc.parent_location_name ? ` · ${escapeHtml(loc.parent_location_name)}` : '';
return `
<a href="/app/locations/${loc.id}" class="list-group-item list-group-item-action">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">${escapeHtml(loc.name)}${parent}</div>
<div class="text-muted small">${city}</div>
</div>
<span class="badge" style="background-color: ${typeColor}; color: white;">
${typeLabel}
</span>
</div>
</a>
`;
}).join('');
} catch (error) {
console.error('Failed to load locations:', error);
list.innerHTML = '';
empty.classList.remove('d-none');
}
}
function renderCustomerTags(tags) { function renderCustomerTags(tags) {
const container = document.getElementById('customerTagsContainer'); const container = document.getElementById('customerTagsContainer');
const emptyState = document.getElementById('customerTagsEmpty'); const emptyState = document.getElementById('customerTagsEmpty');
@ -1342,16 +1626,360 @@ function formatBytes(value) {
return `${size.toFixed(1)} ${units[unitIndex]}`; return `${size.toFixed(1)} ${units[unitIndex]}`;
} }
function openNextcloudCreateUser() { let nextcloudInstanceCache = null;
alert('Opret bruger: kommer snart');
async function resolveNextcloudInstance() {
if (nextcloudInstanceCache?.id) return nextcloudInstanceCache;
const response = await fetch(`/api/v1/nextcloud/customers/${customerId}/instance`);
if (!response.ok) return null;
const instance = await response.json();
if (!instance?.id) return null;
nextcloudInstanceCache = instance;
return instance;
}
async function loadNextcloudGroups() {
const select = document.getElementById('ncCreateGroups');
if (!select) return;
select.innerHTML = '<option value="">Henter grupper...</option>';
const instance = await resolveNextcloudInstance();
if (!instance) {
select.innerHTML = '<option value="">Ingen Nextcloud instance</option>';
return;
}
try {
const response = await fetch(`/api/v1/nextcloud/instances/${instance.id}/groups?customer_id=${customerId}`);
if (!response.ok) {
select.innerHTML = '<option value="">Kunne ikke hente grupper</option>';
return;
}
const payload = await response.json();
const groups = payload?.payload?.ocs?.data?.groups || payload?.groups || [];
if (!Array.isArray(groups) || groups.length === 0) {
select.innerHTML = '<option value="">Ingen grupper fundet</option>';
return;
}
select.innerHTML = groups.map(group => `<option value="${escapeHtml(group)}">${escapeHtml(group)}</option>`).join('');
} catch (error) {
console.error('Failed to load Nextcloud groups:', error);
select.innerHTML = '<option value="">Kunne ikke hente grupper</option>';
}
}
async function loadNextcloudUsers(selectId, inputId, searchValue = '', requireSearch = false) {
const select = document.getElementById(selectId);
if (!select) return;
if (requireSearch && !searchValue.trim()) {
select.innerHTML = '<option value="">Skriv for at søge</option>';
return;
}
select.innerHTML = '<option value="">Henter brugere...</option>';
const instance = await resolveNextcloudInstance();
if (!instance) {
select.innerHTML = '<option value="">Ingen Nextcloud instance</option>';
return;
}
try {
const search = encodeURIComponent(searchValue.trim());
const url = new URL(`/api/v1/nextcloud/instances/${instance.id}/users`, window.location.origin);
url.searchParams.set('customer_id', customerId);
url.searchParams.set('include_details', 'true');
url.searchParams.set('limit', '200');
if (search) url.searchParams.set('search', search);
const response = await fetch(url.toString());
if (!response.ok) {
select.innerHTML = '<option value="">Kunne ikke hente brugere</option>';
return;
}
const payload = await response.json();
const users = payload?.users || [];
if (!Array.isArray(users) || users.length === 0) {
select.innerHTML = '<option value="">Ingen brugere fundet</option>';
return;
}
const options = users
.map(user => {
const uid = escapeHtml(user.uid || '');
const display = escapeHtml(user.display_name || '-');
const email = escapeHtml(user.email || '-');
const label = `${uid} - ${display} (${email})`;
return `<option value="${uid}">${label}</option>`;
})
.join('');
select.innerHTML = options;
if (inputId) {
const input = document.getElementById(inputId);
if (input) {
input.value = select.value || '';
}
if (!select.dataset.bound) {
select.addEventListener('change', () => {
const target = document.getElementById(inputId);
if (target) target.value = select.value;
});
select.dataset.bound = 'true';
}
}
} catch (error) {
console.error('Failed to load Nextcloud users:', error);
select.innerHTML = '<option value="">Kunne ikke hente brugere</option>';
}
}
async function applyNextcloudUserGroups(uid) {
if (!uid) return;
const instance = await resolveNextcloudInstance();
if (!instance) return;
try {
const response = await fetch(`/api/v1/nextcloud/instances/${instance.id}/users/${encodeURIComponent(uid)}?customer_id=${customerId}`);
if (!response.ok) return;
const payload = await response.json();
const data = payload?.payload?.ocs?.data || {};
const groups = Array.isArray(data.groups) ? data.groups : [];
const groupSelect = document.getElementById('ncCreateGroups');
if (!groupSelect) return;
Array.from(groupSelect.options).forEach(option => {
option.selected = groups.includes(option.value);
});
const displayInput = document.getElementById('ncCreateDisplayName');
const emailInput = document.getElementById('ncCreateEmail');
if (displayInput && !displayInput.value && data.displayname) {
displayInput.value = data.displayname;
}
if (emailInput && !emailInput.value && data.email) {
emailInput.value = data.email;
}
} catch (error) {
console.error('Failed to apply Nextcloud groups:', error);
}
}
async function openNextcloudCreateUser() {
document.getElementById('ncCreateUid').value = '';
document.getElementById('ncCreateDisplayName').value = '';
document.getElementById('ncCreateEmail').value = '';
const searchInput = document.getElementById('ncCreateSearch');
const userSelect = document.getElementById('ncCreateUserSelect');
if (searchInput) {
searchInput.value = '';
if (!searchInput.dataset.bound) {
let createTimer = null;
searchInput.addEventListener('input', () => {
clearTimeout(createTimer);
createTimer = setTimeout(() => {
loadNextcloudUsers('ncCreateUserSelect', null, searchInput.value, false);
}, 300);
});
searchInput.dataset.bound = 'true';
}
}
if (userSelect && !userSelect.dataset.bound) {
userSelect.addEventListener('change', () => {
const uid = userSelect.value;
if (uid) applyNextcloudUserGroups(uid);
});
userSelect.dataset.bound = 'true';
}
await loadNextcloudGroups();
await loadNextcloudUsers('ncCreateUserSelect', null, searchInput ? searchInput.value : '', false);
const modal = new bootstrap.Modal(document.getElementById('nextcloudCreateUserModal'));
modal.show();
} }
function openNextcloudResetPassword() { function openNextcloudResetPassword() {
alert('Reset password: kommer snart'); document.getElementById('ncResetUid').value = '';
document.getElementById('ncResetSendEmail').checked = true;
const searchInput = document.getElementById('ncResetSearch');
if (searchInput) {
searchInput.value = '';
if (!searchInput.dataset.bound) {
let resetTimer = null;
searchInput.addEventListener('input', () => {
clearTimeout(resetTimer);
resetTimer = setTimeout(() => {
loadNextcloudUsers('ncResetUserSelect', 'ncResetUid', searchInput.value, true);
}, 300);
});
searchInput.dataset.bound = 'true';
}
}
loadNextcloudUsers('ncResetUserSelect', 'ncResetUid', searchInput ? searchInput.value : '', true);
const modal = new bootstrap.Modal(document.getElementById('nextcloudResetPasswordModal'));
modal.show();
} }
function openNextcloudDisableUser() { function openNextcloudDisableUser() {
alert('Luk bruger: kommer snart'); document.getElementById('ncDisableUid').value = '';
const searchInput = document.getElementById('ncDisableSearch');
if (searchInput) {
searchInput.value = '';
if (!searchInput.dataset.bound) {
let disableTimer = null;
searchInput.addEventListener('input', () => {
clearTimeout(disableTimer);
disableTimer = setTimeout(() => {
loadNextcloudUsers('ncDisableUserSelect', 'ncDisableUid', searchInput.value, true);
}, 300);
});
searchInput.dataset.bound = 'true';
}
}
loadNextcloudUsers('ncDisableUserSelect', 'ncDisableUid', searchInput ? searchInput.value : '', true);
const modal = new bootstrap.Modal(document.getElementById('nextcloudDisableUserModal'));
modal.show();
}
async function createNextcloudUser() {
const uid = document.getElementById('ncCreateUid').value.trim();
if (!uid) {
alert('Brugernavn er påkrævet.');
return;
}
const instance = await resolveNextcloudInstance();
if (!instance) {
alert('Ingen Nextcloud instance konfigureret for denne kunde.');
return;
}
const groupsSelect = document.getElementById('ncCreateGroups');
const groups = Array.from(groupsSelect.selectedOptions || []).map(opt => opt.value).filter(Boolean);
const payload = {
uid,
display_name: document.getElementById('ncCreateDisplayName').value.trim() || null,
email: document.getElementById('ncCreateEmail').value.trim() || null,
groups,
send_welcome: true
};
try {
const response = await fetch(`/api/v1/nextcloud/instances/${instance.id}/users?customer_id=${customerId}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}
);
const result = await response.json();
if (!response.ok) {
alert(result.detail || 'Kunne ikke oprette bruger.');
return;
}
if (result.generated_password) {
alert(`Bruger oprettet. Genereret password: ${result.generated_password}`);
} else {
alert('Bruger oprettet.');
}
bootstrap.Modal.getInstance(document.getElementById('nextcloudCreateUserModal')).hide();
} catch (error) {
console.error('Failed to create Nextcloud user:', error);
alert('Kunne ikke oprette bruger.');
}
}
async function resetNextcloudPassword() {
const uid = document.getElementById('ncResetUid').value.trim();
if (!uid) {
alert('Brugernavn er påkrævet.');
return;
}
const instance = await resolveNextcloudInstance();
if (!instance) {
alert('Ingen Nextcloud instance konfigureret for denne kunde.');
return;
}
const payload = {
send_email: document.getElementById('ncResetSendEmail').checked
};
try {
const response = await fetch(`/api/v1/nextcloud/instances/${instance.id}/users/${encodeURIComponent(uid)}/reset-password?customer_id=${customerId}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}
);
const result = await response.json();
if (!response.ok) {
alert(result.detail || 'Kunne ikke reset password.');
return;
}
if (result.generated_password) {
alert(`Password nulstillet. Genereret password: ${result.generated_password}`);
} else {
alert('Password nulstillet.');
}
bootstrap.Modal.getInstance(document.getElementById('nextcloudResetPasswordModal')).hide();
} catch (error) {
console.error('Failed to reset Nextcloud password:', error);
alert('Kunne ikke reset password.');
}
}
async function disableNextcloudUser() {
const uid = document.getElementById('ncDisableUid').value.trim();
if (!uid) {
alert('Brugernavn er påkrævet.');
return;
}
if (!confirm(`Er du sikker på, at du vil deaktivere brugeren ${uid}?`)) {
return;
}
const instance = await resolveNextcloudInstance();
if (!instance) {
alert('Ingen Nextcloud instance konfigureret for denne kunde.');
return;
}
try {
const response = await fetch(`/api/v1/nextcloud/instances/${instance.id}/users/${encodeURIComponent(uid)}/disable?customer_id=${customerId}`,
{ method: 'POST' }
);
const result = await response.json();
if (!response.ok) {
alert(result.detail || 'Kunne ikke deaktivere bruger.');
return;
}
alert('Bruger deaktiveret.');
bootstrap.Modal.getInstance(document.getElementById('nextcloudDisableUserModal')).hide();
} catch (error) {
console.error('Failed to disable Nextcloud user:', error);
alert('Kunne ikke deaktivere bruger.');
}
} }
async function loadUtilityCompany() { async function loadUtilityCompany() {
@ -1428,53 +2056,76 @@ function displayUtilityCompany(payload) {
async function loadContacts() { async function loadContacts() {
const container = document.getElementById('contactsContainer'); const container = document.getElementById('contactsContainer');
container.innerHTML = '<div class="col-12 text-center py-5"><div class="spinner-border text-primary"></div></div>'; container.innerHTML = `
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Navn</th>
<th>Titel</th>
<th>Email</th>
<th>Telefon</th>
<th>Mobil</th>
<th>Primær</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="6" class="text-center py-4">
<div class="spinner-border text-primary"></div>
</td>
</tr>
</tbody>
</table>
`;
try { try {
const response = await fetch(`/api/v1/customers/${customerId}/contacts`); const response = await fetch(`/api/v1/customers/${customerId}/contacts`);
const contacts = await response.json(); const contacts = await response.json();
if (!contacts || contacts.length === 0) { if (!contacts || contacts.length === 0) {
container.innerHTML = '<div class="col-12 text-center py-5 text-muted">Ingen kontakter endnu</div>'; container.innerHTML = '<div class="text-center py-5 text-muted">Ingen kontakter endnu</div>';
return; return;
} }
container.innerHTML = contacts.map(contact => ` const rows = contacts.map(contact => {
<div class="col-md-6"> const email = contact.email ? `<a href="mailto:${contact.email}">${escapeHtml(contact.email)}</a>` : '—';
<div class="contact-card"> const phone = contact.phone ? `<a href="tel:${contact.phone}">${escapeHtml(contact.phone)}</a>` : '—';
<div class="d-flex justify-content-between align-items-start mb-3"> const mobile = contact.mobile ? `<a href="tel:${contact.mobile}">${escapeHtml(contact.mobile)}</a>` : '—';
<div> const title = contact.title ? escapeHtml(contact.title) : '—';
<h6 class="fw-bold mb-1">${escapeHtml(contact.name)}</h6> const primaryBadge = contact.is_primary ? '<span class="badge bg-primary">Primær</span>' : '—';
<div class="text-muted small">${contact.title || 'Kontakt'}</div>
</div> return `
${contact.is_primary ? '<span class="badge bg-primary">Primær</span>' : ''} <tr>
</div> <td class="fw-semibold">${escapeHtml(contact.name || '-') }</td>
<div class="d-flex flex-column gap-2"> <td>${title}</td>
${contact.email ? ` <td>${email}</td>
<div class="d-flex align-items-center"> <td>${phone}</td>
<i class="bi bi-envelope me-2 text-muted"></i> <td>${mobile}</td>
<a href="mailto:${contact.email}">${contact.email}</a> <td>${primaryBadge}</td>
</div> </tr>
` : ''} `;
${contact.phone ? ` }).join('');
<div class="d-flex align-items-center">
<i class="bi bi-telephone me-2 text-muted"></i> container.innerHTML = `
<a href="tel:${contact.phone}">${contact.phone}</a> <table class="table table-hover align-middle mb-0">
</div> <thead class="table-light">
` : ''} <tr>
${contact.mobile ? ` <th>Navn</th>
<div class="d-flex align-items-center"> <th>Titel</th>
<i class="bi bi-phone me-2 text-muted"></i> <th>Email</th>
<a href="tel:${contact.mobile}">${contact.mobile}</a> <th>Telefon</th>
</div> <th>Mobil</th>
` : ''} <th>Primær</th>
</div> </tr>
</div> </thead>
</div> <tbody>
`).join(''); ${rows}
</tbody>
</table>
`;
} catch (error) { } catch (error) {
console.error('Failed to load contacts:', error); console.error('Failed to load contacts:', error);
container.innerHTML = '<div class="col-12 text-center py-5 text-danger">Kunne ikke indlæse kontakter</div>'; container.innerHTML = '<div class="text-center py-5 text-danger">Kunne ikke indlæse kontakter</div>';
} }
} }
@ -1537,6 +2188,180 @@ async function loadCustomerPipeline() {
renderCustomerPipeline(opportunities); renderCustomerPipeline(opportunities);
} }
let customerHardware = [];
let hardwareLocationsById = {};
function getHardwareGroupLabel(item, groupBy) {
if (groupBy === 'location') {
return item.location_name || 'Ukendt lokation';
}
if (groupBy === 'type') {
return item.asset_type ? item.asset_type.replace('_', ' ') : 'Ukendt type';
}
if (groupBy === 'model') {
const brand = item.brand || '';
const model = item.model || '';
const label = `${brand} ${model}`.trim();
return label || 'Ukendt model';
}
if (groupBy === 'vendor') {
return item.brand || 'Ukendt leverandør';
}
return 'Ukendt';
}
function renderHardwareTable(groupBy) {
const container = document.getElementById('customerHardwareContainer');
const empty = document.getElementById('customerHardwareEmpty');
if (!container || !empty) return;
if (!customerHardware.length) {
container.classList.add('d-none');
empty.classList.remove('d-none');
return;
}
container.classList.remove('d-none');
empty.classList.add('d-none');
const grouped = {};
customerHardware.forEach(item => {
const label = getHardwareGroupLabel(item, groupBy);
if (!grouped[label]) {
grouped[label] = [];
}
grouped[label].push(item);
});
const sortedGroups = Object.keys(grouped).sort((a, b) => a.localeCompare(b, 'da-DK'));
const rows = sortedGroups.map(group => {
const items = grouped[group];
const groupRow = `
<tr class="table-secondary">
<td colspan="6" class="fw-semibold">${escapeHtml(group)} (${items.length})</td>
</tr>
`;
const itemRows = items.map(item => {
const assetLabel = `${item.brand || ''} ${item.model || ''}`.trim() || 'Ukendt hardware';
const serial = item.serial_number || '—';
const location = item.location_name || '—';
const status = item.status || '—';
return `
<tr>
<td class="fw-semibold">${escapeHtml(assetLabel)}</td>
<td>${escapeHtml(item.asset_type || '—')}</td>
<td>${escapeHtml(serial)}</td>
<td>${escapeHtml(location)}</td>
<td>${escapeHtml(status)}</td>
<td class="text-end">
<a class="btn btn-sm btn-outline-primary" href="/hardware/${item.id}">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
`;
}).join('');
return groupRow + itemRows;
}).join('');
container.innerHTML = `
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Hardware</th>
<th>Type</th>
<th>Serienr.</th>
<th>Lokation</th>
<th>Status</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
`;
}
async function loadCustomerHardware() {
const container = document.getElementById('customerHardwareContainer');
const empty = document.getElementById('customerHardwareEmpty');
const groupBySelect = document.getElementById('hardwareGroupBy');
if (!container || !empty || !groupBySelect) return;
container.classList.remove('d-none');
empty.classList.add('d-none');
container.innerHTML = `
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Hardware</th>
<th>Type</th>
<th>Serienr.</th>
<th>Lokation</th>
<th>Status</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="6" class="text-center py-4">
<div class="spinner-border text-primary"></div>
</td>
</tr>
</tbody>
</table>
`;
try {
const response = await fetch(`/api/v1/hardware?customer_id=${customerId}`);
if (!response.ok) {
throw new Error('Kunne ikke hente hardware');
}
const hardware = await response.json();
customerHardware = Array.isArray(hardware) ? hardware : [];
const locationIds = customerHardware
.map(item => item.current_location_id)
.filter(id => id !== null && id !== undefined);
hardwareLocationsById = {};
if (locationIds.length > 0) {
const uniqueIds = Array.from(new Set(locationIds));
const locationsResponse = await fetch(`/api/v1/locations/by-ids?ids=${uniqueIds.join(',')}`);
if (locationsResponse.ok) {
const locations = await locationsResponse.json();
(locations || []).forEach(loc => {
hardwareLocationsById[loc.id] = loc.name;
});
}
}
customerHardware = customerHardware.map(item => ({
...item,
location_name: hardwareLocationsById[item.current_location_id] || item.location_name || null
}));
renderHardwareTable(groupBySelect.value || 'location');
} catch (error) {
console.error('Failed to load hardware:', error);
container.classList.add('d-none');
empty.classList.remove('d-none');
}
}
document.addEventListener('change', (event) => {
if (event.target && event.target.id === 'hardwareGroupBy') {
renderHardwareTable(event.target.value);
}
});
function renderCustomerPipeline(opportunities) { function renderCustomerPipeline(opportunities) {
const tbody = document.getElementById('customerOpportunitiesTable'); const tbody = document.getElementById('customerOpportunitiesTable');
if (!opportunities || opportunities.length === 0) { if (!opportunities || opportunities.length === 0) {

View File

@ -377,7 +377,7 @@
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<!-- Link to create new location, pre-filled? Or just general create --> <!-- Link to create new location, pre-filled? Or just general create -->
<a href="/locations" class="text-decoration-none"> <a href="/app/locations" class="text-decoration-none">
<div class="action-card"> <div class="action-card">
<i class="bi bi-building-add"></i> <i class="bi bi-building-add"></i>
<div>Ny Lokation</div> <div>Ny Lokation</div>
@ -461,7 +461,7 @@
{% if cases and cases|length > 0 %} {% if cases and cases|length > 0 %}
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">
{% for case in cases %} {% for case in cases %}
<a href="/cases/{{ case.case_id }}" class="list-group-item list-group-item-action py-3 px-2"> <a href="/sag/{{ case.case_id }}" class="list-group-item list-group-item-action py-3 px-2">
<div class="d-flex w-100 justify-content-between"> <div class="d-flex w-100 justify-content-between">
<h6 class="mb-1 text-primary">{{ case.titel }}</h6> <h6 class="mb-1 text-primary">{{ case.titel }}</h6>
<small>{{ case.created_at }}</small> <small>{{ case.created_at }}</small>

View File

@ -38,7 +38,8 @@ from app.modules.locations.models.schemas import (
OperatingHours, OperatingHoursCreate, OperatingHoursUpdate, OperatingHours, OperatingHoursCreate, OperatingHoursUpdate,
Service, ServiceCreate, ServiceUpdate, Service, ServiceCreate, ServiceUpdate,
Capacity, CapacityCreate, CapacityUpdate, Capacity, CapacityCreate, CapacityUpdate,
BulkUpdateRequest, BulkDeleteRequest, LocationStats BulkUpdateRequest, BulkDeleteRequest, LocationStats,
LocationWizardCreateRequest, LocationWizardCreateResponse
) )
router = APIRouter() router = APIRouter()
@ -71,7 +72,7 @@ def _normalize_form_data(form_data: Any) -> dict:
@router.get("/locations", response_model=List[Location]) @router.get("/locations", response_model=List[Location])
async def list_locations( async def list_locations(
location_type: Optional[str] = Query(None, description="Filter by location type (kompleks, bygning, etage, customer_site, rum, vehicle)"), location_type: Optional[str] = Query(None, description="Filter by location type (kompleks, bygning, etage, customer_site, rum, kantine, moedelokale, vehicle)"),
is_active: Optional[bool] = Query(None, description="Filter by active status"), is_active: Optional[bool] = Query(None, description="Filter by active status"),
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=1000) limit: int = Query(50, ge=1, le=1000)
@ -80,7 +81,7 @@ async def list_locations(
List all locations with optional filters and pagination. List all locations with optional filters and pagination.
Query Parameters: Query Parameters:
- location_type: Filter by type (kompleks, bygning, etage, customer_site, rum, vehicle) - location_type: Filter by type (kompleks, bygning, etage, customer_site, rum, kantine, moedelokale, vehicle)
- is_active: Filter by active status (true/false) - is_active: Filter by active status (true/false)
- skip: Pagination offset (default 0) - skip: Pagination offset (default 0)
- limit: Results per page (default 50, max 1000) - limit: Results per page (default 50, max 1000)
@ -255,6 +256,306 @@ async def create_location(request: Request):
) )
# ============================================================================
# 11b. GET /api/v1/locations/by-customer/{customer_id} - Filter by customer
# ============================================================================
@router.get("/locations/by-customer/{customer_id}", response_model=List[Location])
async def get_locations_by_customer(customer_id: int):
"""
Get all locations linked to a customer.
Path parameter: customer_id
Returns: List of Location objects ordered by name
"""
try:
query = """
SELECT l.*, p.name AS parent_location_name, c.name AS customer_name
FROM locations_locations l
LEFT JOIN locations_locations p ON l.parent_location_id = p.id
LEFT JOIN customers c ON l.customer_id = c.id
WHERE l.customer_id = %s AND l.deleted_at IS NULL
ORDER BY l.name ASC
"""
results = execute_query(query, (customer_id,))
return [Location(**row) for row in results]
except Exception as e:
logger.error(f"❌ Error getting customer locations: {str(e)}")
raise HTTPException(
status_code=500,
detail="Failed to get locations by customer"
)
# ============================================================================
# 11c. GET /api/v1/locations/by-ids - Fetch by IDs
# ============================================================================
@router.get("/locations/by-ids", response_model=List[Location])
async def get_locations_by_ids(ids: str = Query(..., description="Comma-separated location IDs")):
"""
Get locations by a comma-separated list of IDs.
"""
try:
id_values = [int(value) for value in ids.split(',') if value.strip().isdigit()]
if not id_values:
return []
placeholders = ",".join(["%s"] * len(id_values))
query = f"""
SELECT l.*, p.name AS parent_location_name, c.name AS customer_name
FROM locations_locations l
LEFT JOIN locations_locations p ON l.parent_location_id = p.id
LEFT JOIN customers c ON l.customer_id = c.id
WHERE l.id IN ({placeholders}) AND l.deleted_at IS NULL
ORDER BY l.name ASC
"""
results = execute_query(query, tuple(id_values))
return [Location(**row) for row in results]
except Exception as e:
logger.error(f"❌ Error getting locations by ids: {str(e)}")
raise HTTPException(
status_code=500,
detail="Failed to get locations by ids"
)
# =========================================================================
# 2b. POST /api/v1/locations/bulk-create - Create location with floors/rooms
# =========================================================================
@router.post("/locations/bulk-create", response_model=LocationWizardCreateResponse)
async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
"""
Create a root location with floors and rooms in a single request.
Request body: LocationWizardCreateRequest
Returns: IDs of created locations
"""
try:
root = data.root
auto_suffix = data.auto_suffix
payload_names = [root.name]
for floor in data.floors:
payload_names.append(floor.name)
for room in floor.rooms:
payload_names.append(room.name)
normalized_names = [name.strip().lower() for name in payload_names if name]
if not auto_suffix and len(normalized_names) != len(set(normalized_names)):
raise HTTPException(
status_code=400,
detail="Duplicate names found in wizard payload"
)
if not auto_suffix:
placeholders = ",".join(["%s"] * len(payload_names))
existing_query = f"""
SELECT name FROM locations_locations
WHERE name IN ({placeholders}) AND deleted_at IS NULL
"""
existing = execute_query(existing_query, tuple(payload_names))
if existing:
existing_names = ", ".join(sorted({row.get("name") for row in existing if row.get("name")}))
raise HTTPException(
status_code=400,
detail=f"Locations already exist with names: {existing_names}"
)
if root.customer_id is not None:
customer_query = "SELECT id FROM customers WHERE id = %s AND deleted_at IS NULL"
customer = execute_query(customer_query, (root.customer_id,))
if not customer:
raise HTTPException(status_code=400, detail="customer_id does not exist")
if root.parent_location_id is not None:
parent_query = "SELECT id FROM locations_locations WHERE id = %s AND deleted_at IS NULL"
parent = execute_query(parent_query, (root.parent_location_id,))
if not parent:
raise HTTPException(status_code=400, detail="parent_location_id does not exist")
insert_query = """
INSERT INTO locations_locations (
name, location_type, parent_location_id, customer_id, address_street, address_city,
address_postal_code, address_country, latitude, longitude,
phone, email, notes, is_active, created_at, updated_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
RETURNING *
"""
reserved_names = set()
def _normalize_name(value: str) -> str:
return (value or "").strip().lower()
def _name_exists(value: str) -> bool:
normalized = _normalize_name(value)
if normalized in reserved_names:
return True
check_query = "SELECT 1 FROM locations_locations WHERE name = %s AND deleted_at IS NULL"
existing = execute_query(check_query, (value,))
return bool(existing)
def _reserve_name(value: str) -> None:
normalized = _normalize_name(value)
if normalized:
reserved_names.add(normalized)
def _resolve_unique_name(base_name: str) -> str:
if not auto_suffix:
_reserve_name(base_name)
return base_name
base_name = base_name.strip()
if not _name_exists(base_name):
_reserve_name(base_name)
return base_name
suffix = 2
while True:
candidate = f"{base_name} ({suffix})"
if not _name_exists(candidate):
_reserve_name(candidate)
return candidate
suffix += 1
def insert_location_record(
name: str,
location_type: str,
parent_location_id: Optional[int],
customer_id: Optional[int],
address_street: Optional[str],
address_city: Optional[str],
address_postal_code: Optional[str],
address_country: Optional[str],
latitude: Optional[float],
longitude: Optional[float],
phone: Optional[str],
email: Optional[str],
notes: Optional[str],
is_active: bool
) -> Location:
params = (
name,
location_type,
parent_location_id,
customer_id,
address_street,
address_city,
address_postal_code,
address_country,
latitude,
longitude,
phone,
email,
notes,
is_active
)
result = execute_query(insert_query, params)
if not result:
raise HTTPException(status_code=500, detail="Failed to create location")
return Location(**result[0])
resolved_root_name = _resolve_unique_name(root.name)
root_location = insert_location_record(
name=resolved_root_name,
location_type=root.location_type,
parent_location_id=root.parent_location_id,
customer_id=root.customer_id,
address_street=root.address_street,
address_city=root.address_city,
address_postal_code=root.address_postal_code,
address_country=root.address_country,
latitude=root.latitude,
longitude=root.longitude,
phone=root.phone,
email=root.email,
notes=root.notes,
is_active=root.is_active
)
audit_query = """
INSERT INTO locations_audit_log (location_id, event_type, user_id, changes, created_at)
VALUES (%s, %s, %s, %s, NOW())
"""
root_changes = root.model_dump()
root_changes["name"] = resolved_root_name
execute_query(audit_query, (root_location.id, 'created', None, json.dumps({"after": root_changes})))
floor_ids: List[int] = []
room_ids: List[int] = []
for floor in data.floors:
resolved_floor_name = _resolve_unique_name(floor.name)
floor_location = insert_location_record(
name=resolved_floor_name,
location_type=floor.location_type,
parent_location_id=root_location.id,
customer_id=root.customer_id,
address_street=root.address_street,
address_city=root.address_city,
address_postal_code=root.address_postal_code,
address_country=root.address_country,
latitude=root.latitude,
longitude=root.longitude,
phone=root.phone,
email=root.email,
notes=None,
is_active=floor.is_active
)
floor_ids.append(floor_location.id)
execute_query(audit_query, (
floor_location.id,
'created',
None,
json.dumps({"after": {"name": resolved_floor_name, "location_type": floor.location_type, "parent_location_id": root_location.id}})
))
for room in floor.rooms:
resolved_room_name = _resolve_unique_name(room.name)
room_location = insert_location_record(
name=resolved_room_name,
location_type=room.location_type,
parent_location_id=floor_location.id,
customer_id=root.customer_id,
address_street=root.address_street,
address_city=root.address_city,
address_postal_code=root.address_postal_code,
address_country=root.address_country,
latitude=root.latitude,
longitude=root.longitude,
phone=root.phone,
email=root.email,
notes=None,
is_active=room.is_active
)
room_ids.append(room_location.id)
execute_query(audit_query, (
room_location.id,
'created',
None,
json.dumps({"after": {"name": resolved_room_name, "location_type": room.location_type, "parent_location_id": floor_location.id}})
))
created_total = 1 + len(floor_ids) + len(room_ids)
logger.info("✅ Wizard created %s locations (root=%s)", created_total, root_location.id)
return LocationWizardCreateResponse(
root_id=root_location.id,
floor_ids=floor_ids,
room_ids=room_ids,
created_total=created_total
)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error creating location hierarchy: {str(e)}")
raise HTTPException(status_code=500, detail="Failed to create location hierarchy")
# ============================================================================ # ============================================================================
# 3. GET /api/v1/locations/{id} - Get single location with all relationships # 3. GET /api/v1/locations/{id} - Get single location with all relationships
# ============================================================================ # ============================================================================
@ -467,7 +768,7 @@ async def update_location(id: int, data: LocationUpdate):
detail="customer_id does not exist" detail="customer_id does not exist"
) )
if key == 'location_type': if key == 'location_type':
allowed_types = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'vehicle'] allowed_types = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'kantine', 'moedelokale', 'vehicle']
if value not in allowed_types: if value not in allowed_types:
logger.warning(f"⚠️ Invalid location_type: {value}") logger.warning(f"⚠️ Invalid location_type: {value}")
raise HTTPException( raise HTTPException(
@ -2587,7 +2888,7 @@ async def bulk_update_locations(data: BulkUpdateRequest):
# Validate location_type if provided # Validate location_type if provided
if 'location_type' in data.updates: if 'location_type' in data.updates:
allowed_types = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'vehicle'] allowed_types = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'kantine', 'moedelokale', 'vehicle']
if data.updates['location_type'] not in allowed_types: if data.updates['location_type'] not in allowed_types:
logger.warning(f"⚠️ Invalid location_type: {data.updates['location_type']}") logger.warning(f"⚠️ Invalid location_type: {data.updates['location_type']}")
raise HTTPException( raise HTTPException(
@ -2804,7 +3105,7 @@ async def get_locations_by_type(
""" """
Get all locations of a specific type with pagination. Get all locations of a specific type with pagination.
Path parameter: location_type - one of (kompleks, bygning, etage, customer_site, rum, vehicle) Path parameter: location_type - one of (kompleks, bygning, etage, customer_site, rum, kantine, moedelokale, vehicle)
Query parameters: skip, limit for pagination Query parameters: skip, limit for pagination
Returns: Paginated list of Location objects ordered by name Returns: Paginated list of Location objects ordered by name
@ -2815,7 +3116,7 @@ async def get_locations_by_type(
""" """
try: try:
# Validate location_type is one of allowed values # Validate location_type is one of allowed values
allowed_types = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'vehicle'] allowed_types = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'kantine', 'moedelokale', 'vehicle']
if location_type not in allowed_types: if location_type not in allowed_types:
logger.warning(f"⚠️ Invalid location_type: {location_type}") logger.warning(f"⚠️ Invalid location_type: {location_type}")
raise HTTPException( raise HTTPException(

View File

@ -52,6 +52,8 @@ LOCATION_TYPES = [
{"value": "etage", "label": "Etage"}, {"value": "etage", "label": "Etage"},
{"value": "customer_site", "label": "Kundesite"}, {"value": "customer_site", "label": "Kundesite"},
{"value": "rum", "label": "Rum"}, {"value": "rum", "label": "Rum"},
{"value": "kantine", "label": "Kantine"},
{"value": "moedelokale", "label": "Mødelokale"},
{"value": "vehicle", "label": "Køretøj"}, {"value": "vehicle", "label": "Køretøj"},
] ]
@ -307,6 +309,52 @@ def create_location_view():
raise HTTPException(status_code=500, detail=f"Error rendering create form: {str(e)}") raise HTTPException(status_code=500, detail=f"Error rendering create form: {str(e)}")
# =========================================================================
# 2b. GET /app/locations/wizard - Wizard for floors and rooms
# =========================================================================
@router.get("/app/locations/wizard", response_class=HTMLResponse)
def location_wizard_view():
"""
Render the location wizard form.
"""
try:
logger.info("🧭 Rendering location wizard")
parent_locations = execute_query("""
SELECT id, name, location_type
FROM locations_locations
WHERE is_active = true
ORDER BY name
LIMIT 1000
""")
customers = execute_query("""
SELECT id, name, email, phone
FROM customers
WHERE deleted_at IS NULL AND is_active = true
ORDER BY name
LIMIT 1000
""")
html = render_template(
"modules/locations/templates/wizard.html",
location_types=LOCATION_TYPES,
parent_locations=parent_locations,
customers=customers,
cancel_url="/app/locations",
)
logger.info("✅ Rendered location wizard")
return HTMLResponse(content=html)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error rendering wizard: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error rendering wizard: {str(e)}")
# ============================================================================ # ============================================================================
# 3. GET /app/locations/{id} - Detail view (HTML) # 3. GET /app/locations/{id} - Detail view (HTML)
# ============================================================================ # ============================================================================
@ -341,6 +389,32 @@ def detail_location_view(id: int = Path(..., gt=0)):
location = location[0] # Get first result location = location[0] # Get first result
hierarchy = []
current_parent_id = location.get("parent_location_id")
while current_parent_id:
parent = execute_query(
"SELECT id, name, location_type, parent_location_id FROM locations_locations WHERE id = %s",
(current_parent_id,)
)
if not parent:
break
parent_row = parent[0]
hierarchy.insert(0, parent_row)
current_parent_id = parent_row.get("parent_location_id")
children = execute_query(
"""
SELECT id, name, location_type
FROM locations_locations
WHERE parent_location_id = %s AND deleted_at IS NULL
ORDER BY name
""",
(id,)
)
location["hierarchy"] = hierarchy
location["children"] = children
# Query customers # Query customers
customers = execute_query(""" customers = execute_query("""
SELECT id, name, email, phone SELECT id, name, email, phone

View File

@ -20,7 +20,7 @@ class LocationBase(BaseModel):
name: str = Field(..., min_length=1, max_length=255, description="Location name (unique)") name: str = Field(..., min_length=1, max_length=255, description="Location name (unique)")
location_type: str = Field( location_type: str = Field(
..., ...,
description="Type: kompleks | bygning | etage | customer_site | rum | vehicle" description="Type: kompleks | bygning | etage | customer_site | rum | kantine | moedelokale | vehicle"
) )
parent_location_id: Optional[int] = Field( parent_location_id: Optional[int] = Field(
None, None,
@ -45,7 +45,7 @@ class LocationBase(BaseModel):
@classmethod @classmethod
def validate_location_type(cls, v): def validate_location_type(cls, v):
"""Validate location_type is one of allowed values""" """Validate location_type is one of allowed values"""
allowed = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'vehicle'] allowed = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'kantine', 'moedelokale', 'vehicle']
if v not in allowed: if v not in allowed:
raise ValueError(f'location_type must be one of {allowed}') raise ValueError(f'location_type must be one of {allowed}')
return v return v
@ -61,7 +61,7 @@ class LocationUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255) name: Optional[str] = Field(None, min_length=1, max_length=255)
location_type: Optional[str] = Field( location_type: Optional[str] = Field(
None, None,
description="Type: kompleks | bygning | etage | customer_site | rum | vehicle" description="Type: kompleks | bygning | etage | customer_site | rum | kantine | moedelokale | vehicle"
) )
parent_location_id: Optional[int] = None parent_location_id: Optional[int] = None
customer_id: Optional[int] = None customer_id: Optional[int] = None
@ -81,7 +81,7 @@ class LocationUpdate(BaseModel):
def validate_location_type(cls, v): def validate_location_type(cls, v):
if v is None: if v is None:
return v return v
allowed = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'vehicle'] allowed = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'kantine', 'moedelokale', 'vehicle']
if v not in allowed: if v not in allowed:
raise ValueError(f'location_type must be one of {allowed}') raise ValueError(f'location_type must be one of {allowed}')
return v return v
@ -291,6 +291,51 @@ class BulkDeleteRequest(BaseModel):
ids: List[int] = Field(..., min_items=1, description="Location IDs to soft-delete") ids: List[int] = Field(..., min_items=1, description="Location IDs to soft-delete")
class LocationWizardRoom(BaseModel):
"""Room definition for location wizard"""
name: str = Field(..., min_length=1, max_length=255)
location_type: str = Field("rum", description="Type: rum | kantine | moedelokale")
is_active: bool = Field(True, description="Whether room is active")
@field_validator('location_type')
@classmethod
def validate_room_type(cls, v):
allowed = ['rum', 'kantine', 'moedelokale']
if v not in allowed:
raise ValueError(f'location_type must be one of {allowed}')
return v
class LocationWizardFloor(BaseModel):
"""Floor definition for location wizard"""
name: str = Field(..., min_length=1, max_length=255)
location_type: str = Field("etage", description="Type: etage")
rooms: List[LocationWizardRoom] = Field(default_factory=list)
is_active: bool = Field(True, description="Whether floor is active")
@field_validator('location_type')
@classmethod
def validate_floor_type(cls, v):
if v != 'etage':
raise ValueError('location_type must be etage for floors')
return v
class LocationWizardCreateRequest(BaseModel):
"""Request for creating a location with floors and rooms"""
root: LocationCreate
floors: List[LocationWizardFloor] = Field(..., min_items=1)
auto_suffix: bool = Field(True, description="Auto-suffix names if duplicates exist")
class LocationWizardCreateResponse(BaseModel):
"""Response for location wizard creation"""
root_id: int
floor_ids: List[int] = Field(default_factory=list)
room_ids: List[int] = Field(default_factory=list)
created_total: int
# ============================================================================ # ============================================================================
# 7. RESPONSE MODELS # 7. RESPONSE MODELS
# ============================================================================ # ============================================================================

View File

@ -50,7 +50,7 @@
{% set option_value = type_option['value'] if type_option is mapping else type_option %} {% set option_value = type_option['value'] if type_option is mapping else type_option %}
{% set option_label = type_option['label'] if type_option is mapping else type_option %} {% set option_label = type_option['label'] if type_option is mapping else type_option %}
<option value="{{ option_value }}"> <option value="{{ option_value }}">
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %} {% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'kantine' %}Kantine{% elif option_value == 'moedelokale' %}Mødelokale{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
</option> </option>
{% endfor %} {% endfor %}
{% endif %} {% endif %}

View File

@ -39,6 +39,8 @@
'bygning': 'Bygning', 'bygning': 'Bygning',
'etage': 'Etage', 'etage': 'Etage',
'rum': 'Rum', 'rum': 'Rum',
'kantine': 'Kantine',
'moedelokale': 'Mødelokale',
'customer_site': 'Kundesite', 'customer_site': 'Kundesite',
'vehicle': 'Køretøj' 'vehicle': 'Køretøj'
}.get(location.location_type, location.location_type) %} }.get(location.location_type, location.location_type) %}
@ -48,6 +50,8 @@
'bygning': '#1abc9c', 'bygning': '#1abc9c',
'etage': '#3498db', 'etage': '#3498db',
'rum': '#e67e22', 'rum': '#e67e22',
'kantine': '#d35400',
'moedelokale': '#16a085',
'customer_site': '#9b59b6', 'customer_site': '#9b59b6',
'vehicle': '#8e44ad' 'vehicle': '#8e44ad'
}.get(location.location_type, '#6c757d') %} }.get(location.location_type, '#6c757d') %}
@ -512,7 +516,7 @@
{% set option_value = type_option['value'] if type_option is mapping else type_option %} {% set option_value = type_option['value'] if type_option is mapping else type_option %}
{% set option_label = type_option['label'] if type_option is mapping else type_option %} {% set option_label = type_option['label'] if type_option is mapping else type_option %}
<option value="{{ option_value }}"> <option value="{{ option_value }}">
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %} {% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'kantine' %}Kantine{% elif option_value == 'moedelokale' %}Mødelokale{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
</option> </option>
{% endfor %} {% endfor %}
{% endif %} {% endif %}

View File

@ -51,7 +51,7 @@
{% set option_value = type_option['value'] if type_option is mapping else type_option %} {% set option_value = type_option['value'] if type_option is mapping else type_option %}
{% set option_label = type_option['label'] if type_option is mapping else type_option %} {% set option_label = type_option['label'] if type_option is mapping else type_option %}
<option value="{{ option_value }}" {% if location.location_type == option_value %}selected{% endif %}> <option value="{{ option_value }}" {% if location.location_type == option_value %}selected{% endif %}>
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %} {% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'kantine' %}Kantine{% elif option_value == 'moedelokale' %}Mødelokale{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
</option> </option>
{% endfor %} {% endfor %}
{% endif %} {% endif %}

View File

@ -41,7 +41,7 @@
{% set option_value = type_option['value'] if type_option is mapping else type_option %} {% set option_value = type_option['value'] if type_option is mapping else type_option %}
{% set option_label = type_option['label'] if type_option is mapping else type_option %} {% set option_label = type_option['label'] if type_option is mapping else type_option %}
<option value="{{ option_value }}" {% if location_type == option_value %}selected{% endif %}> <option value="{{ option_value }}" {% if location_type == option_value %}selected{% endif %}>
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %} {% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'kantine' %}Kantine{% elif option_value == 'moedelokale' %}Mødelokale{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
</option> </option>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
@ -76,6 +76,9 @@
<a href="/app/locations/create" class="btn btn-primary btn-sm"> <a href="/app/locations/create" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-2"></i>Opret lokation <i class="bi bi-plus-lg me-2"></i>Opret lokation
</a> </a>
<a href="/app/locations/wizard" class="btn btn-outline-primary btn-sm">
<i class="bi bi-diagram-3 me-2"></i>Wizard
</a>
<button type="button" class="btn btn-outline-danger btn-sm" id="bulkDeleteBtn" disabled> <button type="button" class="btn btn-outline-danger btn-sm" id="bulkDeleteBtn" disabled>
<i class="bi bi-trash me-2"></i>Slet valgte <i class="bi bi-trash me-2"></i>Slet valgte
</button> </button>
@ -115,6 +118,8 @@
'etage': 'Etage', 'etage': 'Etage',
'customer_site': 'Kundesite', 'customer_site': 'Kundesite',
'rum': 'Rum', 'rum': 'Rum',
'kantine': 'Kantine',
'moedelokale': 'Mødelokale',
'vehicle': 'Køretøj' 'vehicle': 'Køretøj'
}.get(node.location_type, node.location_type) %} }.get(node.location_type, node.location_type) %}
@ -124,6 +129,8 @@
'etage': '#3498db', 'etage': '#3498db',
'customer_site': '#9b59b6', 'customer_site': '#9b59b6',
'rum': '#e67e22', 'rum': '#e67e22',
'kantine': '#d35400',
'moedelokale': '#16a085',
'vehicle': '#8e44ad' 'vehicle': '#8e44ad'
}.get(node.location_type, '#6c757d') %} }.get(node.location_type, '#6c757d') %}

View File

@ -32,7 +32,7 @@
{% set option_value = type_option['value'] if type_option is mapping else type_option %} {% set option_value = type_option['value'] if type_option is mapping else type_option %}
{% set option_label = type_option['label'] if type_option is mapping else type_option %} {% set option_label = type_option['label'] if type_option is mapping else type_option %}
<option value="{{ option_value }}"> <option value="{{ option_value }}">
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %} {% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'kantine' %}Kantine{% elif option_value == 'moedelokale' %}Mødelokale{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
</option> </option>
{% endfor %} {% endfor %}
{% endif %} {% endif %}

View File

@ -0,0 +1,393 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Wizard: Lokationer - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid px-4 py-4">
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/" class="text-decoration-none">Hjem</a></li>
<li class="breadcrumb-item"><a href="/app/locations" class="text-decoration-none">Lokaliteter</a></li>
<li class="breadcrumb-item active">Wizard</li>
</ol>
</nav>
<div class="row mb-4">
<div class="col-12">
<h1 class="h2 fw-700 mb-2">Wizard: Opret lokation</h1>
<p class="text-muted small">Opret en adresse med etager og rum i en samlet arbejdsgang</p>
</div>
</div>
<div id="errorAlert" class="alert alert-danger alert-dismissible fade hide" role="alert">
<strong>Fejl!</strong> <span id="errorMessage"></span>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Luk"></button>
</div>
<form id="wizardForm">
<div class="card border-0 mb-4">
<div class="card-body p-4">
<div class="d-flex align-items-center justify-content-between mb-3">
<h2 class="h5 fw-600 mb-0">Trin 1: Lokation</h2>
<span class="badge bg-primary">Adresse</span>
</div>
<div class="row">
<div class="col-lg-6 mb-3">
<label for="rootName" class="form-label">Navn *</label>
<input type="text" class="form-control" id="rootName" name="root_name" required maxlength="255" placeholder="f.eks. Hovedkontor">
</div>
<div class="col-lg-6 mb-3">
<label for="rootType" class="form-label">Type *</label>
<select class="form-select" id="rootType" name="root_type" required>
<option value="">Vælg type</option>
{% if location_types %}
{% for type_option in location_types %}
{% set option_value = type_option['value'] if type_option is mapping else type_option %}
{% set option_label = type_option['label'] if type_option is mapping else type_option %}
{% if option_value not in ['rum', 'kantine', 'moedelokale'] %}
<option value="{{ option_value }}">
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
</option>
{% endif %}
{% endfor %}
{% endif %}
</select>
<div class="form-text">Tip: Vælg "Bygning" for klassisk etage/rum-setup.</div>
</div>
</div>
<div class="row">
<div class="col-lg-6 mb-3">
<label for="parentLocation" class="form-label">Overordnet lokation</label>
<select class="form-select" id="parentLocation" name="parent_location_id">
<option value="">Ingen (øverste niveau)</option>
{% if parent_locations %}
{% for parent in parent_locations %}
<option value="{{ parent.id }}">
{{ parent.name }}{% if parent.location_type %} ({{ parent.location_type }}){% endif %}
</option>
{% endfor %}
{% endif %}
</select>
</div>
<div class="col-lg-6 mb-3">
<label for="customerId" class="form-label">Kunde (valgfri)</label>
<select class="form-select" id="customerId" name="customer_id">
<option value="">Ingen</option>
{% if customers %}
{% for customer in customers %}
<option value="{{ customer.id }}">{{ customer.name }}</option>
{% endfor %}
{% endif %}
</select>
</div>
</div>
<div class="row">
<div class="col-lg-6 mb-3">
<label for="addressStreet" class="form-label">Vejnavn og nummer</label>
<input type="text" class="form-control" id="addressStreet" name="address_street" placeholder="f.eks. Hovedgaden 123">
</div>
<div class="col-lg-3 mb-3">
<label for="addressCity" class="form-label">By</label>
<input type="text" class="form-control" id="addressCity" name="address_city" placeholder="f.eks. København">
</div>
<div class="col-lg-3 mb-3">
<label for="addressPostal" class="form-label">Postnummer</label>
<input type="text" class="form-control" id="addressPostal" name="address_postal_code" placeholder="f.eks. 1000">
</div>
</div>
<div class="row">
<div class="col-lg-3 mb-3">
<label for="addressCountry" class="form-label">Land</label>
<input type="text" class="form-control" id="addressCountry" name="address_country" value="DK" placeholder="DK">
</div>
<div class="col-lg-3 mb-3">
<label for="phone" class="form-label">Telefon</label>
<input type="tel" class="form-control" id="phone" name="phone" placeholder="+45 12 34 56 78">
</div>
<div class="col-lg-6 mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" placeholder="kontakt@lokation.dk">
</div>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="rootActive" name="root_active" checked>
<label class="form-check-label" for="rootActive">Lokation er aktiv</label>
</div>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="autoPrefix" checked>
<label class="form-check-label" for="autoPrefix">Prefiks etager/rum med lokationsnavn</label>
<div class="form-text">Hjælper mod navnekonflikter (navne skal være unikke globalt).</div>
</div>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="autoSuffix" checked>
<label class="form-check-label" for="autoSuffix">Tilføj automatisk suffix ved dubletter</label>
<div class="form-text">Eksempel: "Stue" bliver til "Stue (2)" hvis navnet findes.</div>
</div>
</div>
</div>
<div class="card border-0 mb-4">
<div class="card-body p-4">
<div class="d-flex align-items-center justify-content-between mb-3">
<h2 class="h5 fw-600 mb-0">Trin 2: Etager</h2>
<button type="button" class="btn btn-outline-primary btn-sm" id="addFloorBtn">
<i class="bi bi-plus-lg me-2"></i>Tilføj etage
</button>
</div>
<div id="floorsContainer" class="d-flex flex-column gap-3"></div>
</div>
</div>
<div class="d-flex justify-content-between gap-2">
<a href="{{ cancel_url }}" class="btn btn-outline-secondary">Annuller</a>
<button type="submit" class="btn btn-primary" id="submitBtn">
<i class="bi bi-check-lg me-2"></i>Opret lokation
</button>
</div>
</form>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const floorsContainer = document.getElementById('floorsContainer');
const addFloorBtn = document.getElementById('addFloorBtn');
const form = document.getElementById('wizardForm');
const submitBtn = document.getElementById('submitBtn');
const errorAlert = document.getElementById('errorAlert');
const errorMessage = document.getElementById('errorMessage');
let floorIndex = 0;
function createRoomRow(roomIndex) {
const roomRow = document.createElement('div');
roomRow.className = 'row g-2 align-items-center room-row';
roomRow.innerHTML = `
<div class="col-md-6">
<input type="text" class="form-control form-control-sm room-name" placeholder="Rum ${roomIndex + 1}" required>
</div>
<div class="col-md-4">
<select class="form-select form-select-sm room-type">
<option value="rum">Rum</option>
<option value="kantine">Kantine</option>
<option value="moedelokale">Mødelokale</option>
</select>
</div>
<div class="col-md-2 text-end">
<button type="button" class="btn btn-outline-danger btn-sm remove-room">
<i class="bi bi-x-lg"></i>
</button>
</div>
`;
return roomRow;
}
function addRoom(floorCard) {
const roomsContainer = floorCard.querySelector('.rooms-container');
const roomIndex = roomsContainer.querySelectorAll('.room-row').length;
const roomRow = createRoomRow(roomIndex);
roomsContainer.appendChild(roomRow);
roomRow.querySelector('.remove-room').addEventListener('click', function() {
roomRow.remove();
});
}
function addFloor() {
const floorCard = document.createElement('div');
floorCard.className = 'border rounded-3 p-3 bg-light';
floorCard.dataset.floorIndex = String(floorIndex);
floorCard.innerHTML = `
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="fw-600">Etage</div>
<button type="button" class="btn btn-outline-danger btn-sm remove-floor">
<i class="bi bi-trash"></i>
</button>
</div>
<div class="row g-2 align-items-center mb-3">
<div class="col-md-8">
<input type="text" class="form-control floor-name" placeholder="Etage ${floorIndex + 1}" required>
</div>
<div class="col-md-4">
<div class="form-check">
<input class="form-check-input floor-active" type="checkbox" checked>
<label class="form-check-label">Aktiv</label>
</div>
</div>
</div>
<div class="d-flex align-items-center justify-content-between mb-2">
<div class="text-muted small">Rum</div>
<button type="button" class="btn btn-outline-primary btn-sm add-room">
<i class="bi bi-plus-lg me-2"></i>Tilføj rum
</button>
</div>
<div class="rooms-container d-flex flex-column gap-2"></div>
`;
floorCard.querySelector('.remove-floor').addEventListener('click', function() {
floorCard.remove();
});
floorCard.querySelector('.add-room').addEventListener('click', function() {
addRoom(floorCard);
});
floorsContainer.appendChild(floorCard);
addRoom(floorCard);
floorIndex += 1;
}
addFloorBtn.addEventListener('click', addFloor);
addFloor();
form.addEventListener('submit', async function(event) {
event.preventDefault();
errorAlert.classList.add('hide');
const rootName = document.getElementById('rootName').value.trim();
const rootType = document.getElementById('rootType').value;
const autoPrefix = document.getElementById('autoPrefix').checked;
const autoSuffix = document.getElementById('autoSuffix').checked;
if (!rootName || !rootType) {
errorMessage.textContent = 'Udfyld navn og type for lokationen.';
errorAlert.classList.remove('hide');
return;
}
const floorCards = Array.from(document.querySelectorAll('[data-floor-index]'));
if (floorCards.length === 0) {
errorMessage.textContent = 'Tilføj mindst én etage.';
errorAlert.classList.remove('hide');
return;
}
const floorsPayload = [];
const nameRegistry = new Set();
function registerName(name) {
const normalized = name.trim().toLowerCase();
if (!normalized) {
return false;
}
if (nameRegistry.has(normalized)) {
return false;
}
nameRegistry.add(normalized);
return true;
}
if (!autoSuffix && !registerName(rootName)) {
errorMessage.textContent = 'Der er dublerede navne i lokationen.';
errorAlert.classList.remove('hide');
return;
}
for (const floorCard of floorCards) {
const floorNameInput = floorCard.querySelector('.floor-name').value.trim();
if (!floorNameInput) {
errorMessage.textContent = 'Alle etager skal have et navn.';
errorAlert.classList.remove('hide');
return;
}
const floorName = autoPrefix ? `${rootName} - ${floorNameInput}` : floorNameInput;
if (!autoSuffix && !registerName(floorName)) {
errorMessage.textContent = 'Der er dublerede etagenavne. Skift navne eller brug prefiks.';
errorAlert.classList.remove('hide');
return;
}
const roomsContainer = floorCard.querySelector('.rooms-container');
const roomRows = Array.from(roomsContainer.querySelectorAll('.room-row'));
if (roomRows.length === 0) {
errorMessage.textContent = 'Tilføj mindst ét rum til hver etage.';
errorAlert.classList.remove('hide');
return;
}
const roomsPayload = [];
for (const roomRow of roomRows) {
const roomNameInput = roomRow.querySelector('.room-name').value.trim();
if (!roomNameInput) {
errorMessage.textContent = 'Alle rum skal have et navn.';
errorAlert.classList.remove('hide');
return;
}
const roomName = autoPrefix ? `${rootName} - ${floorNameInput} - ${roomNameInput}` : roomNameInput;
if (!autoSuffix && !registerName(roomName)) {
errorMessage.textContent = 'Der er dublerede rumnavne. Skift navne eller brug prefiks.';
errorAlert.classList.remove('hide');
return;
}
roomsPayload.push({
name: roomName,
location_type: roomRow.querySelector('.room-type').value,
is_active: true
});
}
floorsPayload.push({
name: floorName,
location_type: 'etage',
is_active: floorCard.querySelector('.floor-active').checked,
rooms: roomsPayload
});
}
const payload = {
root: {
name: rootName,
location_type: rootType,
parent_location_id: document.getElementById('parentLocation').value || null,
customer_id: document.getElementById('customerId').value || null,
address_street: document.getElementById('addressStreet').value || null,
address_city: document.getElementById('addressCity').value || null,
address_postal_code: document.getElementById('addressPostal').value || null,
address_country: document.getElementById('addressCountry').value || 'DK',
phone: document.getElementById('phone').value || null,
email: document.getElementById('email').value || null,
notes: null,
is_active: document.getElementById('rootActive').checked
},
floors: floorsPayload,
auto_suffix: autoSuffix
};
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="bi bi-hourglass-split me-2"></i>Opretter...';
try {
const response = await fetch('/api/v1/locations/bulk-create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.ok) {
const result = await response.json();
window.location.href = `/app/locations/${result.root_id}`;
return;
}
const errorData = await response.json();
errorMessage.textContent = errorData.detail || 'Fejl ved oprettelse af lokationer.';
errorAlert.classList.remove('hide');
} catch (error) {
console.error('Error:', error);
errorMessage.textContent = 'En fejl opstod. Prøv igen senere.';
errorAlert.classList.remove('hide');
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="bi bi-check-lg me-2"></i>Opret lokation';
}
});
});
</script>
{% endblock %}

View File

@ -181,6 +181,52 @@ async def list_groups(instance_id: int, customer_id: Optional[int] = Query(None)
return response return response
@router.get("/instances/{instance_id}/users")
async def list_users(
instance_id: int,
customer_id: Optional[int] = Query(None),
search: Optional[str] = Query(None),
include_details: bool = Query(False),
limit: int = Query(200, ge=1, le=500),
):
if include_details:
response = await service.list_users_details(instance_id, customer_id, search, limit)
else:
response = await service.list_users(instance_id, customer_id, search)
if customer_id is not None:
_audit(
customer_id,
instance_id,
"users",
{
"instance_id": instance_id,
"search": search,
"include_details": include_details,
"limit": limit,
},
response,
)
return response
@router.get("/instances/{instance_id}/users/{uid}")
async def get_user_details(
instance_id: int,
uid: str,
customer_id: Optional[int] = Query(None),
):
response = await service.get_user_details(instance_id, uid, customer_id)
if customer_id is not None:
_audit(
customer_id,
instance_id,
"user_details",
{"instance_id": instance_id, "uid": uid},
response,
)
return response
@router.get("/instances/{instance_id}/shares") @router.get("/instances/{instance_id}/shares")
async def list_shares(instance_id: int, customer_id: Optional[int] = Query(None)): async def list_shares(instance_id: int, customer_id: Optional[int] = Query(None)):
response = await service.list_public_shares(instance_id, customer_id) response = await service.list_public_shares(instance_id, customer_id)

View File

@ -179,6 +179,67 @@ class NextcloudService:
use_cache=True, use_cache=True,
) )
async def list_users(
self,
instance_id: int,
customer_id: Optional[int] = None,
search: Optional[str] = None,
) -> dict:
instance = self._get_instance(instance_id, customer_id)
if not instance or not instance["is_enabled"]:
return {"users": []}
params = {"search": search} if search else None
return await self._ocs_request(
instance,
"/ocs/v1.php/cloud/users",
method="GET",
params=params,
use_cache=False,
)
async def get_user_details(
self,
instance_id: int,
uid: str,
customer_id: Optional[int] = None,
) -> dict:
instance = self._get_instance(instance_id, customer_id)
if not instance or not instance["is_enabled"]:
return {"user": None}
return await self._ocs_request(
instance,
f"/ocs/v1.php/cloud/users/{uid}",
method="GET",
use_cache=False,
)
async def list_users_details(
self,
instance_id: int,
customer_id: Optional[int] = None,
search: Optional[str] = None,
limit: int = 200,
) -> dict:
response = await self.list_users(instance_id, customer_id, search)
users = response.get("payload", {}).get("ocs", {}).get("data", {}).get("users", [])
if not isinstance(users, list):
users = []
users = users[: max(1, min(limit, 500))]
detailed = []
for uid in users:
detail_resp = await self.get_user_details(instance_id, uid, customer_id)
data = detail_resp.get("payload", {}).get("ocs", {}).get("data", {}) if isinstance(detail_resp, dict) else {}
detailed.append({
"uid": uid,
"display_name": data.get("displayname") if isinstance(data, dict) else None,
"email": data.get("email") if isinstance(data, dict) else None,
})
return {"users": detailed}
async def list_public_shares(self, instance_id: int, customer_id: Optional[int] = None) -> dict: async def list_public_shares(self, instance_id: int, customer_id: Optional[int] = None) -> dict:
instance = self._get_instance(instance_id, customer_id) instance = self._get_instance(instance_id, customer_id)
if not instance or not instance["is_enabled"]: if not instance or not instance["is_enabled"]:

View File

@ -32,11 +32,15 @@ async def list_sager(
tag: Optional[str] = Query(None), tag: Optional[str] = Query(None),
customer_id: Optional[int] = Query(None), customer_id: Optional[int] = Query(None),
ansvarlig_bruger_id: Optional[int] = Query(None), ansvarlig_bruger_id: Optional[int] = Query(None),
include_deferred: bool = Query(False),
): ):
"""List all cases with optional filtering.""" """List all cases with optional filtering."""
try: try:
query = "SELECT * FROM sag_sager WHERE deleted_at IS NULL" query = "SELECT * FROM sag_sager WHERE deleted_at IS NULL"
params = [] params = []
if not include_deferred:
query += " AND (deferred_until IS NULL OR deferred_until <= NOW())"
if status: if status:
query += " AND status = %s" query += " AND status = %s"

View File

@ -41,8 +41,11 @@ async def sager_liste(
params = [] params = []
if not include_deferred: if not include_deferred:
query += " AND (s.deferred_until IS NULL OR s.deferred_until <= NOW())" query += " AND ("
query += " AND (s.deferred_until_case_id IS NULL OR s.deferred_until_status IS NULL OR ds.status = s.deferred_until_status)" query += "s.deferred_until IS NULL"
query += " OR s.deferred_until <= NOW()"
query += " OR (s.deferred_until_case_id IS NOT NULL AND s.deferred_until_status IS NOT NULL AND ds.status = s.deferred_until_status)"
query += ")"
if status: if status:
query += " AND s.status = %s" query += " AND s.status = %s"

View File

@ -1,13 +1,15 @@
""" """
Products API Products API
""" """
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query, Depends
from typing import List, Dict, Any, Optional, Tuple from typing import List, Dict, Any, Optional, Tuple
from app.core.database import execute_query, execute_query_single from app.core.database import execute_query, execute_query_single
from app.core.config import settings from app.core.config import settings
from app.core.auth_dependencies import require_permission
import logging import logging
import os import os
import aiohttp import aiohttp
import json
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -20,6 +22,122 @@ def _apigw_headers() -> Dict[str, str]:
return {"Authorization": f"Bearer {token}"} return {"Authorization": f"Bearer {token}"}
def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: str = "manual") -> Dict[str, Any]:
supplier_name = payload.get("supplier_name")
supplier_code = payload.get("supplier_code")
supplier_sku = payload.get("supplier_sku") or payload.get("sku")
supplier_price = payload.get("supplier_price") or payload.get("price")
supplier_currency = payload.get("supplier_currency") or payload.get("currency") or "DKK"
supplier_stock = payload.get("supplier_stock") or payload.get("stock_qty")
supplier_url = payload.get("supplier_url") or payload.get("supplier_link")
supplier_product_url = (
payload.get("supplier_product_url")
or payload.get("product_url")
or payload.get("product_link")
or payload.get("url")
)
match_query = None
match_params = None
if supplier_code and supplier_sku:
match_query = """
SELECT * FROM product_suppliers
WHERE product_id = %s AND supplier_code = %s AND supplier_sku = %s
LIMIT 1
"""
match_params = (product_id, supplier_code, supplier_sku)
elif supplier_url:
match_query = """
SELECT * FROM product_suppliers
WHERE product_id = %s AND supplier_url = %s
LIMIT 1
"""
match_params = (product_id, supplier_url)
elif supplier_name and supplier_sku:
match_query = """
SELECT * FROM product_suppliers
WHERE product_id = %s AND supplier_name = %s AND supplier_sku = %s
LIMIT 1
"""
match_params = (product_id, supplier_name, supplier_sku)
existing = execute_query_single(match_query, match_params) if match_query else None
if existing:
update_query = """
UPDATE product_suppliers
SET supplier_name = %s,
supplier_code = %s,
supplier_sku = %s,
supplier_price = %s,
supplier_currency = %s,
supplier_stock = %s,
supplier_url = %s,
supplier_product_url = %s,
source = %s,
last_updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING *
"""
result = execute_query(
update_query,
(
supplier_name,
supplier_code,
supplier_sku,
supplier_price,
supplier_currency,
supplier_stock,
supplier_url,
supplier_product_url,
source,
existing.get("id"),
)
)
return result[0] if result else existing
insert_query = """
INSERT INTO product_suppliers (
product_id,
supplier_name,
supplier_code,
supplier_sku,
supplier_price,
supplier_currency,
supplier_stock,
supplier_url,
supplier_product_url,
source,
last_updated_at
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
RETURNING *
"""
result = execute_query(
insert_query,
(
product_id,
supplier_name,
supplier_code,
supplier_sku,
supplier_price,
supplier_currency,
supplier_stock,
supplier_url,
supplier_product_url,
source,
)
)
return result[0] if result else {}
def _log_product_audit(product_id: int, event_type: str, user_id: Optional[int], changes: Dict[str, Any]) -> None:
audit_query = """
INSERT INTO product_audit_log (product_id, event_type, user_id, changes)
VALUES (%s, %s, %s, %s)
"""
execute_query(audit_query, (product_id, event_type, user_id, json.dumps(changes)))
def _normalize_query(raw_query: str) -> Tuple[str, List[str]]: def _normalize_query(raw_query: str) -> Tuple[str, List[str]]:
normalized = " ".join( normalized = " ".join(
"".join(ch.lower() if ch.isalnum() else " " for ch in raw_query).split() "".join(ch.lower() if ch.isalnum() else " " for ch in raw_query).split()
@ -149,6 +267,7 @@ async def import_apigw_product(payload: Dict[str, Any]):
(sku_internal,) (sku_internal,)
) )
if existing: if existing:
_upsert_product_supplier(existing["id"], product, source="gateway")
return existing return existing
sales_price = product.get("price") sales_price = product.get("price")
@ -194,7 +313,10 @@ async def import_apigw_product(payload: Dict[str, Any]):
True, True,
) )
result = execute_query(insert_query, params) result = execute_query(insert_query, params)
return result[0] if result else {} created = result[0] if result else {}
if created:
_upsert_product_supplier(created["id"], product, source="gateway")
return created
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@ -446,6 +568,191 @@ async def get_product(product_id: int):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.patch("/products/{product_id}", response_model=Dict[str, Any])
async def update_product(
product_id: int,
payload: Dict[str, Any],
current_user: dict = Depends(require_permission("products.update"))
):
"""Update product fields like name."""
try:
name = payload.get("name")
if name is not None:
name = name.strip()
if not name:
raise HTTPException(status_code=400, detail="name cannot be empty")
existing = execute_query_single(
"SELECT name FROM products WHERE id = %s AND deleted_at IS NULL",
(product_id,)
)
if not existing:
raise HTTPException(status_code=404, detail="Product not found")
query = """
UPDATE products
SET name = COALESCE(%s, name),
updated_at = CURRENT_TIMESTAMP
WHERE id = %s AND deleted_at IS NULL
RETURNING *
"""
result = execute_query(query, (name, product_id))
if not result:
raise HTTPException(status_code=404, detail="Product not found")
if name is not None and name != existing.get("name"):
_log_product_audit(
product_id,
"name_updated",
current_user.get("id") if current_user else None,
{"old": existing.get("name"), "new": name}
)
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error updating product: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/products/{product_id}", response_model=Dict[str, Any])
async def delete_product(
product_id: int,
current_user: dict = Depends(require_permission("products.delete"))
):
"""Soft-delete a product."""
try:
query = """
UPDATE products
SET deleted_at = CURRENT_TIMESTAMP,
status = 'inactive',
updated_at = CURRENT_TIMESTAMP
WHERE id = %s AND deleted_at IS NULL
RETURNING id
"""
result = execute_query(query, (product_id,))
if not result:
raise HTTPException(status_code=404, detail="Product not found")
_log_product_audit(
product_id,
"deleted",
current_user.get("id") if current_user else None,
{"status": "inactive"}
)
return {"status": "deleted", "id": result[0].get("id")}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error deleting product: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/products/{product_id}/suppliers", response_model=List[Dict[str, Any]])
async def list_product_suppliers(product_id: int):
"""List suppliers for a product."""
try:
query = """
SELECT
id,
product_id,
supplier_name,
supplier_code,
supplier_sku,
supplier_price,
supplier_currency,
supplier_stock,
supplier_url,
supplier_product_url,
source,
last_updated_at
FROM product_suppliers
WHERE product_id = %s
ORDER BY supplier_name NULLS LAST, supplier_price NULLS LAST
"""
return execute_query(query, (product_id,)) or []
except Exception as e:
logger.error("❌ Error loading product suppliers: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/products/{product_id}/suppliers", response_model=Dict[str, Any])
async def upsert_product_supplier(product_id: int, payload: Dict[str, Any]):
"""Create or update a product supplier."""
try:
return _upsert_product_supplier(product_id, payload, source=payload.get("source") or "manual")
except Exception as e:
logger.error("❌ Error saving product supplier: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/products/{product_id}/suppliers/{supplier_id}", response_model=Dict[str, Any])
async def delete_product_supplier(product_id: int, supplier_id: int):
"""Delete a product supplier."""
try:
query = "DELETE FROM product_suppliers WHERE id = %s AND product_id = %s"
execute_query(query, (supplier_id, product_id))
return {"status": "deleted"}
except Exception as e:
logger.error("❌ Error deleting product supplier: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/products/{product_id}/suppliers/refresh", response_model=Dict[str, Any])
async def refresh_product_suppliers(product_id: int):
"""Refresh suppliers from API Gateway using EAN only."""
try:
product = execute_query_single(
"SELECT ean FROM products WHERE id = %s AND deleted_at IS NULL",
(product_id,),
)
if not product:
raise HTTPException(status_code=404, detail="Product not found")
ean = (product.get("ean") or "").strip()
if not ean:
raise HTTPException(status_code=400, detail="Product has no EAN to search")
base_url = settings.APIGW_BASE_URL or settings.APIGATEWAY_URL
url = f"{base_url.rstrip('/')}/api/v1/products/search"
params = {"q": ean, "per_page": 25}
timeout = aiohttp.ClientTimeout(total=settings.APIGW_TIMEOUT_SECONDS)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, headers=_apigw_headers(), params=params) as response:
if response.status >= 400:
detail = await response.text()
raise HTTPException(status_code=response.status, detail=detail)
data = await response.json()
results = data.get("products") if isinstance(data, dict) else []
if not isinstance(results, list):
results = []
saved = 0
seen_keys = set()
for item in results:
key = (
item.get("supplier_code"),
item.get("sku"),
item.get("product_url") or item.get("url")
)
if key in seen_keys:
continue
seen_keys.add(key)
_upsert_product_supplier(product_id, item, source="gateway")
saved += 1
return {
"status": "refreshed",
"saved": saved,
"queries": [{"q": ean}]
}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error refreshing product suppliers: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/products/{product_id}/price-history", response_model=List[Dict[str, Any]]) @router.get("/products/{product_id}/price-history", response_model=List[Dict[str, Any]])
async def list_product_price_history(product_id: int, limit: int = Query(100)): async def list_product_price_history(product_id: int, limit: int = Query(100)):
"""List price history entries for a product.""" """List price history entries for a product."""

View File

@ -83,12 +83,23 @@
<div class="fs-3 fw-semibold mt-2" id="productPrice">-</div> <div class="fs-3 fw-semibold mt-2" id="productPrice">-</div>
<div class="product-muted" id="productSku">-</div> <div class="product-muted" id="productSku">-</div>
<div class="product-muted" id="productSupplierPrice">-</div> <div class="product-muted" id="productSupplierPrice">-</div>
<div class="badge-soft mt-2" id="productBestPrice" style="display: none;"></div>
</div> </div>
</div> </div>
<div class="row g-3"> <div class="row g-3">
<div class="col-lg-4"> <div class="col-lg-4">
<div class="product-card h-100"> <div class="product-card h-100">
<div class="d-flex align-items-center justify-content-between mb-2">
<h5 class="mb-0">Produktnavn</h5>
<button class="btn btn-outline-danger btn-sm" onclick="deleteProduct()">Slet</button>
</div>
<div class="mb-3">
<label class="form-label">Navn</label>
<input type="text" class="form-control" id="productNameInput">
</div>
<button class="btn btn-outline-primary w-100 mb-3" onclick="updateProductName()">Opdater navn</button>
<div class="small mb-3" id="productNameMessage"></div>
<h5 class="mb-3">Opdater pris</h5> <h5 class="mb-3">Opdater pris</h5>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Ny salgspris</label> <label class="form-label">Ny salgspris</label>
@ -128,6 +139,16 @@
</div> </div>
</div> </div>
<div class="col-lg-8"> <div class="col-lg-8">
<div class="product-card mb-3">
<h5 class="mb-3">Produktinfo</h5>
<div class="table-responsive">
<table class="table table-sm product-table mb-0">
<tbody id="productInfoBody">
<tr><td class="text-center product-muted py-3">Indlaeser...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="product-card mb-3"> <div class="product-card mb-3">
<h5 class="mb-3">Pris historik</h5> <h5 class="mb-3">Pris historik</h5>
<div class="table-responsive"> <div class="table-responsive">
@ -170,6 +191,78 @@
</table> </table>
</div> </div>
</div> </div>
<div class="product-card mt-3">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
<h5 class="mb-0">Grosister og priser</h5>
<div class="d-flex align-items-center gap-2">
<select class="form-select form-select-sm" style="max-width: 160px;" onchange="changeSupplierSort(this.value)">
<option value="price">Pris</option>
<option value="stock">Lager</option>
<option value="name">Navn</option>
<option value="updated">Opdateret</option>
</select>
<button class="btn btn-outline-secondary btn-sm" onclick="refreshSuppliersFromGateway()">Opdater fra Gateway</button>
</div>
</div>
<div class="table-responsive mb-3">
<table class="table table-sm product-table mb-0">
<thead>
<tr>
<th>Grosist</th>
<th>SKU</th>
<th>Pris</th>
<th>Lager</th>
<th>Link</th>
<th>Opdateret</th>
<th></th>
</tr>
</thead>
<tbody id="supplierListBody">
<tr><td colspan="7" class="text-center product-muted py-3">Indlaeser...</td></tr>
</tbody>
</table>
</div>
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label">Grosist</label>
<input type="text" class="form-control" id="supplierListName" placeholder="Navn">
</div>
<div class="col-md-2">
<label class="form-label">Supplier code</label>
<input type="text" class="form-control" id="supplierListCode" placeholder="Code">
</div>
<div class="col-md-2">
<label class="form-label">SKU</label>
<input type="text" class="form-control" id="supplierListSku" placeholder="SKU">
<div class="d-flex gap-2 mt-1">
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="useProductEan()">Brug EAN</button>
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="useProductSku()">Brug SKU</button>
</div>
</div>
<div class="col-md-2">
<label class="form-label">Pris</label>
<input type="number" class="form-control" id="supplierListPrice" step="0.01" min="0">
</div>
<div class="col-md-3">
<label class="form-label">Produktlink</label>
<input type="text" class="form-control" id="supplierListUrl" placeholder="https://">
</div>
<div class="col-md-2">
<label class="form-label">Lager</label>
<input type="number" class="form-control" id="supplierListStock" min="0">
</div>
<div class="col-md-2">
<label class="form-label">Valuta</label>
<input type="text" class="form-control" id="supplierListCurrency" placeholder="DKK">
</div>
<div class="col-md-3">
<button class="btn btn-outline-primary w-100" onclick="submitSupplierList()">Tilfoej/Opdater</button>
</div>
<div class="col-12">
<div class="small" id="supplierListMessage"></div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -177,6 +270,13 @@
<script> <script>
const productId = {{ product_id }}; const productId = {{ product_id }};
const productDetailState = {
product: null
};
const supplierListState = {
suppliers: [],
sort: 'price'
};
function escapeHtml(value) { function escapeHtml(value) {
return String(value || '').replace(/[&<>"']/g, (ch) => ({ return String(value || '').replace(/[&<>"']/g, (ch) => ({
@ -218,6 +318,72 @@ function setSupplierMessage(message, tone = 'text-muted') {
el.textContent = message; el.textContent = message;
} }
function setSupplierListMessage(message, tone = 'text-muted') {
const el = document.getElementById('supplierListMessage');
if (!el) return;
el.className = `small ${tone}`;
el.textContent = message;
}
function setProductNameMessage(message, tone = 'text-muted') {
const el = document.getElementById('productNameMessage');
if (!el) return;
el.className = `small ${tone}`;
el.textContent = message;
}
function updateBestPriceBadge(suppliers) {
const badge = document.getElementById('productBestPrice');
if (!badge) return;
const prices = (suppliers || [])
.map(supplier => supplier.supplier_price)
.filter(price => typeof price === 'number');
if (!prices.length) {
badge.style.display = 'none';
return;
}
const best = Math.min(...prices);
badge.textContent = `Bedste pris: ${formatCurrency(best)}`;
badge.style.display = 'inline-flex';
}
function renderProductInfo(product) {
const body = document.getElementById('productInfoBody');
if (!body) return;
const rows = [
['EAN', product.ean],
['Type', product.type],
['Status', product.status],
['SKU intern', product.sku_internal],
['Producent', product.manufacturer],
['Leverandoer', product.supplier_name],
['Leverandoer SKU', product.supplier_sku],
['Leverandoer pris', product.supplier_price != null ? formatCurrency(product.supplier_price) : null],
['Salgspris', product.sales_price != null ? formatCurrency(product.sales_price) : null],
['Kostpris', product.cost_price != null ? formatCurrency(product.cost_price) : null],
['Moms', product.vat_rate != null ? `${product.vat_rate}%` : null],
['Faktureringsinterval', product.billing_period],
['Auto forny', product.auto_renew ? 'Ja' : 'Nej'],
['Minimum binding', product.minimum_term_months != null ? `${product.minimum_term_months} mdr.` : null]
];
body.innerHTML = rows.map(([label, value]) => `
<tr>
<th>${label}</th>
<td>${value != null && value !== '' ? escapeHtml(value) : '-'}</td>
</tr>
`).join('');
}
function prefillSupplierSku(product) {
const skuField = document.getElementById('supplierListSku');
if (!skuField || skuField.value) return;
if (product.ean) {
skuField.value = product.ean;
} else if (product.sku_internal) {
skuField.value = product.sku_internal;
}
}
async function loadProductDetail() { async function loadProductDetail() {
try { try {
const res = await fetch(`/api/v1/products/${productId}`); const res = await fetch(`/api/v1/products/${productId}`);
@ -238,6 +404,10 @@ async function loadProductDetail() {
document.getElementById('priceNewValue').value = product.sales_price != null ? product.sales_price : ''; document.getElementById('priceNewValue').value = product.sales_price != null ? product.sales_price : '';
document.getElementById('supplierName').value = product.supplier_name || ''; document.getElementById('supplierName').value = product.supplier_name || '';
document.getElementById('supplierPrice').value = product.supplier_price != null ? product.supplier_price : ''; document.getElementById('supplierPrice').value = product.supplier_price != null ? product.supplier_price : '';
document.getElementById('productNameInput').value = product.name || '';
productDetailState.product = product;
renderProductInfo(product);
prefillSupplierSku(product);
} catch (e) { } catch (e) {
setMessage(e.message || 'Fejl ved indlaesning', 'text-danger'); setMessage(e.message || 'Fejl ved indlaesning', 'text-danger');
} }
@ -294,6 +464,183 @@ async function loadSalesHistory() {
} }
} }
async function loadSupplierList() {
const tbody = document.getElementById('supplierListBody');
try {
const res = await fetch(`/api/v1/products/${productId}/suppliers`);
if (!res.ok) throw new Error('Kunne ikke hente grosister');
const suppliers = await res.json();
supplierListState.suppliers = Array.isArray(suppliers) ? suppliers : [];
updateBestPriceBadge(supplierListState.suppliers);
const sorted = getSortedSuppliers();
if (!sorted.length) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center product-muted py-3">Ingen grosister endnu</td></tr>';
return;
}
tbody.innerHTML = sorted.map(entry => {
const link = entry.supplier_product_url || entry.supplier_url;
return `
<tr>
<td>${escapeHtml(entry.supplier_name || entry.supplier_code || '-')}</td>
<td>${escapeHtml(entry.supplier_sku || '-')}</td>
<td>${entry.supplier_price != null ? formatCurrency(entry.supplier_price) : '-'}</td>
<td>${entry.supplier_stock != null ? entry.supplier_stock : '-'}</td>
<td>${link ? `<a href="${escapeHtml(link)}" target="_blank" rel="noopener">Link</a>` : '-'}</td>
<td>${entry.last_updated_at ? formatDate(entry.last_updated_at) : '-'}</td>
<td><button class="btn btn-sm btn-outline-danger" onclick="deleteSupplier(${entry.id})">Slet</button></td>
</tr>
`;
}).join('');
} catch (e) {
tbody.innerHTML = `<tr><td colspan="7" class="text-center text-danger py-3">${escapeHtml(e.message || 'Fejl')}</td></tr>`;
}
}
function getSortedSuppliers() {
const suppliers = supplierListState.suppliers || [];
const sort = supplierListState.sort;
if (sort === 'name') {
return [...suppliers].sort((a, b) => String(a.supplier_name || '').localeCompare(String(b.supplier_name || '')));
}
if (sort === 'stock') {
return [...suppliers].sort((a, b) => (b.supplier_stock || 0) - (a.supplier_stock || 0));
}
if (sort === 'updated') {
return [...suppliers].sort((a, b) => String(b.last_updated_at || '').localeCompare(String(a.last_updated_at || '')));
}
return [...suppliers].sort((a, b) => (a.supplier_price || 0) - (b.supplier_price || 0));
}
function changeSupplierSort(value) {
supplierListState.sort = value;
loadSupplierList();
}
async function submitSupplierList() {
const payload = {
supplier_name: document.getElementById('supplierListName').value.trim() || null,
supplier_code: document.getElementById('supplierListCode').value.trim() || null,
supplier_sku: document.getElementById('supplierListSku').value.trim() || null,
supplier_price: document.getElementById('supplierListPrice').value ? Number(document.getElementById('supplierListPrice').value) : null,
supplier_currency: document.getElementById('supplierListCurrency').value.trim() || null,
supplier_stock: document.getElementById('supplierListStock').value ? Number(document.getElementById('supplierListStock').value) : null,
supplier_product_url: document.getElementById('supplierListUrl').value.trim() || null,
source: 'manual'
};
try {
const res = await fetch(`/api/v1/products/${productId}/suppliers`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || 'Kunne ikke gemme grosist');
}
await res.json();
setSupplierListMessage('Grosist gemt', 'text-success');
await loadSupplierList();
} catch (e) {
setSupplierListMessage(e.message || 'Fejl', 'text-danger');
}
}
async function deleteSupplier(supplierId) {
if (!confirm('Vil du slette denne grosist?')) return;
try {
const res = await fetch(`/api/v1/products/${productId}/suppliers/${supplierId}`, { method: 'DELETE' });
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || 'Sletning fejlede');
}
await res.json();
await loadSupplierList();
} catch (e) {
setSupplierListMessage(e.message || 'Fejl', 'text-danger');
}
}
function useProductEan() {
const skuField = document.getElementById('supplierListSku');
if (!skuField || !productDetailState.product) return;
skuField.value = productDetailState.product.ean || '';
}
function useProductSku() {
const skuField = document.getElementById('supplierListSku');
if (!skuField || !productDetailState.product) return;
skuField.value = productDetailState.product.sku_internal || '';
}
async function refreshSuppliersFromGateway() {
setSupplierListMessage('Opdaterer fra Gateway...', 'text-muted');
try {
const res = await fetch(`/api/v1/products/${productId}/suppliers/refresh`, { method: 'POST' });
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || 'Opdatering fejlede');
}
const payload = await res.json();
const saved = Number(payload.saved || 0);
const queries = Array.isArray(payload.queries) ? payload.queries : [];
const queryText = queries.length
? ` (forsog: ${queries.map((query) => {
if (query.supplier_code) {
return `${query.supplier_code}:${query.q}`;
}
return query.q;
}).join(', ')})`
: '';
if (!saved) {
setSupplierListMessage(`Ingen grosister fundet i Gateway${queryText}`, 'text-warning');
} else {
setSupplierListMessage(`Grosister opdateret (${saved})${queryText}`, 'text-success');
}
await loadSupplierList();
} catch (e) {
setSupplierListMessage(e.message || 'Fejl', 'text-danger');
}
}
async function updateProductName() {
const name = document.getElementById('productNameInput').value.trim();
if (!name) {
setProductNameMessage('Angiv et navn', 'text-danger');
return;
}
try {
const res = await fetch(`/api/v1/products/${productId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || 'Opdatering fejlede');
}
await res.json();
setProductNameMessage('Navn opdateret', 'text-success');
await loadProductDetail();
} catch (e) {
setProductNameMessage(e.message || 'Fejl', 'text-danger');
}
}
async function deleteProduct() {
if (!confirm('Vil du slette dette produkt?')) return;
try {
const res = await fetch(`/api/v1/products/${productId}`, { method: 'DELETE' });
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || 'Sletning fejlede');
}
window.location.href = '/products';
} catch (e) {
setProductNameMessage(e.message || 'Fejl', 'text-danger');
}
}
async function submitPriceUpdate() { async function submitPriceUpdate() {
const newPrice = document.getElementById('priceNewValue').value; const newPrice = document.getElementById('priceNewValue').value;
const note = document.getElementById('priceNote').value.trim(); const note = document.getElementById('priceNote').value.trim();
@ -361,6 +708,7 @@ document.addEventListener('DOMContentLoaded', () => {
loadProductDetail(); loadProductDetail();
loadPriceHistory(); loadPriceHistory();
loadSalesHistory(); loadSalesHistory();
loadSupplierList();
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -71,10 +71,29 @@ async def get_setting(key: str):
"""Get a specific setting by key""" """Get a specific setting by key"""
query = "SELECT * FROM settings WHERE key = %s" query = "SELECT * FROM settings WHERE key = %s"
result = execute_query(query, (key,)) result = execute_query(query, (key,))
if not result and key == "case_types":
seed_query = """
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (key) DO NOTHING
"""
execute_query(
seed_query,
(
"case_types",
'["ticket", "opgave", "ordre", "projekt", "service"]',
"system",
"Sags-typer",
"json",
True,
)
)
result = execute_query(query, (key,))
if not result: if not result:
raise HTTPException(status_code=404, detail="Setting not found") raise HTTPException(status_code=404, detail="Setting not found")
return result[0] return result[0]

View File

@ -236,16 +236,10 @@
<i class="bi bi-headset me-2"></i>Support <i class="bi bi-headset me-2"></i>Support
</a> </a>
<ul class="dropdown-menu mt-2"> <ul class="dropdown-menu mt-2">
<li><a class="dropdown-item py-2" href="/ticket/dashboard"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
<li><a class="dropdown-item py-2" href="/ticket/tickets"><i class="bi bi-ticket-detailed me-2"></i>Alle Tickets</a></li>
<li><a class="dropdown-item py-2" href="/ticket/archived"><i class="bi bi-archive me-2"></i>Arkiverede Tickets</a></li>
<li><a class="dropdown-item py-2" href="/ticket/worklog/review"><i class="bi bi-clock-history me-2"></i>Godkend Worklog</a></li>
<li><a class="dropdown-item py-2" href="/conversations/my"><i class="bi bi-mic me-2"></i>Mine Samtaler</a></li> <li><a class="dropdown-item py-2" href="/conversations/my"><i class="bi bi-mic me-2"></i>Mine Samtaler</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="/hardware"><i class="bi bi-laptop me-2"></i>Hardware Assets</a></li> <li><a class="dropdown-item py-2" href="/hardware"><i class="bi bi-laptop me-2"></i>Hardware Assets</a></li>
<li><a class="dropdown-item py-2" href="/app/locations"><i class="bi bi-map-fill me-2"></i>Lokaliteter</a></li> <li><a class="dropdown-item py-2" href="/app/locations"><i class="bi bi-map-fill me-2"></i>Lokaliteter</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="#">Ny Ticket</a></li>
<li><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li> <li><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li>
<li><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li> <li><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li>
<li><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li> <li><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li>
@ -375,7 +369,7 @@
<h6 class="text-muted text-uppercase small fw-bold mb-0"> <h6 class="text-muted text-uppercase small fw-bold mb-0">
<i class="bi bi-people me-2"></i>CRM <i class="bi bi-people me-2"></i>CRM
</h6> </h6>
<a href="/crm/workflow" class="btn btn-sm btn-outline-primary"> <a href="/devportal" class="btn btn-sm btn-outline-primary">
<i class="bi bi-diagram-3 me-1"></i>Se Workflow <i class="bi bi-diagram-3 me-1"></i>Se Workflow
</a> </a>
</div> </div>
@ -390,7 +384,7 @@
<h6 class="text-muted text-uppercase small fw-bold mb-0"> <h6 class="text-muted text-uppercase small fw-bold mb-0">
<i class="bi bi-headset me-2"></i>Support <i class="bi bi-headset me-2"></i>Support
</h6> </h6>
<a href="/support/workflow" class="btn btn-sm btn-outline-primary"> <a href="/devportal" class="btn btn-sm btn-outline-primary">
<i class="bi bi-diagram-3 me-1"></i>Se Workflow <i class="bi bi-diagram-3 me-1"></i>Se Workflow
</a> </a>
</div> </div>
@ -405,7 +399,7 @@
<h6 class="text-muted text-uppercase small fw-bold mb-0"> <h6 class="text-muted text-uppercase small fw-bold mb-0">
<i class="bi bi-cart3 me-2"></i>Salg <i class="bi bi-cart3 me-2"></i>Salg
</h6> </h6>
<a href="/sales/workflow" class="btn btn-sm btn-outline-primary"> <a href="/devportal" class="btn btn-sm btn-outline-primary">
<i class="bi bi-diagram-3 me-1"></i>Se Workflow <i class="bi bi-diagram-3 me-1"></i>Se Workflow
</a> </a>
</div> </div>
@ -420,7 +414,7 @@
<h6 class="text-muted text-uppercase small fw-bold mb-0"> <h6 class="text-muted text-uppercase small fw-bold mb-0">
<i class="bi bi-currency-dollar me-2"></i>Økonomi <i class="bi bi-currency-dollar me-2"></i>Økonomi
</h6> </h6>
<a href="/finance/workflow" class="btn btn-sm btn-outline-primary"> <a href="/devportal" class="btn btn-sm btn-outline-primary">
<i class="bi bi-diagram-3 me-1"></i>Se Workflow <i class="bi bi-diagram-3 me-1"></i>Se Workflow
</a> </a>
</div> </div>
@ -617,7 +611,7 @@
// Load live statistics for the three boxes // Load live statistics for the three boxes
async function loadLiveStats() { async function loadLiveStats() {
try { try {
const response = await fetch('/api/v1/dashboard/live-stats'); const response = await fetch('/api/v1/live-stats');
const data = await response.json(); const data = await response.json();
// Update Sales Box // Update Sales Box
@ -666,7 +660,7 @@
// Load recent activity // Load recent activity
async function loadRecentActivity() { async function loadRecentActivity() {
try { try {
const response = await fetch('/api/v1/dashboard/recent-activity'); const response = await fetch('/api/v1/recent-activity');
const activities = await response.json(); const activities = await response.json();
const activityList = document.getElementById('recentActivityList'); const activityList = document.getElementById('recentActivityList');
@ -930,7 +924,6 @@
const workflows = { const workflows = {
customer: [ customer: [
{ label: 'Opret ordre', icon: 'cart-plus', action: (data) => window.location.href = `/orders/new?customer=${data.id}` }, { label: 'Opret ordre', icon: 'cart-plus', action: (data) => window.location.href = `/orders/new?customer=${data.id}` },
{ label: 'Opret sag', icon: 'ticket-detailed', action: (data) => window.location.href = `/tickets/new?customer=${data.id}` },
{ label: 'Ring til kontakt', icon: 'telephone', action: (data) => alert('Ring til: ' + (data.phone || 'Intet telefonnummer')) }, { label: 'Ring til kontakt', icon: 'telephone', action: (data) => alert('Ring til: ' + (data.phone || 'Intet telefonnummer')) },
{ label: 'Vis kunde', icon: 'eye', action: (data) => window.location.href = `/customers/${data.id}` } { label: 'Vis kunde', icon: 'eye', action: (data) => window.location.href = `/customers/${data.id}` }
], ],
@ -951,11 +944,6 @@
{ label: 'Opret kassekladde', icon: 'journal-text', action: (data) => alert('Kassekladde funktionalitet kommer snart') }, { label: 'Opret kassekladde', icon: 'journal-text', action: (data) => alert('Kassekladde funktionalitet kommer snart') },
{ label: 'Opret kreditnota', icon: 'file-earmark-minus', action: (data) => window.location.href = `/invoices/${data.id}/credit-note` } { label: 'Opret kreditnota', icon: 'file-earmark-minus', action: (data) => window.location.href = `/invoices/${data.id}/credit-note` }
], ],
ticket: [
{ label: 'Åbn sag', icon: 'folder2-open', action: (data) => window.location.href = `/tickets/${data.id}` },
{ label: 'Luk sag', icon: 'check-circle', action: (data) => alert('Luk sag funktionalitet kommer snart') },
{ label: 'Tildel medarbejder', icon: 'person-plus', action: (data) => alert('Tildel funktionalitet kommer snart') }
],
rodekasse: [ rodekasse: [
{ label: 'Behandle', icon: 'pencil-square', action: (data) => window.location.href = `/rodekasse/${data.id}` }, { label: 'Behandle', icon: 'pencil-square', action: (data) => window.location.href = `/rodekasse/${data.id}` },
{ label: 'Arkiver', icon: 'archive', action: (data) => alert('Arkiver funktionalitet kommer snart') }, { label: 'Arkiver', icon: 'archive', action: (data) => alert('Arkiver funktionalitet kommer snart') },
@ -1353,6 +1341,7 @@
}, 30000); }, 30000);
</script> </script>
{% block scripts %}{% endblock %}
{% block extra_js %}{% endblock %} {% block extra_js %}{% endblock %}
</body> </body>
</html> </html>

View File

@ -252,11 +252,14 @@ async def update_subscription_status(subscription_id: int, payload: Dict[str, An
@router.get("/subscriptions", response_model=List[Dict[str, Any]]) @router.get("/subscriptions", response_model=List[Dict[str, Any]])
async def list_subscriptions(status: str = Query("active")): async def list_subscriptions(status: str = Query("all")):
"""List subscriptions by status (default: active).""" """List subscriptions by status (default: all)."""
try: try:
where_clause = "WHERE s.status = %s" where_clause = ""
params = (status,) params: List[Any] = []
if status and status != "all":
where_clause = "WHERE s.status = %s"
params.append(status)
query = f""" query = f"""
SELECT SELECT
@ -279,25 +282,30 @@ async def list_subscriptions(status: str = Query("active")):
{where_clause} {where_clause}
ORDER BY s.start_date DESC, s.id DESC ORDER BY s.start_date DESC, s.id DESC
""" """
return execute_query(query, params) or [] return execute_query(query, tuple(params)) or []
except Exception as e: except Exception as e:
logger.error(f"❌ Error listing subscriptions: {e}", exc_info=True) logger.error(f"❌ Error listing subscriptions: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/subscriptions/stats/summary", response_model=Dict[str, Any]) @router.get("/subscriptions/stats/summary", response_model=Dict[str, Any])
async def subscription_stats(status: str = Query("active")): async def subscription_stats(status: str = Query("all")):
"""Summary stats for subscriptions by status (default: active).""" """Summary stats for subscriptions by status (default: all)."""
try: try:
query = """ where_clause = ""
params: List[Any] = []
if status and status != "all":
where_clause = "WHERE status = %s"
params.append(status)
query = f"""
SELECT SELECT
COUNT(*) AS subscription_count, COUNT(*) AS subscription_count,
COALESCE(SUM(price), 0) AS total_amount, COALESCE(SUM(price), 0) AS total_amount,
COALESCE(AVG(price), 0) AS avg_amount COALESCE(AVG(price), 0) AS avg_amount
FROM sag_subscriptions FROM sag_subscriptions
WHERE status = %s {where_clause}
""" """
result = execute_query(query, (status,)) result = execute_query(query, tuple(params))
return result[0] if result else { return result[0] if result else {
"subscription_count": 0, "subscription_count": 0,
"total_amount": 0, "total_amount": 0,

View File

@ -9,6 +9,15 @@
<h1 class="h3 mb-0">🔁 Abonnementer</h1> <h1 class="h3 mb-0">🔁 Abonnementer</h1>
<p class="text-muted">Alle solgte, aktive abonnementer</p> <p class="text-muted">Alle solgte, aktive abonnementer</p>
</div> </div>
<div class="col-auto">
<select class="form-select" id="subscriptionStatusFilter" style="min-width: 180px;">
<option value="all" selected>Alle statuser</option>
<option value="active">Aktiv</option>
<option value="paused">Pauset</option>
<option value="cancelled">Opsagt</option>
<option value="draft">Kladde</option>
</select>
</div>
</div> </div>
<div class="row g-3 mb-4" id="statsCards"> <div class="row g-3 mb-4" id="statsCards">
@ -40,7 +49,7 @@
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3"> <div class="card-header bg-white border-0 py-3">
<h5 class="mb-0">Aktive abonnementer</h5> <h5 class="mb-0" id="subscriptionsTitle">Abonnementer</h5>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <div class="table-responsive">
@ -73,13 +82,26 @@
<script> <script>
async function loadSubscriptions() { async function loadSubscriptions() {
try { try {
const stats = await fetch('/api/v1/subscriptions/stats/summary').then(r => r.json()); const status = document.getElementById('subscriptionStatusFilter')?.value || 'all';
const stats = await fetch(`/api/v1/subscriptions/stats/summary?status=${encodeURIComponent(status)}`).then(r => r.json());
document.getElementById('activeCount').textContent = stats.subscription_count || 0; document.getElementById('activeCount').textContent = stats.subscription_count || 0;
document.getElementById('totalAmount').textContent = formatCurrency(stats.total_amount || 0); document.getElementById('totalAmount').textContent = formatCurrency(stats.total_amount || 0);
document.getElementById('avgAmount').textContent = formatCurrency(stats.avg_amount || 0); document.getElementById('avgAmount').textContent = formatCurrency(stats.avg_amount || 0);
const subscriptions = await fetch('/api/v1/subscriptions').then(r => r.json()); const subscriptions = await fetch(`/api/v1/subscriptions?status=${encodeURIComponent(status)}`).then(r => r.json());
renderSubscriptions(subscriptions); renderSubscriptions(subscriptions);
const title = document.getElementById('subscriptionsTitle');
if (title) {
const labelMap = {
all: 'Alle abonnementer',
active: 'Aktive abonnementer',
paused: 'Pausede abonnementer',
cancelled: 'Opsagte abonnementer',
draft: 'Kladder'
};
title.textContent = labelMap[status] || 'Abonnementer';
}
} catch (e) { } catch (e) {
console.error('Error loading subscriptions:', e); console.error('Error loading subscriptions:', e);
document.getElementById('subscriptionsBody').innerHTML = ` document.getElementById('subscriptionsBody').innerHTML = `
@ -159,6 +181,12 @@ function formatDate(dateStr) {
return date.toLocaleDateString('da-DK'); return date.toLocaleDateString('da-DK');
} }
document.addEventListener('DOMContentLoaded', loadSubscriptions); document.addEventListener('DOMContentLoaded', () => {
const filter = document.getElementById('subscriptionStatusFilter');
if (filter) {
filter.addEventListener('change', loadSubscriptions);
}
loadSubscriptions();
});
</script> </script>
{% endblock %} {% endblock %}

View File

@ -18,6 +18,16 @@ router = APIRouter()
templates = Jinja2Templates(directory="app") templates = Jinja2Templates(directory="app")
@router.get("/", include_in_schema=False)
async def ticket_root_redirect():
return RedirectResponse(url="/sag", status_code=302)
@router.get("/{path:path}", include_in_schema=False)
async def ticket_catchall_redirect(path: str):
return RedirectResponse(url="/sag", status_code=302)
def _format_long_text(value: Optional[str]) -> str: def _format_long_text(value: Optional[str]) -> str:
if not value: if not value:
return "" return ""

View File

@ -68,6 +68,8 @@ from app.opportunities.frontend import views as opportunities_views
from app.auth.backend import router as auth_api from app.auth.backend import router as auth_api
from app.auth.backend import views as auth_views from app.auth.backend import views as auth_views
from app.auth.backend import admin as auth_admin_api from app.auth.backend import admin as auth_admin_api
from app.devportal.backend import router as devportal_api
from app.devportal.backend import views as devportal_views
# Modules # Modules
from app.modules.webshop.backend import router as webshop_api from app.modules.webshop.backend import router as webshop_api
@ -263,6 +265,7 @@ app.include_router(hardware_module_api.router, prefix="/api/v1", tags=["Hardware
app.include_router(locations_api, prefix="/api/v1", tags=["Locations"]) app.include_router(locations_api, prefix="/api/v1", tags=["Locations"])
app.include_router(nextcloud_api.router, prefix="/api/v1/nextcloud", tags=["Nextcloud"]) app.include_router(nextcloud_api.router, prefix="/api/v1/nextcloud", tags=["Nextcloud"])
app.include_router(search_api.router, prefix="/api/v1", tags=["Search"]) app.include_router(search_api.router, prefix="/api/v1", tags=["Search"])
app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["Devportal"])
# Frontend Routers # Frontend Routers
app.include_router(dashboard_views.router, tags=["Frontend"]) app.include_router(dashboard_views.router, tags=["Frontend"])
@ -287,6 +290,7 @@ app.include_router(auth_views.router, tags=["Frontend"])
app.include_router(sag_views.router, tags=["Frontend"]) app.include_router(sag_views.router, tags=["Frontend"])
app.include_router(hardware_module_views.router, tags=["Frontend"]) app.include_router(hardware_module_views.router, tags=["Frontend"])
app.include_router(locations_views.router, tags=["Frontend"]) app.include_router(locations_views.router, tags=["Frontend"])
app.include_router(devportal_views.router, tags=["Frontend"])
# Serve static files (UI) # Serve static files (UI)
app.mount("/static", StaticFiles(directory="static", html=True), name="static") app.mount("/static", StaticFiles(directory="static", html=True), name="static")

View File

@ -0,0 +1,30 @@
-- Migration 110: Product suppliers
-- Track multiple suppliers per product
CREATE TABLE IF NOT EXISTS product_suppliers (
id SERIAL PRIMARY KEY,
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
supplier_name VARCHAR(255),
supplier_code VARCHAR(100),
supplier_sku VARCHAR(100),
supplier_price DECIMAL(10, 2),
supplier_currency VARCHAR(10) DEFAULT 'DKK',
supplier_stock INTEGER,
supplier_url TEXT,
supplier_product_url TEXT,
source VARCHAR(50) DEFAULT 'manual',
last_updated_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_product_suppliers_product
ON product_suppliers(product_id);
CREATE INDEX IF NOT EXISTS idx_product_suppliers_supplier
ON product_suppliers(supplier_code, supplier_sku);
CREATE TRIGGER trigger_product_suppliers_updated_at
BEFORE UPDATE ON product_suppliers
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

View File

@ -0,0 +1,17 @@
-- Migration 111: Product audit log
-- Track product changes (rename/delete)
CREATE TABLE IF NOT EXISTS product_audit_log (
id SERIAL PRIMARY KEY,
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
event_type VARCHAR(50) NOT NULL,
user_id INTEGER,
changes JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_product_audit_log_product
ON product_audit_log(product_id);
CREATE INDEX IF NOT EXISTS idx_product_audit_log_created_at
ON product_audit_log(created_at);

View File

@ -0,0 +1,23 @@
-- Migration: 112_locations_add_room_types
-- Created: 2026-02-08
-- Description: Add kantine and moedelokale to location_type allowed values
BEGIN;
ALTER TABLE locations_locations
DROP CONSTRAINT IF EXISTS locations_locations_location_type_check;
ALTER TABLE locations_locations
ADD CONSTRAINT locations_locations_location_type_check
CHECK (location_type IN (
'kompleks',
'bygning',
'etage',
'customer_site',
'rum',
'kantine',
'moedelokale',
'vehicle'
));
COMMIT;

View File

@ -0,0 +1,11 @@
-- Migration 113: Add last_2fa_at to users table
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'last_2fa_at'
) THEN
ALTER TABLE users ADD COLUMN last_2fa_at TIMESTAMP;
END IF;
END $$;