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

View File

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

View File

@ -16,7 +16,7 @@ import logging
logger = logging.getLogger(__name__)
# JWT Settings
SECRET_KEY = getattr(settings, 'JWT_SECRET_KEY', 'your-secret-key-change-in-production')
SECRET_KEY = settings.JWT_SECRET_KEY
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 8 # 8 timer
@ -267,7 +267,7 @@ class AuthService:
user = execute_query_single(
"""SELECT user_id, username, email, password_hash, full_name,
is_active, is_superadmin, failed_login_attempts, locked_until,
is_2fa_enabled, totp_secret
is_2fa_enabled, totp_secret, last_2fa_at
FROM users
WHERE username = %s OR email = %s""",
(username, username))
@ -322,11 +322,18 @@ class AuthService:
logger.warning(f"❌ Login failed: Invalid password - {username} (attempt {failed_attempts})")
return None, "Invalid username or password"
# 2FA check
# 2FA check (only once per grace window)
if user.get('is_2fa_enabled'):
if not user.get('totp_secret'):
return None, "2FA not configured"
last_2fa_at = user.get("last_2fa_at")
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 within_grace:
if not otp_code:
return None, "2FA code required"
@ -334,6 +341,11 @@ class AuthService:
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
execute_update(
"""UPDATE users

View File

@ -33,6 +33,9 @@ class Settings(BaseSettings):
# Security
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"]
CORS_ORIGINS: str = "http://localhost:8000,http://localhost:3000"
@ -44,6 +47,9 @@ class Settings(BaseSettings):
SHADOW_ADMIN_EMAIL: str = "shadowadmin@bmcnetworks.dk"
SHADOW_ADMIN_FULL_NAME: str = "Shadow Administrator"
# 2FA grace period (hours) before re-prompting
TWO_FA_GRACE_HOURS: int = 24
# Logging
LOG_LEVEL: str = "INFO"
LOG_FILE: str = "logs/app.log"

View File

@ -311,6 +311,11 @@
<i class="bi bi-table"></i>Abonnements Matrix
</a>
</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">
<a class="nav-link" data-bs-toggle="tab" href="#hardware">
<i class="bi bi-hdd"></i>Hardware
@ -459,10 +464,26 @@
<i class="bi bi-plus-lg me-2"></i>Tilføj Kontakt
</button>
</div>
<div class="row g-4" id="contactsContainer">
<div class="col-12 text-center py-5">
<div class="table-responsive" id="contactsContainer">
<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>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
@ -610,11 +631,71 @@
</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 -->
<div class="tab-pane fade" id="hardware">
<h5 class="fw-bold mb-4">Hardware</h5>
<div class="text-muted text-center py-5">
Hardwaremodul kommer snart...
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<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>
@ -795,6 +876,118 @@
</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 -->
<div class="modal fade" id="subscriptionModal" tabindex="-1">
<div class="modal-dialog">
@ -949,6 +1142,22 @@ document.addEventListener('DOMContentLoaded', () => {
}, { 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
const activityTab = document.querySelector('a[href="#activity"]');
if (activityTab) {
@ -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) {
const container = document.getElementById('customerTagsContainer');
const emptyState = document.getElementById('customerTagsEmpty');
@ -1342,16 +1626,360 @@ function formatBytes(value) {
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
function openNextcloudCreateUser() {
alert('Opret bruger: kommer snart');
let nextcloudInstanceCache = null;
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() {
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() {
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() {
@ -1428,53 +2056,76 @@ function displayUtilityCompany(payload) {
async function loadContacts() {
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 {
const response = await fetch(`/api/v1/customers/${customerId}/contacts`);
const contacts = await response.json();
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;
}
container.innerHTML = contacts.map(contact => `
<div class="col-md-6">
<div class="contact-card">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h6 class="fw-bold mb-1">${escapeHtml(contact.name)}</h6>
<div class="text-muted small">${contact.title || 'Kontakt'}</div>
</div>
${contact.is_primary ? '<span class="badge bg-primary">Primær</span>' : ''}
</div>
<div class="d-flex flex-column gap-2">
${contact.email ? `
<div class="d-flex align-items-center">
<i class="bi bi-envelope me-2 text-muted"></i>
<a href="mailto:${contact.email}">${contact.email}</a>
</div>
` : ''}
${contact.phone ? `
<div class="d-flex align-items-center">
<i class="bi bi-telephone me-2 text-muted"></i>
<a href="tel:${contact.phone}">${contact.phone}</a>
</div>
` : ''}
${contact.mobile ? `
<div class="d-flex align-items-center">
<i class="bi bi-phone me-2 text-muted"></i>
<a href="tel:${contact.mobile}">${contact.mobile}</a>
</div>
` : ''}
</div>
</div>
</div>
`).join('');
const rows = contacts.map(contact => {
const email = contact.email ? `<a href="mailto:${contact.email}">${escapeHtml(contact.email)}</a>` : '—';
const phone = contact.phone ? `<a href="tel:${contact.phone}">${escapeHtml(contact.phone)}</a>` : '—';
const mobile = contact.mobile ? `<a href="tel:${contact.mobile}">${escapeHtml(contact.mobile)}</a>` : '—';
const title = contact.title ? escapeHtml(contact.title) : '—';
const primaryBadge = contact.is_primary ? '<span class="badge bg-primary">Primær</span>' : '—';
return `
<tr>
<td class="fw-semibold">${escapeHtml(contact.name || '-') }</td>
<td>${title}</td>
<td>${email}</td>
<td>${phone}</td>
<td>${mobile}</td>
<td>${primaryBadge}</td>
</tr>
`;
}).join('');
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>
${rows}
</tbody>
</table>
`;
} catch (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);
}
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) {
const tbody = document.getElementById('customerOpportunitiesTable');
if (!opportunities || opportunities.length === 0) {

View File

@ -377,7 +377,7 @@
</div>
<div class="col-md-3">
<!-- 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">
<i class="bi bi-building-add"></i>
<div>Ny Lokation</div>
@ -461,7 +461,7 @@
{% if cases and cases|length > 0 %}
<div class="list-group list-group-flush">
{% 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">
<h6 class="mb-1 text-primary">{{ case.titel }}</h6>
<small>{{ case.created_at }}</small>

View File

@ -38,7 +38,8 @@ from app.modules.locations.models.schemas import (
OperatingHours, OperatingHoursCreate, OperatingHoursUpdate,
Service, ServiceCreate, ServiceUpdate,
Capacity, CapacityCreate, CapacityUpdate,
BulkUpdateRequest, BulkDeleteRequest, LocationStats
BulkUpdateRequest, BulkDeleteRequest, LocationStats,
LocationWizardCreateRequest, LocationWizardCreateResponse
)
router = APIRouter()
@ -71,7 +72,7 @@ def _normalize_form_data(form_data: Any) -> dict:
@router.get("/locations", response_model=List[Location])
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"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=1000)
@ -80,7 +81,7 @@ async def list_locations(
List all locations with optional filters and pagination.
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)
- skip: Pagination offset (default 0)
- 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
# ============================================================================
@ -467,7 +768,7 @@ async def update_location(id: int, data: LocationUpdate):
detail="customer_id does not exist"
)
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:
logger.warning(f"⚠️ Invalid location_type: {value}")
raise HTTPException(
@ -2587,7 +2888,7 @@ async def bulk_update_locations(data: BulkUpdateRequest):
# Validate location_type if provided
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:
logger.warning(f"⚠️ Invalid location_type: {data.updates['location_type']}")
raise HTTPException(
@ -2804,7 +3105,7 @@ async def get_locations_by_type(
"""
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
Returns: Paginated list of Location objects ordered by name
@ -2815,7 +3116,7 @@ async def get_locations_by_type(
"""
try:
# 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:
logger.warning(f"⚠️ Invalid location_type: {location_type}")
raise HTTPException(

View File

@ -52,6 +52,8 @@ LOCATION_TYPES = [
{"value": "etage", "label": "Etage"},
{"value": "customer_site", "label": "Kundesite"},
{"value": "rum", "label": "Rum"},
{"value": "kantine", "label": "Kantine"},
{"value": "moedelokale", "label": "Mødelokale"},
{"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)}")
# =========================================================================
# 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)
# ============================================================================
@ -341,6 +389,32 @@ def detail_location_view(id: int = Path(..., gt=0)):
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
customers = execute_query("""
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)")
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(
None,
@ -45,7 +45,7 @@ class LocationBase(BaseModel):
@classmethod
def validate_location_type(cls, v):
"""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:
raise ValueError(f'location_type must be one of {allowed}')
return v
@ -61,7 +61,7 @@ class LocationUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255)
location_type: Optional[str] = Field(
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
customer_id: Optional[int] = None
@ -81,7 +81,7 @@ class LocationUpdate(BaseModel):
def validate_location_type(cls, v):
if v is None:
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:
raise ValueError(f'location_type must be one of {allowed}')
return v
@ -291,6 +291,51 @@ class BulkDeleteRequest(BaseModel):
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
# ============================================================================

View File

@ -50,7 +50,7 @@
{% 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 %}
<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>
{% endfor %}
{% endif %}

View File

@ -39,6 +39,8 @@
'bygning': 'Bygning',
'etage': 'Etage',
'rum': 'Rum',
'kantine': 'Kantine',
'moedelokale': 'Mødelokale',
'customer_site': 'Kundesite',
'vehicle': 'Køretøj'
}.get(location.location_type, location.location_type) %}
@ -48,6 +50,8 @@
'bygning': '#1abc9c',
'etage': '#3498db',
'rum': '#e67e22',
'kantine': '#d35400',
'moedelokale': '#16a085',
'customer_site': '#9b59b6',
'vehicle': '#8e44ad'
}.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_label = type_option['label'] if type_option is mapping else type_option %}
<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>
{% endfor %}
{% endif %}

View File

@ -51,7 +51,7 @@
{% 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 %}
<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>
{% endfor %}
{% endif %}

View File

@ -41,7 +41,7 @@
{% 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 %}
<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>
{% endfor %}
{% endif %}
@ -76,6 +76,9 @@
<a href="/app/locations/create" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-2"></i>Opret lokation
</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>
<i class="bi bi-trash me-2"></i>Slet valgte
</button>
@ -115,6 +118,8 @@
'etage': 'Etage',
'customer_site': 'Kundesite',
'rum': 'Rum',
'kantine': 'Kantine',
'moedelokale': 'Mødelokale',
'vehicle': 'Køretøj'
}.get(node.location_type, node.location_type) %}
@ -124,6 +129,8 @@
'etage': '#3498db',
'customer_site': '#9b59b6',
'rum': '#e67e22',
'kantine': '#d35400',
'moedelokale': '#16a085',
'vehicle': '#8e44ad'
}.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_label = type_option['label'] if type_option is mapping else type_option %}
<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>
{% endfor %}
{% 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
@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")
async def list_shares(instance_id: int, customer_id: Optional[int] = Query(None)):
response = await service.list_public_shares(instance_id, customer_id)

View File

@ -179,6 +179,67 @@ class NextcloudService:
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:
instance = self._get_instance(instance_id, customer_id)
if not instance or not instance["is_enabled"]:

View File

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

View File

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

View File

@ -1,13 +1,15 @@
"""
Products API
"""
from fastapi import APIRouter, HTTPException, Query
from fastapi import APIRouter, HTTPException, Query, Depends
from typing import List, Dict, Any, Optional, Tuple
from app.core.database import execute_query, execute_query_single
from app.core.config import settings
from app.core.auth_dependencies import require_permission
import logging
import os
import aiohttp
import json
logger = logging.getLogger(__name__)
router = APIRouter()
@ -20,6 +22,122 @@ def _apigw_headers() -> Dict[str, str]:
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]]:
normalized = " ".join(
"".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,)
)
if existing:
_upsert_product_supplier(existing["id"], product, source="gateway")
return existing
sales_price = product.get("price")
@ -194,7 +313,10 @@ async def import_apigw_product(payload: Dict[str, Any]):
True,
)
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:
raise
except Exception as e:
@ -446,6 +568,191 @@ async def get_product(product_id: int):
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]])
async def list_product_price_history(product_id: int, limit: int = Query(100)):
"""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="product-muted" id="productSku">-</div>
<div class="product-muted" id="productSupplierPrice">-</div>
<div class="badge-soft mt-2" id="productBestPrice" style="display: none;"></div>
</div>
</div>
<div class="row g-3">
<div class="col-lg-4">
<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>
<div class="mb-3">
<label class="form-label">Ny salgspris</label>
@ -128,6 +139,16 @@
</div>
</div>
<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">
<h5 class="mb-3">Pris historik</h5>
<div class="table-responsive">
@ -170,6 +191,78 @@
</table>
</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>
@ -177,6 +270,13 @@
<script>
const productId = {{ product_id }};
const productDetailState = {
product: null
};
const supplierListState = {
suppliers: [],
sort: 'price'
};
function escapeHtml(value) {
return String(value || '').replace(/[&<>"']/g, (ch) => ({
@ -218,6 +318,72 @@ function setSupplierMessage(message, tone = 'text-muted') {
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() {
try {
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('supplierName').value = product.supplier_name || '';
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) {
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() {
const newPrice = document.getElementById('priceNewValue').value;
const note = document.getElementById('priceNote').value.trim();
@ -361,6 +708,7 @@ document.addEventListener('DOMContentLoaded', () => {
loadProductDetail();
loadPriceHistory();
loadSalesHistory();
loadSupplierList();
});
</script>
{% endblock %}

View File

@ -72,6 +72,25 @@ async def get_setting(key: str):
query = "SELECT * FROM settings WHERE key = %s"
result = execute_query(query, (key,))
if not result and key == "case_types":
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:
raise HTTPException(status_code=404, detail="Setting not found")

View File

@ -236,16 +236,10 @@
<i class="bi bi-headset me-2"></i>Support
</a>
<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><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="/app/locations"><i class="bi bi-map-fill me-2"></i>Lokaliteter</a></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="/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>
@ -375,7 +369,7 @@
<h6 class="text-muted text-uppercase small fw-bold mb-0">
<i class="bi bi-people me-2"></i>CRM
</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
</a>
</div>
@ -390,7 +384,7 @@
<h6 class="text-muted text-uppercase small fw-bold mb-0">
<i class="bi bi-headset me-2"></i>Support
</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
</a>
</div>
@ -405,7 +399,7 @@
<h6 class="text-muted text-uppercase small fw-bold mb-0">
<i class="bi bi-cart3 me-2"></i>Salg
</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
</a>
</div>
@ -420,7 +414,7 @@
<h6 class="text-muted text-uppercase small fw-bold mb-0">
<i class="bi bi-currency-dollar me-2"></i>Økonomi
</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
</a>
</div>
@ -617,7 +611,7 @@
// Load live statistics for the three boxes
async function loadLiveStats() {
try {
const response = await fetch('/api/v1/dashboard/live-stats');
const response = await fetch('/api/v1/live-stats');
const data = await response.json();
// Update Sales Box
@ -666,7 +660,7 @@
// Load recent activity
async function loadRecentActivity() {
try {
const response = await fetch('/api/v1/dashboard/recent-activity');
const response = await fetch('/api/v1/recent-activity');
const activities = await response.json();
const activityList = document.getElementById('recentActivityList');
@ -930,7 +924,6 @@
const workflows = {
customer: [
{ 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: '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 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: [
{ label: 'Behandle', icon: 'pencil-square', action: (data) => window.location.href = `/rodekasse/${data.id}` },
{ label: 'Arkiver', icon: 'archive', action: (data) => alert('Arkiver funktionalitet kommer snart') },
@ -1353,6 +1341,7 @@
}, 30000);
</script>
{% block scripts %}{% endblock %}
{% block extra_js %}{% endblock %}
</body>
</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]])
async def list_subscriptions(status: str = Query("active")):
"""List subscriptions by status (default: active)."""
async def list_subscriptions(status: str = Query("all")):
"""List subscriptions by status (default: all)."""
try:
where_clause = ""
params: List[Any] = []
if status and status != "all":
where_clause = "WHERE s.status = %s"
params = (status,)
params.append(status)
query = f"""
SELECT
@ -279,25 +282,30 @@ async def list_subscriptions(status: str = Query("active")):
{where_clause}
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:
logger.error(f"❌ Error listing subscriptions: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/subscriptions/stats/summary", response_model=Dict[str, Any])
async def subscription_stats(status: str = Query("active")):
"""Summary stats for subscriptions by status (default: active)."""
async def subscription_stats(status: str = Query("all")):
"""Summary stats for subscriptions by status (default: all)."""
try:
query = """
where_clause = ""
params: List[Any] = []
if status and status != "all":
where_clause = "WHERE status = %s"
params.append(status)
query = f"""
SELECT
COUNT(*) AS subscription_count,
COALESCE(SUM(price), 0) AS total_amount,
COALESCE(AVG(price), 0) AS avg_amount
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 {
"subscription_count": 0,
"total_amount": 0,

View File

@ -9,6 +9,15 @@
<h1 class="h3 mb-0">🔁 Abonnementer</h1>
<p class="text-muted">Alle solgte, aktive abonnementer</p>
</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 class="row g-3 mb-4" id="statsCards">
@ -40,7 +49,7 @@
<div class="card border-0 shadow-sm">
<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 class="card-body p-0">
<div class="table-responsive">
@ -73,13 +82,26 @@
<script>
async function loadSubscriptions() {
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('totalAmount').textContent = formatCurrency(stats.total_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);
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) {
console.error('Error loading subscriptions:', e);
document.getElementById('subscriptionsBody').innerHTML = `
@ -159,6 +181,12 @@ function formatDate(dateStr) {
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>
{% endblock %}

View File

@ -18,6 +18,16 @@ router = APIRouter()
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:
if not value:
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 views as auth_views
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
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(nextcloud_api.router, prefix="/api/v1/nextcloud", tags=["Nextcloud"])
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
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(hardware_module_views.router, tags=["Frontend"])
app.include_router(locations_views.router, tags=["Frontend"])
app.include_router(devportal_views.router, tags=["Frontend"])
# Serve static files (UI)
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 $$;