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 pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.core.auth_service import AuthService
|
from app.core.auth_service import AuthService
|
||||||
|
from app.core.config import settings
|
||||||
from app.core.auth_dependencies import get_current_user
|
from app.core.auth_dependencies import get_current_user
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@ -26,7 +27,7 @@ class LoginResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class LogoutRequest(BaseModel):
|
class LogoutRequest(BaseModel):
|
||||||
token_jti: str
|
token_jti: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class TwoFactorCodeRequest(BaseModel):
|
class TwoFactorCodeRequest(BaseModel):
|
||||||
@ -74,8 +75,8 @@ async def login(request: Request, credentials: LoginRequest, response: Response)
|
|||||||
key="access_token",
|
key="access_token",
|
||||||
value=access_token,
|
value=access_token,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
samesite="lax",
|
samesite=settings.COOKIE_SAMESITE,
|
||||||
secure=False
|
secure=settings.COOKIE_SECURE
|
||||||
)
|
)
|
||||||
|
|
||||||
return LoginResponse(
|
return LoginResponse(
|
||||||
@ -86,18 +87,20 @@ async def login(request: Request, credentials: LoginRequest, response: Response)
|
|||||||
|
|
||||||
@router.post("/logout")
|
@router.post("/logout")
|
||||||
async def logout(
|
async def logout(
|
||||||
request: LogoutRequest,
|
|
||||||
response: Response,
|
response: Response,
|
||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user),
|
||||||
|
request: Optional[LogoutRequest] = None
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Revoke JWT token (logout)
|
Revoke JWT token (logout)
|
||||||
"""
|
"""
|
||||||
AuthService.revoke_token(
|
token_jti = request.token_jti if request and request.token_jti else current_user.get("token_jti")
|
||||||
request.token_jti,
|
if token_jti:
|
||||||
current_user['id'],
|
AuthService.revoke_token(
|
||||||
current_user.get('is_shadow_admin', False)
|
token_jti,
|
||||||
)
|
current_user['id'],
|
||||||
|
current_user.get('is_shadow_admin', False)
|
||||||
|
)
|
||||||
|
|
||||||
response.delete_cookie("access_token")
|
response.delete_cookie("access_token")
|
||||||
|
|
||||||
|
|||||||
@ -50,6 +50,7 @@ async def get_current_user(
|
|||||||
username = payload.get("username")
|
username = payload.get("username")
|
||||||
is_superadmin = payload.get("is_superadmin", False)
|
is_superadmin = payload.get("is_superadmin", False)
|
||||||
is_shadow_admin = payload.get("shadow_admin", False)
|
is_shadow_admin = payload.get("shadow_admin", False)
|
||||||
|
token_jti = payload.get("jti")
|
||||||
|
|
||||||
# Add IP address to user info
|
# Add IP address to user info
|
||||||
ip_address = request.client.host if request.client else None
|
ip_address = request.client.host if request.client else None
|
||||||
@ -64,6 +65,7 @@ async def get_current_user(
|
|||||||
"is_shadow_admin": True,
|
"is_shadow_admin": True,
|
||||||
"is_2fa_enabled": True,
|
"is_2fa_enabled": True,
|
||||||
"ip_address": ip_address,
|
"ip_address": ip_address,
|
||||||
|
"token_jti": token_jti,
|
||||||
"permissions": AuthService.get_all_permissions()
|
"permissions": AuthService.get_all_permissions()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,6 +83,7 @@ async def get_current_user(
|
|||||||
"is_shadow_admin": False,
|
"is_shadow_admin": False,
|
||||||
"is_2fa_enabled": user_details.get('is_2fa_enabled') if user_details else False,
|
"is_2fa_enabled": user_details.get('is_2fa_enabled') if user_details else False,
|
||||||
"ip_address": ip_address,
|
"ip_address": ip_address,
|
||||||
|
"token_jti": token_jti,
|
||||||
"permissions": AuthService.get_user_permissions(user_id)
|
"permissions": AuthService.get_user_permissions(user_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import logging
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# JWT Settings
|
# JWT Settings
|
||||||
SECRET_KEY = getattr(settings, 'JWT_SECRET_KEY', 'your-secret-key-change-in-production')
|
SECRET_KEY = settings.JWT_SECRET_KEY
|
||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 8 # 8 timer
|
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 8 # 8 timer
|
||||||
|
|
||||||
@ -267,7 +267,7 @@ class AuthService:
|
|||||||
user = execute_query_single(
|
user = execute_query_single(
|
||||||
"""SELECT user_id, username, email, password_hash, full_name,
|
"""SELECT user_id, username, email, password_hash, full_name,
|
||||||
is_active, is_superadmin, failed_login_attempts, locked_until,
|
is_active, is_superadmin, failed_login_attempts, locked_until,
|
||||||
is_2fa_enabled, totp_secret
|
is_2fa_enabled, totp_secret, last_2fa_at
|
||||||
FROM users
|
FROM users
|
||||||
WHERE username = %s OR email = %s""",
|
WHERE username = %s OR email = %s""",
|
||||||
(username, username))
|
(username, username))
|
||||||
@ -322,17 +322,29 @@ class AuthService:
|
|||||||
logger.warning(f"❌ Login failed: Invalid password - {username} (attempt {failed_attempts})")
|
logger.warning(f"❌ Login failed: Invalid password - {username} (attempt {failed_attempts})")
|
||||||
return None, "Invalid username or password"
|
return None, "Invalid username or password"
|
||||||
|
|
||||||
# 2FA check
|
# 2FA check (only once per grace window)
|
||||||
if user.get('is_2fa_enabled'):
|
if user.get('is_2fa_enabled'):
|
||||||
if not user.get('totp_secret'):
|
if not user.get('totp_secret'):
|
||||||
return None, "2FA not configured"
|
return None, "2FA not configured"
|
||||||
|
|
||||||
if not otp_code:
|
last_2fa_at = user.get("last_2fa_at")
|
||||||
return None, "2FA code required"
|
grace_hours = max(1, int(settings.TWO_FA_GRACE_HOURS))
|
||||||
|
grace_window = timedelta(hours=grace_hours)
|
||||||
|
now = datetime.utcnow()
|
||||||
|
within_grace = bool(last_2fa_at and (now - last_2fa_at) < grace_window)
|
||||||
|
|
||||||
if not AuthService.verify_totp_code(user['totp_secret'], otp_code):
|
if not within_grace:
|
||||||
logger.warning(f"❌ Login failed: Invalid 2FA - {username}")
|
if not otp_code:
|
||||||
return None, "Invalid 2FA code"
|
return None, "2FA code required"
|
||||||
|
|
||||||
|
if not AuthService.verify_totp_code(user['totp_secret'], otp_code):
|
||||||
|
logger.warning(f"❌ Login failed: Invalid 2FA - {username}")
|
||||||
|
return None, "Invalid 2FA code"
|
||||||
|
|
||||||
|
execute_update(
|
||||||
|
"UPDATE users SET last_2fa_at = CURRENT_TIMESTAMP WHERE user_id = %s",
|
||||||
|
(user['user_id'],)
|
||||||
|
)
|
||||||
|
|
||||||
# Success! Reset failed attempts and update last login
|
# Success! Reset failed attempts and update last login
|
||||||
execute_update(
|
execute_update(
|
||||||
|
|||||||
@ -33,6 +33,9 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Security
|
# Security
|
||||||
SECRET_KEY: str = "dev-secret-key-change-in-production"
|
SECRET_KEY: str = "dev-secret-key-change-in-production"
|
||||||
|
JWT_SECRET_KEY: str = "dev-jwt-secret-key-change-in-production"
|
||||||
|
COOKIE_SECURE: bool = False
|
||||||
|
COOKIE_SAMESITE: str = "lax"
|
||||||
ALLOWED_ORIGINS: List[str] = ["http://localhost:8000", "http://localhost:3000"]
|
ALLOWED_ORIGINS: List[str] = ["http://localhost:8000", "http://localhost:3000"]
|
||||||
CORS_ORIGINS: str = "http://localhost:8000,http://localhost:3000"
|
CORS_ORIGINS: str = "http://localhost:8000,http://localhost:3000"
|
||||||
|
|
||||||
@ -43,6 +46,9 @@ class Settings(BaseSettings):
|
|||||||
SHADOW_ADMIN_TOTP_SECRET: str = ""
|
SHADOW_ADMIN_TOTP_SECRET: str = ""
|
||||||
SHADOW_ADMIN_EMAIL: str = "shadowadmin@bmcnetworks.dk"
|
SHADOW_ADMIN_EMAIL: str = "shadowadmin@bmcnetworks.dk"
|
||||||
SHADOW_ADMIN_FULL_NAME: str = "Shadow Administrator"
|
SHADOW_ADMIN_FULL_NAME: str = "Shadow Administrator"
|
||||||
|
|
||||||
|
# 2FA grace period (hours) before re-prompting
|
||||||
|
TWO_FA_GRACE_HOURS: int = 24
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL: str = "INFO"
|
LOG_LEVEL: str = "INFO"
|
||||||
|
|||||||
@ -311,6 +311,11 @@
|
|||||||
<i class="bi bi-table"></i>Abonnements Matrix
|
<i class="bi bi-table"></i>Abonnements Matrix
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#locations">
|
||||||
|
<i class="bi bi-geo-alt"></i>Lokationer
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" data-bs-toggle="tab" href="#hardware">
|
<a class="nav-link" data-bs-toggle="tab" href="#hardware">
|
||||||
<i class="bi bi-hdd"></i>Hardware
|
<i class="bi bi-hdd"></i>Hardware
|
||||||
@ -459,10 +464,26 @@
|
|||||||
<i class="bi bi-plus-lg me-2"></i>Tilføj Kontakt
|
<i class="bi bi-plus-lg me-2"></i>Tilføj Kontakt
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="row g-4" id="contactsContainer">
|
<div class="table-responsive" id="contactsContainer">
|
||||||
<div class="col-12 text-center py-5">
|
<table class="table table-hover align-middle mb-0">
|
||||||
<div class="spinner-border text-primary"></div>
|
<thead class="table-light">
|
||||||
</div>
|
<tr>
|
||||||
|
<th>Navn</th>
|
||||||
|
<th>Titel</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Telefon</th>
|
||||||
|
<th>Mobil</th>
|
||||||
|
<th>Primær</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -610,11 +631,71 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Locations Tab -->
|
||||||
|
<div class="tab-pane fade" id="locations">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h5 class="fw-bold mb-0">Lokationer</h5>
|
||||||
|
<small class="text-muted">Lokationer knyttet til kunden</small>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="/app/locations/wizard" class="btn btn-sm btn-primary">
|
||||||
|
<i class="bi bi-plus-lg me-2"></i>Opret lokation
|
||||||
|
</a>
|
||||||
|
<a href="/app/locations" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-list-ul me-2"></i>Se alle
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="customerLocationsList" class="list-group mb-3"></div>
|
||||||
|
<div id="customerLocationsEmpty" class="text-center py-5 text-muted">
|
||||||
|
Ingen lokationer fundet for denne kunde
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Hardware Tab -->
|
<!-- Hardware Tab -->
|
||||||
<div class="tab-pane fade" id="hardware">
|
<div class="tab-pane fade" id="hardware">
|
||||||
<h5 class="fw-bold mb-4">Hardware</h5>
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<div class="text-muted text-center py-5">
|
<div>
|
||||||
Hardwaremodul kommer snart...
|
<h5 class="fw-bold mb-0">Hardware</h5>
|
||||||
|
<small class="text-muted">Hardware knyttet til kunden</small>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<a class="btn btn-sm btn-primary" href="/hardware/new">
|
||||||
|
<i class="bi bi-plus-lg me-2"></i>Tilføj hardware
|
||||||
|
</a>
|
||||||
|
<label for="hardwareGroupBy" class="form-label mb-0 small text-muted">Gruppér efter</label>
|
||||||
|
<select class="form-select form-select-sm" id="hardwareGroupBy" style="min-width: 180px;">
|
||||||
|
<option value="location">Lokation</option>
|
||||||
|
<option value="type">Type</option>
|
||||||
|
<option value="model">Model/Version</option>
|
||||||
|
<option value="vendor">Leverandør</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive" id="customerHardwareContainer">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Hardware</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Serienr.</th>
|
||||||
|
<th>Lokation</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="text-end">Handling</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="customerHardwareEmpty" class="text-center py-5 text-muted d-none">
|
||||||
|
Ingen hardware fundet for denne kunde
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -794,6 +875,118 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Nextcloud Create User Modal -->
|
||||||
|
<div class="modal fade" id="nextcloudCreateUserModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-primary text-white">
|
||||||
|
<h5 class="modal-title"><i class="bi bi-person-plus me-2"></i>Opret Nextcloud bruger</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Søg eksisterende bruger</label>
|
||||||
|
<input type="text" class="form-control" id="ncCreateSearch" placeholder="Skriv for at søge">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Vælg bruger (til grupper)</label>
|
||||||
|
<select class="form-select" id="ncCreateUserSelect"></select>
|
||||||
|
<div class="form-text">Vælg en eksisterende bruger for at kopiere grupper.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Brugernavn *</label>
|
||||||
|
<input type="text" class="form-control" id="ncCreateUid" placeholder="f.eks. fornavn.efternavn" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Navn (display)</label>
|
||||||
|
<input type="text" class="form-control" id="ncCreateDisplayName" placeholder="Visningsnavn">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Email</label>
|
||||||
|
<input type="email" class="form-control" id="ncCreateEmail" placeholder="mail@firma.dk">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Grupper</label>
|
||||||
|
<select class="form-select" id="ncCreateGroups" multiple size="6"></select>
|
||||||
|
<div class="form-text">Hold Cmd/Ctrl nede for at vælge flere.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="createNextcloudUser()">Opret bruger</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nextcloud Reset Password Modal -->
|
||||||
|
<div class="modal fade" id="nextcloudResetPasswordModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-secondary text-white">
|
||||||
|
<h5 class="modal-title"><i class="bi bi-key me-2"></i>Reset Nextcloud password</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Søg bruger</label>
|
||||||
|
<input type="text" class="form-control" id="ncResetSearch" placeholder="Skriv for at søge">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Vælg bruger</label>
|
||||||
|
<select class="form-select" id="ncResetUserSelect"></select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Brugernavn *</label>
|
||||||
|
<input type="text" class="form-control" id="ncResetUid" placeholder="f.eks. fornavn.efternavn" required readonly>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="ncResetSendEmail" checked>
|
||||||
|
<label class="form-check-label" for="ncResetSendEmail">Send email med nyt password</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="resetNextcloudPassword()">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nextcloud Disable User Modal -->
|
||||||
|
<div class="modal fade" id="nextcloudDisableUserModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-danger text-white">
|
||||||
|
<h5 class="modal-title"><i class="bi bi-person-x me-2"></i>Luk Nextcloud bruger</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Søg bruger</label>
|
||||||
|
<input type="text" class="form-control" id="ncDisableSearch" placeholder="Skriv for at søge">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Vælg bruger</label>
|
||||||
|
<select class="form-select" id="ncDisableUserSelect"></select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Brugernavn *</label>
|
||||||
|
<input type="text" class="form-control" id="ncDisableUid" placeholder="f.eks. fornavn.efternavn" required readonly>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-warning mb-0">
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||||
|
Brugeren bliver deaktiveret i Nextcloud.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||||
|
<button type="button" class="btn btn-danger" onclick="disableNextcloudUser()">Luk bruger</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Subscription Modal -->
|
<!-- Subscription Modal -->
|
||||||
<div class="modal fade" id="subscriptionModal" tabindex="-1">
|
<div class="modal fade" id="subscriptionModal" tabindex="-1">
|
||||||
@ -948,6 +1141,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
loadCustomerPipeline();
|
loadCustomerPipeline();
|
||||||
}, { once: false });
|
}, { once: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load locations when tab is shown
|
||||||
|
const locationsTab = document.querySelector('a[href="#locations"]');
|
||||||
|
if (locationsTab) {
|
||||||
|
locationsTab.addEventListener('shown.bs.tab', () => {
|
||||||
|
loadCustomerLocations();
|
||||||
|
}, { once: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load hardware when tab is shown
|
||||||
|
const hardwareTab = document.querySelector('a[href="#hardware"]');
|
||||||
|
if (hardwareTab) {
|
||||||
|
hardwareTab.addEventListener('shown.bs.tab', () => {
|
||||||
|
loadCustomerHardware();
|
||||||
|
}, { once: false });
|
||||||
|
}
|
||||||
|
|
||||||
// Load activity when tab is shown
|
// Load activity when tab is shown
|
||||||
const activityTab = document.querySelector('a[href="#activity"]');
|
const activityTab = document.querySelector('a[href="#activity"]');
|
||||||
@ -1105,6 +1314,81 @@ async function loadCustomerTags() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadCustomerLocations() {
|
||||||
|
const list = document.getElementById('customerLocationsList');
|
||||||
|
const empty = document.getElementById('customerLocationsEmpty');
|
||||||
|
|
||||||
|
if (!list || !empty) return;
|
||||||
|
|
||||||
|
list.innerHTML = `
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
empty.classList.add('d-none');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/locations/by-customer/${customerId}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Kunne ikke hente lokationer');
|
||||||
|
}
|
||||||
|
|
||||||
|
const locations = await response.json();
|
||||||
|
if (!Array.isArray(locations) || locations.length === 0) {
|
||||||
|
list.innerHTML = '';
|
||||||
|
empty.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeLabels = {
|
||||||
|
kompleks: 'Kompleks',
|
||||||
|
bygning: 'Bygning',
|
||||||
|
etage: 'Etage',
|
||||||
|
customer_site: 'Kundesite',
|
||||||
|
rum: 'Rum',
|
||||||
|
kantine: 'Kantine',
|
||||||
|
moedelokale: 'Mødelokale',
|
||||||
|
vehicle: 'Køretøj'
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeColors = {
|
||||||
|
kompleks: '#0f4c75',
|
||||||
|
bygning: '#1abc9c',
|
||||||
|
etage: '#3498db',
|
||||||
|
customer_site: '#9b59b6',
|
||||||
|
rum: '#e67e22',
|
||||||
|
kantine: '#d35400',
|
||||||
|
moedelokale: '#16a085',
|
||||||
|
vehicle: '#8e44ad'
|
||||||
|
};
|
||||||
|
|
||||||
|
list.innerHTML = locations.map(loc => {
|
||||||
|
const typeLabel = typeLabels[loc.location_type] || loc.location_type || '-';
|
||||||
|
const typeColor = typeColors[loc.location_type] || '#6c757d';
|
||||||
|
const city = loc.address_city ? escapeHtml(loc.address_city) : '—';
|
||||||
|
const parent = loc.parent_location_name ? ` · ${escapeHtml(loc.parent_location_name)}` : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<a href="/app/locations/${loc.id}" class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">${escapeHtml(loc.name)}${parent}</div>
|
||||||
|
<div class="text-muted small">${city}</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge" style="background-color: ${typeColor}; color: white;">
|
||||||
|
${typeLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load locations:', error);
|
||||||
|
list.innerHTML = '';
|
||||||
|
empty.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderCustomerTags(tags) {
|
function renderCustomerTags(tags) {
|
||||||
const container = document.getElementById('customerTagsContainer');
|
const container = document.getElementById('customerTagsContainer');
|
||||||
const emptyState = document.getElementById('customerTagsEmpty');
|
const emptyState = document.getElementById('customerTagsEmpty');
|
||||||
@ -1342,16 +1626,360 @@ function formatBytes(value) {
|
|||||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openNextcloudCreateUser() {
|
let nextcloudInstanceCache = null;
|
||||||
alert('Opret bruger: kommer snart');
|
|
||||||
|
async function resolveNextcloudInstance() {
|
||||||
|
if (nextcloudInstanceCache?.id) return nextcloudInstanceCache;
|
||||||
|
const response = await fetch(`/api/v1/nextcloud/customers/${customerId}/instance`);
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const instance = await response.json();
|
||||||
|
if (!instance?.id) return null;
|
||||||
|
nextcloudInstanceCache = instance;
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNextcloudGroups() {
|
||||||
|
const select = document.getElementById('ncCreateGroups');
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
select.innerHTML = '<option value="">Henter grupper...</option>';
|
||||||
|
|
||||||
|
const instance = await resolveNextcloudInstance();
|
||||||
|
if (!instance) {
|
||||||
|
select.innerHTML = '<option value="">Ingen Nextcloud instance</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/nextcloud/instances/${instance.id}/groups?customer_id=${customerId}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
select.innerHTML = '<option value="">Kunne ikke hente grupper</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
const groups = payload?.payload?.ocs?.data?.groups || payload?.groups || [];
|
||||||
|
if (!Array.isArray(groups) || groups.length === 0) {
|
||||||
|
select.innerHTML = '<option value="">Ingen grupper fundet</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
select.innerHTML = groups.map(group => `<option value="${escapeHtml(group)}">${escapeHtml(group)}</option>`).join('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load Nextcloud groups:', error);
|
||||||
|
select.innerHTML = '<option value="">Kunne ikke hente grupper</option>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNextcloudUsers(selectId, inputId, searchValue = '', requireSearch = false) {
|
||||||
|
const select = document.getElementById(selectId);
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
if (requireSearch && !searchValue.trim()) {
|
||||||
|
select.innerHTML = '<option value="">Skriv for at søge</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
select.innerHTML = '<option value="">Henter brugere...</option>';
|
||||||
|
|
||||||
|
const instance = await resolveNextcloudInstance();
|
||||||
|
if (!instance) {
|
||||||
|
select.innerHTML = '<option value="">Ingen Nextcloud instance</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const search = encodeURIComponent(searchValue.trim());
|
||||||
|
const url = new URL(`/api/v1/nextcloud/instances/${instance.id}/users`, window.location.origin);
|
||||||
|
url.searchParams.set('customer_id', customerId);
|
||||||
|
url.searchParams.set('include_details', 'true');
|
||||||
|
url.searchParams.set('limit', '200');
|
||||||
|
if (search) url.searchParams.set('search', search);
|
||||||
|
const response = await fetch(url.toString());
|
||||||
|
if (!response.ok) {
|
||||||
|
select.innerHTML = '<option value="">Kunne ikke hente brugere</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
const users = payload?.users || [];
|
||||||
|
if (!Array.isArray(users) || users.length === 0) {
|
||||||
|
select.innerHTML = '<option value="">Ingen brugere fundet</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = users
|
||||||
|
.map(user => {
|
||||||
|
const uid = escapeHtml(user.uid || '');
|
||||||
|
const display = escapeHtml(user.display_name || '-');
|
||||||
|
const email = escapeHtml(user.email || '-');
|
||||||
|
const label = `${uid} - ${display} (${email})`;
|
||||||
|
return `<option value="${uid}">${label}</option>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
select.innerHTML = options;
|
||||||
|
|
||||||
|
if (inputId) {
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
if (input) {
|
||||||
|
input.value = select.value || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!select.dataset.bound) {
|
||||||
|
select.addEventListener('change', () => {
|
||||||
|
const target = document.getElementById(inputId);
|
||||||
|
if (target) target.value = select.value;
|
||||||
|
});
|
||||||
|
select.dataset.bound = 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load Nextcloud users:', error);
|
||||||
|
select.innerHTML = '<option value="">Kunne ikke hente brugere</option>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyNextcloudUserGroups(uid) {
|
||||||
|
if (!uid) return;
|
||||||
|
const instance = await resolveNextcloudInstance();
|
||||||
|
if (!instance) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/nextcloud/instances/${instance.id}/users/${encodeURIComponent(uid)}?customer_id=${customerId}`);
|
||||||
|
if (!response.ok) return;
|
||||||
|
const payload = await response.json();
|
||||||
|
const data = payload?.payload?.ocs?.data || {};
|
||||||
|
const groups = Array.isArray(data.groups) ? data.groups : [];
|
||||||
|
|
||||||
|
const groupSelect = document.getElementById('ncCreateGroups');
|
||||||
|
if (!groupSelect) return;
|
||||||
|
|
||||||
|
Array.from(groupSelect.options).forEach(option => {
|
||||||
|
option.selected = groups.includes(option.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayInput = document.getElementById('ncCreateDisplayName');
|
||||||
|
const emailInput = document.getElementById('ncCreateEmail');
|
||||||
|
if (displayInput && !displayInput.value && data.displayname) {
|
||||||
|
displayInput.value = data.displayname;
|
||||||
|
}
|
||||||
|
if (emailInput && !emailInput.value && data.email) {
|
||||||
|
emailInput.value = data.email;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to apply Nextcloud groups:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openNextcloudCreateUser() {
|
||||||
|
document.getElementById('ncCreateUid').value = '';
|
||||||
|
document.getElementById('ncCreateDisplayName').value = '';
|
||||||
|
document.getElementById('ncCreateEmail').value = '';
|
||||||
|
const searchInput = document.getElementById('ncCreateSearch');
|
||||||
|
const userSelect = document.getElementById('ncCreateUserSelect');
|
||||||
|
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.value = '';
|
||||||
|
if (!searchInput.dataset.bound) {
|
||||||
|
let createTimer = null;
|
||||||
|
searchInput.addEventListener('input', () => {
|
||||||
|
clearTimeout(createTimer);
|
||||||
|
createTimer = setTimeout(() => {
|
||||||
|
loadNextcloudUsers('ncCreateUserSelect', null, searchInput.value, false);
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
searchInput.dataset.bound = 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userSelect && !userSelect.dataset.bound) {
|
||||||
|
userSelect.addEventListener('change', () => {
|
||||||
|
const uid = userSelect.value;
|
||||||
|
if (uid) applyNextcloudUserGroups(uid);
|
||||||
|
});
|
||||||
|
userSelect.dataset.bound = 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadNextcloudGroups();
|
||||||
|
await loadNextcloudUsers('ncCreateUserSelect', null, searchInput ? searchInput.value : '', false);
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('nextcloudCreateUserModal'));
|
||||||
|
modal.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openNextcloudResetPassword() {
|
function openNextcloudResetPassword() {
|
||||||
alert('Reset password: kommer snart');
|
document.getElementById('ncResetUid').value = '';
|
||||||
|
document.getElementById('ncResetSendEmail').checked = true;
|
||||||
|
const searchInput = document.getElementById('ncResetSearch');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.value = '';
|
||||||
|
if (!searchInput.dataset.bound) {
|
||||||
|
let resetTimer = null;
|
||||||
|
searchInput.addEventListener('input', () => {
|
||||||
|
clearTimeout(resetTimer);
|
||||||
|
resetTimer = setTimeout(() => {
|
||||||
|
loadNextcloudUsers('ncResetUserSelect', 'ncResetUid', searchInput.value, true);
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
searchInput.dataset.bound = 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadNextcloudUsers('ncResetUserSelect', 'ncResetUid', searchInput ? searchInput.value : '', true);
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('nextcloudResetPasswordModal'));
|
||||||
|
modal.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openNextcloudDisableUser() {
|
function openNextcloudDisableUser() {
|
||||||
alert('Luk bruger: kommer snart');
|
document.getElementById('ncDisableUid').value = '';
|
||||||
|
const searchInput = document.getElementById('ncDisableSearch');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.value = '';
|
||||||
|
if (!searchInput.dataset.bound) {
|
||||||
|
let disableTimer = null;
|
||||||
|
searchInput.addEventListener('input', () => {
|
||||||
|
clearTimeout(disableTimer);
|
||||||
|
disableTimer = setTimeout(() => {
|
||||||
|
loadNextcloudUsers('ncDisableUserSelect', 'ncDisableUid', searchInput.value, true);
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
searchInput.dataset.bound = 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadNextcloudUsers('ncDisableUserSelect', 'ncDisableUid', searchInput ? searchInput.value : '', true);
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('nextcloudDisableUserModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNextcloudUser() {
|
||||||
|
const uid = document.getElementById('ncCreateUid').value.trim();
|
||||||
|
if (!uid) {
|
||||||
|
alert('Brugernavn er påkrævet.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = await resolveNextcloudInstance();
|
||||||
|
if (!instance) {
|
||||||
|
alert('Ingen Nextcloud instance konfigureret for denne kunde.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupsSelect = document.getElementById('ncCreateGroups');
|
||||||
|
const groups = Array.from(groupsSelect.selectedOptions || []).map(opt => opt.value).filter(Boolean);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
uid,
|
||||||
|
display_name: document.getElementById('ncCreateDisplayName').value.trim() || null,
|
||||||
|
email: document.getElementById('ncCreateEmail').value.trim() || null,
|
||||||
|
groups,
|
||||||
|
send_welcome: true
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/nextcloud/instances/${instance.id}/users?customer_id=${customerId}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
alert(result.detail || 'Kunne ikke oprette bruger.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.generated_password) {
|
||||||
|
alert(`Bruger oprettet. Genereret password: ${result.generated_password}`);
|
||||||
|
} else {
|
||||||
|
alert('Bruger oprettet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('nextcloudCreateUserModal')).hide();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create Nextcloud user:', error);
|
||||||
|
alert('Kunne ikke oprette bruger.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetNextcloudPassword() {
|
||||||
|
const uid = document.getElementById('ncResetUid').value.trim();
|
||||||
|
if (!uid) {
|
||||||
|
alert('Brugernavn er påkrævet.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = await resolveNextcloudInstance();
|
||||||
|
if (!instance) {
|
||||||
|
alert('Ingen Nextcloud instance konfigureret for denne kunde.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
send_email: document.getElementById('ncResetSendEmail').checked
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/nextcloud/instances/${instance.id}/users/${encodeURIComponent(uid)}/reset-password?customer_id=${customerId}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
alert(result.detail || 'Kunne ikke reset password.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.generated_password) {
|
||||||
|
alert(`Password nulstillet. Genereret password: ${result.generated_password}`);
|
||||||
|
} else {
|
||||||
|
alert('Password nulstillet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('nextcloudResetPasswordModal')).hide();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reset Nextcloud password:', error);
|
||||||
|
alert('Kunne ikke reset password.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disableNextcloudUser() {
|
||||||
|
const uid = document.getElementById('ncDisableUid').value.trim();
|
||||||
|
if (!uid) {
|
||||||
|
alert('Brugernavn er påkrævet.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`Er du sikker på, at du vil deaktivere brugeren ${uid}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = await resolveNextcloudInstance();
|
||||||
|
if (!instance) {
|
||||||
|
alert('Ingen Nextcloud instance konfigureret for denne kunde.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/nextcloud/instances/${instance.id}/users/${encodeURIComponent(uid)}/disable?customer_id=${customerId}`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
alert(result.detail || 'Kunne ikke deaktivere bruger.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
alert('Bruger deaktiveret.');
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('nextcloudDisableUserModal')).hide();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to disable Nextcloud user:', error);
|
||||||
|
alert('Kunne ikke deaktivere bruger.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadUtilityCompany() {
|
async function loadUtilityCompany() {
|
||||||
@ -1428,53 +2056,76 @@ function displayUtilityCompany(payload) {
|
|||||||
|
|
||||||
async function loadContacts() {
|
async function loadContacts() {
|
||||||
const container = document.getElementById('contactsContainer');
|
const container = document.getElementById('contactsContainer');
|
||||||
container.innerHTML = '<div class="col-12 text-center py-5"><div class="spinner-border text-primary"></div></div>';
|
container.innerHTML = `
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Navn</th>
|
||||||
|
<th>Titel</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Telefon</th>
|
||||||
|
<th>Mobil</th>
|
||||||
|
<th>Primær</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/customers/${customerId}/contacts`);
|
const response = await fetch(`/api/v1/customers/${customerId}/contacts`);
|
||||||
const contacts = await response.json();
|
const contacts = await response.json();
|
||||||
|
|
||||||
if (!contacts || contacts.length === 0) {
|
if (!contacts || contacts.length === 0) {
|
||||||
container.innerHTML = '<div class="col-12 text-center py-5 text-muted">Ingen kontakter endnu</div>';
|
container.innerHTML = '<div class="text-center py-5 text-muted">Ingen kontakter endnu</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = contacts.map(contact => `
|
const rows = contacts.map(contact => {
|
||||||
<div class="col-md-6">
|
const email = contact.email ? `<a href="mailto:${contact.email}">${escapeHtml(contact.email)}</a>` : '—';
|
||||||
<div class="contact-card">
|
const phone = contact.phone ? `<a href="tel:${contact.phone}">${escapeHtml(contact.phone)}</a>` : '—';
|
||||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
const mobile = contact.mobile ? `<a href="tel:${contact.mobile}">${escapeHtml(contact.mobile)}</a>` : '—';
|
||||||
<div>
|
const title = contact.title ? escapeHtml(contact.title) : '—';
|
||||||
<h6 class="fw-bold mb-1">${escapeHtml(contact.name)}</h6>
|
const primaryBadge = contact.is_primary ? '<span class="badge bg-primary">Primær</span>' : '—';
|
||||||
<div class="text-muted small">${contact.title || 'Kontakt'}</div>
|
|
||||||
</div>
|
return `
|
||||||
${contact.is_primary ? '<span class="badge bg-primary">Primær</span>' : ''}
|
<tr>
|
||||||
</div>
|
<td class="fw-semibold">${escapeHtml(contact.name || '-') }</td>
|
||||||
<div class="d-flex flex-column gap-2">
|
<td>${title}</td>
|
||||||
${contact.email ? `
|
<td>${email}</td>
|
||||||
<div class="d-flex align-items-center">
|
<td>${phone}</td>
|
||||||
<i class="bi bi-envelope me-2 text-muted"></i>
|
<td>${mobile}</td>
|
||||||
<a href="mailto:${contact.email}">${contact.email}</a>
|
<td>${primaryBadge}</td>
|
||||||
</div>
|
</tr>
|
||||||
` : ''}
|
`;
|
||||||
${contact.phone ? `
|
}).join('');
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<i class="bi bi-telephone me-2 text-muted"></i>
|
container.innerHTML = `
|
||||||
<a href="tel:${contact.phone}">${contact.phone}</a>
|
<table class="table table-hover align-middle mb-0">
|
||||||
</div>
|
<thead class="table-light">
|
||||||
` : ''}
|
<tr>
|
||||||
${contact.mobile ? `
|
<th>Navn</th>
|
||||||
<div class="d-flex align-items-center">
|
<th>Titel</th>
|
||||||
<i class="bi bi-phone me-2 text-muted"></i>
|
<th>Email</th>
|
||||||
<a href="tel:${contact.mobile}">${contact.mobile}</a>
|
<th>Telefon</th>
|
||||||
</div>
|
<th>Mobil</th>
|
||||||
` : ''}
|
<th>Primær</th>
|
||||||
</div>
|
</tr>
|
||||||
</div>
|
</thead>
|
||||||
</div>
|
<tbody>
|
||||||
`).join('');
|
${rows}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load contacts:', error);
|
console.error('Failed to load contacts:', error);
|
||||||
container.innerHTML = '<div class="col-12 text-center py-5 text-danger">Kunne ikke indlæse kontakter</div>';
|
container.innerHTML = '<div class="text-center py-5 text-danger">Kunne ikke indlæse kontakter</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1537,6 +2188,180 @@ async function loadCustomerPipeline() {
|
|||||||
renderCustomerPipeline(opportunities);
|
renderCustomerPipeline(opportunities);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let customerHardware = [];
|
||||||
|
let hardwareLocationsById = {};
|
||||||
|
|
||||||
|
function getHardwareGroupLabel(item, groupBy) {
|
||||||
|
if (groupBy === 'location') {
|
||||||
|
return item.location_name || 'Ukendt lokation';
|
||||||
|
}
|
||||||
|
if (groupBy === 'type') {
|
||||||
|
return item.asset_type ? item.asset_type.replace('_', ' ') : 'Ukendt type';
|
||||||
|
}
|
||||||
|
if (groupBy === 'model') {
|
||||||
|
const brand = item.brand || '';
|
||||||
|
const model = item.model || '';
|
||||||
|
const label = `${brand} ${model}`.trim();
|
||||||
|
return label || 'Ukendt model';
|
||||||
|
}
|
||||||
|
if (groupBy === 'vendor') {
|
||||||
|
return item.brand || 'Ukendt leverandør';
|
||||||
|
}
|
||||||
|
return 'Ukendt';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHardwareTable(groupBy) {
|
||||||
|
const container = document.getElementById('customerHardwareContainer');
|
||||||
|
const empty = document.getElementById('customerHardwareEmpty');
|
||||||
|
|
||||||
|
if (!container || !empty) return;
|
||||||
|
|
||||||
|
if (!customerHardware.length) {
|
||||||
|
container.classList.add('d-none');
|
||||||
|
empty.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.classList.remove('d-none');
|
||||||
|
empty.classList.add('d-none');
|
||||||
|
|
||||||
|
const grouped = {};
|
||||||
|
customerHardware.forEach(item => {
|
||||||
|
const label = getHardwareGroupLabel(item, groupBy);
|
||||||
|
if (!grouped[label]) {
|
||||||
|
grouped[label] = [];
|
||||||
|
}
|
||||||
|
grouped[label].push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedGroups = Object.keys(grouped).sort((a, b) => a.localeCompare(b, 'da-DK'));
|
||||||
|
|
||||||
|
const rows = sortedGroups.map(group => {
|
||||||
|
const items = grouped[group];
|
||||||
|
const groupRow = `
|
||||||
|
<tr class="table-secondary">
|
||||||
|
<td colspan="6" class="fw-semibold">${escapeHtml(group)} (${items.length})</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const itemRows = items.map(item => {
|
||||||
|
const assetLabel = `${item.brand || ''} ${item.model || ''}`.trim() || 'Ukendt hardware';
|
||||||
|
const serial = item.serial_number || '—';
|
||||||
|
const location = item.location_name || '—';
|
||||||
|
const status = item.status || '—';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td class="fw-semibold">${escapeHtml(assetLabel)}</td>
|
||||||
|
<td>${escapeHtml(item.asset_type || '—')}</td>
|
||||||
|
<td>${escapeHtml(serial)}</td>
|
||||||
|
<td>${escapeHtml(location)}</td>
|
||||||
|
<td>${escapeHtml(status)}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<a class="btn btn-sm btn-outline-primary" href="/hardware/${item.id}">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return groupRow + itemRows;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Hardware</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Serienr.</th>
|
||||||
|
<th>Lokation</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="text-end">Handling</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${rows}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCustomerHardware() {
|
||||||
|
const container = document.getElementById('customerHardwareContainer');
|
||||||
|
const empty = document.getElementById('customerHardwareEmpty');
|
||||||
|
const groupBySelect = document.getElementById('hardwareGroupBy');
|
||||||
|
|
||||||
|
if (!container || !empty || !groupBySelect) return;
|
||||||
|
|
||||||
|
container.classList.remove('d-none');
|
||||||
|
empty.classList.add('d-none');
|
||||||
|
container.innerHTML = `
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Hardware</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Serienr.</th>
|
||||||
|
<th>Lokation</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="text-end">Handling</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/hardware?customer_id=${customerId}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Kunne ikke hente hardware');
|
||||||
|
}
|
||||||
|
const hardware = await response.json();
|
||||||
|
customerHardware = Array.isArray(hardware) ? hardware : [];
|
||||||
|
|
||||||
|
const locationIds = customerHardware
|
||||||
|
.map(item => item.current_location_id)
|
||||||
|
.filter(id => id !== null && id !== undefined);
|
||||||
|
|
||||||
|
hardwareLocationsById = {};
|
||||||
|
if (locationIds.length > 0) {
|
||||||
|
const uniqueIds = Array.from(new Set(locationIds));
|
||||||
|
const locationsResponse = await fetch(`/api/v1/locations/by-ids?ids=${uniqueIds.join(',')}`);
|
||||||
|
if (locationsResponse.ok) {
|
||||||
|
const locations = await locationsResponse.json();
|
||||||
|
(locations || []).forEach(loc => {
|
||||||
|
hardwareLocationsById[loc.id] = loc.name;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customerHardware = customerHardware.map(item => ({
|
||||||
|
...item,
|
||||||
|
location_name: hardwareLocationsById[item.current_location_id] || item.location_name || null
|
||||||
|
}));
|
||||||
|
|
||||||
|
renderHardwareTable(groupBySelect.value || 'location');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load hardware:', error);
|
||||||
|
container.classList.add('d-none');
|
||||||
|
empty.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('change', (event) => {
|
||||||
|
if (event.target && event.target.id === 'hardwareGroupBy') {
|
||||||
|
renderHardwareTable(event.target.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function renderCustomerPipeline(opportunities) {
|
function renderCustomerPipeline(opportunities) {
|
||||||
const tbody = document.getElementById('customerOpportunitiesTable');
|
const tbody = document.getElementById('customerOpportunitiesTable');
|
||||||
if (!opportunities || opportunities.length === 0) {
|
if (!opportunities || opportunities.length === 0) {
|
||||||
|
|||||||
@ -377,7 +377,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<!-- Link to create new location, pre-filled? Or just general create -->
|
<!-- Link to create new location, pre-filled? Or just general create -->
|
||||||
<a href="/locations" class="text-decoration-none">
|
<a href="/app/locations" class="text-decoration-none">
|
||||||
<div class="action-card">
|
<div class="action-card">
|
||||||
<i class="bi bi-building-add"></i>
|
<i class="bi bi-building-add"></i>
|
||||||
<div>Ny Lokation</div>
|
<div>Ny Lokation</div>
|
||||||
@ -461,7 +461,7 @@
|
|||||||
{% if cases and cases|length > 0 %}
|
{% if cases and cases|length > 0 %}
|
||||||
<div class="list-group list-group-flush">
|
<div class="list-group list-group-flush">
|
||||||
{% for case in cases %}
|
{% for case in cases %}
|
||||||
<a href="/cases/{{ case.case_id }}" class="list-group-item list-group-item-action py-3 px-2">
|
<a href="/sag/{{ case.case_id }}" class="list-group-item list-group-item-action py-3 px-2">
|
||||||
<div class="d-flex w-100 justify-content-between">
|
<div class="d-flex w-100 justify-content-between">
|
||||||
<h6 class="mb-1 text-primary">{{ case.titel }}</h6>
|
<h6 class="mb-1 text-primary">{{ case.titel }}</h6>
|
||||||
<small>{{ case.created_at }}</small>
|
<small>{{ case.created_at }}</small>
|
||||||
|
|||||||
@ -38,7 +38,8 @@ from app.modules.locations.models.schemas import (
|
|||||||
OperatingHours, OperatingHoursCreate, OperatingHoursUpdate,
|
OperatingHours, OperatingHoursCreate, OperatingHoursUpdate,
|
||||||
Service, ServiceCreate, ServiceUpdate,
|
Service, ServiceCreate, ServiceUpdate,
|
||||||
Capacity, CapacityCreate, CapacityUpdate,
|
Capacity, CapacityCreate, CapacityUpdate,
|
||||||
BulkUpdateRequest, BulkDeleteRequest, LocationStats
|
BulkUpdateRequest, BulkDeleteRequest, LocationStats,
|
||||||
|
LocationWizardCreateRequest, LocationWizardCreateResponse
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -71,7 +72,7 @@ def _normalize_form_data(form_data: Any) -> dict:
|
|||||||
|
|
||||||
@router.get("/locations", response_model=List[Location])
|
@router.get("/locations", response_model=List[Location])
|
||||||
async def list_locations(
|
async def list_locations(
|
||||||
location_type: Optional[str] = Query(None, description="Filter by location type (kompleks, bygning, etage, customer_site, rum, vehicle)"),
|
location_type: Optional[str] = Query(None, description="Filter by location type (kompleks, bygning, etage, customer_site, rum, kantine, moedelokale, vehicle)"),
|
||||||
is_active: Optional[bool] = Query(None, description="Filter by active status"),
|
is_active: Optional[bool] = Query(None, description="Filter by active status"),
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(50, ge=1, le=1000)
|
limit: int = Query(50, ge=1, le=1000)
|
||||||
@ -80,7 +81,7 @@ async def list_locations(
|
|||||||
List all locations with optional filters and pagination.
|
List all locations with optional filters and pagination.
|
||||||
|
|
||||||
Query Parameters:
|
Query Parameters:
|
||||||
- location_type: Filter by type (kompleks, bygning, etage, customer_site, rum, vehicle)
|
- location_type: Filter by type (kompleks, bygning, etage, customer_site, rum, kantine, moedelokale, vehicle)
|
||||||
- is_active: Filter by active status (true/false)
|
- is_active: Filter by active status (true/false)
|
||||||
- skip: Pagination offset (default 0)
|
- skip: Pagination offset (default 0)
|
||||||
- limit: Results per page (default 50, max 1000)
|
- limit: Results per page (default 50, max 1000)
|
||||||
@ -255,6 +256,306 @@ async def create_location(request: Request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 11b. GET /api/v1/locations/by-customer/{customer_id} - Filter by customer
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/locations/by-customer/{customer_id}", response_model=List[Location])
|
||||||
|
async def get_locations_by_customer(customer_id: int):
|
||||||
|
"""
|
||||||
|
Get all locations linked to a customer.
|
||||||
|
|
||||||
|
Path parameter: customer_id
|
||||||
|
Returns: List of Location objects ordered by name
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
query = """
|
||||||
|
SELECT l.*, p.name AS parent_location_name, c.name AS customer_name
|
||||||
|
FROM locations_locations l
|
||||||
|
LEFT JOIN locations_locations p ON l.parent_location_id = p.id
|
||||||
|
LEFT JOIN customers c ON l.customer_id = c.id
|
||||||
|
WHERE l.customer_id = %s AND l.deleted_at IS NULL
|
||||||
|
ORDER BY l.name ASC
|
||||||
|
"""
|
||||||
|
results = execute_query(query, (customer_id,))
|
||||||
|
return [Location(**row) for row in results]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error getting customer locations: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Failed to get locations by customer"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 11c. GET /api/v1/locations/by-ids - Fetch by IDs
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/locations/by-ids", response_model=List[Location])
|
||||||
|
async def get_locations_by_ids(ids: str = Query(..., description="Comma-separated location IDs")):
|
||||||
|
"""
|
||||||
|
Get locations by a comma-separated list of IDs.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
id_values = [int(value) for value in ids.split(',') if value.strip().isdigit()]
|
||||||
|
if not id_values:
|
||||||
|
return []
|
||||||
|
|
||||||
|
placeholders = ",".join(["%s"] * len(id_values))
|
||||||
|
query = f"""
|
||||||
|
SELECT l.*, p.name AS parent_location_name, c.name AS customer_name
|
||||||
|
FROM locations_locations l
|
||||||
|
LEFT JOIN locations_locations p ON l.parent_location_id = p.id
|
||||||
|
LEFT JOIN customers c ON l.customer_id = c.id
|
||||||
|
WHERE l.id IN ({placeholders}) AND l.deleted_at IS NULL
|
||||||
|
ORDER BY l.name ASC
|
||||||
|
"""
|
||||||
|
results = execute_query(query, tuple(id_values))
|
||||||
|
return [Location(**row) for row in results]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error getting locations by ids: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Failed to get locations by ids"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 2b. POST /api/v1/locations/bulk-create - Create location with floors/rooms
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@router.post("/locations/bulk-create", response_model=LocationWizardCreateResponse)
|
||||||
|
async def bulk_create_location_hierarchy(data: LocationWizardCreateRequest):
|
||||||
|
"""
|
||||||
|
Create a root location with floors and rooms in a single request.
|
||||||
|
|
||||||
|
Request body: LocationWizardCreateRequest
|
||||||
|
Returns: IDs of created locations
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
root = data.root
|
||||||
|
auto_suffix = data.auto_suffix
|
||||||
|
|
||||||
|
payload_names = [root.name]
|
||||||
|
for floor in data.floors:
|
||||||
|
payload_names.append(floor.name)
|
||||||
|
for room in floor.rooms:
|
||||||
|
payload_names.append(room.name)
|
||||||
|
|
||||||
|
normalized_names = [name.strip().lower() for name in payload_names if name]
|
||||||
|
if not auto_suffix and len(normalized_names) != len(set(normalized_names)):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Duplicate names found in wizard payload"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not auto_suffix:
|
||||||
|
placeholders = ",".join(["%s"] * len(payload_names))
|
||||||
|
existing_query = f"""
|
||||||
|
SELECT name FROM locations_locations
|
||||||
|
WHERE name IN ({placeholders}) AND deleted_at IS NULL
|
||||||
|
"""
|
||||||
|
existing = execute_query(existing_query, tuple(payload_names))
|
||||||
|
if existing:
|
||||||
|
existing_names = ", ".join(sorted({row.get("name") for row in existing if row.get("name")}))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Locations already exist with names: {existing_names}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if root.customer_id is not None:
|
||||||
|
customer_query = "SELECT id FROM customers WHERE id = %s AND deleted_at IS NULL"
|
||||||
|
customer = execute_query(customer_query, (root.customer_id,))
|
||||||
|
if not customer:
|
||||||
|
raise HTTPException(status_code=400, detail="customer_id does not exist")
|
||||||
|
|
||||||
|
if root.parent_location_id is not None:
|
||||||
|
parent_query = "SELECT id FROM locations_locations WHERE id = %s AND deleted_at IS NULL"
|
||||||
|
parent = execute_query(parent_query, (root.parent_location_id,))
|
||||||
|
if not parent:
|
||||||
|
raise HTTPException(status_code=400, detail="parent_location_id does not exist")
|
||||||
|
|
||||||
|
insert_query = """
|
||||||
|
INSERT INTO locations_locations (
|
||||||
|
name, location_type, parent_location_id, customer_id, address_street, address_city,
|
||||||
|
address_postal_code, address_country, latitude, longitude,
|
||||||
|
phone, email, notes, is_active, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
|
||||||
|
reserved_names = set()
|
||||||
|
|
||||||
|
def _normalize_name(value: str) -> str:
|
||||||
|
return (value or "").strip().lower()
|
||||||
|
|
||||||
|
def _name_exists(value: str) -> bool:
|
||||||
|
normalized = _normalize_name(value)
|
||||||
|
if normalized in reserved_names:
|
||||||
|
return True
|
||||||
|
check_query = "SELECT 1 FROM locations_locations WHERE name = %s AND deleted_at IS NULL"
|
||||||
|
existing = execute_query(check_query, (value,))
|
||||||
|
return bool(existing)
|
||||||
|
|
||||||
|
def _reserve_name(value: str) -> None:
|
||||||
|
normalized = _normalize_name(value)
|
||||||
|
if normalized:
|
||||||
|
reserved_names.add(normalized)
|
||||||
|
|
||||||
|
def _resolve_unique_name(base_name: str) -> str:
|
||||||
|
if not auto_suffix:
|
||||||
|
_reserve_name(base_name)
|
||||||
|
return base_name
|
||||||
|
base_name = base_name.strip()
|
||||||
|
if not _name_exists(base_name):
|
||||||
|
_reserve_name(base_name)
|
||||||
|
return base_name
|
||||||
|
suffix = 2
|
||||||
|
while True:
|
||||||
|
candidate = f"{base_name} ({suffix})"
|
||||||
|
if not _name_exists(candidate):
|
||||||
|
_reserve_name(candidate)
|
||||||
|
return candidate
|
||||||
|
suffix += 1
|
||||||
|
|
||||||
|
def insert_location_record(
|
||||||
|
name: str,
|
||||||
|
location_type: str,
|
||||||
|
parent_location_id: Optional[int],
|
||||||
|
customer_id: Optional[int],
|
||||||
|
address_street: Optional[str],
|
||||||
|
address_city: Optional[str],
|
||||||
|
address_postal_code: Optional[str],
|
||||||
|
address_country: Optional[str],
|
||||||
|
latitude: Optional[float],
|
||||||
|
longitude: Optional[float],
|
||||||
|
phone: Optional[str],
|
||||||
|
email: Optional[str],
|
||||||
|
notes: Optional[str],
|
||||||
|
is_active: bool
|
||||||
|
) -> Location:
|
||||||
|
params = (
|
||||||
|
name,
|
||||||
|
location_type,
|
||||||
|
parent_location_id,
|
||||||
|
customer_id,
|
||||||
|
address_street,
|
||||||
|
address_city,
|
||||||
|
address_postal_code,
|
||||||
|
address_country,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
phone,
|
||||||
|
email,
|
||||||
|
notes,
|
||||||
|
is_active
|
||||||
|
)
|
||||||
|
result = execute_query(insert_query, params)
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create location")
|
||||||
|
return Location(**result[0])
|
||||||
|
|
||||||
|
resolved_root_name = _resolve_unique_name(root.name)
|
||||||
|
root_location = insert_location_record(
|
||||||
|
name=resolved_root_name,
|
||||||
|
location_type=root.location_type,
|
||||||
|
parent_location_id=root.parent_location_id,
|
||||||
|
customer_id=root.customer_id,
|
||||||
|
address_street=root.address_street,
|
||||||
|
address_city=root.address_city,
|
||||||
|
address_postal_code=root.address_postal_code,
|
||||||
|
address_country=root.address_country,
|
||||||
|
latitude=root.latitude,
|
||||||
|
longitude=root.longitude,
|
||||||
|
phone=root.phone,
|
||||||
|
email=root.email,
|
||||||
|
notes=root.notes,
|
||||||
|
is_active=root.is_active
|
||||||
|
)
|
||||||
|
|
||||||
|
audit_query = """
|
||||||
|
INSERT INTO locations_audit_log (location_id, event_type, user_id, changes, created_at)
|
||||||
|
VALUES (%s, %s, %s, %s, NOW())
|
||||||
|
"""
|
||||||
|
root_changes = root.model_dump()
|
||||||
|
root_changes["name"] = resolved_root_name
|
||||||
|
execute_query(audit_query, (root_location.id, 'created', None, json.dumps({"after": root_changes})))
|
||||||
|
|
||||||
|
floor_ids: List[int] = []
|
||||||
|
room_ids: List[int] = []
|
||||||
|
|
||||||
|
for floor in data.floors:
|
||||||
|
resolved_floor_name = _resolve_unique_name(floor.name)
|
||||||
|
floor_location = insert_location_record(
|
||||||
|
name=resolved_floor_name,
|
||||||
|
location_type=floor.location_type,
|
||||||
|
parent_location_id=root_location.id,
|
||||||
|
customer_id=root.customer_id,
|
||||||
|
address_street=root.address_street,
|
||||||
|
address_city=root.address_city,
|
||||||
|
address_postal_code=root.address_postal_code,
|
||||||
|
address_country=root.address_country,
|
||||||
|
latitude=root.latitude,
|
||||||
|
longitude=root.longitude,
|
||||||
|
phone=root.phone,
|
||||||
|
email=root.email,
|
||||||
|
notes=None,
|
||||||
|
is_active=floor.is_active
|
||||||
|
)
|
||||||
|
floor_ids.append(floor_location.id)
|
||||||
|
execute_query(audit_query, (
|
||||||
|
floor_location.id,
|
||||||
|
'created',
|
||||||
|
None,
|
||||||
|
json.dumps({"after": {"name": resolved_floor_name, "location_type": floor.location_type, "parent_location_id": root_location.id}})
|
||||||
|
))
|
||||||
|
|
||||||
|
for room in floor.rooms:
|
||||||
|
resolved_room_name = _resolve_unique_name(room.name)
|
||||||
|
room_location = insert_location_record(
|
||||||
|
name=resolved_room_name,
|
||||||
|
location_type=room.location_type,
|
||||||
|
parent_location_id=floor_location.id,
|
||||||
|
customer_id=root.customer_id,
|
||||||
|
address_street=root.address_street,
|
||||||
|
address_city=root.address_city,
|
||||||
|
address_postal_code=root.address_postal_code,
|
||||||
|
address_country=root.address_country,
|
||||||
|
latitude=root.latitude,
|
||||||
|
longitude=root.longitude,
|
||||||
|
phone=root.phone,
|
||||||
|
email=root.email,
|
||||||
|
notes=None,
|
||||||
|
is_active=room.is_active
|
||||||
|
)
|
||||||
|
room_ids.append(room_location.id)
|
||||||
|
execute_query(audit_query, (
|
||||||
|
room_location.id,
|
||||||
|
'created',
|
||||||
|
None,
|
||||||
|
json.dumps({"after": {"name": resolved_room_name, "location_type": room.location_type, "parent_location_id": floor_location.id}})
|
||||||
|
))
|
||||||
|
|
||||||
|
created_total = 1 + len(floor_ids) + len(room_ids)
|
||||||
|
logger.info("✅ Wizard created %s locations (root=%s)", created_total, root_location.id)
|
||||||
|
|
||||||
|
return LocationWizardCreateResponse(
|
||||||
|
root_id=root_location.id,
|
||||||
|
floor_ids=floor_ids,
|
||||||
|
room_ids=room_ids,
|
||||||
|
created_total=created_total
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error creating location hierarchy: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create location hierarchy")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# 3. GET /api/v1/locations/{id} - Get single location with all relationships
|
# 3. GET /api/v1/locations/{id} - Get single location with all relationships
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@ -467,7 +768,7 @@ async def update_location(id: int, data: LocationUpdate):
|
|||||||
detail="customer_id does not exist"
|
detail="customer_id does not exist"
|
||||||
)
|
)
|
||||||
if key == 'location_type':
|
if key == 'location_type':
|
||||||
allowed_types = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'vehicle']
|
allowed_types = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'kantine', 'moedelokale', 'vehicle']
|
||||||
if value not in allowed_types:
|
if value not in allowed_types:
|
||||||
logger.warning(f"⚠️ Invalid location_type: {value}")
|
logger.warning(f"⚠️ Invalid location_type: {value}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@ -2587,7 +2888,7 @@ async def bulk_update_locations(data: BulkUpdateRequest):
|
|||||||
|
|
||||||
# Validate location_type if provided
|
# Validate location_type if provided
|
||||||
if 'location_type' in data.updates:
|
if 'location_type' in data.updates:
|
||||||
allowed_types = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'vehicle']
|
allowed_types = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'kantine', 'moedelokale', 'vehicle']
|
||||||
if data.updates['location_type'] not in allowed_types:
|
if data.updates['location_type'] not in allowed_types:
|
||||||
logger.warning(f"⚠️ Invalid location_type: {data.updates['location_type']}")
|
logger.warning(f"⚠️ Invalid location_type: {data.updates['location_type']}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@ -2804,7 +3105,7 @@ async def get_locations_by_type(
|
|||||||
"""
|
"""
|
||||||
Get all locations of a specific type with pagination.
|
Get all locations of a specific type with pagination.
|
||||||
|
|
||||||
Path parameter: location_type - one of (kompleks, bygning, etage, customer_site, rum, vehicle)
|
Path parameter: location_type - one of (kompleks, bygning, etage, customer_site, rum, kantine, moedelokale, vehicle)
|
||||||
Query parameters: skip, limit for pagination
|
Query parameters: skip, limit for pagination
|
||||||
|
|
||||||
Returns: Paginated list of Location objects ordered by name
|
Returns: Paginated list of Location objects ordered by name
|
||||||
@ -2815,7 +3116,7 @@ async def get_locations_by_type(
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Validate location_type is one of allowed values
|
# Validate location_type is one of allowed values
|
||||||
allowed_types = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'vehicle']
|
allowed_types = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'kantine', 'moedelokale', 'vehicle']
|
||||||
if location_type not in allowed_types:
|
if location_type not in allowed_types:
|
||||||
logger.warning(f"⚠️ Invalid location_type: {location_type}")
|
logger.warning(f"⚠️ Invalid location_type: {location_type}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@ -52,6 +52,8 @@ LOCATION_TYPES = [
|
|||||||
{"value": "etage", "label": "Etage"},
|
{"value": "etage", "label": "Etage"},
|
||||||
{"value": "customer_site", "label": "Kundesite"},
|
{"value": "customer_site", "label": "Kundesite"},
|
||||||
{"value": "rum", "label": "Rum"},
|
{"value": "rum", "label": "Rum"},
|
||||||
|
{"value": "kantine", "label": "Kantine"},
|
||||||
|
{"value": "moedelokale", "label": "Mødelokale"},
|
||||||
{"value": "vehicle", "label": "Køretøj"},
|
{"value": "vehicle", "label": "Køretøj"},
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -307,6 +309,52 @@ def create_location_view():
|
|||||||
raise HTTPException(status_code=500, detail=f"Error rendering create form: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Error rendering create form: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 2b. GET /app/locations/wizard - Wizard for floors and rooms
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@router.get("/app/locations/wizard", response_class=HTMLResponse)
|
||||||
|
def location_wizard_view():
|
||||||
|
"""
|
||||||
|
Render the location wizard form.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info("🧭 Rendering location wizard")
|
||||||
|
|
||||||
|
parent_locations = execute_query("""
|
||||||
|
SELECT id, name, location_type
|
||||||
|
FROM locations_locations
|
||||||
|
WHERE is_active = true
|
||||||
|
ORDER BY name
|
||||||
|
LIMIT 1000
|
||||||
|
""")
|
||||||
|
|
||||||
|
customers = execute_query("""
|
||||||
|
SELECT id, name, email, phone
|
||||||
|
FROM customers
|
||||||
|
WHERE deleted_at IS NULL AND is_active = true
|
||||||
|
ORDER BY name
|
||||||
|
LIMIT 1000
|
||||||
|
""")
|
||||||
|
|
||||||
|
html = render_template(
|
||||||
|
"modules/locations/templates/wizard.html",
|
||||||
|
location_types=LOCATION_TYPES,
|
||||||
|
parent_locations=parent_locations,
|
||||||
|
customers=customers,
|
||||||
|
cancel_url="/app/locations",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("✅ Rendered location wizard")
|
||||||
|
return HTMLResponse(content=html)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error rendering wizard: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error rendering wizard: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# 3. GET /app/locations/{id} - Detail view (HTML)
|
# 3. GET /app/locations/{id} - Detail view (HTML)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@ -341,6 +389,32 @@ def detail_location_view(id: int = Path(..., gt=0)):
|
|||||||
|
|
||||||
location = location[0] # Get first result
|
location = location[0] # Get first result
|
||||||
|
|
||||||
|
hierarchy = []
|
||||||
|
current_parent_id = location.get("parent_location_id")
|
||||||
|
while current_parent_id:
|
||||||
|
parent = execute_query(
|
||||||
|
"SELECT id, name, location_type, parent_location_id FROM locations_locations WHERE id = %s",
|
||||||
|
(current_parent_id,)
|
||||||
|
)
|
||||||
|
if not parent:
|
||||||
|
break
|
||||||
|
parent_row = parent[0]
|
||||||
|
hierarchy.insert(0, parent_row)
|
||||||
|
current_parent_id = parent_row.get("parent_location_id")
|
||||||
|
|
||||||
|
children = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT id, name, location_type
|
||||||
|
FROM locations_locations
|
||||||
|
WHERE parent_location_id = %s AND deleted_at IS NULL
|
||||||
|
ORDER BY name
|
||||||
|
""",
|
||||||
|
(id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
location["hierarchy"] = hierarchy
|
||||||
|
location["children"] = children
|
||||||
|
|
||||||
# Query customers
|
# Query customers
|
||||||
customers = execute_query("""
|
customers = execute_query("""
|
||||||
SELECT id, name, email, phone
|
SELECT id, name, email, phone
|
||||||
|
|||||||
@ -20,7 +20,7 @@ class LocationBase(BaseModel):
|
|||||||
name: str = Field(..., min_length=1, max_length=255, description="Location name (unique)")
|
name: str = Field(..., min_length=1, max_length=255, description="Location name (unique)")
|
||||||
location_type: str = Field(
|
location_type: str = Field(
|
||||||
...,
|
...,
|
||||||
description="Type: kompleks | bygning | etage | customer_site | rum | vehicle"
|
description="Type: kompleks | bygning | etage | customer_site | rum | kantine | moedelokale | vehicle"
|
||||||
)
|
)
|
||||||
parent_location_id: Optional[int] = Field(
|
parent_location_id: Optional[int] = Field(
|
||||||
None,
|
None,
|
||||||
@ -45,7 +45,7 @@ class LocationBase(BaseModel):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def validate_location_type(cls, v):
|
def validate_location_type(cls, v):
|
||||||
"""Validate location_type is one of allowed values"""
|
"""Validate location_type is one of allowed values"""
|
||||||
allowed = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'vehicle']
|
allowed = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'kantine', 'moedelokale', 'vehicle']
|
||||||
if v not in allowed:
|
if v not in allowed:
|
||||||
raise ValueError(f'location_type must be one of {allowed}')
|
raise ValueError(f'location_type must be one of {allowed}')
|
||||||
return v
|
return v
|
||||||
@ -61,7 +61,7 @@ class LocationUpdate(BaseModel):
|
|||||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
location_type: Optional[str] = Field(
|
location_type: Optional[str] = Field(
|
||||||
None,
|
None,
|
||||||
description="Type: kompleks | bygning | etage | customer_site | rum | vehicle"
|
description="Type: kompleks | bygning | etage | customer_site | rum | kantine | moedelokale | vehicle"
|
||||||
)
|
)
|
||||||
parent_location_id: Optional[int] = None
|
parent_location_id: Optional[int] = None
|
||||||
customer_id: Optional[int] = None
|
customer_id: Optional[int] = None
|
||||||
@ -81,7 +81,7 @@ class LocationUpdate(BaseModel):
|
|||||||
def validate_location_type(cls, v):
|
def validate_location_type(cls, v):
|
||||||
if v is None:
|
if v is None:
|
||||||
return v
|
return v
|
||||||
allowed = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'vehicle']
|
allowed = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'kantine', 'moedelokale', 'vehicle']
|
||||||
if v not in allowed:
|
if v not in allowed:
|
||||||
raise ValueError(f'location_type must be one of {allowed}')
|
raise ValueError(f'location_type must be one of {allowed}')
|
||||||
return v
|
return v
|
||||||
@ -291,6 +291,51 @@ class BulkDeleteRequest(BaseModel):
|
|||||||
ids: List[int] = Field(..., min_items=1, description="Location IDs to soft-delete")
|
ids: List[int] = Field(..., min_items=1, description="Location IDs to soft-delete")
|
||||||
|
|
||||||
|
|
||||||
|
class LocationWizardRoom(BaseModel):
|
||||||
|
"""Room definition for location wizard"""
|
||||||
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
location_type: str = Field("rum", description="Type: rum | kantine | moedelokale")
|
||||||
|
is_active: bool = Field(True, description="Whether room is active")
|
||||||
|
|
||||||
|
@field_validator('location_type')
|
||||||
|
@classmethod
|
||||||
|
def validate_room_type(cls, v):
|
||||||
|
allowed = ['rum', 'kantine', 'moedelokale']
|
||||||
|
if v not in allowed:
|
||||||
|
raise ValueError(f'location_type must be one of {allowed}')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class LocationWizardFloor(BaseModel):
|
||||||
|
"""Floor definition for location wizard"""
|
||||||
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
location_type: str = Field("etage", description="Type: etage")
|
||||||
|
rooms: List[LocationWizardRoom] = Field(default_factory=list)
|
||||||
|
is_active: bool = Field(True, description="Whether floor is active")
|
||||||
|
|
||||||
|
@field_validator('location_type')
|
||||||
|
@classmethod
|
||||||
|
def validate_floor_type(cls, v):
|
||||||
|
if v != 'etage':
|
||||||
|
raise ValueError('location_type must be etage for floors')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class LocationWizardCreateRequest(BaseModel):
|
||||||
|
"""Request for creating a location with floors and rooms"""
|
||||||
|
root: LocationCreate
|
||||||
|
floors: List[LocationWizardFloor] = Field(..., min_items=1)
|
||||||
|
auto_suffix: bool = Field(True, description="Auto-suffix names if duplicates exist")
|
||||||
|
|
||||||
|
|
||||||
|
class LocationWizardCreateResponse(BaseModel):
|
||||||
|
"""Response for location wizard creation"""
|
||||||
|
root_id: int
|
||||||
|
floor_ids: List[int] = Field(default_factory=list)
|
||||||
|
room_ids: List[int] = Field(default_factory=list)
|
||||||
|
created_total: int
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# 7. RESPONSE MODELS
|
# 7. RESPONSE MODELS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@ -50,7 +50,7 @@
|
|||||||
{% set option_value = type_option['value'] if type_option is mapping else type_option %}
|
{% set option_value = type_option['value'] if type_option is mapping else type_option %}
|
||||||
{% set option_label = type_option['label'] if type_option is mapping else type_option %}
|
{% set option_label = type_option['label'] if type_option is mapping else type_option %}
|
||||||
<option value="{{ option_value }}">
|
<option value="{{ option_value }}">
|
||||||
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
|
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'kantine' %}Kantine{% elif option_value == 'moedelokale' %}Mødelokale{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -39,6 +39,8 @@
|
|||||||
'bygning': 'Bygning',
|
'bygning': 'Bygning',
|
||||||
'etage': 'Etage',
|
'etage': 'Etage',
|
||||||
'rum': 'Rum',
|
'rum': 'Rum',
|
||||||
|
'kantine': 'Kantine',
|
||||||
|
'moedelokale': 'Mødelokale',
|
||||||
'customer_site': 'Kundesite',
|
'customer_site': 'Kundesite',
|
||||||
'vehicle': 'Køretøj'
|
'vehicle': 'Køretøj'
|
||||||
}.get(location.location_type, location.location_type) %}
|
}.get(location.location_type, location.location_type) %}
|
||||||
@ -48,6 +50,8 @@
|
|||||||
'bygning': '#1abc9c',
|
'bygning': '#1abc9c',
|
||||||
'etage': '#3498db',
|
'etage': '#3498db',
|
||||||
'rum': '#e67e22',
|
'rum': '#e67e22',
|
||||||
|
'kantine': '#d35400',
|
||||||
|
'moedelokale': '#16a085',
|
||||||
'customer_site': '#9b59b6',
|
'customer_site': '#9b59b6',
|
||||||
'vehicle': '#8e44ad'
|
'vehicle': '#8e44ad'
|
||||||
}.get(location.location_type, '#6c757d') %}
|
}.get(location.location_type, '#6c757d') %}
|
||||||
@ -512,7 +516,7 @@
|
|||||||
{% set option_value = type_option['value'] if type_option is mapping else type_option %}
|
{% set option_value = type_option['value'] if type_option is mapping else type_option %}
|
||||||
{% set option_label = type_option['label'] if type_option is mapping else type_option %}
|
{% set option_label = type_option['label'] if type_option is mapping else type_option %}
|
||||||
<option value="{{ option_value }}">
|
<option value="{{ option_value }}">
|
||||||
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
|
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'kantine' %}Kantine{% elif option_value == 'moedelokale' %}Mødelokale{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -51,7 +51,7 @@
|
|||||||
{% set option_value = type_option['value'] if type_option is mapping else type_option %}
|
{% set option_value = type_option['value'] if type_option is mapping else type_option %}
|
||||||
{% set option_label = type_option['label'] if type_option is mapping else type_option %}
|
{% set option_label = type_option['label'] if type_option is mapping else type_option %}
|
||||||
<option value="{{ option_value }}" {% if location.location_type == option_value %}selected{% endif %}>
|
<option value="{{ option_value }}" {% if location.location_type == option_value %}selected{% endif %}>
|
||||||
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
|
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'kantine' %}Kantine{% elif option_value == 'moedelokale' %}Mødelokale{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -41,7 +41,7 @@
|
|||||||
{% set option_value = type_option['value'] if type_option is mapping else type_option %}
|
{% set option_value = type_option['value'] if type_option is mapping else type_option %}
|
||||||
{% set option_label = type_option['label'] if type_option is mapping else type_option %}
|
{% set option_label = type_option['label'] if type_option is mapping else type_option %}
|
||||||
<option value="{{ option_value }}" {% if location_type == option_value %}selected{% endif %}>
|
<option value="{{ option_value }}" {% if location_type == option_value %}selected{% endif %}>
|
||||||
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
|
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'kantine' %}Kantine{% elif option_value == 'moedelokale' %}Mødelokale{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -76,6 +76,9 @@
|
|||||||
<a href="/app/locations/create" class="btn btn-primary btn-sm">
|
<a href="/app/locations/create" class="btn btn-primary btn-sm">
|
||||||
<i class="bi bi-plus-lg me-2"></i>Opret lokation
|
<i class="bi bi-plus-lg me-2"></i>Opret lokation
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/app/locations/wizard" class="btn btn-outline-primary btn-sm">
|
||||||
|
<i class="bi bi-diagram-3 me-2"></i>Wizard
|
||||||
|
</a>
|
||||||
<button type="button" class="btn btn-outline-danger btn-sm" id="bulkDeleteBtn" disabled>
|
<button type="button" class="btn btn-outline-danger btn-sm" id="bulkDeleteBtn" disabled>
|
||||||
<i class="bi bi-trash me-2"></i>Slet valgte
|
<i class="bi bi-trash me-2"></i>Slet valgte
|
||||||
</button>
|
</button>
|
||||||
@ -115,6 +118,8 @@
|
|||||||
'etage': 'Etage',
|
'etage': 'Etage',
|
||||||
'customer_site': 'Kundesite',
|
'customer_site': 'Kundesite',
|
||||||
'rum': 'Rum',
|
'rum': 'Rum',
|
||||||
|
'kantine': 'Kantine',
|
||||||
|
'moedelokale': 'Mødelokale',
|
||||||
'vehicle': 'Køretøj'
|
'vehicle': 'Køretøj'
|
||||||
}.get(node.location_type, node.location_type) %}
|
}.get(node.location_type, node.location_type) %}
|
||||||
|
|
||||||
@ -124,6 +129,8 @@
|
|||||||
'etage': '#3498db',
|
'etage': '#3498db',
|
||||||
'customer_site': '#9b59b6',
|
'customer_site': '#9b59b6',
|
||||||
'rum': '#e67e22',
|
'rum': '#e67e22',
|
||||||
|
'kantine': '#d35400',
|
||||||
|
'moedelokale': '#16a085',
|
||||||
'vehicle': '#8e44ad'
|
'vehicle': '#8e44ad'
|
||||||
}.get(node.location_type, '#6c757d') %}
|
}.get(node.location_type, '#6c757d') %}
|
||||||
|
|
||||||
|
|||||||
@ -32,7 +32,7 @@
|
|||||||
{% set option_value = type_option['value'] if type_option is mapping else type_option %}
|
{% set option_value = type_option['value'] if type_option is mapping else type_option %}
|
||||||
{% set option_label = type_option['label'] if type_option is mapping else type_option %}
|
{% set option_label = type_option['label'] if type_option is mapping else type_option %}
|
||||||
<option value="{{ option_value }}">
|
<option value="{{ option_value }}">
|
||||||
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
|
{% if option_value == 'kompleks' %}Kompleks{% elif option_value == 'bygning' %}Bygning{% elif option_value == 'etage' %}Etage{% elif option_value == 'customer_site' %}Kundesite{% elif option_value == 'rum' %}Rum{% elif option_value == 'kantine' %}Kantine{% elif option_value == 'moedelokale' %}Mødelokale{% elif option_value == 'vehicle' %}Køretøj{% else %}{{ option_label }}{% endif %}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
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
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/instances/{instance_id}/users")
|
||||||
|
async def list_users(
|
||||||
|
instance_id: int,
|
||||||
|
customer_id: Optional[int] = Query(None),
|
||||||
|
search: Optional[str] = Query(None),
|
||||||
|
include_details: bool = Query(False),
|
||||||
|
limit: int = Query(200, ge=1, le=500),
|
||||||
|
):
|
||||||
|
if include_details:
|
||||||
|
response = await service.list_users_details(instance_id, customer_id, search, limit)
|
||||||
|
else:
|
||||||
|
response = await service.list_users(instance_id, customer_id, search)
|
||||||
|
if customer_id is not None:
|
||||||
|
_audit(
|
||||||
|
customer_id,
|
||||||
|
instance_id,
|
||||||
|
"users",
|
||||||
|
{
|
||||||
|
"instance_id": instance_id,
|
||||||
|
"search": search,
|
||||||
|
"include_details": include_details,
|
||||||
|
"limit": limit,
|
||||||
|
},
|
||||||
|
response,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/instances/{instance_id}/users/{uid}")
|
||||||
|
async def get_user_details(
|
||||||
|
instance_id: int,
|
||||||
|
uid: str,
|
||||||
|
customer_id: Optional[int] = Query(None),
|
||||||
|
):
|
||||||
|
response = await service.get_user_details(instance_id, uid, customer_id)
|
||||||
|
if customer_id is not None:
|
||||||
|
_audit(
|
||||||
|
customer_id,
|
||||||
|
instance_id,
|
||||||
|
"user_details",
|
||||||
|
{"instance_id": instance_id, "uid": uid},
|
||||||
|
response,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.get("/instances/{instance_id}/shares")
|
@router.get("/instances/{instance_id}/shares")
|
||||||
async def list_shares(instance_id: int, customer_id: Optional[int] = Query(None)):
|
async def list_shares(instance_id: int, customer_id: Optional[int] = Query(None)):
|
||||||
response = await service.list_public_shares(instance_id, customer_id)
|
response = await service.list_public_shares(instance_id, customer_id)
|
||||||
|
|||||||
@ -179,6 +179,67 @@ class NextcloudService:
|
|||||||
use_cache=True,
|
use_cache=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def list_users(
|
||||||
|
self,
|
||||||
|
instance_id: int,
|
||||||
|
customer_id: Optional[int] = None,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
|
instance = self._get_instance(instance_id, customer_id)
|
||||||
|
if not instance or not instance["is_enabled"]:
|
||||||
|
return {"users": []}
|
||||||
|
|
||||||
|
params = {"search": search} if search else None
|
||||||
|
return await self._ocs_request(
|
||||||
|
instance,
|
||||||
|
"/ocs/v1.php/cloud/users",
|
||||||
|
method="GET",
|
||||||
|
params=params,
|
||||||
|
use_cache=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_user_details(
|
||||||
|
self,
|
||||||
|
instance_id: int,
|
||||||
|
uid: str,
|
||||||
|
customer_id: Optional[int] = None,
|
||||||
|
) -> dict:
|
||||||
|
instance = self._get_instance(instance_id, customer_id)
|
||||||
|
if not instance or not instance["is_enabled"]:
|
||||||
|
return {"user": None}
|
||||||
|
|
||||||
|
return await self._ocs_request(
|
||||||
|
instance,
|
||||||
|
f"/ocs/v1.php/cloud/users/{uid}",
|
||||||
|
method="GET",
|
||||||
|
use_cache=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_users_details(
|
||||||
|
self,
|
||||||
|
instance_id: int,
|
||||||
|
customer_id: Optional[int] = None,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
limit: int = 200,
|
||||||
|
) -> dict:
|
||||||
|
response = await self.list_users(instance_id, customer_id, search)
|
||||||
|
users = response.get("payload", {}).get("ocs", {}).get("data", {}).get("users", [])
|
||||||
|
if not isinstance(users, list):
|
||||||
|
users = []
|
||||||
|
|
||||||
|
users = users[: max(1, min(limit, 500))]
|
||||||
|
detailed = []
|
||||||
|
for uid in users:
|
||||||
|
detail_resp = await self.get_user_details(instance_id, uid, customer_id)
|
||||||
|
data = detail_resp.get("payload", {}).get("ocs", {}).get("data", {}) if isinstance(detail_resp, dict) else {}
|
||||||
|
detailed.append({
|
||||||
|
"uid": uid,
|
||||||
|
"display_name": data.get("displayname") if isinstance(data, dict) else None,
|
||||||
|
"email": data.get("email") if isinstance(data, dict) else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"users": detailed}
|
||||||
|
|
||||||
async def list_public_shares(self, instance_id: int, customer_id: Optional[int] = None) -> dict:
|
async def list_public_shares(self, instance_id: int, customer_id: Optional[int] = None) -> dict:
|
||||||
instance = self._get_instance(instance_id, customer_id)
|
instance = self._get_instance(instance_id, customer_id)
|
||||||
if not instance or not instance["is_enabled"]:
|
if not instance or not instance["is_enabled"]:
|
||||||
|
|||||||
@ -32,11 +32,15 @@ async def list_sager(
|
|||||||
tag: Optional[str] = Query(None),
|
tag: Optional[str] = Query(None),
|
||||||
customer_id: Optional[int] = Query(None),
|
customer_id: Optional[int] = Query(None),
|
||||||
ansvarlig_bruger_id: Optional[int] = Query(None),
|
ansvarlig_bruger_id: Optional[int] = Query(None),
|
||||||
|
include_deferred: bool = Query(False),
|
||||||
):
|
):
|
||||||
"""List all cases with optional filtering."""
|
"""List all cases with optional filtering."""
|
||||||
try:
|
try:
|
||||||
query = "SELECT * FROM sag_sager WHERE deleted_at IS NULL"
|
query = "SELECT * FROM sag_sager WHERE deleted_at IS NULL"
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
|
if not include_deferred:
|
||||||
|
query += " AND (deferred_until IS NULL OR deferred_until <= NOW())"
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
query += " AND status = %s"
|
query += " AND status = %s"
|
||||||
|
|||||||
@ -41,8 +41,11 @@ async def sager_liste(
|
|||||||
params = []
|
params = []
|
||||||
|
|
||||||
if not include_deferred:
|
if not include_deferred:
|
||||||
query += " AND (s.deferred_until IS NULL OR s.deferred_until <= NOW())"
|
query += " AND ("
|
||||||
query += " AND (s.deferred_until_case_id IS NULL OR s.deferred_until_status IS NULL OR ds.status = s.deferred_until_status)"
|
query += "s.deferred_until IS NULL"
|
||||||
|
query += " OR s.deferred_until <= NOW()"
|
||||||
|
query += " OR (s.deferred_until_case_id IS NOT NULL AND s.deferred_until_status IS NOT NULL AND ds.status = s.deferred_until_status)"
|
||||||
|
query += ")"
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
query += " AND s.status = %s"
|
query += " AND s.status = %s"
|
||||||
|
|||||||
@ -1,13 +1,15 @@
|
|||||||
"""
|
"""
|
||||||
Products API
|
Products API
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query, Depends
|
||||||
from typing import List, Dict, Any, Optional, Tuple
|
from typing import List, Dict, Any, Optional, Tuple
|
||||||
from app.core.database import execute_query, execute_query_single
|
from app.core.database import execute_query, execute_query_single
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.auth_dependencies import require_permission
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
import json
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -20,6 +22,122 @@ def _apigw_headers() -> Dict[str, str]:
|
|||||||
return {"Authorization": f"Bearer {token}"}
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: str = "manual") -> Dict[str, Any]:
|
||||||
|
supplier_name = payload.get("supplier_name")
|
||||||
|
supplier_code = payload.get("supplier_code")
|
||||||
|
supplier_sku = payload.get("supplier_sku") or payload.get("sku")
|
||||||
|
supplier_price = payload.get("supplier_price") or payload.get("price")
|
||||||
|
supplier_currency = payload.get("supplier_currency") or payload.get("currency") or "DKK"
|
||||||
|
supplier_stock = payload.get("supplier_stock") or payload.get("stock_qty")
|
||||||
|
supplier_url = payload.get("supplier_url") or payload.get("supplier_link")
|
||||||
|
supplier_product_url = (
|
||||||
|
payload.get("supplier_product_url")
|
||||||
|
or payload.get("product_url")
|
||||||
|
or payload.get("product_link")
|
||||||
|
or payload.get("url")
|
||||||
|
)
|
||||||
|
|
||||||
|
match_query = None
|
||||||
|
match_params = None
|
||||||
|
if supplier_code and supplier_sku:
|
||||||
|
match_query = """
|
||||||
|
SELECT * FROM product_suppliers
|
||||||
|
WHERE product_id = %s AND supplier_code = %s AND supplier_sku = %s
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
match_params = (product_id, supplier_code, supplier_sku)
|
||||||
|
elif supplier_url:
|
||||||
|
match_query = """
|
||||||
|
SELECT * FROM product_suppliers
|
||||||
|
WHERE product_id = %s AND supplier_url = %s
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
match_params = (product_id, supplier_url)
|
||||||
|
elif supplier_name and supplier_sku:
|
||||||
|
match_query = """
|
||||||
|
SELECT * FROM product_suppliers
|
||||||
|
WHERE product_id = %s AND supplier_name = %s AND supplier_sku = %s
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
match_params = (product_id, supplier_name, supplier_sku)
|
||||||
|
|
||||||
|
existing = execute_query_single(match_query, match_params) if match_query else None
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
update_query = """
|
||||||
|
UPDATE product_suppliers
|
||||||
|
SET supplier_name = %s,
|
||||||
|
supplier_code = %s,
|
||||||
|
supplier_sku = %s,
|
||||||
|
supplier_price = %s,
|
||||||
|
supplier_currency = %s,
|
||||||
|
supplier_stock = %s,
|
||||||
|
supplier_url = %s,
|
||||||
|
supplier_product_url = %s,
|
||||||
|
source = %s,
|
||||||
|
last_updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
result = execute_query(
|
||||||
|
update_query,
|
||||||
|
(
|
||||||
|
supplier_name,
|
||||||
|
supplier_code,
|
||||||
|
supplier_sku,
|
||||||
|
supplier_price,
|
||||||
|
supplier_currency,
|
||||||
|
supplier_stock,
|
||||||
|
supplier_url,
|
||||||
|
supplier_product_url,
|
||||||
|
source,
|
||||||
|
existing.get("id"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result[0] if result else existing
|
||||||
|
|
||||||
|
insert_query = """
|
||||||
|
INSERT INTO product_suppliers (
|
||||||
|
product_id,
|
||||||
|
supplier_name,
|
||||||
|
supplier_code,
|
||||||
|
supplier_sku,
|
||||||
|
supplier_price,
|
||||||
|
supplier_currency,
|
||||||
|
supplier_stock,
|
||||||
|
supplier_url,
|
||||||
|
supplier_product_url,
|
||||||
|
source,
|
||||||
|
last_updated_at
|
||||||
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
result = execute_query(
|
||||||
|
insert_query,
|
||||||
|
(
|
||||||
|
product_id,
|
||||||
|
supplier_name,
|
||||||
|
supplier_code,
|
||||||
|
supplier_sku,
|
||||||
|
supplier_price,
|
||||||
|
supplier_currency,
|
||||||
|
supplier_stock,
|
||||||
|
supplier_url,
|
||||||
|
supplier_product_url,
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result[0] if result else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _log_product_audit(product_id: int, event_type: str, user_id: Optional[int], changes: Dict[str, Any]) -> None:
|
||||||
|
audit_query = """
|
||||||
|
INSERT INTO product_audit_log (product_id, event_type, user_id, changes)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
"""
|
||||||
|
execute_query(audit_query, (product_id, event_type, user_id, json.dumps(changes)))
|
||||||
|
|
||||||
|
|
||||||
def _normalize_query(raw_query: str) -> Tuple[str, List[str]]:
|
def _normalize_query(raw_query: str) -> Tuple[str, List[str]]:
|
||||||
normalized = " ".join(
|
normalized = " ".join(
|
||||||
"".join(ch.lower() if ch.isalnum() else " " for ch in raw_query).split()
|
"".join(ch.lower() if ch.isalnum() else " " for ch in raw_query).split()
|
||||||
@ -149,6 +267,7 @@ async def import_apigw_product(payload: Dict[str, Any]):
|
|||||||
(sku_internal,)
|
(sku_internal,)
|
||||||
)
|
)
|
||||||
if existing:
|
if existing:
|
||||||
|
_upsert_product_supplier(existing["id"], product, source="gateway")
|
||||||
return existing
|
return existing
|
||||||
|
|
||||||
sales_price = product.get("price")
|
sales_price = product.get("price")
|
||||||
@ -194,7 +313,10 @@ async def import_apigw_product(payload: Dict[str, Any]):
|
|||||||
True,
|
True,
|
||||||
)
|
)
|
||||||
result = execute_query(insert_query, params)
|
result = execute_query(insert_query, params)
|
||||||
return result[0] if result else {}
|
created = result[0] if result else {}
|
||||||
|
if created:
|
||||||
|
_upsert_product_supplier(created["id"], product, source="gateway")
|
||||||
|
return created
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -446,6 +568,191 @@ async def get_product(product_id: int):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/products/{product_id}", response_model=Dict[str, Any])
|
||||||
|
async def update_product(
|
||||||
|
product_id: int,
|
||||||
|
payload: Dict[str, Any],
|
||||||
|
current_user: dict = Depends(require_permission("products.update"))
|
||||||
|
):
|
||||||
|
"""Update product fields like name."""
|
||||||
|
try:
|
||||||
|
name = payload.get("name")
|
||||||
|
if name is not None:
|
||||||
|
name = name.strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="name cannot be empty")
|
||||||
|
|
||||||
|
existing = execute_query_single(
|
||||||
|
"SELECT name FROM products WHERE id = %s AND deleted_at IS NULL",
|
||||||
|
(product_id,)
|
||||||
|
)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
|
||||||
|
query = """
|
||||||
|
UPDATE products
|
||||||
|
SET name = COALESCE(%s, name),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s AND deleted_at IS NULL
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
result = execute_query(query, (name, product_id))
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
if name is not None and name != existing.get("name"):
|
||||||
|
_log_product_audit(
|
||||||
|
product_id,
|
||||||
|
"name_updated",
|
||||||
|
current_user.get("id") if current_user else None,
|
||||||
|
{"old": existing.get("name"), "new": name}
|
||||||
|
)
|
||||||
|
return result[0]
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Error updating product: %s", e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/products/{product_id}", response_model=Dict[str, Any])
|
||||||
|
async def delete_product(
|
||||||
|
product_id: int,
|
||||||
|
current_user: dict = Depends(require_permission("products.delete"))
|
||||||
|
):
|
||||||
|
"""Soft-delete a product."""
|
||||||
|
try:
|
||||||
|
query = """
|
||||||
|
UPDATE products
|
||||||
|
SET deleted_at = CURRENT_TIMESTAMP,
|
||||||
|
status = 'inactive',
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s AND deleted_at IS NULL
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
result = execute_query(query, (product_id,))
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
_log_product_audit(
|
||||||
|
product_id,
|
||||||
|
"deleted",
|
||||||
|
current_user.get("id") if current_user else None,
|
||||||
|
{"status": "inactive"}
|
||||||
|
)
|
||||||
|
return {"status": "deleted", "id": result[0].get("id")}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Error deleting product: %s", e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/products/{product_id}/suppliers", response_model=List[Dict[str, Any]])
|
||||||
|
async def list_product_suppliers(product_id: int):
|
||||||
|
"""List suppliers for a product."""
|
||||||
|
try:
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
product_id,
|
||||||
|
supplier_name,
|
||||||
|
supplier_code,
|
||||||
|
supplier_sku,
|
||||||
|
supplier_price,
|
||||||
|
supplier_currency,
|
||||||
|
supplier_stock,
|
||||||
|
supplier_url,
|
||||||
|
supplier_product_url,
|
||||||
|
source,
|
||||||
|
last_updated_at
|
||||||
|
FROM product_suppliers
|
||||||
|
WHERE product_id = %s
|
||||||
|
ORDER BY supplier_name NULLS LAST, supplier_price NULLS LAST
|
||||||
|
"""
|
||||||
|
return execute_query(query, (product_id,)) or []
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Error loading product suppliers: %s", e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/products/{product_id}/suppliers", response_model=Dict[str, Any])
|
||||||
|
async def upsert_product_supplier(product_id: int, payload: Dict[str, Any]):
|
||||||
|
"""Create or update a product supplier."""
|
||||||
|
try:
|
||||||
|
return _upsert_product_supplier(product_id, payload, source=payload.get("source") or "manual")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Error saving product supplier: %s", e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/products/{product_id}/suppliers/{supplier_id}", response_model=Dict[str, Any])
|
||||||
|
async def delete_product_supplier(product_id: int, supplier_id: int):
|
||||||
|
"""Delete a product supplier."""
|
||||||
|
try:
|
||||||
|
query = "DELETE FROM product_suppliers WHERE id = %s AND product_id = %s"
|
||||||
|
execute_query(query, (supplier_id, product_id))
|
||||||
|
return {"status": "deleted"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Error deleting product supplier: %s", e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/products/{product_id}/suppliers/refresh", response_model=Dict[str, Any])
|
||||||
|
async def refresh_product_suppliers(product_id: int):
|
||||||
|
"""Refresh suppliers from API Gateway using EAN only."""
|
||||||
|
try:
|
||||||
|
product = execute_query_single(
|
||||||
|
"SELECT ean FROM products WHERE id = %s AND deleted_at IS NULL",
|
||||||
|
(product_id,),
|
||||||
|
)
|
||||||
|
if not product:
|
||||||
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
|
||||||
|
ean = (product.get("ean") or "").strip()
|
||||||
|
if not ean:
|
||||||
|
raise HTTPException(status_code=400, detail="Product has no EAN to search")
|
||||||
|
|
||||||
|
base_url = settings.APIGW_BASE_URL or settings.APIGATEWAY_URL
|
||||||
|
url = f"{base_url.rstrip('/')}/api/v1/products/search"
|
||||||
|
params = {"q": ean, "per_page": 25}
|
||||||
|
timeout = aiohttp.ClientTimeout(total=settings.APIGW_TIMEOUT_SECONDS)
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
async with session.get(url, headers=_apigw_headers(), params=params) as response:
|
||||||
|
if response.status >= 400:
|
||||||
|
detail = await response.text()
|
||||||
|
raise HTTPException(status_code=response.status, detail=detail)
|
||||||
|
data = await response.json()
|
||||||
|
|
||||||
|
results = data.get("products") if isinstance(data, dict) else []
|
||||||
|
if not isinstance(results, list):
|
||||||
|
results = []
|
||||||
|
|
||||||
|
saved = 0
|
||||||
|
seen_keys = set()
|
||||||
|
for item in results:
|
||||||
|
key = (
|
||||||
|
item.get("supplier_code"),
|
||||||
|
item.get("sku"),
|
||||||
|
item.get("product_url") or item.get("url")
|
||||||
|
)
|
||||||
|
if key in seen_keys:
|
||||||
|
continue
|
||||||
|
seen_keys.add(key)
|
||||||
|
_upsert_product_supplier(product_id, item, source="gateway")
|
||||||
|
saved += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "refreshed",
|
||||||
|
"saved": saved,
|
||||||
|
"queries": [{"q": ean}]
|
||||||
|
}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Error refreshing product suppliers: %s", e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/products/{product_id}/price-history", response_model=List[Dict[str, Any]])
|
@router.get("/products/{product_id}/price-history", response_model=List[Dict[str, Any]])
|
||||||
async def list_product_price_history(product_id: int, limit: int = Query(100)):
|
async def list_product_price_history(product_id: int, limit: int = Query(100)):
|
||||||
"""List price history entries for a product."""
|
"""List price history entries for a product."""
|
||||||
|
|||||||
@ -83,12 +83,23 @@
|
|||||||
<div class="fs-3 fw-semibold mt-2" id="productPrice">-</div>
|
<div class="fs-3 fw-semibold mt-2" id="productPrice">-</div>
|
||||||
<div class="product-muted" id="productSku">-</div>
|
<div class="product-muted" id="productSku">-</div>
|
||||||
<div class="product-muted" id="productSupplierPrice">-</div>
|
<div class="product-muted" id="productSupplierPrice">-</div>
|
||||||
|
<div class="badge-soft mt-2" id="productBestPrice" style="display: none;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-4">
|
||||||
<div class="product-card h-100">
|
<div class="product-card h-100">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||||
|
<h5 class="mb-0">Produktnavn</h5>
|
||||||
|
<button class="btn btn-outline-danger btn-sm" onclick="deleteProduct()">Slet</button>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Navn</label>
|
||||||
|
<input type="text" class="form-control" id="productNameInput">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-outline-primary w-100 mb-3" onclick="updateProductName()">Opdater navn</button>
|
||||||
|
<div class="small mb-3" id="productNameMessage"></div>
|
||||||
<h5 class="mb-3">Opdater pris</h5>
|
<h5 class="mb-3">Opdater pris</h5>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Ny salgspris</label>
|
<label class="form-label">Ny salgspris</label>
|
||||||
@ -128,6 +139,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-8">
|
<div class="col-lg-8">
|
||||||
|
<div class="product-card mb-3">
|
||||||
|
<h5 class="mb-3">Produktinfo</h5>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm product-table mb-0">
|
||||||
|
<tbody id="productInfoBody">
|
||||||
|
<tr><td class="text-center product-muted py-3">Indlaeser...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="product-card mb-3">
|
<div class="product-card mb-3">
|
||||||
<h5 class="mb-3">Pris historik</h5>
|
<h5 class="mb-3">Pris historik</h5>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
@ -170,6 +191,78 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="product-card mt-3">
|
||||||
|
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
|
||||||
|
<h5 class="mb-0">Grosister og priser</h5>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<select class="form-select form-select-sm" style="max-width: 160px;" onchange="changeSupplierSort(this.value)">
|
||||||
|
<option value="price">Pris</option>
|
||||||
|
<option value="stock">Lager</option>
|
||||||
|
<option value="name">Navn</option>
|
||||||
|
<option value="updated">Opdateret</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" onclick="refreshSuppliersFromGateway()">Opdater fra Gateway</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive mb-3">
|
||||||
|
<table class="table table-sm product-table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Grosist</th>
|
||||||
|
<th>SKU</th>
|
||||||
|
<th>Pris</th>
|
||||||
|
<th>Lager</th>
|
||||||
|
<th>Link</th>
|
||||||
|
<th>Opdateret</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="supplierListBody">
|
||||||
|
<tr><td colspan="7" class="text-center product-muted py-3">Indlaeser...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Grosist</label>
|
||||||
|
<input type="text" class="form-control" id="supplierListName" placeholder="Navn">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Supplier code</label>
|
||||||
|
<input type="text" class="form-control" id="supplierListCode" placeholder="Code">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">SKU</label>
|
||||||
|
<input type="text" class="form-control" id="supplierListSku" placeholder="SKU">
|
||||||
|
<div class="d-flex gap-2 mt-1">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="useProductEan()">Brug EAN</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="useProductSku()">Brug SKU</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Pris</label>
|
||||||
|
<input type="number" class="form-control" id="supplierListPrice" step="0.01" min="0">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Produktlink</label>
|
||||||
|
<input type="text" class="form-control" id="supplierListUrl" placeholder="https://">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Lager</label>
|
||||||
|
<input type="number" class="form-control" id="supplierListStock" min="0">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Valuta</label>
|
||||||
|
<input type="text" class="form-control" id="supplierListCurrency" placeholder="DKK">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<button class="btn btn-outline-primary w-100" onclick="submitSupplierList()">Tilfoej/Opdater</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="small" id="supplierListMessage"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -177,6 +270,13 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
const productId = {{ product_id }};
|
const productId = {{ product_id }};
|
||||||
|
const productDetailState = {
|
||||||
|
product: null
|
||||||
|
};
|
||||||
|
const supplierListState = {
|
||||||
|
suppliers: [],
|
||||||
|
sort: 'price'
|
||||||
|
};
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
return String(value || '').replace(/[&<>"']/g, (ch) => ({
|
return String(value || '').replace(/[&<>"']/g, (ch) => ({
|
||||||
@ -218,6 +318,72 @@ function setSupplierMessage(message, tone = 'text-muted') {
|
|||||||
el.textContent = message;
|
el.textContent = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setSupplierListMessage(message, tone = 'text-muted') {
|
||||||
|
const el = document.getElementById('supplierListMessage');
|
||||||
|
if (!el) return;
|
||||||
|
el.className = `small ${tone}`;
|
||||||
|
el.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setProductNameMessage(message, tone = 'text-muted') {
|
||||||
|
const el = document.getElementById('productNameMessage');
|
||||||
|
if (!el) return;
|
||||||
|
el.className = `small ${tone}`;
|
||||||
|
el.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBestPriceBadge(suppliers) {
|
||||||
|
const badge = document.getElementById('productBestPrice');
|
||||||
|
if (!badge) return;
|
||||||
|
const prices = (suppliers || [])
|
||||||
|
.map(supplier => supplier.supplier_price)
|
||||||
|
.filter(price => typeof price === 'number');
|
||||||
|
if (!prices.length) {
|
||||||
|
badge.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const best = Math.min(...prices);
|
||||||
|
badge.textContent = `Bedste pris: ${formatCurrency(best)}`;
|
||||||
|
badge.style.display = 'inline-flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProductInfo(product) {
|
||||||
|
const body = document.getElementById('productInfoBody');
|
||||||
|
if (!body) return;
|
||||||
|
const rows = [
|
||||||
|
['EAN', product.ean],
|
||||||
|
['Type', product.type],
|
||||||
|
['Status', product.status],
|
||||||
|
['SKU intern', product.sku_internal],
|
||||||
|
['Producent', product.manufacturer],
|
||||||
|
['Leverandoer', product.supplier_name],
|
||||||
|
['Leverandoer SKU', product.supplier_sku],
|
||||||
|
['Leverandoer pris', product.supplier_price != null ? formatCurrency(product.supplier_price) : null],
|
||||||
|
['Salgspris', product.sales_price != null ? formatCurrency(product.sales_price) : null],
|
||||||
|
['Kostpris', product.cost_price != null ? formatCurrency(product.cost_price) : null],
|
||||||
|
['Moms', product.vat_rate != null ? `${product.vat_rate}%` : null],
|
||||||
|
['Faktureringsinterval', product.billing_period],
|
||||||
|
['Auto forny', product.auto_renew ? 'Ja' : 'Nej'],
|
||||||
|
['Minimum binding', product.minimum_term_months != null ? `${product.minimum_term_months} mdr.` : null]
|
||||||
|
];
|
||||||
|
body.innerHTML = rows.map(([label, value]) => `
|
||||||
|
<tr>
|
||||||
|
<th>${label}</th>
|
||||||
|
<td>${value != null && value !== '' ? escapeHtml(value) : '-'}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefillSupplierSku(product) {
|
||||||
|
const skuField = document.getElementById('supplierListSku');
|
||||||
|
if (!skuField || skuField.value) return;
|
||||||
|
if (product.ean) {
|
||||||
|
skuField.value = product.ean;
|
||||||
|
} else if (product.sku_internal) {
|
||||||
|
skuField.value = product.sku_internal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadProductDetail() {
|
async function loadProductDetail() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/v1/products/${productId}`);
|
const res = await fetch(`/api/v1/products/${productId}`);
|
||||||
@ -238,6 +404,10 @@ async function loadProductDetail() {
|
|||||||
document.getElementById('priceNewValue').value = product.sales_price != null ? product.sales_price : '';
|
document.getElementById('priceNewValue').value = product.sales_price != null ? product.sales_price : '';
|
||||||
document.getElementById('supplierName').value = product.supplier_name || '';
|
document.getElementById('supplierName').value = product.supplier_name || '';
|
||||||
document.getElementById('supplierPrice').value = product.supplier_price != null ? product.supplier_price : '';
|
document.getElementById('supplierPrice').value = product.supplier_price != null ? product.supplier_price : '';
|
||||||
|
document.getElementById('productNameInput').value = product.name || '';
|
||||||
|
productDetailState.product = product;
|
||||||
|
renderProductInfo(product);
|
||||||
|
prefillSupplierSku(product);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMessage(e.message || 'Fejl ved indlaesning', 'text-danger');
|
setMessage(e.message || 'Fejl ved indlaesning', 'text-danger');
|
||||||
}
|
}
|
||||||
@ -294,6 +464,183 @@ async function loadSalesHistory() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadSupplierList() {
|
||||||
|
const tbody = document.getElementById('supplierListBody');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/products/${productId}/suppliers`);
|
||||||
|
if (!res.ok) throw new Error('Kunne ikke hente grosister');
|
||||||
|
const suppliers = await res.json();
|
||||||
|
supplierListState.suppliers = Array.isArray(suppliers) ? suppliers : [];
|
||||||
|
updateBestPriceBadge(supplierListState.suppliers);
|
||||||
|
const sorted = getSortedSuppliers();
|
||||||
|
if (!sorted.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="7" class="text-center product-muted py-3">Ingen grosister endnu</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = sorted.map(entry => {
|
||||||
|
const link = entry.supplier_product_url || entry.supplier_url;
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHtml(entry.supplier_name || entry.supplier_code || '-')}</td>
|
||||||
|
<td>${escapeHtml(entry.supplier_sku || '-')}</td>
|
||||||
|
<td>${entry.supplier_price != null ? formatCurrency(entry.supplier_price) : '-'}</td>
|
||||||
|
<td>${entry.supplier_stock != null ? entry.supplier_stock : '-'}</td>
|
||||||
|
<td>${link ? `<a href="${escapeHtml(link)}" target="_blank" rel="noopener">Link</a>` : '-'}</td>
|
||||||
|
<td>${entry.last_updated_at ? formatDate(entry.last_updated_at) : '-'}</td>
|
||||||
|
<td><button class="btn btn-sm btn-outline-danger" onclick="deleteSupplier(${entry.id})">Slet</button></td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
} catch (e) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="7" class="text-center text-danger py-3">${escapeHtml(e.message || 'Fejl')}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSortedSuppliers() {
|
||||||
|
const suppliers = supplierListState.suppliers || [];
|
||||||
|
const sort = supplierListState.sort;
|
||||||
|
if (sort === 'name') {
|
||||||
|
return [...suppliers].sort((a, b) => String(a.supplier_name || '').localeCompare(String(b.supplier_name || '')));
|
||||||
|
}
|
||||||
|
if (sort === 'stock') {
|
||||||
|
return [...suppliers].sort((a, b) => (b.supplier_stock || 0) - (a.supplier_stock || 0));
|
||||||
|
}
|
||||||
|
if (sort === 'updated') {
|
||||||
|
return [...suppliers].sort((a, b) => String(b.last_updated_at || '').localeCompare(String(a.last_updated_at || '')));
|
||||||
|
}
|
||||||
|
return [...suppliers].sort((a, b) => (a.supplier_price || 0) - (b.supplier_price || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeSupplierSort(value) {
|
||||||
|
supplierListState.sort = value;
|
||||||
|
loadSupplierList();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitSupplierList() {
|
||||||
|
const payload = {
|
||||||
|
supplier_name: document.getElementById('supplierListName').value.trim() || null,
|
||||||
|
supplier_code: document.getElementById('supplierListCode').value.trim() || null,
|
||||||
|
supplier_sku: document.getElementById('supplierListSku').value.trim() || null,
|
||||||
|
supplier_price: document.getElementById('supplierListPrice').value ? Number(document.getElementById('supplierListPrice').value) : null,
|
||||||
|
supplier_currency: document.getElementById('supplierListCurrency').value.trim() || null,
|
||||||
|
supplier_stock: document.getElementById('supplierListStock').value ? Number(document.getElementById('supplierListStock').value) : null,
|
||||||
|
supplier_product_url: document.getElementById('supplierListUrl').value.trim() || null,
|
||||||
|
source: 'manual'
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/products/${productId}/suppliers`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.detail || 'Kunne ikke gemme grosist');
|
||||||
|
}
|
||||||
|
await res.json();
|
||||||
|
setSupplierListMessage('Grosist gemt', 'text-success');
|
||||||
|
await loadSupplierList();
|
||||||
|
} catch (e) {
|
||||||
|
setSupplierListMessage(e.message || 'Fejl', 'text-danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSupplier(supplierId) {
|
||||||
|
if (!confirm('Vil du slette denne grosist?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/products/${productId}/suppliers/${supplierId}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.detail || 'Sletning fejlede');
|
||||||
|
}
|
||||||
|
await res.json();
|
||||||
|
await loadSupplierList();
|
||||||
|
} catch (e) {
|
||||||
|
setSupplierListMessage(e.message || 'Fejl', 'text-danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useProductEan() {
|
||||||
|
const skuField = document.getElementById('supplierListSku');
|
||||||
|
if (!skuField || !productDetailState.product) return;
|
||||||
|
skuField.value = productDetailState.product.ean || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function useProductSku() {
|
||||||
|
const skuField = document.getElementById('supplierListSku');
|
||||||
|
if (!skuField || !productDetailState.product) return;
|
||||||
|
skuField.value = productDetailState.product.sku_internal || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSuppliersFromGateway() {
|
||||||
|
setSupplierListMessage('Opdaterer fra Gateway...', 'text-muted');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/products/${productId}/suppliers/refresh`, { method: 'POST' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.detail || 'Opdatering fejlede');
|
||||||
|
}
|
||||||
|
const payload = await res.json();
|
||||||
|
const saved = Number(payload.saved || 0);
|
||||||
|
const queries = Array.isArray(payload.queries) ? payload.queries : [];
|
||||||
|
const queryText = queries.length
|
||||||
|
? ` (forsog: ${queries.map((query) => {
|
||||||
|
if (query.supplier_code) {
|
||||||
|
return `${query.supplier_code}:${query.q}`;
|
||||||
|
}
|
||||||
|
return query.q;
|
||||||
|
}).join(', ')})`
|
||||||
|
: '';
|
||||||
|
if (!saved) {
|
||||||
|
setSupplierListMessage(`Ingen grosister fundet i Gateway${queryText}`, 'text-warning');
|
||||||
|
} else {
|
||||||
|
setSupplierListMessage(`Grosister opdateret (${saved})${queryText}`, 'text-success');
|
||||||
|
}
|
||||||
|
await loadSupplierList();
|
||||||
|
} catch (e) {
|
||||||
|
setSupplierListMessage(e.message || 'Fejl', 'text-danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateProductName() {
|
||||||
|
const name = document.getElementById('productNameInput').value.trim();
|
||||||
|
if (!name) {
|
||||||
|
setProductNameMessage('Angiv et navn', 'text-danger');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/products/${productId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.detail || 'Opdatering fejlede');
|
||||||
|
}
|
||||||
|
await res.json();
|
||||||
|
setProductNameMessage('Navn opdateret', 'text-success');
|
||||||
|
await loadProductDetail();
|
||||||
|
} catch (e) {
|
||||||
|
setProductNameMessage(e.message || 'Fejl', 'text-danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteProduct() {
|
||||||
|
if (!confirm('Vil du slette dette produkt?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/products/${productId}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.detail || 'Sletning fejlede');
|
||||||
|
}
|
||||||
|
window.location.href = '/products';
|
||||||
|
} catch (e) {
|
||||||
|
setProductNameMessage(e.message || 'Fejl', 'text-danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submitPriceUpdate() {
|
async function submitPriceUpdate() {
|
||||||
const newPrice = document.getElementById('priceNewValue').value;
|
const newPrice = document.getElementById('priceNewValue').value;
|
||||||
const note = document.getElementById('priceNote').value.trim();
|
const note = document.getElementById('priceNote').value.trim();
|
||||||
@ -361,6 +708,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
loadProductDetail();
|
loadProductDetail();
|
||||||
loadPriceHistory();
|
loadPriceHistory();
|
||||||
loadSalesHistory();
|
loadSalesHistory();
|
||||||
|
loadSupplierList();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -71,10 +71,29 @@ async def get_setting(key: str):
|
|||||||
"""Get a specific setting by key"""
|
"""Get a specific setting by key"""
|
||||||
query = "SELECT * FROM settings WHERE key = %s"
|
query = "SELECT * FROM settings WHERE key = %s"
|
||||||
result = execute_query(query, (key,))
|
result = execute_query(query, (key,))
|
||||||
|
|
||||||
|
if not result and key == "case_types":
|
||||||
|
seed_query = """
|
||||||
|
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (key) DO NOTHING
|
||||||
|
"""
|
||||||
|
execute_query(
|
||||||
|
seed_query,
|
||||||
|
(
|
||||||
|
"case_types",
|
||||||
|
'["ticket", "opgave", "ordre", "projekt", "service"]',
|
||||||
|
"system",
|
||||||
|
"Sags-typer",
|
||||||
|
"json",
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = execute_query(query, (key,))
|
||||||
|
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Setting not found")
|
raise HTTPException(status_code=404, detail="Setting not found")
|
||||||
|
|
||||||
return result[0]
|
return result[0]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -236,16 +236,10 @@
|
|||||||
<i class="bi bi-headset me-2"></i>Support
|
<i class="bi bi-headset me-2"></i>Support
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu mt-2">
|
<ul class="dropdown-menu mt-2">
|
||||||
<li><a class="dropdown-item py-2" href="/ticket/dashboard"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="/ticket/tickets"><i class="bi bi-ticket-detailed me-2"></i>Alle Tickets</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="/ticket/archived"><i class="bi bi-archive me-2"></i>Arkiverede Tickets</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="/ticket/worklog/review"><i class="bi bi-clock-history me-2"></i>Godkend Worklog</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="/conversations/my"><i class="bi bi-mic me-2"></i>Mine Samtaler</a></li>
|
<li><a class="dropdown-item py-2" href="/conversations/my"><i class="bi bi-mic me-2"></i>Mine Samtaler</a></li>
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="/hardware"><i class="bi bi-laptop me-2"></i>Hardware Assets</a></li>
|
<li><a class="dropdown-item py-2" href="/hardware"><i class="bi bi-laptop me-2"></i>Hardware Assets</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="/app/locations"><i class="bi bi-map-fill me-2"></i>Lokaliteter</a></li>
|
<li><a class="dropdown-item py-2" href="/app/locations"><i class="bi bi-map-fill me-2"></i>Lokaliteter</a></li>
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li><a class="dropdown-item py-2" href="#">Ny Ticket</a></li>
|
|
||||||
<li><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li>
|
<li><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li>
|
<li><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li>
|
||||||
<li><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li>
|
<li><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li>
|
||||||
@ -375,7 +369,7 @@
|
|||||||
<h6 class="text-muted text-uppercase small fw-bold mb-0">
|
<h6 class="text-muted text-uppercase small fw-bold mb-0">
|
||||||
<i class="bi bi-people me-2"></i>CRM
|
<i class="bi bi-people me-2"></i>CRM
|
||||||
</h6>
|
</h6>
|
||||||
<a href="/crm/workflow" class="btn btn-sm btn-outline-primary">
|
<a href="/devportal" class="btn btn-sm btn-outline-primary">
|
||||||
<i class="bi bi-diagram-3 me-1"></i>Se Workflow
|
<i class="bi bi-diagram-3 me-1"></i>Se Workflow
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -390,7 +384,7 @@
|
|||||||
<h6 class="text-muted text-uppercase small fw-bold mb-0">
|
<h6 class="text-muted text-uppercase small fw-bold mb-0">
|
||||||
<i class="bi bi-headset me-2"></i>Support
|
<i class="bi bi-headset me-2"></i>Support
|
||||||
</h6>
|
</h6>
|
||||||
<a href="/support/workflow" class="btn btn-sm btn-outline-primary">
|
<a href="/devportal" class="btn btn-sm btn-outline-primary">
|
||||||
<i class="bi bi-diagram-3 me-1"></i>Se Workflow
|
<i class="bi bi-diagram-3 me-1"></i>Se Workflow
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -405,7 +399,7 @@
|
|||||||
<h6 class="text-muted text-uppercase small fw-bold mb-0">
|
<h6 class="text-muted text-uppercase small fw-bold mb-0">
|
||||||
<i class="bi bi-cart3 me-2"></i>Salg
|
<i class="bi bi-cart3 me-2"></i>Salg
|
||||||
</h6>
|
</h6>
|
||||||
<a href="/sales/workflow" class="btn btn-sm btn-outline-primary">
|
<a href="/devportal" class="btn btn-sm btn-outline-primary">
|
||||||
<i class="bi bi-diagram-3 me-1"></i>Se Workflow
|
<i class="bi bi-diagram-3 me-1"></i>Se Workflow
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -420,7 +414,7 @@
|
|||||||
<h6 class="text-muted text-uppercase small fw-bold mb-0">
|
<h6 class="text-muted text-uppercase small fw-bold mb-0">
|
||||||
<i class="bi bi-currency-dollar me-2"></i>Økonomi
|
<i class="bi bi-currency-dollar me-2"></i>Økonomi
|
||||||
</h6>
|
</h6>
|
||||||
<a href="/finance/workflow" class="btn btn-sm btn-outline-primary">
|
<a href="/devportal" class="btn btn-sm btn-outline-primary">
|
||||||
<i class="bi bi-diagram-3 me-1"></i>Se Workflow
|
<i class="bi bi-diagram-3 me-1"></i>Se Workflow
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -617,7 +611,7 @@
|
|||||||
// Load live statistics for the three boxes
|
// Load live statistics for the three boxes
|
||||||
async function loadLiveStats() {
|
async function loadLiveStats() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/dashboard/live-stats');
|
const response = await fetch('/api/v1/live-stats');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
// Update Sales Box
|
// Update Sales Box
|
||||||
@ -666,7 +660,7 @@
|
|||||||
// Load recent activity
|
// Load recent activity
|
||||||
async function loadRecentActivity() {
|
async function loadRecentActivity() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/dashboard/recent-activity');
|
const response = await fetch('/api/v1/recent-activity');
|
||||||
const activities = await response.json();
|
const activities = await response.json();
|
||||||
|
|
||||||
const activityList = document.getElementById('recentActivityList');
|
const activityList = document.getElementById('recentActivityList');
|
||||||
@ -930,7 +924,6 @@
|
|||||||
const workflows = {
|
const workflows = {
|
||||||
customer: [
|
customer: [
|
||||||
{ label: 'Opret ordre', icon: 'cart-plus', action: (data) => window.location.href = `/orders/new?customer=${data.id}` },
|
{ label: 'Opret ordre', icon: 'cart-plus', action: (data) => window.location.href = `/orders/new?customer=${data.id}` },
|
||||||
{ label: 'Opret sag', icon: 'ticket-detailed', action: (data) => window.location.href = `/tickets/new?customer=${data.id}` },
|
|
||||||
{ label: 'Ring til kontakt', icon: 'telephone', action: (data) => alert('Ring til: ' + (data.phone || 'Intet telefonnummer')) },
|
{ label: 'Ring til kontakt', icon: 'telephone', action: (data) => alert('Ring til: ' + (data.phone || 'Intet telefonnummer')) },
|
||||||
{ label: 'Vis kunde', icon: 'eye', action: (data) => window.location.href = `/customers/${data.id}` }
|
{ label: 'Vis kunde', icon: 'eye', action: (data) => window.location.href = `/customers/${data.id}` }
|
||||||
],
|
],
|
||||||
@ -951,11 +944,6 @@
|
|||||||
{ label: 'Opret kassekladde', icon: 'journal-text', action: (data) => alert('Kassekladde funktionalitet kommer snart') },
|
{ label: 'Opret kassekladde', icon: 'journal-text', action: (data) => alert('Kassekladde funktionalitet kommer snart') },
|
||||||
{ label: 'Opret kreditnota', icon: 'file-earmark-minus', action: (data) => window.location.href = `/invoices/${data.id}/credit-note` }
|
{ label: 'Opret kreditnota', icon: 'file-earmark-minus', action: (data) => window.location.href = `/invoices/${data.id}/credit-note` }
|
||||||
],
|
],
|
||||||
ticket: [
|
|
||||||
{ label: 'Åbn sag', icon: 'folder2-open', action: (data) => window.location.href = `/tickets/${data.id}` },
|
|
||||||
{ label: 'Luk sag', icon: 'check-circle', action: (data) => alert('Luk sag funktionalitet kommer snart') },
|
|
||||||
{ label: 'Tildel medarbejder', icon: 'person-plus', action: (data) => alert('Tildel funktionalitet kommer snart') }
|
|
||||||
],
|
|
||||||
rodekasse: [
|
rodekasse: [
|
||||||
{ label: 'Behandle', icon: 'pencil-square', action: (data) => window.location.href = `/rodekasse/${data.id}` },
|
{ label: 'Behandle', icon: 'pencil-square', action: (data) => window.location.href = `/rodekasse/${data.id}` },
|
||||||
{ label: 'Arkiver', icon: 'archive', action: (data) => alert('Arkiver funktionalitet kommer snart') },
|
{ label: 'Arkiver', icon: 'archive', action: (data) => alert('Arkiver funktionalitet kommer snart') },
|
||||||
@ -1353,6 +1341,7 @@
|
|||||||
}, 30000);
|
}, 30000);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
{% block extra_js %}{% endblock %}
|
{% block extra_js %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@ -252,11 +252,14 @@ async def update_subscription_status(subscription_id: int, payload: Dict[str, An
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/subscriptions", response_model=List[Dict[str, Any]])
|
@router.get("/subscriptions", response_model=List[Dict[str, Any]])
|
||||||
async def list_subscriptions(status: str = Query("active")):
|
async def list_subscriptions(status: str = Query("all")):
|
||||||
"""List subscriptions by status (default: active)."""
|
"""List subscriptions by status (default: all)."""
|
||||||
try:
|
try:
|
||||||
where_clause = "WHERE s.status = %s"
|
where_clause = ""
|
||||||
params = (status,)
|
params: List[Any] = []
|
||||||
|
if status and status != "all":
|
||||||
|
where_clause = "WHERE s.status = %s"
|
||||||
|
params.append(status)
|
||||||
|
|
||||||
query = f"""
|
query = f"""
|
||||||
SELECT
|
SELECT
|
||||||
@ -279,25 +282,30 @@ async def list_subscriptions(status: str = Query("active")):
|
|||||||
{where_clause}
|
{where_clause}
|
||||||
ORDER BY s.start_date DESC, s.id DESC
|
ORDER BY s.start_date DESC, s.id DESC
|
||||||
"""
|
"""
|
||||||
return execute_query(query, params) or []
|
return execute_query(query, tuple(params)) or []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Error listing subscriptions: {e}", exc_info=True)
|
logger.error(f"❌ Error listing subscriptions: {e}", exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/subscriptions/stats/summary", response_model=Dict[str, Any])
|
@router.get("/subscriptions/stats/summary", response_model=Dict[str, Any])
|
||||||
async def subscription_stats(status: str = Query("active")):
|
async def subscription_stats(status: str = Query("all")):
|
||||||
"""Summary stats for subscriptions by status (default: active)."""
|
"""Summary stats for subscriptions by status (default: all)."""
|
||||||
try:
|
try:
|
||||||
query = """
|
where_clause = ""
|
||||||
|
params: List[Any] = []
|
||||||
|
if status and status != "all":
|
||||||
|
where_clause = "WHERE status = %s"
|
||||||
|
params.append(status)
|
||||||
|
query = f"""
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) AS subscription_count,
|
COUNT(*) AS subscription_count,
|
||||||
COALESCE(SUM(price), 0) AS total_amount,
|
COALESCE(SUM(price), 0) AS total_amount,
|
||||||
COALESCE(AVG(price), 0) AS avg_amount
|
COALESCE(AVG(price), 0) AS avg_amount
|
||||||
FROM sag_subscriptions
|
FROM sag_subscriptions
|
||||||
WHERE status = %s
|
{where_clause}
|
||||||
"""
|
"""
|
||||||
result = execute_query(query, (status,))
|
result = execute_query(query, tuple(params))
|
||||||
return result[0] if result else {
|
return result[0] if result else {
|
||||||
"subscription_count": 0,
|
"subscription_count": 0,
|
||||||
"total_amount": 0,
|
"total_amount": 0,
|
||||||
|
|||||||
@ -9,6 +9,15 @@
|
|||||||
<h1 class="h3 mb-0">🔁 Abonnementer</h1>
|
<h1 class="h3 mb-0">🔁 Abonnementer</h1>
|
||||||
<p class="text-muted">Alle solgte, aktive abonnementer</p>
|
<p class="text-muted">Alle solgte, aktive abonnementer</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<select class="form-select" id="subscriptionStatusFilter" style="min-width: 180px;">
|
||||||
|
<option value="all" selected>Alle statuser</option>
|
||||||
|
<option value="active">Aktiv</option>
|
||||||
|
<option value="paused">Pauset</option>
|
||||||
|
<option value="cancelled">Opsagt</option>
|
||||||
|
<option value="draft">Kladde</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-3 mb-4" id="statsCards">
|
<div class="row g-3 mb-4" id="statsCards">
|
||||||
@ -40,7 +49,7 @@
|
|||||||
|
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm">
|
||||||
<div class="card-header bg-white border-0 py-3">
|
<div class="card-header bg-white border-0 py-3">
|
||||||
<h5 class="mb-0">Aktive abonnementer</h5>
|
<h5 class="mb-0" id="subscriptionsTitle">Abonnementer</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
@ -73,13 +82,26 @@
|
|||||||
<script>
|
<script>
|
||||||
async function loadSubscriptions() {
|
async function loadSubscriptions() {
|
||||||
try {
|
try {
|
||||||
const stats = await fetch('/api/v1/subscriptions/stats/summary').then(r => r.json());
|
const status = document.getElementById('subscriptionStatusFilter')?.value || 'all';
|
||||||
|
const stats = await fetch(`/api/v1/subscriptions/stats/summary?status=${encodeURIComponent(status)}`).then(r => r.json());
|
||||||
document.getElementById('activeCount').textContent = stats.subscription_count || 0;
|
document.getElementById('activeCount').textContent = stats.subscription_count || 0;
|
||||||
document.getElementById('totalAmount').textContent = formatCurrency(stats.total_amount || 0);
|
document.getElementById('totalAmount').textContent = formatCurrency(stats.total_amount || 0);
|
||||||
document.getElementById('avgAmount').textContent = formatCurrency(stats.avg_amount || 0);
|
document.getElementById('avgAmount').textContent = formatCurrency(stats.avg_amount || 0);
|
||||||
|
|
||||||
const subscriptions = await fetch('/api/v1/subscriptions').then(r => r.json());
|
const subscriptions = await fetch(`/api/v1/subscriptions?status=${encodeURIComponent(status)}`).then(r => r.json());
|
||||||
renderSubscriptions(subscriptions);
|
renderSubscriptions(subscriptions);
|
||||||
|
|
||||||
|
const title = document.getElementById('subscriptionsTitle');
|
||||||
|
if (title) {
|
||||||
|
const labelMap = {
|
||||||
|
all: 'Alle abonnementer',
|
||||||
|
active: 'Aktive abonnementer',
|
||||||
|
paused: 'Pausede abonnementer',
|
||||||
|
cancelled: 'Opsagte abonnementer',
|
||||||
|
draft: 'Kladder'
|
||||||
|
};
|
||||||
|
title.textContent = labelMap[status] || 'Abonnementer';
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error loading subscriptions:', e);
|
console.error('Error loading subscriptions:', e);
|
||||||
document.getElementById('subscriptionsBody').innerHTML = `
|
document.getElementById('subscriptionsBody').innerHTML = `
|
||||||
@ -159,6 +181,12 @@ function formatDate(dateStr) {
|
|||||||
return date.toLocaleDateString('da-DK');
|
return date.toLocaleDateString('da-DK');
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', loadSubscriptions);
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const filter = document.getElementById('subscriptionStatusFilter');
|
||||||
|
if (filter) {
|
||||||
|
filter.addEventListener('change', loadSubscriptions);
|
||||||
|
}
|
||||||
|
loadSubscriptions();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -18,6 +18,16 @@ router = APIRouter()
|
|||||||
templates = Jinja2Templates(directory="app")
|
templates = Jinja2Templates(directory="app")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", include_in_schema=False)
|
||||||
|
async def ticket_root_redirect():
|
||||||
|
return RedirectResponse(url="/sag", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{path:path}", include_in_schema=False)
|
||||||
|
async def ticket_catchall_redirect(path: str):
|
||||||
|
return RedirectResponse(url="/sag", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
def _format_long_text(value: Optional[str]) -> str:
|
def _format_long_text(value: Optional[str]) -> str:
|
||||||
if not value:
|
if not value:
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
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 router as auth_api
|
||||||
from app.auth.backend import views as auth_views
|
from app.auth.backend import views as auth_views
|
||||||
from app.auth.backend import admin as auth_admin_api
|
from app.auth.backend import admin as auth_admin_api
|
||||||
|
from app.devportal.backend import router as devportal_api
|
||||||
|
from app.devportal.backend import views as devportal_views
|
||||||
|
|
||||||
# Modules
|
# Modules
|
||||||
from app.modules.webshop.backend import router as webshop_api
|
from app.modules.webshop.backend import router as webshop_api
|
||||||
@ -263,6 +265,7 @@ app.include_router(hardware_module_api.router, prefix="/api/v1", tags=["Hardware
|
|||||||
app.include_router(locations_api, prefix="/api/v1", tags=["Locations"])
|
app.include_router(locations_api, prefix="/api/v1", tags=["Locations"])
|
||||||
app.include_router(nextcloud_api.router, prefix="/api/v1/nextcloud", tags=["Nextcloud"])
|
app.include_router(nextcloud_api.router, prefix="/api/v1/nextcloud", tags=["Nextcloud"])
|
||||||
app.include_router(search_api.router, prefix="/api/v1", tags=["Search"])
|
app.include_router(search_api.router, prefix="/api/v1", tags=["Search"])
|
||||||
|
app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["Devportal"])
|
||||||
|
|
||||||
# Frontend Routers
|
# Frontend Routers
|
||||||
app.include_router(dashboard_views.router, tags=["Frontend"])
|
app.include_router(dashboard_views.router, tags=["Frontend"])
|
||||||
@ -287,6 +290,7 @@ app.include_router(auth_views.router, tags=["Frontend"])
|
|||||||
app.include_router(sag_views.router, tags=["Frontend"])
|
app.include_router(sag_views.router, tags=["Frontend"])
|
||||||
app.include_router(hardware_module_views.router, tags=["Frontend"])
|
app.include_router(hardware_module_views.router, tags=["Frontend"])
|
||||||
app.include_router(locations_views.router, tags=["Frontend"])
|
app.include_router(locations_views.router, tags=["Frontend"])
|
||||||
|
app.include_router(devportal_views.router, tags=["Frontend"])
|
||||||
|
|
||||||
# Serve static files (UI)
|
# Serve static files (UI)
|
||||||
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
|
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
|
||||||
|
|||||||
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