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:
parent
6320809f17
commit
693ac4cfd6
@ -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)
|
||||
)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
# ============================================================================
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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') %}
|
||||
|
||||
|
||||
@ -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 %}
|
||||
|
||||
393
app/modules/locations/templates/wizard.html
Normal file
393
app/modules/locations/templates/wizard.html
Normal 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 %}
|
||||
@ -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)
|
||||
|
||||
@ -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"]:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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>
|
||||
@ -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,
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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 ""
|
||||
|
||||
4
main.py
4
main.py
@ -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")
|
||||
|
||||
30
migrations/110_product_suppliers.sql
Normal file
30
migrations/110_product_suppliers.sql
Normal 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();
|
||||
17
migrations/111_product_audit_log.sql
Normal file
17
migrations/111_product_audit_log.sql
Normal 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);
|
||||
23
migrations/112_locations_add_room_types.sql
Normal file
23
migrations/112_locations_add_room_types.sql
Normal 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;
|
||||
11
migrations/113_auth_2fa_grace.sql
Normal file
11
migrations/113_auth_2fa_grace.sql
Normal 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 $$;
|
||||
Loading…
Reference in New Issue
Block a user